This is page 3 of 7. Use http://codebase.md/php-mcp/server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .github
│ └── workflows
│ ├── changelog.yml
│ └── tests.yml
├── .gitignore
├── .php-cs-fixer.php
├── CHANGELOG.md
├── composer.json
├── CONTRIBUTING.md
├── examples
│ ├── 01-discovery-stdio-calculator
│ │ ├── McpElements.php
│ │ └── server.php
│ ├── 02-discovery-http-userprofile
│ │ ├── McpElements.php
│ │ ├── server.php
│ │ └── UserIdCompletionProvider.php
│ ├── 03-manual-registration-stdio
│ │ ├── server.php
│ │ └── SimpleHandlers.php
│ ├── 04-combined-registration-http
│ │ ├── DiscoveredElements.php
│ │ ├── ManualHandlers.php
│ │ └── server.php
│ ├── 05-stdio-env-variables
│ │ ├── EnvToolHandler.php
│ │ └── server.php
│ ├── 06-custom-dependencies-stdio
│ │ ├── McpTaskHandlers.php
│ │ ├── server.php
│ │ └── Services.php
│ ├── 07-complex-tool-schema-http
│ │ ├── EventTypes.php
│ │ ├── McpEventScheduler.php
│ │ └── server.php
│ └── 08-schema-showcase-streamable
│ ├── SchemaShowcaseElements.php
│ └── server.php
├── LICENSE
├── phpunit.xml
├── README.md
├── src
│ ├── Attributes
│ │ ├── CompletionProvider.php
│ │ ├── McpPrompt.php
│ │ ├── McpResource.php
│ │ ├── McpResourceTemplate.php
│ │ ├── McpTool.php
│ │ └── Schema.php
│ ├── Configuration.php
│ ├── Context.php
│ ├── Contracts
│ │ ├── CompletionProviderInterface.php
│ │ ├── EventStoreInterface.php
│ │ ├── LoggerAwareInterface.php
│ │ ├── LoopAwareInterface.php
│ │ ├── ServerTransportInterface.php
│ │ ├── SessionHandlerInterface.php
│ │ └── SessionInterface.php
│ ├── Defaults
│ │ ├── ArrayCache.php
│ │ ├── BasicContainer.php
│ │ ├── DefaultUuidSessionIdGenerator.php
│ │ ├── EnumCompletionProvider.php
│ │ ├── FileCache.php
│ │ ├── InMemoryEventStore.php
│ │ ├── ListCompletionProvider.php
│ │ └── SystemClock.php
│ ├── Dispatcher.php
│ ├── Elements
│ │ ├── RegisteredElement.php
│ │ ├── RegisteredPrompt.php
│ │ ├── RegisteredResource.php
│ │ ├── RegisteredResourceTemplate.php
│ │ └── RegisteredTool.php
│ ├── Exception
│ │ ├── ConfigurationException.php
│ │ ├── DiscoveryException.php
│ │ ├── McpServerException.php
│ │ ├── ProtocolException.php
│ │ └── TransportException.php
│ ├── Protocol.php
│ ├── Registry.php
│ ├── Server.php
│ ├── ServerBuilder.php
│ ├── Session
│ │ ├── ArraySessionHandler.php
│ │ ├── CacheSessionHandler.php
│ │ ├── Session.php
│ │ ├── SessionManager.php
│ │ └── SubscriptionManager.php
│ ├── Transports
│ │ ├── HttpServerTransport.php
│ │ ├── StdioServerTransport.php
│ │ └── StreamableHttpServerTransport.php
│ └── Utils
│ ├── Discoverer.php
│ ├── DocBlockParser.php
│ ├── HandlerResolver.php
│ ├── SchemaGenerator.php
│ └── SchemaValidator.php
└── tests
├── Fixtures
│ ├── Discovery
│ │ ├── DiscoverablePromptHandler.php
│ │ ├── DiscoverableResourceHandler.php
│ │ ├── DiscoverableTemplateHandler.php
│ │ ├── DiscoverableToolHandler.php
│ │ ├── EnhancedCompletionHandler.php
│ │ ├── InvocablePromptFixture.php
│ │ ├── InvocableResourceFixture.php
│ │ ├── InvocableResourceTemplateFixture.php
│ │ ├── InvocableToolFixture.php
│ │ ├── NonDiscoverableClass.php
│ │ └── SubDir
│ │ └── HiddenTool.php
│ ├── Enums
│ │ ├── BackedIntEnum.php
│ │ ├── BackedStringEnum.php
│ │ ├── PriorityEnum.php
│ │ ├── StatusEnum.php
│ │ └── UnitEnum.php
│ ├── General
│ │ ├── CompletionProviderFixture.php
│ │ ├── DocBlockTestFixture.php
│ │ ├── InvokableHandlerFixture.php
│ │ ├── PromptHandlerFixture.php
│ │ ├── RequestAttributeChecker.php
│ │ ├── ResourceHandlerFixture.php
│ │ ├── ToolHandlerFixture.php
│ │ └── VariousTypesHandler.php
│ ├── Middlewares
│ │ ├── ErrorMiddleware.php
│ │ ├── FirstMiddleware.php
│ │ ├── HeaderMiddleware.php
│ │ ├── RequestAttributeMiddleware.php
│ │ ├── SecondMiddleware.php
│ │ ├── ShortCircuitMiddleware.php
│ │ └── ThirdMiddleware.php
│ ├── Schema
│ │ └── SchemaGenerationTarget.php
│ ├── ServerScripts
│ │ ├── HttpTestServer.php
│ │ ├── StdioTestServer.php
│ │ └── StreamableHttpTestServer.php
│ └── Utils
│ ├── AttributeFixtures.php
│ ├── DockBlockParserFixture.php
│ └── SchemaGeneratorFixture.php
├── Integration
│ ├── DiscoveryTest.php
│ ├── HttpServerTransportTest.php
│ ├── SchemaGenerationTest.php
│ ├── StdioServerTransportTest.php
│ └── StreamableHttpServerTransportTest.php
├── Mocks
│ ├── Clients
│ │ ├── MockJsonHttpClient.php
│ │ ├── MockSseClient.php
│ │ └── MockStreamHttpClient.php
│ └── Clock
│ └── FixedClock.php
├── Pest.php
├── TestCase.php
└── Unit
├── Attributes
│ ├── CompletionProviderTest.php
│ ├── McpPromptTest.php
│ ├── McpResourceTemplateTest.php
│ ├── McpResourceTest.php
│ └── McpToolTest.php
├── ConfigurationTest.php
├── Defaults
│ ├── EnumCompletionProviderTest.php
│ └── ListCompletionProviderTest.php
├── DispatcherTest.php
├── Elements
│ ├── RegisteredElementTest.php
│ ├── RegisteredPromptTest.php
│ ├── RegisteredResourceTemplateTest.php
│ ├── RegisteredResourceTest.php
│ └── RegisteredToolTest.php
├── ProtocolTest.php
├── RegistryTest.php
├── ServerBuilderTest.php
├── ServerTest.php
├── Session
│ ├── ArraySessionHandlerTest.php
│ ├── CacheSessionHandlerTest.php
│ ├── SessionManagerTest.php
│ └── SessionTest.php
└── Utils
├── DocBlockParserTest.php
├── HandlerResolverTest.php
└── SchemaValidatorTest.php
```
# Files
--------------------------------------------------------------------------------
/tests/Unit/Elements/RegisteredToolTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Unit\Elements;
4 |
5 | use InvalidArgumentException;
6 | use Mockery;
7 | use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
8 | use PhpMcp\Schema\Tool;
9 | use PhpMcp\Server\Context;
10 | use PhpMcp\Server\Contracts\SessionInterface;
11 | use PhpMcp\Server\Elements\RegisteredTool;
12 | use PhpMcp\Schema\Content\TextContent;
13 | use PhpMcp\Schema\Content\ImageContent;
14 | use PhpMcp\Server\Tests\Fixtures\General\ToolHandlerFixture;
15 | use Psr\Container\ContainerInterface;
16 | use JsonException;
17 | use PhpMcp\Server\Exception\McpServerException;
18 |
19 | uses(MockeryPHPUnitIntegration::class);
20 |
21 | beforeEach(function () {
22 | $this->container = Mockery::mock(ContainerInterface::class);
23 | $this->handlerInstance = new ToolHandlerFixture();
24 | $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)
25 | ->andReturn($this->handlerInstance)->byDefault();
26 |
27 | $this->toolSchema = Tool::make(
28 | name: 'test-tool',
29 | inputSchema: ['type' => 'object', 'properties' => ['name' => ['type' => 'string']]]
30 | );
31 |
32 | $this->registeredTool = RegisteredTool::make(
33 | $this->toolSchema,
34 | [ToolHandlerFixture::class, 'greet']
35 | );
36 | $this->context = new Context(Mockery::mock(SessionInterface::class));
37 | });
38 |
39 | it('constructs correctly and exposes schema', function () {
40 | expect($this->registeredTool->schema)->toBe($this->toolSchema);
41 | expect($this->registeredTool->handler)->toBe([ToolHandlerFixture::class, 'greet']);
42 | expect($this->registeredTool->isManual)->toBeFalse();
43 | });
44 |
45 | it('can be made as a manual registration', function () {
46 | $manualTool = RegisteredTool::make($this->toolSchema, [ToolHandlerFixture::class, 'greet'], true);
47 | expect($manualTool->isManual)->toBeTrue();
48 | });
49 |
50 | it('calls the handler with prepared arguments', function () {
51 | $tool = RegisteredTool::make(
52 | Tool::make('sum-tool', ['type' => 'object', 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']]]),
53 | [ToolHandlerFixture::class, 'sum']
54 | );
55 | $mockHandler = Mockery::mock(ToolHandlerFixture::class);
56 | $mockHandler->shouldReceive('sum')->with(5, 10)->once()->andReturn(15);
57 | $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->andReturn($mockHandler);
58 |
59 | $resultContents = $tool->call($this->container, ['a' => 5, 'b' => '10'], $this->context); // '10' will be cast to int by prepareArguments
60 |
61 | expect($resultContents)->toBeArray()->toHaveCount(1);
62 | expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('15');
63 | });
64 |
65 | it('calls handler with no arguments if tool takes none and none provided', function () {
66 | $tool = RegisteredTool::make(
67 | Tool::make('no-args-tool', ['type' => 'object', 'properties' => []]),
68 | [ToolHandlerFixture::class, 'noParamsTool']
69 | );
70 | $mockHandler = Mockery::mock(ToolHandlerFixture::class);
71 | $mockHandler->shouldReceive('noParamsTool')->withNoArgs()->once()->andReturn(['status' => 'done']);
72 | $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->andReturn($mockHandler);
73 |
74 | $resultContents = $tool->call($this->container, [], $this->context);
75 | expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode(['status' => 'done'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
76 | });
77 |
78 |
79 | dataset('tool_handler_return_values', [
80 | 'string' => ['returnString', "This is a string result."],
81 | 'integer' => ['returnInteger', "12345"],
82 | 'float' => ['returnFloat', "67.89"],
83 | 'boolean_true' => ['returnBooleanTrue', "true"],
84 | 'boolean_false' => ['returnBooleanFalse', "false"],
85 | 'null' => ['returnNull', "(null)"],
86 | 'array_to_json' => ['returnArray', json_encode(['message' => 'Array result', 'data' => [1, 2, 3]], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)],
87 | 'object_to_json' => ['returnStdClass', json_encode((object)['property' => "value"], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)],
88 | ]);
89 |
90 | it('formats various scalar and simple object/array handler results into TextContent', function (string $handlerMethod, string $expectedText) {
91 | $tool = RegisteredTool::make(
92 | Tool::make('format-test-tool', ['type' => 'object', 'properties' => []]),
93 | [ToolHandlerFixture::class, $handlerMethod]
94 | );
95 |
96 | $resultContents = $tool->call($this->container, [], $this->context);
97 |
98 | expect($resultContents)->toBeArray()->toHaveCount(1);
99 | expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe($expectedText);
100 | })->with('tool_handler_return_values');
101 |
102 | it('returns single Content object from handler as array with one Content object', function () {
103 | $tool = RegisteredTool::make(
104 | Tool::make('content-test-tool', ['type' => 'object', 'properties' => []]),
105 | [ToolHandlerFixture::class, 'returnTextContent']
106 | );
107 | $resultContents = $tool->call($this->container, [], $this->context);
108 |
109 | expect($resultContents)->toBeArray()->toHaveCount(1);
110 | expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Pre-formatted TextContent.");
111 | });
112 |
113 | it('returns array of Content objects from handler as is', function () {
114 | $tool = RegisteredTool::make(
115 | Tool::make('content-array-tool', ['type' => 'object', 'properties' => []]),
116 | [ToolHandlerFixture::class, 'returnArrayOfContent']
117 | );
118 | $resultContents = $tool->call($this->container, [], $this->context);
119 |
120 | expect($resultContents)->toBeArray()->toHaveCount(2);
121 | expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Part 1");
122 | expect($resultContents[1])->toBeInstanceOf(ImageContent::class)->data->toBe("imgdata");
123 | });
124 |
125 | it('formats mixed array from handler into array of Content objects', function () {
126 | $tool = RegisteredTool::make(
127 | Tool::make('mixed-array-tool', ['type' => 'object', 'properties' => []]),
128 | [ToolHandlerFixture::class, 'returnMixedArray']
129 | );
130 | $resultContents = $tool->call($this->container, [], $this->context);
131 |
132 | expect($resultContents)->toBeArray()->toHaveCount(8);
133 |
134 | expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("A raw string");
135 | expect($resultContents[1])->toBeInstanceOf(TextContent::class)->text->toBe("A TextContent object"); // Original TextContent is preserved
136 | expect($resultContents[2])->toBeInstanceOf(TextContent::class)->text->toBe("123");
137 | expect($resultContents[3])->toBeInstanceOf(TextContent::class)->text->toBe("true");
138 | expect($resultContents[4])->toBeInstanceOf(TextContent::class)->text->toBe("(null)");
139 | expect($resultContents[5])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode(['nested_key' => 'nested_value', 'sub_array' => [4, 5]], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
140 | expect($resultContents[6])->toBeInstanceOf(ImageContent::class)->data->toBe("img_data_mixed"); // Original ImageContent is preserved
141 | expect($resultContents[7])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode((object)['obj_prop' => 'obj_val'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
142 | });
143 |
144 | it('formats empty array from handler into TextContent with "[]"', function () {
145 | $tool = RegisteredTool::make(
146 | Tool::make('empty-array-tool', ['type' => 'object', 'properties' => []]),
147 | [ToolHandlerFixture::class, 'returnEmptyArray']
148 | );
149 | $resultContents = $tool->call($this->container, [], $this->context);
150 |
151 | expect($resultContents)->toBeArray()->toHaveCount(1);
152 | expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('[]');
153 | });
154 |
155 | it('throws JsonException during formatResult if handler returns unencodable value', function () {
156 | $tool = RegisteredTool::make(
157 | Tool::make('unencodable-tool', ['type' => 'object', 'properties' => []]),
158 | [ToolHandlerFixture::class, 'toolUnencodableResult']
159 | );
160 | $tool->call($this->container, [], $this->context);
161 | })->throws(JsonException::class);
162 |
163 | it('re-throws exceptions from handler execution wrapped in McpServerException from handle()', function () {
164 | $tool = RegisteredTool::make(
165 | Tool::make('exception-tool', ['type' => 'object', 'properties' => []]),
166 | [ToolHandlerFixture::class, 'toolThatThrows']
167 | );
168 |
169 | $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->once()->andReturn(new ToolHandlerFixture());
170 |
171 | $tool->call($this->container, [], $this->context);
172 | })->throws(InvalidArgumentException::class, "Something went wrong in the tool.");
173 |
```
--------------------------------------------------------------------------------
/src/Transports/StdioServerTransport.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Transports;
6 |
7 | use Evenement\EventEmitterTrait;
8 | use PhpMcp\Schema\JsonRpc\Parser;
9 | use PhpMcp\Server\Contracts\LoggerAwareInterface;
10 | use PhpMcp\Server\Contracts\LoopAwareInterface;
11 | use PhpMcp\Server\Contracts\ServerTransportInterface;
12 | use PhpMcp\Server\Exception\TransportException;
13 | use PhpMcp\Schema\JsonRpc\Error;
14 | use PhpMcp\Schema\JsonRpc\Message;
15 | use Psr\Log\LoggerInterface;
16 | use Psr\Log\NullLogger;
17 | use React\ChildProcess\Process;
18 | use React\EventLoop\Loop;
19 | use React\EventLoop\LoopInterface;
20 | use React\Promise\Deferred;
21 | use React\Promise\PromiseInterface;
22 | use React\Stream\ReadableResourceStream;
23 | use React\Stream\ReadableStreamInterface;
24 | use React\Stream\WritableResourceStream;
25 | use React\Stream\WritableStreamInterface;
26 | use Throwable;
27 |
28 | use function React\Promise\reject;
29 |
30 | /**
31 | * Implementation of the STDIO server transport using ReactPHP Process and Streams.
32 | * Listens on STDIN, writes to STDOUT, and emits events for the Protocol.
33 | */
34 | class StdioServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface
35 | {
36 | use EventEmitterTrait;
37 |
38 | protected LoggerInterface $logger;
39 |
40 | protected LoopInterface $loop;
41 |
42 | protected ?Process $process = null;
43 |
44 | protected ?ReadableStreamInterface $stdin = null;
45 |
46 | protected ?WritableStreamInterface $stdout = null;
47 |
48 | protected string $buffer = '';
49 |
50 | protected bool $closing = false;
51 |
52 | protected bool $listening = false;
53 |
54 | private const CLIENT_ID = 'stdio';
55 |
56 | /**
57 | * Constructor takes optional stream resources.
58 | * Defaults to STDIN and STDOUT if not provided.
59 | * Dependencies like Logger and Loop are injected via setters.
60 | *
61 | * @param resource|null $inputStreamResource The readable resource (e.g., STDIN).
62 | * @param resource|null $outputStreamResource The writable resource (e.g., STDOUT).
63 | *
64 | * @throws TransportException If provided resources are invalid.
65 | */
66 | public function __construct(
67 | protected $inputStreamResource = STDIN,
68 | protected $outputStreamResource = STDOUT
69 | ) {
70 | if (str_contains(PHP_OS, 'WIN') && ($this->inputStreamResource === STDIN && $this->outputStreamResource === STDOUT)) {
71 | $message = 'STDIN and STDOUT are not supported as input and output stream resources' .
72 | 'on Windows due to PHP\'s limitations with non blocking pipes.' .
73 | 'Please use WSL or HttpServerTransport, or if you are advanced, provide your own stream resources.';
74 |
75 | throw new TransportException($message);
76 | }
77 |
78 | // if (str_contains(PHP_OS, 'WIN')) {
79 | // $this->inputStreamResource = pclose(popen('winpty -c "'.$this->inputStreamResource.'"', 'r'));
80 | // $this->outputStreamResource = pclose(popen('winpty -c "'.$this->outputStreamResource.'"', 'w'));
81 | // }
82 |
83 | if (! is_resource($this->inputStreamResource) || get_resource_type($this->inputStreamResource) !== 'stream') {
84 | throw new TransportException('Invalid input stream resource provided.');
85 | }
86 |
87 | if (! is_resource($this->outputStreamResource) || get_resource_type($this->outputStreamResource) !== 'stream') {
88 | throw new TransportException('Invalid output stream resource provided.');
89 | }
90 |
91 | $this->logger = new NullLogger();
92 | $this->loop = Loop::get();
93 | }
94 |
95 | public function setLogger(LoggerInterface $logger): void
96 | {
97 | $this->logger = $logger;
98 | }
99 |
100 | public function setLoop(LoopInterface $loop): void
101 | {
102 | $this->loop = $loop;
103 | }
104 |
105 | /**
106 | * Starts listening on STDIN.
107 | *
108 | * @throws TransportException If already listening or streams cannot be opened.
109 | */
110 | public function listen(): void
111 | {
112 | if ($this->listening) {
113 | throw new TransportException('Stdio transport is already listening.');
114 | }
115 |
116 | if ($this->closing) {
117 | throw new TransportException('Cannot listen, transport is closing/closed.');
118 | }
119 |
120 | try {
121 | $this->stdin = new ReadableResourceStream($this->inputStreamResource, $this->loop);
122 | $this->stdout = new WritableResourceStream($this->outputStreamResource, $this->loop);
123 | } catch (Throwable $e) {
124 | $this->logger->error('Failed to open STDIN/STDOUT streams.', ['exception' => $e]);
125 | throw new TransportException("Failed to open standard streams: {$e->getMessage()}", 0, $e);
126 | }
127 |
128 | $this->stdin->on('data', function ($chunk) {
129 | $this->buffer .= $chunk;
130 | $this->processBuffer();
131 | });
132 |
133 | $this->stdin->on('error', function (Throwable $error) {
134 | $this->logger->error('STDIN stream error.', ['error' => $error->getMessage()]);
135 | $this->emit('error', [new TransportException("STDIN error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]);
136 | $this->close();
137 | });
138 |
139 | $this->stdin->on('close', function () {
140 | $this->logger->info('STDIN stream closed.');
141 | $this->emit('client_disconnected', [self::CLIENT_ID, 'STDIN Closed']);
142 | $this->close();
143 | });
144 |
145 | $this->stdout->on('error', function (Throwable $error) {
146 | $this->logger->error('STDOUT stream error.', ['error' => $error->getMessage()]);
147 | $this->emit('error', [new TransportException("STDOUT error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]);
148 | $this->close();
149 | });
150 |
151 | try {
152 | $signalHandler = function (int $signal) {
153 | $this->logger->info("Received signal {$signal}, shutting down.");
154 | $this->close();
155 | };
156 | $this->loop->addSignal(SIGTERM, $signalHandler);
157 | $this->loop->addSignal(SIGINT, $signalHandler);
158 | } catch (Throwable $e) {
159 | $this->logger->debug('Signal handling not supported by current event loop.');
160 | }
161 |
162 | $this->logger->info('Server is up and listening on STDIN 🚀');
163 |
164 | $this->listening = true;
165 | $this->closing = false;
166 | $this->emit('ready');
167 | $this->emit('client_connected', [self::CLIENT_ID]);
168 | }
169 |
170 | /** Processes the internal buffer to find complete lines/frames. */
171 | private function processBuffer(): void
172 | {
173 | while (str_contains($this->buffer, "\n")) {
174 | $pos = strpos($this->buffer, "\n");
175 | $line = substr($this->buffer, 0, $pos);
176 | $this->buffer = substr($this->buffer, $pos + 1);
177 |
178 | $trimmedLine = trim($line);
179 | if (empty($trimmedLine)) {
180 | continue;
181 | }
182 |
183 | try {
184 | $message = Parser::parse($trimmedLine);
185 | } catch (Throwable $e) {
186 | $this->logger->error('Error parsing message', ['exception' => $e]);
187 | $error = Error::forParseError("Invalid JSON: " . $e->getMessage());
188 | $this->sendMessage($error, self::CLIENT_ID);
189 | continue;
190 | }
191 |
192 | $this->emit('message', [$message, self::CLIENT_ID]);
193 | }
194 | }
195 |
196 | /**
197 | * Sends a raw, framed message to STDOUT.
198 | */
199 | public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface
200 | {
201 | if ($this->closing || ! $this->stdout || ! $this->stdout->isWritable()) {
202 | return reject(new TransportException('Stdio transport is closed or STDOUT is not writable.'));
203 | }
204 |
205 | $deferred = new Deferred();
206 | $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
207 | $written = $this->stdout->write($json . "\n");
208 |
209 | if ($written) {
210 | $deferred->resolve(null);
211 | } else {
212 | $this->logger->debug('STDOUT buffer full, waiting for drain.');
213 | $this->stdout->once('drain', function () use ($deferred) {
214 | $this->logger->debug('STDOUT drained.');
215 | $deferred->resolve(null);
216 | });
217 | }
218 |
219 | return $deferred->promise();
220 | }
221 |
222 | /**
223 | * Stops listening and cleans up resources.
224 | */
225 | public function close(): void
226 | {
227 | if ($this->closing) {
228 | return;
229 | }
230 | $this->closing = true;
231 | $this->listening = false;
232 | $this->logger->info('Closing Stdio transport...');
233 |
234 | $this->stdin?->close();
235 | $this->stdout?->close();
236 |
237 | $this->stdin = null;
238 | $this->stdout = null;
239 |
240 | $this->emit('close', ['StdioTransport closed.']);
241 | $this->removeAllListeners();
242 | }
243 | }
244 |
```
--------------------------------------------------------------------------------
/src/Elements/RegisteredResource.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Elements;
6 |
7 | use PhpMcp\Schema\Content\BlobResourceContents;
8 | use PhpMcp\Schema\Content\EmbeddedResource;
9 | use PhpMcp\Schema\Content\ResourceContents;
10 | use PhpMcp\Schema\Content\TextResourceContents;
11 | use PhpMcp\Schema\Resource;
12 | use PhpMcp\Server\Context;
13 | use Psr\Container\ContainerInterface;
14 | use Throwable;
15 |
16 | class RegisteredResource extends RegisteredElement
17 | {
18 | public function __construct(
19 | public readonly Resource $schema,
20 | callable|array|string $handler,
21 | bool $isManual = false,
22 | ) {
23 | parent::__construct($handler, $isManual);
24 | }
25 |
26 | public static function make(Resource $schema, callable|array|string $handler, bool $isManual = false): self
27 | {
28 | return new self($schema, $handler, $isManual);
29 | }
30 |
31 | /**
32 | * Reads the resource content.
33 | *
34 | * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
35 | */
36 | public function read(ContainerInterface $container, string $uri, Context $context): array
37 | {
38 | $result = $this->handle($container, ['uri' => $uri], $context);
39 |
40 | return $this->formatResult($result, $uri, $this->schema->mimeType);
41 | }
42 |
43 | /**
44 | * Formats the raw result of a resource read operation into MCP ResourceContent items.
45 | *
46 | * @param mixed $readResult The raw result from the resource handler method.
47 | * @param string $uri The URI of the resource that was read.
48 | * @param ?string $mimeType The MIME type from the ResourceDefinition.
49 | * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
50 | *
51 | * @throws \RuntimeException If the result cannot be formatted.
52 | *
53 | * Supported result types:
54 | * - ResourceContent: Used as-is
55 | * - EmbeddedResource: Resource is extracted from the EmbeddedResource
56 | * - string: Converted to text content with guessed or provided MIME type
57 | * - stream resource: Read and converted to blob with provided MIME type
58 | * - array with 'blob' key: Used as blob content
59 | * - array with 'text' key: Used as text content
60 | * - SplFileInfo: Read and converted to blob
61 | * - array: Converted to JSON if MIME type is application/json or contains 'json'
62 | * For other MIME types, will try to convert to JSON with a warning
63 | */
64 | protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array
65 | {
66 | if ($readResult instanceof ResourceContents) {
67 | return [$readResult];
68 | }
69 |
70 | if ($readResult instanceof EmbeddedResource) {
71 | return [$readResult->resource];
72 | }
73 |
74 | if (is_array($readResult)) {
75 | if (empty($readResult)) {
76 | return [TextResourceContents::make($uri, 'application/json', '[]')];
77 | }
78 |
79 | $allAreResourceContents = true;
80 | $hasResourceContents = false;
81 | $allAreEmbeddedResource = true;
82 | $hasEmbeddedResource = false;
83 |
84 | foreach ($readResult as $item) {
85 | if ($item instanceof ResourceContents) {
86 | $hasResourceContents = true;
87 | $allAreEmbeddedResource = false;
88 | } elseif ($item instanceof EmbeddedResource) {
89 | $hasEmbeddedResource = true;
90 | $allAreResourceContents = false;
91 | } else {
92 | $allAreResourceContents = false;
93 | $allAreEmbeddedResource = false;
94 | }
95 | }
96 |
97 | if ($allAreResourceContents && $hasResourceContents) {
98 | return $readResult;
99 | }
100 |
101 | if ($allAreEmbeddedResource && $hasEmbeddedResource) {
102 | return array_map(fn($item) => $item->resource, $readResult);
103 | }
104 |
105 | if ($hasResourceContents || $hasEmbeddedResource) {
106 | $result = [];
107 | foreach ($readResult as $item) {
108 | if ($item instanceof ResourceContents) {
109 | $result[] = $item;
110 | } elseif ($item instanceof EmbeddedResource) {
111 | $result[] = $item->resource;
112 | } else {
113 | $result = array_merge($result, $this->formatResult($item, $uri, $mimeType));
114 | }
115 | }
116 | return $result;
117 | }
118 | }
119 |
120 | if (is_string($readResult)) {
121 | $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult);
122 |
123 | return [TextResourceContents::make($uri, $mimeType, $readResult)];
124 | }
125 |
126 | if (is_resource($readResult) && get_resource_type($readResult) === 'stream') {
127 | $result = BlobResourceContents::fromStream(
128 | $uri,
129 | $readResult,
130 | $mimeType ?? 'application/octet-stream'
131 | );
132 |
133 | @fclose($readResult);
134 |
135 | return [$result];
136 | }
137 |
138 | if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) {
139 | $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream';
140 |
141 | return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])];
142 | }
143 |
144 | if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) {
145 | $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain';
146 |
147 | return [TextResourceContents::make($uri, $mimeType, $readResult['text'])];
148 | }
149 |
150 | if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) {
151 | if ($mimeType && str_contains(strtolower($mimeType), 'text')) {
152 | return [TextResourceContents::make($uri, $mimeType, file_get_contents($readResult->getPathname()))];
153 | }
154 |
155 | return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)];
156 | }
157 |
158 | if (is_array($readResult)) {
159 | if ($mimeType && (str_contains(strtolower($mimeType), 'json') ||
160 | $mimeType === 'application/json')) {
161 | try {
162 | $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
163 |
164 | return [TextResourceContents::make($uri, $mimeType, $jsonString)];
165 | } catch (\JsonException $e) {
166 | throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
167 | }
168 | }
169 |
170 | try {
171 | $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
172 | $mimeType = $mimeType ?? 'application/json';
173 |
174 | return [TextResourceContents::make($uri, $mimeType, $jsonString)];
175 | } catch (\JsonException $e) {
176 | throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
177 | }
178 | }
179 |
180 | throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult));
181 | }
182 |
183 | /** Guesses MIME type from string content (very basic) */
184 | private function guessMimeTypeFromString(string $content): string
185 | {
186 | $trimmed = ltrim($content);
187 |
188 | if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) {
189 | if (str_contains($trimmed, '<html')) {
190 | return 'text/html';
191 | }
192 | if (str_contains($trimmed, '<?xml')) {
193 | return 'application/xml';
194 | }
195 |
196 | return 'text/plain';
197 | }
198 |
199 | if (str_starts_with($trimmed, '{') && str_ends_with(rtrim($content), '}')) {
200 | return 'application/json';
201 | }
202 |
203 | if (str_starts_with($trimmed, '[') && str_ends_with(rtrim($content), ']')) {
204 | return 'application/json';
205 | }
206 |
207 | return 'text/plain';
208 | }
209 |
210 | public function toArray(): array
211 | {
212 | return [
213 | 'schema' => $this->schema->toArray(),
214 | ...parent::toArray(),
215 | ];
216 | }
217 |
218 | public static function fromArray(array $data): self|false
219 | {
220 | try {
221 | if (! isset($data['schema']) || ! isset($data['handler'])) {
222 | return false;
223 | }
224 |
225 | return new self(
226 | Resource::fromArray($data['schema']),
227 | $data['handler'],
228 | $data['isManual'] ?? false,
229 | );
230 | } catch (Throwable $e) {
231 | return false;
232 | }
233 | }
234 | }
235 |
```
--------------------------------------------------------------------------------
/src/Defaults/FileCache.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Defaults;
4 |
5 | use DateInterval;
6 | use DateTimeImmutable;
7 | use InvalidArgumentException;
8 | use Psr\SimpleCache\CacheInterface;
9 | use Throwable;
10 |
11 | /**
12 | * Basic PSR-16 file cache implementation.
13 | *
14 | * Stores cache entries serialized in a JSON file.
15 | * Uses file locking for basic concurrency control during writes.
16 | * Not recommended for high-concurrency environments.
17 | */
18 | class FileCache implements CacheInterface
19 | {
20 | /**
21 | * @param string $cacheFile Absolute path to the cache file.
22 | * The directory will be created if it doesn't exist.
23 | * @param int $filePermission Optional file mode (octal) for the cache file (default: 0664).
24 | * @param int $dirPermission Optional directory mode (octal) for the cache directory (default: 0775).
25 | */
26 | public function __construct(
27 | private readonly string $cacheFile,
28 | private readonly int $filePermission = 0664,
29 | private readonly int $dirPermission = 0775
30 | ) {
31 | $this->ensureDirectoryExists(dirname($this->cacheFile));
32 | }
33 |
34 | // ---------------------------------------------------------------------
35 | // PSR-16 Methods
36 | // ---------------------------------------------------------------------
37 |
38 | public function get(string $key, mixed $default = null): mixed
39 | {
40 | $data = $this->readCacheFile();
41 | $key = $this->sanitizeKey($key);
42 |
43 | if (! isset($data[$key])) {
44 | return $default;
45 | }
46 |
47 | if ($this->isExpired($data[$key]['expiry'])) {
48 | $this->delete($key); // Clean up expired entry
49 |
50 | return $default;
51 | }
52 |
53 | return $data[$key]['value'] ?? $default;
54 | }
55 |
56 | public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool
57 | {
58 | $data = $this->readCacheFile();
59 | $key = $this->sanitizeKey($key);
60 |
61 | $data[$key] = [
62 | 'value' => $value,
63 | 'expiry' => $this->calculateExpiry($ttl),
64 | ];
65 |
66 | return $this->writeCacheFile($data);
67 | }
68 |
69 | public function delete(string $key): bool
70 | {
71 | $data = $this->readCacheFile();
72 | $key = $this->sanitizeKey($key);
73 |
74 | if (isset($data[$key])) {
75 | unset($data[$key]);
76 |
77 | return $this->writeCacheFile($data);
78 | }
79 |
80 | return true; // Key didn't exist, considered successful delete
81 | }
82 |
83 | public function clear(): bool
84 | {
85 | // Write an empty array to the file
86 | return $this->writeCacheFile([]);
87 | }
88 |
89 | public function getMultiple(iterable $keys, mixed $default = null): iterable
90 | {
91 | $keys = $this->iterableToArray($keys);
92 | $this->validateKeys($keys);
93 |
94 | $data = $this->readCacheFile();
95 | $results = [];
96 | $needsWrite = false;
97 |
98 | foreach ($keys as $key) {
99 | $sanitizedKey = $this->sanitizeKey($key);
100 | if (! isset($data[$sanitizedKey])) {
101 | $results[$key] = $default;
102 |
103 | continue;
104 | }
105 |
106 | if ($this->isExpired($data[$sanitizedKey]['expiry'])) {
107 | unset($data[$sanitizedKey]); // Clean up expired entry
108 | $needsWrite = true;
109 | $results[$key] = $default;
110 |
111 | continue;
112 | }
113 |
114 | $results[$key] = $data[$sanitizedKey]['value'] ?? $default;
115 | }
116 |
117 | if ($needsWrite) {
118 | $this->writeCacheFile($data);
119 | }
120 |
121 | return $results;
122 | }
123 |
124 | public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool
125 | {
126 | $values = $this->iterableToArray($values);
127 | $this->validateKeys(array_keys($values));
128 |
129 | $data = $this->readCacheFile();
130 | $expiry = $this->calculateExpiry($ttl);
131 |
132 | foreach ($values as $key => $value) {
133 | $sanitizedKey = $this->sanitizeKey((string) $key);
134 | $data[$sanitizedKey] = [
135 | 'value' => $value,
136 | 'expiry' => $expiry,
137 | ];
138 | }
139 |
140 | return $this->writeCacheFile($data);
141 | }
142 |
143 | public function deleteMultiple(iterable $keys): bool
144 | {
145 | $keys = $this->iterableToArray($keys);
146 | $this->validateKeys($keys);
147 |
148 | $data = $this->readCacheFile();
149 | $deleted = false;
150 |
151 | foreach ($keys as $key) {
152 | $sanitizedKey = $this->sanitizeKey($key);
153 | if (isset($data[$sanitizedKey])) {
154 | unset($data[$sanitizedKey]);
155 | $deleted = true;
156 | }
157 | }
158 |
159 | if ($deleted) {
160 | return $this->writeCacheFile($data);
161 | }
162 |
163 | return true; // No keys existed or no changes made
164 | }
165 |
166 | public function has(string $key): bool
167 | {
168 | $data = $this->readCacheFile();
169 | $key = $this->sanitizeKey($key);
170 |
171 | if (! isset($data[$key])) {
172 | return false;
173 | }
174 |
175 | if ($this->isExpired($data[$key]['expiry'])) {
176 | $this->delete($key); // Clean up expired
177 |
178 | return false;
179 | }
180 |
181 | return true;
182 | }
183 |
184 | // ---------------------------------------------------------------------
185 | // Internal Methods
186 | // ---------------------------------------------------------------------
187 |
188 | private function readCacheFile(): array
189 | {
190 | if (! file_exists($this->cacheFile) || filesize($this->cacheFile) === 0) {
191 | return [];
192 | }
193 |
194 | $handle = @fopen($this->cacheFile, 'rb');
195 | if ($handle === false) {
196 | return [];
197 | }
198 |
199 | try {
200 | if (! flock($handle, LOCK_SH)) {
201 | return [];
202 | }
203 | $content = stream_get_contents($handle);
204 | flock($handle, LOCK_UN);
205 |
206 | if ($content === false || $content === '') {
207 | return [];
208 | }
209 |
210 | $data = unserialize($content);
211 | if ($data === false) {
212 | return [];
213 | }
214 |
215 | return $data;
216 | } finally {
217 | if (is_resource($handle)) {
218 | fclose($handle);
219 | }
220 | }
221 | }
222 |
223 | private function writeCacheFile(array $data): bool
224 | {
225 | $jsonData = serialize($data);
226 |
227 | if ($jsonData === false) {
228 | return false;
229 | }
230 |
231 | $handle = @fopen($this->cacheFile, 'cb');
232 | if ($handle === false) {
233 | return false;
234 | }
235 |
236 | try {
237 | if (! flock($handle, LOCK_EX)) {
238 | return false;
239 | }
240 | if (! ftruncate($handle, 0)) {
241 | return false;
242 | }
243 | if (fwrite($handle, $jsonData) === false) {
244 | return false;
245 | }
246 | fflush($handle);
247 | flock($handle, LOCK_UN);
248 | @chmod($this->cacheFile, $this->filePermission);
249 |
250 | return true;
251 | } catch (Throwable $e) {
252 | flock($handle, LOCK_UN); // Ensure lock release on error
253 |
254 | return false;
255 | } finally {
256 | if (is_resource($handle)) {
257 | fclose($handle);
258 | }
259 | }
260 | }
261 |
262 | private function ensureDirectoryExists(string $directory): void
263 | {
264 | if (! is_dir($directory)) {
265 | if (! @mkdir($directory, $this->dirPermission, true)) {
266 | throw new InvalidArgumentException("Cache directory does not exist and could not be created: {$directory}");
267 | }
268 | @chmod($directory, $this->dirPermission);
269 | }
270 | }
271 |
272 | private function calculateExpiry(DateInterval|int|null $ttl): ?int
273 | {
274 | if ($ttl === null) {
275 | return null;
276 | }
277 | $now = time();
278 | if (is_int($ttl)) {
279 | return $ttl <= 0 ? $now - 1 : $now + $ttl;
280 | }
281 | if ($ttl instanceof DateInterval) {
282 | try {
283 | return (new DateTimeImmutable())->add($ttl)->getTimestamp();
284 | } catch (Throwable $e) {
285 | return null;
286 | }
287 | }
288 | throw new InvalidArgumentException('Invalid TTL type provided. Must be null, int, or DateInterval.');
289 | }
290 |
291 | private function isExpired(?int $expiry): bool
292 | {
293 | return $expiry !== null && time() >= $expiry;
294 | }
295 |
296 | private function sanitizeKey(string $key): string
297 | {
298 | if ($key === '') {
299 | throw new InvalidArgumentException('Cache key cannot be empty.');
300 | }
301 |
302 | // PSR-16 validation (optional stricter check)
303 | // if (preg_match('/[{}()\/@:]/', $key)) {
304 | // throw new InvalidArgumentException("Cache key \"{$key}\" contains reserved characters.");
305 | // }
306 | return $key;
307 | }
308 |
309 | private function validateKeys(array $keys): void
310 | {
311 | foreach ($keys as $key) {
312 | if (! is_string($key)) {
313 | throw new InvalidArgumentException('Cache key must be a string, got ' . gettype($key));
314 | }
315 | $this->sanitizeKey($key);
316 | }
317 | }
318 |
319 | private function iterableToArray(iterable $iterable): array
320 | {
321 | if (is_array($iterable)) {
322 | return $iterable;
323 | }
324 |
325 | return iterator_to_array($iterable);
326 | }
327 | }
328 |
```
--------------------------------------------------------------------------------
/tests/Integration/DiscoveryTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | use PhpMcp\Server\Defaults\EnumCompletionProvider;
4 | use PhpMcp\Server\Defaults\ListCompletionProvider;
5 | use PhpMcp\Server\Elements\RegisteredPrompt;
6 | use PhpMcp\Server\Elements\RegisteredResource;
7 | use PhpMcp\Server\Elements\RegisteredResourceTemplate;
8 | use PhpMcp\Server\Elements\RegisteredTool;
9 | use PhpMcp\Server\Registry;
10 | use PhpMcp\Server\Tests\Fixtures\Discovery\DiscoverableToolHandler;
11 | use PhpMcp\Server\Tests\Fixtures\Discovery\InvocablePromptFixture;
12 | use PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceFixture;
13 | use PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceTemplateFixture;
14 | use PhpMcp\Server\Tests\Fixtures\Discovery\InvocableToolFixture;
15 | use PhpMcp\Server\Utils\Discoverer;
16 | use PhpMcp\Server\Utils\DocBlockParser;
17 | use PhpMcp\Server\Utils\SchemaGenerator;
18 | use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture;
19 | use Psr\Log\NullLogger;
20 |
21 | beforeEach(function () {
22 | $logger = new NullLogger();
23 | $this->registry = new Registry($logger);
24 |
25 | $docBlockParser = new DocBlockParser($logger);
26 | $schemaGenerator = new SchemaGenerator($docBlockParser);
27 | $this->discoverer = new Discoverer($this->registry, $logger, $docBlockParser, $schemaGenerator);
28 |
29 | $this->fixtureBasePath = realpath(__DIR__ . '/../Fixtures');
30 | });
31 |
32 | it('discovers all element types correctly from fixture files', function () {
33 | $scanDir = 'Discovery';
34 |
35 | $this->discoverer->discover($this->fixtureBasePath, [$scanDir]);
36 |
37 | $tools = $this->registry->getTools();
38 | expect($tools)->toHaveCount(4); // greet_user, repeatAction, InvokableCalculator, hidden_subdir_tool
39 |
40 | $greetUserTool = $this->registry->getTool('greet_user');
41 | expect($greetUserTool)->toBeInstanceOf(RegisteredTool::class)
42 | ->and($greetUserTool->isManual)->toBeFalse()
43 | ->and($greetUserTool->schema->name)->toBe('greet_user')
44 | ->and($greetUserTool->schema->description)->toBe('Greets a user by name.')
45 | ->and($greetUserTool->handler)->toBe([DiscoverableToolHandler::class, 'greet']);
46 | expect($greetUserTool->schema->inputSchema['properties'] ?? [])->toHaveKey('name');
47 |
48 | $repeatActionTool = $this->registry->getTool('repeatAction');
49 | expect($repeatActionTool)->toBeInstanceOf(RegisteredTool::class)
50 | ->and($repeatActionTool->isManual)->toBeFalse()
51 | ->and($repeatActionTool->schema->description)->toBe('A tool with more complex parameters and inferred name/description.')
52 | ->and($repeatActionTool->schema->annotations->readOnlyHint)->toBeTrue();
53 | expect(array_keys($repeatActionTool->schema->inputSchema['properties'] ?? []))->toEqual(['count', 'loudly', 'mode']);
54 |
55 | $invokableCalcTool = $this->registry->getTool('InvokableCalculator');
56 | expect($invokableCalcTool)->toBeInstanceOf(RegisteredTool::class)
57 | ->and($invokableCalcTool->isManual)->toBeFalse()
58 | ->and($invokableCalcTool->handler)->toBe([InvocableToolFixture::class, '__invoke']);
59 |
60 | expect($this->registry->getTool('private_tool_should_be_ignored'))->toBeNull();
61 | expect($this->registry->getTool('protected_tool_should_be_ignored'))->toBeNull();
62 | expect($this->registry->getTool('static_tool_should_be_ignored'))->toBeNull();
63 |
64 |
65 | $resources = $this->registry->getResources();
66 | expect($resources)->toHaveCount(3); // app_version, ui_settings_discovered, InvocableResourceFixture
67 |
68 | $appVersionRes = $this->registry->getResource('app://info/version');
69 | expect($appVersionRes)->toBeInstanceOf(RegisteredResource::class)
70 | ->and($appVersionRes->isManual)->toBeFalse()
71 | ->and($appVersionRes->schema->name)->toBe('app_version')
72 | ->and($appVersionRes->schema->mimeType)->toBe('text/plain');
73 |
74 | $invokableStatusRes = $this->registry->getResource('invokable://config/status');
75 | expect($invokableStatusRes)->toBeInstanceOf(RegisteredResource::class)
76 | ->and($invokableStatusRes->isManual)->toBeFalse()
77 | ->and($invokableStatusRes->handler)->toBe([InvocableResourceFixture::class, '__invoke']);
78 |
79 |
80 | $prompts = $this->registry->getPrompts();
81 | expect($prompts)->toHaveCount(4); // creative_story_prompt, simpleQuestionPrompt, InvocablePromptFixture, content_creator
82 |
83 | $storyPrompt = $this->registry->getPrompt('creative_story_prompt');
84 | expect($storyPrompt)->toBeInstanceOf(RegisteredPrompt::class)
85 | ->and($storyPrompt->isManual)->toBeFalse()
86 | ->and($storyPrompt->schema->arguments)->toHaveCount(2) // genre, lengthWords
87 | ->and($storyPrompt->completionProviders['genre'])->toBe(CompletionProviderFixture::class);
88 |
89 | $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); // Inferred name
90 | expect($simplePrompt)->toBeInstanceOf(RegisteredPrompt::class)
91 | ->and($simplePrompt->isManual)->toBeFalse();
92 |
93 | $invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt');
94 | expect($invokableGreeter)->toBeInstanceOf(RegisteredPrompt::class)
95 | ->and($invokableGreeter->isManual)->toBeFalse()
96 | ->and($invokableGreeter->handler)->toBe([InvocablePromptFixture::class, '__invoke']);
97 |
98 | $contentCreatorPrompt = $this->registry->getPrompt('content_creator');
99 | expect($contentCreatorPrompt)->toBeInstanceOf(RegisteredPrompt::class)
100 | ->and($contentCreatorPrompt->isManual)->toBeFalse()
101 | ->and($contentCreatorPrompt->completionProviders)->toHaveCount(3);
102 |
103 | $templates = $this->registry->getResourceTemplates();
104 | expect($templates)->toHaveCount(4); // product_details_template, getFileContent, InvocableResourceTemplateFixture, content_template
105 |
106 | $productTemplate = $this->registry->getResourceTemplate('product://{region}/details/{productId}');
107 | expect($productTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class)
108 | ->and($productTemplate->isManual)->toBeFalse()
109 | ->and($productTemplate->schema->name)->toBe('product_details_template')
110 | ->and($productTemplate->completionProviders['region'])->toBe(CompletionProviderFixture::class);
111 | expect($productTemplate->getVariableNames())->toEqualCanonicalizing(['region', 'productId']);
112 |
113 | $invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}');
114 | expect($invokableUserTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class)
115 | ->and($invokableUserTemplate->isManual)->toBeFalse()
116 | ->and($invokableUserTemplate->handler)->toBe([InvocableResourceTemplateFixture::class, '__invoke']);
117 | });
118 |
119 | it('does not discover elements from excluded directories', function () {
120 | $this->discoverer->discover($this->fixtureBasePath, ['Discovery']);
121 |
122 | expect($this->registry->getTool('hidden_subdir_tool'))->not->toBeNull();
123 |
124 | $this->registry->clear();
125 |
126 | $this->discoverer->discover($this->fixtureBasePath, ['Discovery'], ['SubDir']);
127 | expect($this->registry->getTool('hidden_subdir_tool'))->toBeNull();
128 | });
129 |
130 | it('handles empty directories or directories with no PHP files', function () {
131 | $this->discoverer->discover($this->fixtureBasePath, ['EmptyDir']);
132 | expect($this->registry->getTools())->toBeEmpty();
133 | });
134 |
135 | it('correctly infers names and descriptions from methods/classes if not set in attribute', function () {
136 | $this->discoverer->discover($this->fixtureBasePath, ['Discovery']);
137 |
138 | $repeatActionTool = $this->registry->getTool('repeatAction');
139 | expect($repeatActionTool->schema->name)->toBe('repeatAction'); // Method name
140 | expect($repeatActionTool->schema->description)->toBe('A tool with more complex parameters and inferred name/description.'); // Docblock summary
141 |
142 | $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt');
143 | expect($simplePrompt->schema->name)->toBe('simpleQuestionPrompt');
144 | expect($simplePrompt->schema->description)->toBeNull();
145 |
146 | $invokableCalc = $this->registry->getTool('InvokableCalculator'); // Name comes from Attr
147 | expect($invokableCalc->schema->name)->toBe('InvokableCalculator');
148 | expect($invokableCalc->schema->description)->toBe('An invokable calculator tool.');
149 | });
150 |
151 | it('discovers enhanced completion providers with values and enum attributes', function () {
152 | $this->discoverer->discover($this->fixtureBasePath, ['Discovery']);
153 |
154 | $contentPrompt = $this->registry->getPrompt('content_creator');
155 | expect($contentPrompt)->toBeInstanceOf(RegisteredPrompt::class);
156 |
157 | expect($contentPrompt->completionProviders)->toHaveCount(3);
158 |
159 | $typeProvider = $contentPrompt->completionProviders['type'];
160 | expect($typeProvider)->toBeInstanceOf(ListCompletionProvider::class);
161 |
162 | $statusProvider = $contentPrompt->completionProviders['status'];
163 | expect($statusProvider)->toBeInstanceOf(EnumCompletionProvider::class);
164 |
165 | $priorityProvider = $contentPrompt->completionProviders['priority'];
166 | expect($priorityProvider)->toBeInstanceOf(EnumCompletionProvider::class);
167 |
168 | $contentTemplate = $this->registry->getResourceTemplate('content://{category}/{slug}');
169 | expect($contentTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class);
170 | expect($contentTemplate->completionProviders)->toHaveCount(1);
171 |
172 | $categoryProvider = $contentTemplate->completionProviders['category'];
173 | expect($categoryProvider)->toBeInstanceOf(ListCompletionProvider::class);
174 | });
175 |
```
--------------------------------------------------------------------------------
/tests/Unit/Session/SessionManagerTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Unit\Session;
4 |
5 | use Mockery;
6 | use Mockery\MockInterface;
7 | use PhpMcp\Server\Contracts\SessionHandlerInterface;
8 | use PhpMcp\Server\Contracts\SessionInterface;
9 | use PhpMcp\Server\Session\ArraySessionHandler;
10 | use PhpMcp\Server\Session\SessionManager;
11 | use PhpMcp\Server\Tests\Mocks\Clock\FixedClock;
12 | use Psr\Log\LoggerInterface;
13 | use React\EventLoop\Loop;
14 | use React\EventLoop\LoopInterface;
15 | use React\EventLoop\TimerInterface;
16 |
17 | const SESSION_ID_MGR_1 = 'manager-session-1';
18 | const SESSION_ID_MGR_2 = 'manager-session-2';
19 | const DEFAULT_TTL_MGR = 3600;
20 | const GC_INTERVAL_MGR = 5;
21 |
22 | beforeEach(function () {
23 | /** @var MockInterface&SessionHandlerInterface $sessionHandler */
24 | $this->sessionHandler = Mockery::mock(SessionHandlerInterface::class);
25 | /** @var MockInterface&LoggerInterface $logger */
26 | $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing();
27 | $this->loop = Loop::get();
28 |
29 | $this->sessionManager = new SessionManager(
30 | $this->sessionHandler,
31 | $this->logger,
32 | $this->loop,
33 | DEFAULT_TTL_MGR
34 | );
35 |
36 | $this->sessionHandler->shouldReceive('read')->with(Mockery::any())->andReturn(false)->byDefault();
37 | $this->sessionHandler->shouldReceive('write')->with(Mockery::any(), Mockery::any())->andReturn(true)->byDefault();
38 | $this->sessionHandler->shouldReceive('destroy')->with(Mockery::any())->andReturn(true)->byDefault();
39 | $this->sessionHandler->shouldReceive('gc')->with(Mockery::any())->andReturn([])->byDefault();
40 | });
41 |
42 | it('creates a new session with default hydrated values and saves it', function () {
43 | $this->sessionHandler->shouldReceive('write')
44 | ->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) {
45 | $data = json_decode($dataJson, true);
46 | expect($data['initialized'])->toBeFalse();
47 | expect($data['client_info'])->toBeNull();
48 | expect($data['protocol_version'])->toBeNull();
49 | expect($data['subscriptions'])->toEqual([]);
50 | expect($data['message_queue'])->toEqual([]);
51 | expect($data['log_level'])->toBeNull();
52 | return true;
53 | }))->once()->andReturn(true);
54 |
55 | $sessionCreatedEmitted = false;
56 | $emittedSessionId = null;
57 | $emittedSessionObj = null;
58 | $this->sessionManager->on('session_created', function ($id, $session) use (&$sessionCreatedEmitted, &$emittedSessionId, &$emittedSessionObj) {
59 | $sessionCreatedEmitted = true;
60 | $emittedSessionId = $id;
61 | $emittedSessionObj = $session;
62 | });
63 |
64 | $session = $this->sessionManager->createSession(SESSION_ID_MGR_1);
65 |
66 | expect($session)->toBeInstanceOf(SessionInterface::class);
67 | expect($session->getId())->toBe(SESSION_ID_MGR_1);
68 | expect($session->get('initialized'))->toBeFalse();
69 | $this->logger->shouldHaveReceived('info')->with('Session created', ['sessionId' => SESSION_ID_MGR_1]);
70 | expect($sessionCreatedEmitted)->toBeTrue();
71 | expect($emittedSessionId)->toBe(SESSION_ID_MGR_1);
72 | expect($emittedSessionObj)->toBe($session);
73 | });
74 |
75 | it('gets an existing session if handler read returns data', function () {
76 | $existingData = ['user_id' => 123, 'initialized' => true];
77 | $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->once()->andReturn(json_encode($existingData));
78 |
79 | $session = $this->sessionManager->getSession(SESSION_ID_MGR_1);
80 | expect($session)->toBeInstanceOf(SessionInterface::class);
81 | expect($session->getId())->toBe(SESSION_ID_MGR_1);
82 | expect($session->get('user_id'))->toBe(123);
83 | });
84 |
85 | it('returns null from getSession if session does not exist (handler read returns false)', function () {
86 | $this->sessionHandler->shouldReceive('read')->with('non-existent')->once()->andReturn(false);
87 | $session = $this->sessionManager->getSession('non-existent');
88 | expect($session)->toBeNull();
89 | });
90 |
91 | it('returns null from getSession if session data is empty after load', function () {
92 | $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->once()->andReturn(false);
93 | $session = $this->sessionManager->getSession(SESSION_ID_MGR_1);
94 | expect($session)->toBeNull();
95 | });
96 |
97 |
98 | it('deletes a session successfully and emits event', function () {
99 | $this->sessionHandler->shouldReceive('destroy')->with(SESSION_ID_MGR_1)->once()->andReturn(true);
100 |
101 | $sessionDeletedEmitted = false;
102 | $emittedSessionId = null;
103 | $this->sessionManager->on('session_deleted', function ($id) use (&$sessionDeletedEmitted, &$emittedSessionId) {
104 | $sessionDeletedEmitted = true;
105 | $emittedSessionId = $id;
106 | });
107 |
108 | $success = $this->sessionManager->deleteSession(SESSION_ID_MGR_1);
109 |
110 | expect($success)->toBeTrue();
111 | $this->logger->shouldHaveReceived('info')->with('Session deleted', ['sessionId' => SESSION_ID_MGR_1]);
112 | expect($sessionDeletedEmitted)->toBeTrue();
113 | expect($emittedSessionId)->toBe(SESSION_ID_MGR_1);
114 | });
115 |
116 | it('logs warning and does not emit event if deleteSession fails', function () {
117 | $this->sessionHandler->shouldReceive('destroy')->with(SESSION_ID_MGR_1)->once()->andReturn(false);
118 | $sessionDeletedEmitted = false;
119 | $this->sessionManager->on('session_deleted', function () use (&$sessionDeletedEmitted) {
120 | $sessionDeletedEmitted = true;
121 | });
122 |
123 | $success = $this->sessionManager->deleteSession(SESSION_ID_MGR_1);
124 |
125 | expect($success)->toBeFalse();
126 | $this->logger->shouldHaveReceived('warning')->with('Failed to delete session', ['sessionId' => SESSION_ID_MGR_1]);
127 | expect($sessionDeletedEmitted)->toBeFalse();
128 | });
129 |
130 | it('queues message for existing session', function () {
131 | $sessionData = ['message_queue' => []];
132 | $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode($sessionData));
133 | $message = '{"id":1}';
134 |
135 | $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) use ($message) {
136 | $data = json_decode($dataJson, true);
137 | expect($data['message_queue'])->toEqual([$message]);
138 | return true;
139 | }))->once()->andReturn(true);
140 |
141 | $this->sessionManager->queueMessage(SESSION_ID_MGR_1, $message);
142 | });
143 |
144 | it('does nothing on queueMessage if session does not exist', function () {
145 | $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false);
146 | $this->sessionHandler->shouldNotReceive('write');
147 | $this->sessionManager->queueMessage('no-such-session', '{"id":1}');
148 | });
149 |
150 | it('dequeues messages from existing session', function () {
151 | $messages = ['{"id":1}', '{"id":2}'];
152 | $sessionData = ['message_queue' => $messages];
153 | $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode($sessionData));
154 | $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) {
155 | $data = json_decode($dataJson, true);
156 | expect($data['message_queue'])->toEqual([]);
157 | return true;
158 | }))->once()->andReturn(true);
159 |
160 | $dequeued = $this->sessionManager->dequeueMessages(SESSION_ID_MGR_1);
161 | expect($dequeued)->toEqual($messages);
162 | });
163 |
164 | it('returns empty array from dequeueMessages if session does not exist', function () {
165 | $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false);
166 | expect($this->sessionManager->dequeueMessages('no-such-session'))->toBe([]);
167 | });
168 |
169 | it('checks hasQueuedMessages for existing session', function () {
170 | $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode(['message_queue' => ['msg']]));
171 | expect($this->sessionManager->hasQueuedMessages(SESSION_ID_MGR_1))->toBeTrue();
172 |
173 | $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_2)->andReturn(json_encode(['message_queue' => []]));
174 | expect($this->sessionManager->hasQueuedMessages(SESSION_ID_MGR_2))->toBeFalse();
175 | });
176 |
177 | it('returns false from hasQueuedMessages if session does not exist', function () {
178 | $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false);
179 | expect($this->sessionManager->hasQueuedMessages('no-such-session'))->toBeFalse();
180 | });
181 |
182 | it('can stop GC timer on stopGcTimer ', function () {
183 | $loop = Mockery::mock(LoopInterface::class);
184 | $loop->shouldReceive('addPeriodicTimer')->with(Mockery::any(), Mockery::type('callable'))->once()->andReturn(Mockery::mock(TimerInterface::class));
185 | $loop->shouldReceive('cancelTimer')->with(Mockery::type(TimerInterface::class))->once();
186 |
187 | $manager = new SessionManager($this->sessionHandler, $this->logger, $loop);
188 | $manager->startGcTimer();
189 | $manager->stopGcTimer();
190 | });
191 |
192 | it('GC timer callback deletes expired sessions', function () {
193 | $clock = new FixedClock();
194 |
195 | $sessionHandler = new ArraySessionHandler(60, $clock);
196 | $sessionHandler->write('sess_expired', 'data');
197 |
198 | // $clock->addSeconds(100);
199 |
200 | $manager = new SessionManager(
201 | $sessionHandler,
202 | $this->logger,
203 | ttl: 30,
204 | gcInterval: 0.01
205 | );
206 |
207 | $session = $manager->getSession('sess_expired');
208 | expect($session)->toBeNull();
209 | });
210 |
211 |
212 | it('does not start GC timer if already started', function () {
213 | $this->loop = Mockery::mock(LoopInterface::class);
214 | $this->loop->shouldReceive('addPeriodicTimer')->once()->andReturn(Mockery::mock(TimerInterface::class));
215 |
216 | $manager = new SessionManager($this->sessionHandler, $this->logger, $this->loop);
217 | $manager->startGcTimer();
218 | });
219 |
```
--------------------------------------------------------------------------------
/src/Elements/RegisteredElement.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Elements;
6 |
7 | use InvalidArgumentException;
8 | use JsonSerializable;
9 | use PhpMcp\Server\Context;
10 | use PhpMcp\Server\Exception\McpServerException;
11 | use Psr\Container\ContainerInterface;
12 | use ReflectionException;
13 | use ReflectionFunctionAbstract;
14 | use ReflectionMethod;
15 | use ReflectionNamedType;
16 | use ReflectionParameter;
17 | use Throwable;
18 | use TypeError;
19 |
20 | class RegisteredElement implements JsonSerializable
21 | {
22 | /** @var callable|array|string */
23 | public readonly mixed $handler;
24 | public readonly bool $isManual;
25 |
26 | public function __construct(
27 | callable|array|string $handler,
28 | bool $isManual = false,
29 | ) {
30 | $this->handler = $handler;
31 | $this->isManual = $isManual;
32 | }
33 |
34 | public function handle(ContainerInterface $container, array $arguments, Context $context): mixed
35 | {
36 | if (is_string($this->handler)) {
37 | if (class_exists($this->handler) && method_exists($this->handler, '__invoke')) {
38 | $reflection = new \ReflectionMethod($this->handler, '__invoke');
39 | $arguments = $this->prepareArguments($reflection, $arguments, $context);
40 | $instance = $container->get($this->handler);
41 | return call_user_func($instance, ...$arguments);
42 | }
43 |
44 | if (function_exists($this->handler)) {
45 | $reflection = new \ReflectionFunction($this->handler);
46 | $arguments = $this->prepareArguments($reflection, $arguments, $context);
47 | return call_user_func($this->handler, ...$arguments);
48 | }
49 | }
50 |
51 | if (is_callable($this->handler)) {
52 | $reflection = $this->getReflectionForCallable($this->handler);
53 | $arguments = $this->prepareArguments($reflection, $arguments, $context);
54 | return call_user_func($this->handler, ...$arguments);
55 | }
56 |
57 | if (is_array($this->handler)) {
58 | [$className, $methodName] = $this->handler;
59 | $reflection = new \ReflectionMethod($className, $methodName);
60 | $arguments = $this->prepareArguments($reflection, $arguments, $context);
61 |
62 | $instance = $container->get($className);
63 | return call_user_func([$instance, $methodName], ...$arguments);
64 | }
65 |
66 | throw new \InvalidArgumentException('Invalid handler type');
67 | }
68 |
69 |
70 | protected function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments, Context $context): array
71 | {
72 | $finalArgs = [];
73 |
74 | foreach ($reflection->getParameters() as $parameter) {
75 | // TODO: Handle variadic parameters.
76 | $paramName = $parameter->getName();
77 | $paramType = $parameter->getType();
78 | $paramPosition = $parameter->getPosition();
79 |
80 | if ($paramType instanceof ReflectionNamedType && $paramType->getName() === Context::class) {
81 | $finalArgs[$paramPosition] = $context;
82 |
83 | continue;
84 | }
85 |
86 | if (isset($arguments[$paramName])) {
87 | $argument = $arguments[$paramName];
88 | try {
89 | $finalArgs[$paramPosition] = $this->castArgumentType($argument, $parameter);
90 | } catch (InvalidArgumentException $e) {
91 | throw McpServerException::invalidParams($e->getMessage(), $e);
92 | } catch (Throwable $e) {
93 | throw McpServerException::internalError(
94 | "Error processing parameter `{$paramName}`: {$e->getMessage()}",
95 | $e
96 | );
97 | }
98 | } elseif ($parameter->isDefaultValueAvailable()) {
99 | $finalArgs[$paramPosition] = $parameter->getDefaultValue();
100 | } elseif ($parameter->allowsNull()) {
101 | $finalArgs[$paramPosition] = null;
102 | } elseif ($parameter->isOptional()) {
103 | continue;
104 | } else {
105 | $reflectionName = $reflection instanceof \ReflectionMethod
106 | ? $reflection->class . '::' . $reflection->name
107 | : 'Closure';
108 | throw McpServerException::internalError(
109 | "Missing required argument `{$paramName}` for {$reflectionName}."
110 | );
111 | }
112 | }
113 |
114 | return array_values($finalArgs);
115 | }
116 |
117 | /**
118 | * Gets a ReflectionMethod or ReflectionFunction for a callable.
119 | */
120 | private function getReflectionForCallable(callable $handler): \ReflectionMethod|\ReflectionFunction
121 | {
122 | if (is_string($handler)) {
123 | return new \ReflectionFunction($handler);
124 | }
125 |
126 | if ($handler instanceof \Closure) {
127 | return new \ReflectionFunction($handler);
128 | }
129 |
130 | if (is_array($handler) && count($handler) === 2) {
131 | [$class, $method] = $handler;
132 | return new \ReflectionMethod($class, $method);
133 | }
134 |
135 | throw new \InvalidArgumentException('Cannot create reflection for this callable type');
136 | }
137 |
138 | /**
139 | * Attempts type casting based on ReflectionParameter type hints.
140 | *
141 | * @throws InvalidArgumentException If casting is impossible for the required type.
142 | * @throws TypeError If internal PHP casting fails unexpectedly.
143 | */
144 | private function castArgumentType(mixed $argument, ReflectionParameter $parameter): mixed
145 | {
146 | $type = $parameter->getType();
147 |
148 | if ($argument === null) {
149 | if ($type && $type->allowsNull()) {
150 | return null;
151 | }
152 | }
153 |
154 | if (! $type instanceof ReflectionNamedType) {
155 | return $argument;
156 | }
157 |
158 | $typeName = $type->getName();
159 |
160 | if (enum_exists($typeName)) {
161 | if (is_object($argument) && $argument instanceof $typeName) {
162 | return $argument;
163 | }
164 |
165 | if (is_subclass_of($typeName, \BackedEnum::class)) {
166 | $value = $typeName::tryFrom($argument);
167 | if ($value === null) {
168 | throw new InvalidArgumentException(
169 | "Invalid value '{$argument}' for backed enum {$typeName}. Expected one of its backing values.",
170 | );
171 | }
172 | return $value;
173 | } else {
174 | if (is_string($argument)) {
175 | foreach ($typeName::cases() as $case) {
176 | if ($case->name === $argument) {
177 | return $case;
178 | }
179 | }
180 | $validNames = array_map(fn($c) => $c->name, $typeName::cases());
181 | throw new InvalidArgumentException(
182 | "Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: " . implode(', ', $validNames) . "."
183 | );
184 | } else {
185 | throw new InvalidArgumentException(
186 | "Invalid value type '{$argument}' for unit enum {$typeName}. Expected a string matching a case name."
187 | );
188 | }
189 | }
190 | }
191 |
192 | try {
193 | return match (strtolower($typeName)) {
194 | 'int', 'integer' => $this->castToInt($argument),
195 | 'string' => (string) $argument,
196 | 'bool', 'boolean' => $this->castToBoolean($argument),
197 | 'float', 'double' => $this->castToFloat($argument),
198 | 'array' => $this->castToArray($argument),
199 | default => $argument,
200 | };
201 | } catch (TypeError $e) {
202 | throw new InvalidArgumentException(
203 | "Value cannot be cast to required type `{$typeName}`.",
204 | 0,
205 | $e
206 | );
207 | }
208 | }
209 |
210 | /** Helper to cast strictly to boolean */
211 | private function castToBoolean(mixed $argument): bool
212 | {
213 | if (is_bool($argument)) {
214 | return $argument;
215 | }
216 | if ($argument === 1 || $argument === '1' || strtolower((string) $argument) === 'true') {
217 | return true;
218 | }
219 | if ($argument === 0 || $argument === '0' || strtolower((string) $argument) === 'false') {
220 | return false;
221 | }
222 | throw new InvalidArgumentException('Cannot cast value to boolean. Use true/false/1/0.');
223 | }
224 |
225 | /** Helper to cast strictly to integer */
226 | private function castToInt(mixed $argument): int
227 | {
228 | if (is_int($argument)) {
229 | return $argument;
230 | }
231 | if (is_numeric($argument) && floor((float) $argument) == $argument && ! is_string($argument)) {
232 | return (int) $argument;
233 | }
234 | if (is_string($argument) && ctype_digit(ltrim($argument, '-'))) {
235 | return (int) $argument;
236 | }
237 | throw new InvalidArgumentException('Cannot cast value to integer. Expected integer representation.');
238 | }
239 |
240 | /** Helper to cast strictly to float */
241 | private function castToFloat(mixed $argument): float
242 | {
243 | if (is_float($argument)) {
244 | return $argument;
245 | }
246 | if (is_int($argument)) {
247 | return (float) $argument;
248 | }
249 | if (is_numeric($argument)) {
250 | return (float) $argument;
251 | }
252 | throw new InvalidArgumentException('Cannot cast value to float. Expected numeric representation.');
253 | }
254 |
255 | /** Helper to cast strictly to array */
256 | private function castToArray(mixed $argument): array
257 | {
258 | if (is_array($argument)) {
259 | return $argument;
260 | }
261 | throw new InvalidArgumentException('Cannot cast value to array. Expected array.');
262 | }
263 |
264 | public function toArray(): array
265 | {
266 | return [
267 | 'handler' => $this->handler,
268 | 'isManual' => $this->isManual,
269 | ];
270 | }
271 |
272 | public function jsonSerialize(): array
273 | {
274 | return $this->toArray();
275 | }
276 | }
277 |
```
--------------------------------------------------------------------------------
/tests/Mocks/Clients/MockSseClient.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Mocks\Clients;
6 |
7 | use Psr\Http\Message\ResponseInterface;
8 | use React\EventLoop\Loop;
9 | use React\Http\Browser;
10 | use React\Promise\Deferred;
11 | use React\Promise\PromiseInterface;
12 | use React\Stream\ReadableStreamInterface;
13 |
14 | use function React\Promise\reject;
15 |
16 | class MockSseClient
17 | {
18 | public Browser $browser;
19 | private ?ReadableStreamInterface $stream = null;
20 | private string $buffer = '';
21 | private array $receivedMessages = []; // Stores decoded JSON-RPC messages
22 | private array $receivedSseEvents = []; // Stores raw SSE events (type, data, id)
23 | public ?string $endpointUrl = null; // The /message endpoint URL provided by server
24 | public ?string $clientId = null; // The clientId from the /message endpoint URL
25 | public ?ResponseInterface $lastConnectResponse = null; // Last connect response for header testing
26 |
27 | public function __construct(int $timeout = 2)
28 | {
29 | $this->browser = (new Browser())->withTimeout($timeout);
30 | }
31 |
32 | public function connect(string $sseBaseUrl): PromiseInterface
33 | {
34 | return $this->browser->requestStreaming('GET', $sseBaseUrl)
35 | ->then(function (ResponseInterface $response) {
36 | $this->lastConnectResponse = $response; // Store response for header testing
37 | if ($response->getStatusCode() !== 200) {
38 | $body = (string) $response->getBody();
39 | throw new \RuntimeException("SSE connection failed with status {$response->getStatusCode()}: {$body}");
40 | }
41 | $stream = $response->getBody();
42 | assert($stream instanceof ReadableStreamInterface, "SSE response body is not a readable stream");
43 | $this->stream = $stream;
44 | $this->stream->on('data', [$this, 'handleSseData']);
45 | $this->stream->on('close', function () {
46 | $this->stream = null;
47 | });
48 | return $this;
49 | });
50 | }
51 |
52 | public function handleSseData(string $chunk): void
53 | {
54 | $this->buffer .= $chunk;
55 |
56 | while (($eventPos = strpos($this->buffer, "\n\n")) !== false) {
57 | $eventBlock = substr($this->buffer, 0, $eventPos);
58 | $this->buffer = substr($this->buffer, $eventPos + 2);
59 |
60 | $lines = explode("\n", $eventBlock);
61 | $event = ['type' => 'message', 'data' => '', 'id' => null];
62 |
63 | foreach ($lines as $line) {
64 | if (str_starts_with($line, "event:")) {
65 | $event['type'] = trim(substr($line, strlen("event:")));
66 | } elseif (str_starts_with($line, "data:")) {
67 | $event['data'] .= (empty($event['data']) ? "" : "\n") . trim(substr($line, strlen("data:")));
68 | } elseif (str_starts_with($line, "id:")) {
69 | $event['id'] = trim(substr($line, strlen("id:")));
70 | }
71 | }
72 | $this->receivedSseEvents[] = $event;
73 |
74 | if ($event['type'] === 'endpoint' && $event['data']) {
75 | $this->endpointUrl = $event['data'];
76 | $query = parse_url($this->endpointUrl, PHP_URL_QUERY);
77 | if ($query) {
78 | parse_str($query, $params);
79 | $this->clientId = $params['clientId'] ?? null;
80 | }
81 | } elseif ($event['type'] === 'message' && $event['data']) {
82 | try {
83 | $decodedJson = json_decode($event['data'], true, 512, JSON_THROW_ON_ERROR);
84 | $this->receivedMessages[] = $decodedJson;
85 | } catch (\JsonException $e) {
86 | }
87 | }
88 | }
89 | }
90 |
91 | public function getNextMessageResponse(string $expectedRequestId, int $timeoutSecs = 2): PromiseInterface
92 | {
93 | $deferred = new Deferred();
94 | $startTime = microtime(true);
95 |
96 | $checkMessages = null;
97 | $checkMessages = function () use (&$checkMessages, $deferred, $expectedRequestId, $startTime, $timeoutSecs) {
98 | foreach ($this->receivedMessages as $i => $msg) {
99 | if (isset($msg['id']) && $msg['id'] === $expectedRequestId) {
100 | unset($this->receivedMessages[$i]); // Consume message
101 | $this->receivedMessages = array_values($this->receivedMessages);
102 | $deferred->resolve($msg);
103 | return;
104 | }
105 | }
106 |
107 | if (microtime(true) - $startTime > $timeoutSecs) {
108 | $deferred->reject(new \RuntimeException("Timeout waiting for SSE message with ID '{$expectedRequestId}'"));
109 | return;
110 | }
111 |
112 | if ($this->stream) {
113 | Loop::addTimer(0.05, $checkMessages);
114 | } else {
115 | $deferred->reject(new \RuntimeException("SSE Stream closed while waiting for message ID '{$expectedRequestId}'"));
116 | }
117 | };
118 |
119 | $checkMessages(); // Start checking
120 | return $deferred->promise();
121 | }
122 |
123 | public function getNextBatchMessageResponse(int $expectedItemCount, int $timeoutSecs = 2): PromiseInterface
124 | {
125 | $deferred = new Deferred();
126 | $startTime = microtime(true);
127 |
128 | $checkMessages = null;
129 | $checkMessages = function () use (&$checkMessages, $deferred, $expectedItemCount, $startTime, $timeoutSecs) {
130 | foreach ($this->receivedMessages as $i => $msg) {
131 | if (is_array($msg) && !isset($msg['jsonrpc']) && count($msg) === $expectedItemCount) {
132 | $isLikelyBatchResponse = true;
133 | if (empty($msg) && $expectedItemCount === 0) {
134 | } elseif (empty($msg) && $expectedItemCount > 0) {
135 | $isLikelyBatchResponse = false;
136 | } else {
137 | foreach ($msg as $item) {
138 | if (!is_array($item) || (!isset($item['id']) && !isset($item['method']))) {
139 | $isLikelyBatchResponse = false;
140 | break;
141 | }
142 | }
143 | }
144 |
145 | if ($isLikelyBatchResponse) {
146 | unset($this->receivedMessages[$i]);
147 | $this->receivedMessages = array_values($this->receivedMessages);
148 | $deferred->resolve($msg);
149 | return;
150 | }
151 | }
152 | }
153 |
154 | if (microtime(true) - $startTime > $timeoutSecs) {
155 | $deferred->reject(new \RuntimeException("Timeout waiting for SSE Batch Response with {$expectedItemCount} items."));
156 | return;
157 | }
158 |
159 | if ($this->stream) {
160 | Loop::addTimer(0.05, $checkMessages);
161 | } else {
162 | $deferred->reject(new \RuntimeException("SSE Stream closed while waiting for Batch Response."));
163 | }
164 | };
165 |
166 | $checkMessages();
167 | return $deferred->promise();
168 | }
169 |
170 | public function sendHttpRequest(string $requestId, string $method, array $params = []): PromiseInterface
171 | {
172 | if (!$this->endpointUrl || !$this->clientId) {
173 | return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing)."));
174 | }
175 | $payload = [
176 | 'jsonrpc' => '2.0',
177 | 'id' => $requestId,
178 | 'method' => $method,
179 | 'params' => $params,
180 | ];
181 | $body = json_encode($payload);
182 |
183 | return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body)
184 | ->then(function (ResponseInterface $response) use ($requestId) {
185 | $bodyContent = (string) $response->getBody();
186 | if ($response->getStatusCode() !== 202) {
187 | throw new \RuntimeException("HTTP POST request failed with status {$response->getStatusCode()}: {$bodyContent}");
188 | }
189 | return $response;
190 | });
191 | }
192 |
193 | public function sendHttpBatchRequest(array $batchRequestObjects): PromiseInterface
194 | {
195 | if (!$this->endpointUrl || !$this->clientId) {
196 | return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing)."));
197 | }
198 | $body = json_encode($batchRequestObjects);
199 |
200 | return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body)
201 | ->then(function (ResponseInterface $response) {
202 | $bodyContent = (string) $response->getBody();
203 | if ($response->getStatusCode() !== 202) {
204 | throw new \RuntimeException("HTTP BATCH POST request failed with status {$response->getStatusCode()}: {$bodyContent}");
205 | }
206 | return $response;
207 | });
208 | }
209 |
210 | public function sendHttpNotification(string $method, array $params = []): PromiseInterface
211 | {
212 | if (!$this->endpointUrl || !$this->clientId) {
213 | return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing)."));
214 | }
215 | $payload = [
216 | 'jsonrpc' => '2.0',
217 | 'method' => $method,
218 | 'params' => $params,
219 | ];
220 | $body = json_encode($payload);
221 | return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body)
222 | ->then(function (ResponseInterface $response) {
223 | $bodyContent = (string) $response->getBody();
224 | if ($response->getStatusCode() !== 202) {
225 | throw new \RuntimeException("HTTP POST notification failed with status {$response->getStatusCode()}: {$bodyContent}");
226 | }
227 | return null;
228 | });
229 | }
230 |
231 | public function close(): void
232 | {
233 | if ($this->stream) {
234 | $this->stream->close();
235 | $this->stream = null;
236 | }
237 | }
238 | }
239 |
```
--------------------------------------------------------------------------------
/tests/Unit/Session/SessionTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Unit\Session;
4 |
5 | use Mockery;
6 | use PhpMcp\Server\Contracts\SessionHandlerInterface;
7 | use PhpMcp\Server\Contracts\SessionInterface;
8 | use PhpMcp\Server\Session\Session;
9 |
10 | const SESSION_ID_SESS = 'test-session-obj-id';
11 |
12 | beforeEach(function () {
13 | $this->sessionHandler = Mockery::mock(SessionHandlerInterface::class);
14 | $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false)->byDefault();
15 | });
16 |
17 | it('implements SessionInterface', function () {
18 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
19 | expect($session)->toBeInstanceOf(SessionInterface::class);
20 | });
21 |
22 | // --- Constructor and ID Generation ---
23 | it('uses provided ID if given', function () {
24 | $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false);
25 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
26 | expect($session->getId())->toBe(SESSION_ID_SESS);
27 | });
28 |
29 | it('generates an ID if none is provided', function () {
30 | $this->sessionHandler->shouldReceive('read')->with(Mockery::type('string'))->once()->andReturn(false);
31 | $session = new Session($this->sessionHandler);
32 | expect($session->getId())->toBeString()->toHaveLength(32);
33 | });
34 |
35 | it('loads data from handler on construction if session exists', function () {
36 | $initialData = ['foo' => 'bar', 'count' => 5, 'nested' => ['value' => true]];
37 | $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(json_encode($initialData));
38 |
39 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
40 | expect($session->all())->toEqual($initialData);
41 | expect($session->get('foo'))->toBe('bar');
42 | });
43 |
44 | it('initializes with empty data if handler read returns false', function () {
45 | $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false);
46 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
47 | expect($session->all())->toBeEmpty();
48 | });
49 |
50 | it('initializes with empty data if handler read returns invalid JSON', function () {
51 | $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn('this is not json');
52 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
53 | expect($session->all())->toBeEmpty();
54 | });
55 |
56 | it('saves current data to handler', function () {
57 | $this->sessionHandler->shouldReceive('read')->andReturn(false);
58 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
59 | $session->set('name', 'Alice');
60 | $session->set('level', 10);
61 |
62 | $expectedSavedData = json_encode(['name' => 'Alice', 'level' => 10]);
63 | $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_SESS, $expectedSavedData)->once()->andReturn(true);
64 |
65 | $session->save();
66 | });
67 |
68 | it('sets and gets a top-level attribute', function () {
69 | $this->sessionHandler->shouldReceive('read')->andReturn(false);
70 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
71 | $session->set('name', 'Bob');
72 | expect($session->get('name'))->toBe('Bob');
73 | expect($session->has('name'))->toBeTrue();
74 | });
75 |
76 | it('gets default value if attribute does not exist', function () {
77 | $this->sessionHandler->shouldReceive('read')->andReturn(false);
78 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
79 | expect($session->get('nonexistent', 'default_val'))->toBe('default_val');
80 | expect($session->has('nonexistent'))->toBeFalse();
81 | });
82 |
83 | it('sets and gets nested attributes using dot notation', function () {
84 | $this->sessionHandler->shouldReceive('read')->andReturn(false);
85 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
86 | $session->set('user.profile.email', '[email protected]');
87 | $session->set('user.profile.active', true);
88 | $session->set('user.roles', ['admin', 'editor']);
89 |
90 | expect($session->get('user.profile'))->toEqual(['email' => '[email protected]', 'active' => true]);
91 | expect($session->get('user.roles'))->toEqual(['admin', 'editor']);
92 | expect($session->has('user.profile.email'))->toBeTrue();
93 | expect($session->has('user.other_profile.settings'))->toBeFalse();
94 | });
95 |
96 | it('set does not overwrite if overwrite is false and key exists', function () {
97 | $this->sessionHandler->shouldReceive('read')->andReturn(false);
98 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
99 | $session->set('counter', 10);
100 | $session->set('counter', 20, false);
101 | expect($session->get('counter'))->toBe(10);
102 |
103 | $session->set('user.id', 1);
104 | $session->set('user.id', 2, false);
105 | expect($session->get('user.id'))->toBe(1);
106 | });
107 |
108 | it('set overwrites if overwrite is true (default)', function () {
109 | $this->sessionHandler->shouldReceive('read')->andReturn(false);
110 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
111 | $session->set('counter', 10);
112 | $session->set('counter', 20);
113 | expect($session->get('counter'))->toBe(20);
114 | });
115 |
116 |
117 | it('forgets a top-level attribute', function () {
118 | $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['name' => 'Alice', 'age' => 30]));
119 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
120 | $session->forget('age');
121 | expect($session->has('age'))->toBeFalse();
122 | expect($session->has('name'))->toBeTrue();
123 | expect($session->all())->toEqual(['name' => 'Alice']);
124 | });
125 |
126 | it('forgets a nested attribute using dot notation', function () {
127 | $initialData = ['user' => ['profile' => ['email' => '[email protected]', 'status' => 'active'], 'id' => 1]];
128 | $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($initialData));
129 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
130 |
131 | $session->forget('user.profile.status');
132 | expect($session->has('user.profile.status'))->toBeFalse();
133 | expect($session->has('user.profile.email'))->toBeTrue();
134 | expect($session->get('user.profile'))->toEqual(['email' => '[email protected]']);
135 |
136 | $session->forget('user.profile');
137 | expect($session->has('user.profile'))->toBeFalse();
138 | expect($session->get('user'))->toEqual(['id' => 1]);
139 | });
140 |
141 | it('forget does nothing if key does not exist', function () {
142 | $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['name' => 'Test']));
143 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
144 | $session->forget('nonexistent');
145 | $session->forget('another_nonexistent');
146 | expect($session->all())->toEqual(['name' => 'Test']);
147 | });
148 |
149 | it('pulls an attribute (gets and forgets)', function () {
150 | $initialData = ['item' => 'important', 'user' => ['token' => 'abc123xyz']];
151 | $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($initialData));
152 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
153 |
154 | $pulledItem = $session->pull('item', 'default');
155 | expect($pulledItem)->toBe('important');
156 | expect($session->has('item'))->toBeFalse();
157 |
158 | $pulledToken = $session->pull('user.token');
159 | expect($pulledToken)->toBe('abc123xyz');
160 | expect($session->has('user.token'))->toBeFalse();
161 | expect($session->has('user'))->toBeTrue();
162 |
163 | $pulledNonExistent = $session->pull('nonexistent', 'fallback');
164 | expect($pulledNonExistent)->toBe('fallback');
165 | });
166 |
167 | it('clears all session data', function () {
168 | $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['a' => 1, 'b' => 2]));
169 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
170 | $session->clear();
171 | expect($session->all())->toBeEmpty();
172 | });
173 |
174 | it('returns all data with all()', function () {
175 | $data = ['a' => 1, 'b' => ['c' => 3]];
176 | $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($data));
177 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
178 | expect($session->all())->toEqual($data);
179 | });
180 |
181 | it('hydrates session data, merging with defaults and removing id', function () {
182 | $this->sessionHandler->shouldReceive('read')->andReturn(false);
183 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
184 | $newAttributes = [
185 | 'client_info' => ['name' => 'TestClient', 'version' => '1.1'],
186 | 'protocol_version' => '2024-custom',
187 | 'user_custom_key' => 'my_value',
188 | 'id' => 'should_be_ignored_on_hydrate'
189 | ];
190 | $session->hydrate($newAttributes);
191 |
192 | $allData = $session->all();
193 | expect($allData['initialized'])->toBeFalse();
194 | expect($allData['client_info'])->toEqual(['name' => 'TestClient', 'version' => '1.1']);
195 | expect($allData['protocol_version'])->toBe('2024-custom');
196 | expect($allData['message_queue'])->toEqual([]);
197 | expect($allData['log_level'])->toBeNull();
198 | expect($allData['user_custom_key'])->toBe('my_value');
199 | expect($allData)->not->toHaveKey('id');
200 | });
201 |
202 | it('queues messages correctly', function () {
203 | $this->sessionHandler->shouldReceive('read')->andReturn(false);
204 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
205 | expect($session->hasQueuedMessages())->toBeFalse();
206 |
207 | $msg1 = '{"jsonrpc":"2.0","method":"n1"}';
208 | $msg2 = '{"jsonrpc":"2.0","method":"n2"}';
209 | $session->queueMessage($msg1);
210 | $session->queueMessage($msg2);
211 |
212 | expect($session->hasQueuedMessages())->toBeTrue();
213 | expect($session->get('message_queue'))->toEqual([$msg1, $msg2]);
214 | });
215 |
216 | it('dequeues messages and clears queue', function () {
217 | $this->sessionHandler->shouldReceive('read')->andReturn(false);
218 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
219 | $msg1 = '{"id":1}';
220 | $msg2 = '{"id":2}';
221 | $session->queueMessage($msg1);
222 | $session->queueMessage($msg2);
223 |
224 | $dequeued = $session->dequeueMessages();
225 | expect($dequeued)->toEqual([$msg1, $msg2]);
226 | expect($session->hasQueuedMessages())->toBeFalse();
227 | expect($session->get('message_queue', 'not_found'))->toEqual([]);
228 |
229 | expect($session->dequeueMessages())->toEqual([]);
230 | });
231 |
232 | it('jsonSerializes to all session data', function () {
233 | $data = ['serialize' => 'me', 'nested' => ['ok' => true]];
234 | $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($data));
235 | $session = new Session($this->sessionHandler, SESSION_ID_SESS);
236 | expect(json_encode($session))->toBe(json_encode($data));
237 | });
238 |
```
--------------------------------------------------------------------------------
/tests/Unit/Elements/RegisteredResourceTemplateTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Unit\Elements;
4 |
5 | use Mockery;
6 | use PhpMcp\Schema\ResourceTemplate;
7 | use PhpMcp\Server\Context;
8 | use PhpMcp\Server\Contracts\SessionInterface;
9 | use PhpMcp\Server\Elements\RegisteredResourceTemplate;
10 | use PhpMcp\Schema\Content\TextResourceContents;
11 | use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture;
12 | use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture;
13 | use Psr\Container\ContainerInterface;
14 | use PhpMcp\Schema\Annotations;
15 |
16 | beforeEach(function () {
17 | $this->container = Mockery::mock(ContainerInterface::class);
18 | $this->handlerInstance = new ResourceHandlerFixture();
19 | $this->container->shouldReceive('get')
20 | ->with(ResourceHandlerFixture::class)
21 | ->andReturn($this->handlerInstance)
22 | ->byDefault();
23 |
24 | $this->templateUri = 'item://{category}/{itemId}/details';
25 | $this->resourceTemplateSchema = ResourceTemplate::make(
26 | $this->templateUri,
27 | 'item-details-template',
28 | mimeType: 'application/json'
29 | );
30 |
31 | $this->defaultHandlerMethod = 'getUserDocument';
32 | $this->matchingTemplateSchema = ResourceTemplate::make(
33 | 'user://{userId}/doc/{documentId}',
34 | 'user-doc-template',
35 | mimeType: 'application/json'
36 | );
37 |
38 | $this->context = new Context(Mockery::mock(SessionInterface::class));
39 | });
40 |
41 | it('constructs correctly with schema, handler, and completion providers', function () {
42 | $completionProviders = [
43 | 'userId' => CompletionProviderFixture::class,
44 | 'documentId' => 'Another\ProviderClass'
45 | ];
46 |
47 | $schema = ResourceTemplate::make(
48 | 'user://{userId}/doc/{documentId}',
49 | 'user-doc-template',
50 | mimeType: 'application/json'
51 | );
52 |
53 | $template = RegisteredResourceTemplate::make(
54 | schema: $schema,
55 | handler: [ResourceHandlerFixture::class, 'getUserDocument'],
56 | completionProviders: $completionProviders
57 | );
58 |
59 | expect($template->schema)->toBe($schema);
60 | expect($template->handler)->toBe([ResourceHandlerFixture::class, 'getUserDocument']);
61 | expect($template->isManual)->toBeFalse();
62 | expect($template->completionProviders)->toEqual($completionProviders);
63 | expect($template->completionProviders['userId'])->toBe(CompletionProviderFixture::class);
64 | expect($template->completionProviders['documentId'])->toBe('Another\ProviderClass');
65 | expect($template->completionProviders)->not->toHaveKey('nonExistentVar');
66 | });
67 |
68 | it('can be made as a manual registration', function () {
69 | $schema = ResourceTemplate::make(
70 | 'user://{userId}/doc/{documentId}',
71 | 'user-doc-template',
72 | mimeType: 'application/json'
73 | );
74 |
75 | $manualTemplate = RegisteredResourceTemplate::make(
76 | schema: $schema,
77 | handler: [ResourceHandlerFixture::class, 'getUserDocument'],
78 | isManual: true
79 | );
80 |
81 | expect($manualTemplate->isManual)->toBeTrue();
82 | });
83 |
84 | dataset('uri_template_matching_cases', [
85 | 'simple_var' => ['user://{userId}', 'user://12345', ['userId' => '12345']],
86 | 'simple_var_alpha' => ['user://{userId}', 'user://abc-def', ['userId' => 'abc-def']],
87 | 'no_match_missing_var_part' => ['user://{userId}', 'user://', null],
88 | 'no_match_prefix' => ['user://{userId}', 'users://12345', null],
89 | 'multi_var' => ['item://{category}/{itemId}/details', 'item://books/978-abc/details', ['category' => 'books', 'itemId' => '978-abc']],
90 | 'multi_var_empty_segment_fail' => ['item://{category}/{itemId}/details', 'item://books//details', null], // [^/]+ fails on empty segment
91 | 'multi_var_wrong_literal_end' => ['item://{category}/{itemId}/details', 'item://books/978-abc/summary', null],
92 | 'multi_var_no_suffix_literal' => ['item://{category}/{itemId}', 'item://tools/hammer', ['category' => 'tools', 'itemId' => 'hammer']],
93 | 'multi_var_extra_segment_fail' => ['item://{category}/{itemId}', 'item://tools/hammer/extra', null],
94 | 'mixed_literals_vars' => ['user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.jpg', ['userId' => 'kp', 'picId' => 'main']],
95 | 'mixed_wrong_extension' => ['user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.png', null],
96 | 'mixed_wrong_literal_prefix' => ['user://{userId}/profile/img_{picId}.jpg', 'user://kp/profile/pic_main.jpg', null],
97 | 'escapable_chars_in_literal' => ['search://{query}/results.json?page={pageNo}', 'search://term.with.dots/results.json?page=2', ['query' => 'term.with.dots', 'pageNo' => '2']],
98 | ]);
99 |
100 | it('matches URIs against template and extracts variables correctly', function (string $templateString, string $uriToTest, ?array $expectedVariables) {
101 | $schema = ResourceTemplate::make($templateString, 'test-match');
102 | $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getUserDocument']);
103 |
104 | if ($expectedVariables !== null) {
105 | expect($template->matches($uriToTest))->toBeTrue();
106 | $reflection = new \ReflectionClass($template);
107 | $prop = $reflection->getProperty('uriVariables');
108 | $prop->setAccessible(true);
109 | expect($prop->getValue($template))->toEqual($expectedVariables);
110 | } else {
111 | expect($template->matches($uriToTest))->toBeFalse();
112 | }
113 | })->with('uri_template_matching_cases');
114 |
115 | it('gets variable names from compiled template', function () {
116 | $schema = ResourceTemplate::make('foo://{varA}/bar/{varB_ext}.{format}', 'vars-test');
117 | $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getUserDocument']);
118 | expect($template->getVariableNames())->toEqualCanonicalizing(['varA', 'varB_ext', 'format']);
119 | });
120 |
121 | it('reads resource using handler with extracted URI variables', function () {
122 | $uriTemplate = 'item://{category}/{itemId}?format={format}';
123 | $uri = 'item://electronics/tv-123?format=json_pretty';
124 | $schema = ResourceTemplate::make($uriTemplate, 'item-details-template');
125 | $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getTemplatedContent']);
126 |
127 | expect($template->matches($uri))->toBeTrue();
128 |
129 | $resultContents = $template->read($this->container, $uri, $this->context);
130 |
131 | expect($resultContents)->toBeArray()->toHaveCount(1);
132 |
133 | $content = $resultContents[0];
134 | expect($content)->toBeInstanceOf(TextResourceContents::class);
135 | expect($content->uri)->toBe($uri);
136 | expect($content->mimeType)->toBe('application/json');
137 |
138 | $decodedText = json_decode($content->text, true);
139 | expect($decodedText['message'])->toBe("Content for item tv-123 in category electronics, format json_pretty.");
140 | expect($decodedText['category_received'])->toBe('electronics');
141 | expect($decodedText['itemId_received'])->toBe('tv-123');
142 | expect($decodedText['format_received'])->toBe('json_pretty');
143 | });
144 |
145 | it('uses mimeType from schema if handler result does not specify one', function () {
146 | $uriTemplate = 'item://{category}/{itemId}?format={format}';
147 | $uri = 'item://books/bestseller?format=json_pretty';
148 | $schema = ResourceTemplate::make($uriTemplate, 'test-mime', mimeType: 'application/vnd.custom-template-xml');
149 | $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getTemplatedContent']);
150 | expect($template->matches($uri))->toBeTrue();
151 |
152 | $resultContents = $template->read($this->container, $uri, $this->context);
153 | expect($resultContents[0]->mimeType)->toBe('application/vnd.custom-template-xml');
154 | });
155 |
156 | it('formats a simple string result from handler correctly for template', function () {
157 | $uri = 'item://tools/hammer';
158 | $schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom');
159 | $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'returnStringText']);
160 | expect($template->matches($uri))->toBeTrue();
161 |
162 | $mockHandler = Mockery::mock(ResourceHandlerFixture::class);
163 | $mockHandler->shouldReceive('returnStringText')->with($uri)->once()->andReturn('Simple content from template handler');
164 | $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($mockHandler);
165 |
166 | $resultContents = $template->read($this->container, $uri, $this->context);
167 | expect($resultContents[0])->toBeInstanceOf(TextResourceContents::class)
168 | ->and($resultContents[0]->text)->toBe('Simple content from template handler')
169 | ->and($resultContents[0]->mimeType)->toBe('text/x-custom'); // From schema
170 | });
171 |
172 | it('propagates exceptions from handler during read', function () {
173 | $uri = 'item://tools/hammer';
174 | $schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom');
175 | $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'handlerThrowsException']);
176 | expect($template->matches($uri))->toBeTrue();
177 | $template->read($this->container, $uri, $this->context);
178 | })->throws(\DomainException::class, "Cannot read resource");
179 |
180 | it('can be serialized to array and deserialized', function () {
181 | $schema = ResourceTemplate::make(
182 | 'obj://{type}/{id}',
183 | 'my-template',
184 | mimeType: 'application/template+json',
185 | annotations: Annotations::make(priority: 0.7)
186 | );
187 |
188 | $providers = ['type' => CompletionProviderFixture::class];
189 | $serializedProviders = ['type' => serialize(CompletionProviderFixture::class)];
190 |
191 | $original = RegisteredResourceTemplate::make(
192 | $schema,
193 | [ResourceHandlerFixture::class, 'getUserDocument'],
194 | true,
195 | $providers
196 | );
197 |
198 | $array = $original->toArray();
199 |
200 | expect($array['schema']['uriTemplate'])->toBe('obj://{type}/{id}');
201 | expect($array['schema']['name'])->toBe('my-template');
202 | expect($array['schema']['mimeType'])->toBe('application/template+json');
203 | expect($array['schema']['annotations']['priority'])->toBe(0.7);
204 | expect($array['handler'])->toBe([ResourceHandlerFixture::class, 'getUserDocument']);
205 | expect($array['isManual'])->toBeTrue();
206 | expect($array['completionProviders'])->toEqual($serializedProviders);
207 |
208 | $rehydrated = RegisteredResourceTemplate::fromArray($array);
209 | expect($rehydrated)->toBeInstanceOf(RegisteredResourceTemplate::class);
210 | expect($rehydrated->schema->uriTemplate)->toEqual($original->schema->uriTemplate);
211 | expect($rehydrated->schema->name)->toEqual($original->schema->name);
212 | expect($rehydrated->isManual)->toBeTrue();
213 | expect($rehydrated->completionProviders)->toEqual($providers);
214 | });
215 |
216 | it('fromArray returns false on failure', function () {
217 | $badData = ['schema' => ['uriTemplate' => 'fail']];
218 | expect(RegisteredResourceTemplate::fromArray($badData))->toBeFalse();
219 | });
220 |
```
--------------------------------------------------------------------------------
/tests/Mocks/Clients/MockStreamHttpClient.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Mocks\Clients;
6 |
7 | use Psr\Http\Message\ResponseInterface;
8 | use React\EventLoop\Loop;
9 | use React\Http\Browser;
10 | use React\Promise\Deferred;
11 | use React\Promise\PromiseInterface;
12 | use React\Stream\ReadableStreamInterface;
13 |
14 | use function React\Promise\reject;
15 |
16 | class MockStreamHttpClient
17 | {
18 | public Browser $browser;
19 | public string $baseMcpUrl;
20 | public ?string $sessionId = null;
21 |
22 | private ?ReadableStreamInterface $mainSseGetStream = null;
23 | private string $mainSseGetBuffer = '';
24 | private array $mainSseReceivedNotifications = [];
25 |
26 | public function __construct(string $host, int $port, string $mcpPath, int $timeout = 2)
27 | {
28 | $this->browser = (new Browser())->withTimeout($timeout);
29 | $this->baseMcpUrl = "http://{$host}:{$port}/{$mcpPath}";
30 | }
31 |
32 | public function connectMainSseStream(): PromiseInterface
33 | {
34 | if (!$this->sessionId) {
35 | return reject(new \LogicException("Cannot connect main SSE stream without a session ID. Initialize first."));
36 | }
37 |
38 | return $this->browser->requestStreaming('GET', $this->baseMcpUrl, [
39 | 'Accept' => 'text/event-stream',
40 | 'Mcp-Session-Id' => $this->sessionId
41 | ])
42 | ->then(function (ResponseInterface $response) {
43 | if ($response->getStatusCode() !== 200) {
44 | $body = (string) $response->getBody();
45 | throw new \RuntimeException("Main SSE GET connection failed with status {$response->getStatusCode()}: {$body}");
46 | }
47 | $stream = $response->getBody();
48 | assert($stream instanceof ReadableStreamInterface);
49 | $this->mainSseGetStream = $stream;
50 |
51 | $this->mainSseGetStream->on('data', function ($chunk) {
52 | $this->mainSseGetBuffer .= $chunk;
53 | $this->processBufferForNotifications($this->mainSseGetBuffer, $this->mainSseReceivedNotifications);
54 | });
55 | return $this;
56 | });
57 | }
58 |
59 | private function processBufferForNotifications(string &$buffer, array &$targetArray): void
60 | {
61 | while (($eventPos = strpos($buffer, "\n\n")) !== false) {
62 | $eventBlock = substr($buffer, 0, $eventPos);
63 | $buffer = substr($buffer, $eventPos + 2);
64 | $lines = explode("\n", $eventBlock);
65 | $eventData = '';
66 | foreach ($lines as $line) {
67 | if (str_starts_with($line, "data:")) {
68 | $eventData .= (empty($eventData) ? "" : "\n") . trim(substr($line, strlen("data:")));
69 | }
70 | }
71 | if (!empty($eventData)) {
72 | try {
73 | $decodedJson = json_decode($eventData, true, 512, JSON_THROW_ON_ERROR);
74 | if (isset($decodedJson['method']) && str_starts_with($decodedJson['method'], 'notifications/')) {
75 | $targetArray[] = $decodedJson;
76 | }
77 | } catch (\JsonException $e) { /* ignore non-json data lines or log */
78 | }
79 | }
80 | }
81 | }
82 |
83 |
84 | public function sendInitializeRequest(array $params, string $id = 'init-stream-1'): PromiseInterface
85 | {
86 | $payload = ['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => $params, 'id' => $id];
87 | $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream'];
88 | $body = json_encode($payload);
89 |
90 | return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body)
91 | ->then(function (ResponseInterface $response) use ($id) {
92 | $statusCode = $response->getStatusCode();
93 |
94 | if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) {
95 | throw new \RuntimeException("Initialize POST failed or did not return SSE stream. Status: {$statusCode}");
96 | }
97 |
98 | $this->sessionId = $response->getHeaderLine('Mcp-Session-Id');
99 |
100 | $stream = $response->getBody();
101 | assert($stream instanceof ReadableStreamInterface);
102 | return $this->collectSingleSseResponse($stream, $id, "Initialize");
103 | });
104 | }
105 |
106 | public function sendRequest(string $method, array $params, string $id, array $additionalHeaders = []): PromiseInterface
107 | {
108 | $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params, 'id' => $id];
109 | $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream'];
110 | if ($this->sessionId) {
111 | $headers['Mcp-Session-Id'] = $this->sessionId;
112 | }
113 | $headers += $additionalHeaders;
114 |
115 | $body = json_encode($payload);
116 |
117 | return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body)
118 | ->then(function (ResponseInterface $response) use ($id, $method) {
119 | $statusCode = $response->getStatusCode();
120 |
121 | if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) {
122 | $bodyContent = (string) $response->getBody();
123 | throw new \RuntimeException("Request '{$method}' (ID: {$id}) POST failed or did not return SSE stream. Status: {$statusCode}, Body: {$bodyContent}");
124 | }
125 |
126 | $stream = $response->getBody();
127 | assert($stream instanceof ReadableStreamInterface);
128 | return $this->collectSingleSseResponse($stream, $id, $method);
129 | });
130 | }
131 |
132 | public function sendBatchRequest(array $batchPayload): PromiseInterface
133 | {
134 | if (!$this->sessionId) {
135 | return reject(new \LogicException("Session ID not set. Initialize first for batch request."));
136 | }
137 |
138 | $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream', 'Mcp-Session-Id' => $this->sessionId];
139 | $body = json_encode($batchPayload);
140 |
141 | return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body)
142 | ->then(function (ResponseInterface $response) {
143 | $statusCode = $response->getStatusCode();
144 |
145 | if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) {
146 | throw new \RuntimeException("Batch POST failed or did not return SSE stream. Status: {$statusCode}");
147 | }
148 |
149 | $stream = $response->getBody();
150 | assert($stream instanceof ReadableStreamInterface);
151 | return $this->collectSingleSseResponse($stream, null, "Batch", true);
152 | });
153 | }
154 |
155 | private function collectSingleSseResponse(ReadableStreamInterface $stream, ?string $expectedRequestId, string $contextHint, bool $expectBatchArray = false): PromiseInterface
156 | {
157 | $deferred = new Deferred();
158 | $buffer = '';
159 | $streamClosed = false;
160 |
161 | $dataListener = function ($chunk) use (&$buffer, $deferred, $expectedRequestId, $expectBatchArray, $contextHint, &$streamClosed, &$dataListener, $stream) {
162 | if ($streamClosed) return;
163 | $buffer .= $chunk;
164 |
165 | if (str_contains($buffer, "event: message\n")) {
166 | if (preg_match('/data: (.*)\n\n/s', $buffer, $matches)) {
167 | $jsonData = trim($matches[1]);
168 |
169 | try {
170 | $decoded = json_decode($jsonData, true, 512, JSON_THROW_ON_ERROR);
171 | $isValid = false;
172 | if ($expectBatchArray) {
173 | $isValid = is_array($decoded) && !isset($decoded['jsonrpc']);
174 | } else {
175 | $isValid = isset($decoded['id']) && $decoded['id'] === $expectedRequestId;
176 | }
177 |
178 | if ($isValid) {
179 | $deferred->resolve($decoded);
180 | $stream->removeListener('data', $dataListener);
181 | $stream->close();
182 | return;
183 | }
184 | } catch (\JsonException $e) {
185 | $deferred->reject(new \RuntimeException("SSE JSON decode failed for {$contextHint}: {$jsonData}", 0, $e));
186 | $stream->removeListener('data', $dataListener);
187 | $stream->close();
188 | return;
189 | }
190 | }
191 | }
192 | };
193 |
194 | $stream->on('data', $dataListener);
195 | $stream->on('close', function () use ($deferred, $contextHint, &$streamClosed) {
196 | $streamClosed = true;
197 | $deferred->reject(new \RuntimeException("SSE stream for {$contextHint} closed before expected response was received."));
198 | });
199 | $stream->on('error', function ($err) use ($deferred, $contextHint, &$streamClosed) {
200 | $streamClosed = true;
201 | $deferred->reject(new \RuntimeException("SSE stream error for {$contextHint}.", 0, $err instanceof \Throwable ? $err : null));
202 | });
203 |
204 | return timeout($deferred->promise(), 2, Loop::get())
205 | ->finally(function () use ($stream, $dataListener) {
206 | if ($stream->isReadable()) {
207 | $stream->removeListener('data', $dataListener);
208 | }
209 | });
210 | }
211 |
212 | public function sendHttpNotification(string $method, array $params = []): PromiseInterface
213 | {
214 | if (!$this->sessionId) {
215 | return reject(new \LogicException("Session ID not set for notification. Initialize first."));
216 | }
217 | $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params];
218 | $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json', 'Mcp-Session-Id' => $this->sessionId];
219 | $body = json_encode($payload);
220 |
221 | return $this->browser->post($this->baseMcpUrl, $headers, $body)
222 | ->then(function (ResponseInterface $response) {
223 | $statusCode = $response->getStatusCode();
224 |
225 | if ($statusCode !== 202) {
226 | throw new \RuntimeException("POST Notification failed with status {$statusCode}: " . (string)$response->getBody());
227 | }
228 |
229 | return ['statusCode' => $statusCode, 'body' => null];
230 | });
231 | }
232 |
233 | public function sendDeleteRequest(): PromiseInterface
234 | {
235 | if (!$this->sessionId) {
236 | return reject(new \LogicException("Session ID not set for DELETE request. Initialize first."));
237 | }
238 |
239 | $headers = ['Mcp-Session-Id' => $this->sessionId];
240 |
241 | return $this->browser->request('DELETE', $this->baseMcpUrl, $headers)
242 | ->then(function (ResponseInterface $response) {
243 | $statusCode = $response->getStatusCode();
244 | return ['statusCode' => $statusCode, 'body' => (string)$response->getBody()];
245 | });
246 | }
247 |
248 | public function closeMainSseStream(): void
249 | {
250 | if ($this->mainSseGetStream) {
251 | $this->mainSseGetStream->close();
252 | $this->mainSseGetStream = null;
253 | }
254 | }
255 | }
256 |
```
--------------------------------------------------------------------------------
/tests/Unit/ServerTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Unit;
4 |
5 | use LogicException;
6 | use Mockery;
7 | use Mockery\MockInterface;
8 | use PhpMcp\Server\Configuration;
9 | use PhpMcp\Server\Contracts\LoggerAwareInterface;
10 | use PhpMcp\Server\Contracts\LoopAwareInterface;
11 | use PhpMcp\Server\Contracts\ServerTransportInterface;
12 | use PhpMcp\Server\Exception\DiscoveryException;
13 | use PhpMcp\Schema\Implementation;
14 | use PhpMcp\Schema\ServerCapabilities;
15 | use PhpMcp\Server\Protocol;
16 | use PhpMcp\Server\Registry;
17 | use PhpMcp\Server\Server;
18 | use PhpMcp\Server\Session\ArraySessionHandler;
19 | use PhpMcp\Server\Session\SessionManager;
20 | use PhpMcp\Server\Utils\Discoverer;
21 | use Psr\Container\ContainerInterface;
22 | use Psr\Log\LoggerInterface;
23 | use Psr\SimpleCache\CacheInterface;
24 | use React\EventLoop\Loop;
25 | use React\EventLoop\LoopInterface;
26 |
27 | beforeEach(function () {
28 | /** @var MockInterface&LoggerInterface $logger */
29 | $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing();
30 | /** @var MockInterface&LoopInterface $loop */
31 | $this->loop = Mockery::mock(LoopInterface::class)->shouldIgnoreMissing();
32 | /** @var MockInterface&CacheInterface $cache */
33 | $this->cache = Mockery::mock(CacheInterface::class);
34 | /** @var MockInterface&ContainerInterface $container */
35 | $this->container = Mockery::mock(ContainerInterface::class);
36 |
37 | $this->configuration = new Configuration(
38 | serverInfo: Implementation::make('TestServerInstance', '1.0'),
39 | capabilities: ServerCapabilities::make(),
40 | logger: $this->logger,
41 | loop: $this->loop,
42 | cache: $this->cache,
43 | container: $this->container
44 | );
45 |
46 | /** @var MockInterface&Registry $registry */
47 | $this->registry = Mockery::mock(Registry::class);
48 | /** @var MockInterface&Protocol $protocol */
49 | $this->protocol = Mockery::mock(Protocol::class);
50 | /** @var MockInterface&Discoverer $discoverer */
51 | $this->discoverer = Mockery::mock(Discoverer::class);
52 |
53 | $this->sessionManager = new SessionManager(new ArraySessionHandler(), $this->logger, $this->loop);
54 |
55 | $this->server = new Server(
56 | $this->configuration,
57 | $this->registry,
58 | $this->protocol,
59 | $this->sessionManager
60 | );
61 |
62 | $this->registry->allows('hasElements')->withNoArgs()->andReturn(false)->byDefault();
63 | $this->registry->allows('clear')->withAnyArgs()->byDefault();
64 | $this->registry->allows('save')->withAnyArgs()->andReturn(true)->byDefault();
65 | });
66 |
67 | afterEach(function () {
68 | $this->sessionManager->stopGcTimer();
69 | });
70 |
71 | it('provides getters for core components', function () {
72 | expect($this->server->getConfiguration())->toBe($this->configuration);
73 | expect($this->server->getRegistry())->toBe($this->registry);
74 | expect($this->server->getProtocol())->toBe($this->protocol);
75 | expect($this->server->getSessionManager())->toBe($this->sessionManager);
76 | });
77 |
78 | it('provides a static make method returning ServerBuilder', function () {
79 | expect(Server::make())->toBeInstanceOf(\PhpMcp\Server\ServerBuilder::class);
80 | });
81 |
82 | it('skips discovery if already run and not forced', function () {
83 | $reflector = new \ReflectionClass($this->server);
84 | $prop = $reflector->getProperty('discoveryRan');
85 | $prop->setAccessible(true);
86 | $prop->setValue($this->server, true);
87 |
88 | $this->registry->shouldNotReceive('clear');
89 | $this->discoverer->shouldNotReceive('discover');
90 | $this->registry->shouldNotReceive('save');
91 |
92 | $this->server->discover(sys_get_temp_dir(), discoverer: $this->discoverer);
93 | $this->logger->shouldHaveReceived('debug')->with('Discovery skipped: Already run or loaded from cache.');
94 | });
95 |
96 | it('forces discovery even if already run, calling injected discoverer', function () {
97 | $reflector = new \ReflectionClass($this->server);
98 | $prop = $reflector->getProperty('discoveryRan');
99 | $prop->setAccessible(true);
100 | $prop->setValue($this->server, true);
101 |
102 | $basePath = realpath(sys_get_temp_dir());
103 | $scanDirs = ['.', 'src'];
104 |
105 |
106 | $this->registry->shouldReceive('clear')->once();
107 | $this->discoverer->shouldReceive('discover')
108 | ->with($basePath, $scanDirs, Mockery::type('array'))
109 | ->once();
110 | $this->registry->shouldReceive('save')->once()->andReturn(true);
111 |
112 | $this->server->discover($basePath, $scanDirs, [], force: true, discoverer: $this->discoverer);
113 |
114 | expect($prop->getValue($this->server))->toBeTrue();
115 | });
116 |
117 | it('calls registry clear and discoverer, then saves to cache by default', function () {
118 | $basePath = realpath(sys_get_temp_dir());
119 | $scanDirs = ['app', 'lib'];
120 | $userExcludeDirs = ['specific_exclude'];
121 | $finalExcludeDirs = array_unique(array_merge(
122 | ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules', '.git', '.svn'],
123 | $userExcludeDirs
124 | ));
125 |
126 |
127 | $this->registry->shouldReceive('clear')->once();
128 | $this->discoverer->shouldReceive('discover')
129 | ->with($basePath, $scanDirs, Mockery::on(function ($arg) use ($finalExcludeDirs) {
130 | expect($arg)->toBeArray();
131 | expect($arg)->toEqualCanonicalizing($finalExcludeDirs);
132 | return true;
133 | }))
134 | ->once();
135 | $this->registry->shouldReceive('save')->once()->andReturn(true);
136 |
137 | $this->server->discover($basePath, $scanDirs, $userExcludeDirs, discoverer: $this->discoverer);
138 |
139 | $reflector = new \ReflectionClass($this->server);
140 | $prop = $reflector->getProperty('discoveryRan');
141 | $prop->setAccessible(true);
142 | expect($prop->getValue($this->server))->toBeTrue();
143 | });
144 |
145 | it('does not save to cache if saveToCache is false', function () {
146 | $basePath = realpath(sys_get_temp_dir());
147 |
148 | $this->registry->shouldReceive('clear')->once();
149 | $this->discoverer->shouldReceive('discover')->once();
150 | $this->registry->shouldNotReceive('save');
151 |
152 | $this->server->discover($basePath, saveToCache: false, discoverer: $this->discoverer);
153 | });
154 |
155 | it('throws InvalidArgumentException for bad base path in discover', function () {
156 | $this->discoverer->shouldNotReceive('discover');
157 | $this->server->discover('/non/existent/path/for/sure/I/hope', discoverer: $this->discoverer);
158 | })->throws(\InvalidArgumentException::class, 'Invalid discovery base path');
159 |
160 | it('throws DiscoveryException if Discoverer fails during discovery', function () {
161 | $basePath = realpath(sys_get_temp_dir());
162 |
163 | $this->registry->shouldReceive('clear')->once();
164 | $this->discoverer->shouldReceive('discover')->once()->andThrow(new \RuntimeException('Filesystem error'));
165 | $this->registry->shouldNotReceive('save');
166 |
167 | $this->server->discover($basePath, discoverer: $this->discoverer);
168 | })->throws(DiscoveryException::class, 'Element discovery failed: Filesystem error');
169 |
170 | it('resets discoveryRan flag on Discoverer failure', function () {
171 | $basePath = realpath(sys_get_temp_dir());
172 | $this->registry->shouldReceive('clear')->once();
173 | $this->discoverer->shouldReceive('discover')->once()->andThrow(new \RuntimeException('Failure'));
174 |
175 | try {
176 | $this->server->discover($basePath, discoverer: $this->discoverer);
177 | } catch (DiscoveryException $e) {
178 | // Expected
179 | }
180 |
181 | $reflector = new \ReflectionClass($this->server);
182 | $prop = $reflector->getProperty('discoveryRan');
183 | $prop->setAccessible(true);
184 | expect($prop->getValue($this->server))->toBeFalse();
185 | });
186 |
187 |
188 | // --- Listening Tests ---
189 | it('throws LogicException if listen is called when already listening', function () {
190 | $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
191 | $this->protocol->shouldReceive('bindTransport')->with($transport)->once();
192 |
193 | $this->server->listen($transport, false);
194 | $this->server->listen($transport, false);
195 | })->throws(LogicException::class, 'Server is already listening');
196 |
197 | it('warns if no elements and discovery not run when listen is called', function () {
198 | $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
199 | $this->protocol->shouldReceive('bindTransport')->with($transport)->once();
200 |
201 | $this->registry->shouldReceive('hasElements')->andReturn(false);
202 |
203 | $this->logger->shouldReceive('warning')
204 | ->once()
205 | ->with(Mockery::pattern('/Starting listener, but no MCP elements are registered and discovery has not been run/'));
206 |
207 | $this->server->listen($transport, false);
208 | });
209 |
210 | it('injects logger and loop into aware transports during listen', function () {
211 | $transport = Mockery::mock(ServerTransportInterface::class, LoggerAwareInterface::class, LoopAwareInterface::class);
212 | $transport->shouldReceive('setLogger')->with($this->logger)->once();
213 | $transport->shouldReceive('setLoop')->with($this->loop)->once();
214 | $transport->shouldReceive('on', 'once', 'listen', 'emit', 'close', 'removeAllListeners')->withAnyArgs();
215 | $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->withAnyArgs();
216 |
217 | $this->server->listen($transport);
218 | });
219 |
220 | it('binds protocol, starts transport listener, and runs loop by default', function () {
221 | $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
222 | $transport->shouldReceive('listen')->once();
223 | $this->protocol->shouldReceive('bindTransport')->with($transport)->once();
224 | $this->loop->shouldReceive('run')->once();
225 | $this->protocol->shouldReceive('unbindTransport')->once();
226 |
227 | $this->server->listen($transport);
228 | expect(getPrivateProperty($this->server, 'isListening'))->toBeFalse();
229 | });
230 |
231 | it('does not run loop if runLoop is false in listen', function () {
232 | $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
233 | $this->protocol->shouldReceive('bindTransport')->with($transport)->once();
234 |
235 | $this->loop->shouldNotReceive('run');
236 |
237 | $this->server->listen($transport, runLoop: false);
238 | expect(getPrivateProperty($this->server, 'isListening'))->toBeTrue();
239 |
240 | $this->protocol->shouldReceive('unbindTransport');
241 | $transport->shouldReceive('removeAllListeners');
242 | $transport->shouldReceive('close');
243 | $this->server->endListen($transport);
244 | });
245 |
246 | it('calls endListen if transport listen throws immediately', function () {
247 | $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
248 | $transport->shouldReceive('listen')->once()->andThrow(new \RuntimeException("Port in use"));
249 | $this->protocol->shouldReceive('bindTransport')->once();
250 | $this->protocol->shouldReceive('unbindTransport')->once();
251 |
252 | $this->loop->shouldNotReceive('run');
253 |
254 | try {
255 | $this->server->listen($transport);
256 | } catch (\RuntimeException $e) {
257 | expect($e->getMessage())->toBe("Port in use");
258 | }
259 | expect(getPrivateProperty($this->server, 'isListening'))->toBeFalse();
260 | });
261 |
262 | it('endListen unbinds protocol and closes transport if listening', function () {
263 | $transport = Mockery::mock(ServerTransportInterface::class);
264 | $reflector = new \ReflectionClass($this->server);
265 | $prop = $reflector->getProperty('isListening');
266 | $prop->setAccessible(true);
267 | $prop->setValue($this->server, true);
268 |
269 | $this->protocol->shouldReceive('unbindTransport')->once();
270 | $transport->shouldReceive('removeAllListeners')->with('close')->once();
271 | $transport->shouldReceive('close')->once();
272 |
273 | $this->server->endListen($transport);
274 | expect($prop->getValue($this->server))->toBeFalse();
275 | });
276 |
```
--------------------------------------------------------------------------------
/src/Elements/RegisteredResourceTemplate.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Elements;
6 |
7 | use PhpMcp\Schema\Content\BlobResourceContents;
8 | use PhpMcp\Schema\Content\EmbeddedResource;
9 | use PhpMcp\Schema\Content\ResourceContents;
10 | use PhpMcp\Schema\Content\TextResourceContents;
11 | use PhpMcp\Schema\ResourceTemplate;
12 | use PhpMcp\Schema\Result\CompletionCompleteResult;
13 | use PhpMcp\Server\Context;
14 | use PhpMcp\Server\Contracts\SessionInterface;
15 | use Psr\Container\ContainerInterface;
16 | use Throwable;
17 |
18 | class RegisteredResourceTemplate extends RegisteredElement
19 | {
20 | protected array $variableNames;
21 | protected array $uriVariables;
22 | protected string $uriTemplateRegex;
23 |
24 | public function __construct(
25 | public readonly ResourceTemplate $schema,
26 | callable|array|string $handler,
27 | bool $isManual = false,
28 | public readonly array $completionProviders = []
29 | ) {
30 | parent::__construct($handler, $isManual);
31 |
32 | $this->compileTemplate();
33 | }
34 |
35 | public static function make(ResourceTemplate $schema, callable|array|string $handler, bool $isManual = false, array $completionProviders = []): self
36 | {
37 | return new self($schema, $handler, $isManual, $completionProviders);
38 | }
39 |
40 | /**
41 | * Gets the resource template.
42 | *
43 | * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
44 | */
45 | public function read(ContainerInterface $container, string $uri, Context $context): array
46 | {
47 | $arguments = array_merge($this->uriVariables, ['uri' => $uri]);
48 |
49 | $result = $this->handle($container, $arguments, $context);
50 |
51 | return $this->formatResult($result, $uri, $this->schema->mimeType);
52 | }
53 |
54 | public function complete(ContainerInterface $container, string $argument, string $value, SessionInterface $session): CompletionCompleteResult
55 | {
56 | $providerClassOrInstance = $this->completionProviders[$argument] ?? null;
57 | if ($providerClassOrInstance === null) {
58 | return new CompletionCompleteResult([]);
59 | }
60 |
61 | if (is_string($providerClassOrInstance)) {
62 | if (! class_exists($providerClassOrInstance)) {
63 | throw new \RuntimeException("Completion provider class '{$providerClassOrInstance}' does not exist.");
64 | }
65 |
66 | $provider = $container->get($providerClassOrInstance);
67 | } else {
68 | $provider = $providerClassOrInstance;
69 | }
70 |
71 | $completions = $provider->getCompletions($value, $session);
72 |
73 | $total = count($completions);
74 | $hasMore = $total > 100;
75 |
76 | $pagedCompletions = array_slice($completions, 0, 100);
77 |
78 | return new CompletionCompleteResult($pagedCompletions, $total, $hasMore);
79 | }
80 |
81 |
82 | public function getVariableNames(): array
83 | {
84 | return $this->variableNames;
85 | }
86 |
87 | public function matches(string $uri): bool
88 | {
89 | if (preg_match($this->uriTemplateRegex, $uri, $matches)) {
90 | $variables = [];
91 | foreach ($this->variableNames as $varName) {
92 | if (isset($matches[$varName])) {
93 | $variables[$varName] = $matches[$varName];
94 | }
95 | }
96 |
97 | $this->uriVariables = $variables;
98 |
99 | return true;
100 | }
101 |
102 | return false;
103 | }
104 |
105 | private function compileTemplate(): void
106 | {
107 | $this->variableNames = [];
108 | $regexParts = [];
109 |
110 | $segments = preg_split('/(\{\w+\})/', $this->schema->uriTemplate, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
111 |
112 | foreach ($segments as $segment) {
113 | if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) {
114 | $varName = $matches[1];
115 | $this->variableNames[] = $varName;
116 | $regexParts[] = '(?P<' . $varName . '>[^/]+)';
117 | } else {
118 | $regexParts[] = preg_quote($segment, '#');
119 | }
120 | }
121 |
122 | $this->uriTemplateRegex = '#^' . implode('', $regexParts) . '$#';
123 | }
124 |
125 | /**
126 | * Formats the raw result of a resource read operation into MCP ResourceContent items.
127 | *
128 | * @param mixed $readResult The raw result from the resource handler method.
129 | * @param string $uri The URI of the resource that was read.
130 | * @param ?string $defaultMimeType The default MIME type from the ResourceDefinition.
131 | * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
132 | *
133 | * @throws \RuntimeException If the result cannot be formatted.
134 | *
135 | * Supported result types:
136 | * - ResourceContent: Used as-is
137 | * - EmbeddedResource: Resource is extracted from the EmbeddedResource
138 | * - string: Converted to text content with guessed or provided MIME type
139 | * - stream resource: Read and converted to blob with provided MIME type
140 | * - array with 'blob' key: Used as blob content
141 | * - array with 'text' key: Used as text content
142 | * - SplFileInfo: Read and converted to blob
143 | * - array: Converted to JSON if MIME type is application/json or contains 'json'
144 | * For other MIME types, will try to convert to JSON with a warning
145 | */
146 | protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array
147 | {
148 | if ($readResult instanceof ResourceContents) {
149 | return [$readResult];
150 | }
151 |
152 | if ($readResult instanceof EmbeddedResource) {
153 | return [$readResult->resource];
154 | }
155 |
156 | if (is_array($readResult)) {
157 | if (empty($readResult)) {
158 | return [TextResourceContents::make($uri, 'application/json', '[]')];
159 | }
160 |
161 | $allAreResourceContents = true;
162 | $hasResourceContents = false;
163 | $allAreEmbeddedResource = true;
164 | $hasEmbeddedResource = false;
165 |
166 | foreach ($readResult as $item) {
167 | if ($item instanceof ResourceContents) {
168 | $hasResourceContents = true;
169 | $allAreEmbeddedResource = false;
170 | } elseif ($item instanceof EmbeddedResource) {
171 | $hasEmbeddedResource = true;
172 | $allAreResourceContents = false;
173 | } else {
174 | $allAreResourceContents = false;
175 | $allAreEmbeddedResource = false;
176 | }
177 | }
178 |
179 | if ($allAreResourceContents && $hasResourceContents) {
180 | return $readResult;
181 | }
182 |
183 | if ($allAreEmbeddedResource && $hasEmbeddedResource) {
184 | return array_map(fn($item) => $item->resource, $readResult);
185 | }
186 |
187 | if ($hasResourceContents || $hasEmbeddedResource) {
188 | $result = [];
189 | foreach ($readResult as $item) {
190 | if ($item instanceof ResourceContents) {
191 | $result[] = $item;
192 | } elseif ($item instanceof EmbeddedResource) {
193 | $result[] = $item->resource;
194 | } else {
195 | $result = array_merge($result, $this->formatResult($item, $uri, $mimeType));
196 | }
197 | }
198 | return $result;
199 | }
200 | }
201 |
202 | if (is_string($readResult)) {
203 | $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult);
204 |
205 | return [TextResourceContents::make($uri, $mimeType, $readResult)];
206 | }
207 |
208 | if (is_resource($readResult) && get_resource_type($readResult) === 'stream') {
209 | $result = BlobResourceContents::fromStream(
210 | $uri,
211 | $readResult,
212 | $mimeType ?? 'application/octet-stream'
213 | );
214 |
215 | @fclose($readResult);
216 |
217 | return [$result];
218 | }
219 |
220 | if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) {
221 | $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream';
222 |
223 | return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])];
224 | }
225 |
226 | if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) {
227 | $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain';
228 |
229 | return [TextResourceContents::make($uri, $mimeType, $readResult['text'])];
230 | }
231 |
232 | if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) {
233 | if ($mimeType && str_contains(strtolower($mimeType), 'text')) {
234 | return [TextResourceContents::make($uri, $mimeType, file_get_contents($readResult->getPathname()))];
235 | }
236 |
237 | return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)];
238 | }
239 |
240 | if (is_array($readResult)) {
241 | if ($mimeType && (str_contains(strtolower($mimeType), 'json') ||
242 | $mimeType === 'application/json')) {
243 | try {
244 | $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
245 |
246 | return [TextResourceContents::make($uri, $mimeType, $jsonString)];
247 | } catch (\JsonException $e) {
248 | throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
249 | }
250 | }
251 |
252 | try {
253 | $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
254 | $mimeType = $mimeType ?? 'application/json';
255 |
256 | return [TextResourceContents::make($uri, $mimeType, $jsonString)];
257 | } catch (\JsonException $e) {
258 | throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
259 | }
260 | }
261 |
262 | throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult));
263 | }
264 |
265 | /** Guesses MIME type from string content (very basic) */
266 | private function guessMimeTypeFromString(string $content): string
267 | {
268 | $trimmed = ltrim($content);
269 |
270 | if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) {
271 | if (str_contains($trimmed, '<html')) {
272 | return 'text/html';
273 | }
274 | if (str_contains($trimmed, '<?xml')) {
275 | return 'application/xml';
276 | }
277 |
278 | return 'text/plain';
279 | }
280 |
281 | if (str_starts_with($trimmed, '{') && str_ends_with(rtrim($content), '}')) {
282 | return 'application/json';
283 | }
284 |
285 | if (str_starts_with($trimmed, '[') && str_ends_with(rtrim($content), ']')) {
286 | return 'application/json';
287 | }
288 |
289 | return 'text/plain';
290 | }
291 |
292 | public function toArray(): array
293 | {
294 | $completionProviders = [];
295 | foreach ($this->completionProviders as $argument => $provider) {
296 | $completionProviders[$argument] = serialize($provider);
297 | }
298 |
299 | return [
300 | 'schema' => $this->schema->toArray(),
301 | 'completionProviders' => $completionProviders,
302 | ...parent::toArray(),
303 | ];
304 | }
305 |
306 | public static function fromArray(array $data): self|false
307 | {
308 | try {
309 | if (! isset($data['schema']) || ! isset($data['handler'])) {
310 | return false;
311 | }
312 |
313 | $completionProviders = [];
314 | foreach ($data['completionProviders'] ?? [] as $argument => $provider) {
315 | $completionProviders[$argument] = unserialize($provider);
316 | }
317 |
318 | return new self(
319 | ResourceTemplate::fromArray($data['schema']),
320 | $data['handler'],
321 | $data['isManual'] ?? false,
322 | $completionProviders,
323 | );
324 | } catch (Throwable $e) {
325 | return false;
326 | }
327 | }
328 | }
329 |
```
--------------------------------------------------------------------------------
/tests/Unit/Elements/RegisteredResourceTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Unit\Elements;
4 |
5 | use Mockery;
6 | use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
7 | use PhpMcp\Schema\Resource as ResourceSchema;
8 | use PhpMcp\Server\Context;
9 | use PhpMcp\Server\Contracts\SessionInterface;
10 | use PhpMcp\Server\Elements\RegisteredResource;
11 | use PhpMcp\Schema\Content\TextResourceContents;
12 | use PhpMcp\Schema\Content\BlobResourceContents;
13 | use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture;
14 | use Psr\Container\ContainerInterface;
15 | use PhpMcp\Server\Exception\McpServerException;
16 |
17 | uses(MockeryPHPUnitIntegration::class);
18 |
19 | beforeEach(function () {
20 | $this->container = Mockery::mock(ContainerInterface::class);
21 | $this->handlerInstance = new ResourceHandlerFixture();
22 | $this->container->shouldReceive('get')
23 | ->with(ResourceHandlerFixture::class)
24 | ->andReturn($this->handlerInstance)
25 | ->byDefault();
26 |
27 | $this->testUri = 'test://resource/item.txt';
28 | $this->resourceSchema = ResourceSchema::make($this->testUri, 'test-resource', mimeType: 'text/plain');
29 | $this->registeredResource = RegisteredResource::make(
30 | $this->resourceSchema,
31 | [ResourceHandlerFixture::class, 'returnStringText']
32 | );
33 | $this->context = new Context(Mockery::mock(SessionInterface::class));
34 | });
35 |
36 | afterEach(function () {
37 | if (ResourceHandlerFixture::$unlinkableSplFile && file_exists(ResourceHandlerFixture::$unlinkableSplFile)) {
38 | @unlink(ResourceHandlerFixture::$unlinkableSplFile);
39 | ResourceHandlerFixture::$unlinkableSplFile = null;
40 | }
41 | });
42 |
43 | it('constructs correctly and exposes schema', function () {
44 | expect($this->registeredResource->schema)->toBe($this->resourceSchema);
45 | expect($this->registeredResource->handler)->toBe([ResourceHandlerFixture::class, 'returnStringText']);
46 | expect($this->registeredResource->isManual)->toBeFalse();
47 | });
48 |
49 | it('can be made as a manual registration', function () {
50 | $manualResource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnStringText'], true);
51 | expect($manualResource->isManual)->toBeTrue();
52 | });
53 |
54 | it('passes URI to handler if handler method expects it', function () {
55 | $resource = RegisteredResource::make(
56 | ResourceSchema::make($this->testUri, 'needs-uri'),
57 | [ResourceHandlerFixture::class, 'resourceHandlerNeedsUri']
58 | );
59 |
60 | $handlerMock = Mockery::mock(ResourceHandlerFixture::class);
61 | $handlerMock->shouldReceive('resourceHandlerNeedsUri')
62 | ->with($this->testUri)
63 | ->once()
64 | ->andReturn("Confirmed URI: {$this->testUri}");
65 | $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock);
66 |
67 | $result = $resource->read($this->container, $this->testUri, $this->context);
68 | expect($result[0]->text)->toBe("Confirmed URI: {$this->testUri}");
69 | });
70 |
71 | it('does not require handler method to accept URI', function () {
72 | $resource = RegisteredResource::make(
73 | ResourceSchema::make($this->testUri, 'no-uri-param'),
74 | [ResourceHandlerFixture::class, 'resourceHandlerDoesNotNeedUri']
75 | );
76 | $handlerMock = Mockery::mock(ResourceHandlerFixture::class);
77 | $handlerMock->shouldReceive('resourceHandlerDoesNotNeedUri')->once()->andReturn("Success no URI");
78 | $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock);
79 |
80 | $result = $resource->read($this->container, $this->testUri, $this->context);
81 | expect($result[0]->text)->toBe("Success no URI");
82 | });
83 |
84 |
85 | dataset('resource_handler_return_types', [
86 | 'string_text' => ['returnStringText', 'text/plain', fn($text, $uri) => expect($text)->toBe("Plain string content for {$uri}"), null],
87 | 'string_json_guess' => ['returnStringJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_json'])->toBe($uri), null],
88 | 'string_html_guess' => ['returnStringHtml', 'text/html', fn($text, $uri) => expect($text)->toContain("<title>{$uri}</title>"), null],
89 | 'array_json_schema_mime' => ['returnArrayJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_array'])->toBe($uri), null], // schema has text/plain, overridden by array + JSON content
90 | 'empty_array' => ['returnEmptyArray', 'application/json', fn($text) => expect($text)->toBe('[]'), null],
91 | 'stream_octet' => ['returnStream', 'application/octet-stream', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Streamed content for {$uri}")],
92 | 'array_for_blob' => ['returnArrayForBlobSchema', 'application/x-custom-blob-array', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Blob for {$uri} via array")],
93 | 'array_for_text' => ['returnArrayForTextSchema', 'text/vnd.custom-array-text', fn($text, $uri) => expect($text)->toBe("Text from array for {$uri} via array"), null],
94 | 'direct_TextResourceContents' => ['returnTextResourceContents', 'text/special-contents', fn($text) => expect($text)->toBe('Direct TextResourceContents'), null],
95 | 'direct_BlobResourceContents' => ['returnBlobResourceContents', 'application/custom-blob-contents', null, fn($blob) => expect(base64_decode($blob ?? ''))->toBe('blobbycontents')],
96 | 'direct_EmbeddedResource' => ['returnEmbeddedResource', 'application/vnd.custom-embedded', fn($text) => expect($text)->toBe('Direct EmbeddedResource content'), null],
97 | ]);
98 |
99 | it('formats various handler return types correctly', function (string $handlerMethod, string $expectedMime, ?callable $textAssertion, ?callable $blobAssertion) {
100 | $schema = ResourceSchema::make($this->testUri, 'format-test');
101 | $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, $handlerMethod]);
102 |
103 | $resultContents = $resource->read($this->container, $this->testUri, $this->context);
104 |
105 | expect($resultContents)->toBeArray()->toHaveCount(1);
106 | $content = $resultContents[0];
107 |
108 | expect($content->uri)->toBe($this->testUri);
109 | expect($content->mimeType)->toBe($expectedMime);
110 |
111 | if ($textAssertion) {
112 | expect($content)->toBeInstanceOf(TextResourceContents::class);
113 | $textAssertion($content->text, $this->testUri);
114 | }
115 | if ($blobAssertion) {
116 | expect($content)->toBeInstanceOf(BlobResourceContents::class);
117 | $blobAssertion($content->blob, $this->testUri);
118 | }
119 | })->with('resource_handler_return_types');
120 |
121 | it('formats SplFileInfo based on schema MIME type (text)', function () {
122 | $schema = ResourceSchema::make($this->testUri, 'spl-text', mimeType: 'text/markdown');
123 | $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, 'returnSplFileInfo']);
124 | $result = $resource->read($this->container, $this->testUri, $this->context);
125 |
126 | expect($result[0])->toBeInstanceOf(TextResourceContents::class);
127 | expect($result[0]->mimeType)->toBe('text/markdown');
128 | expect($result[0]->text)->toBe("Content from SplFileInfo for {$this->testUri}");
129 | });
130 |
131 | it('formats SplFileInfo based on schema MIME type (blob if not text like)', function () {
132 | $schema = ResourceSchema::make($this->testUri, 'spl-blob', mimeType: 'image/png');
133 | $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, 'returnSplFileInfo']);
134 | $result = $resource->read($this->container, $this->testUri, $this->context);
135 |
136 | expect($result[0])->toBeInstanceOf(BlobResourceContents::class);
137 | expect($result[0]->mimeType)->toBe('image/png');
138 | expect(base64_decode($result[0]->blob ?? ''))->toBe("Content from SplFileInfo for {$this->testUri}");
139 | });
140 |
141 | it('formats array of ResourceContents as is', function () {
142 | $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnArrayOfResourceContents']);
143 | $results = $resource->read($this->container, $this->testUri, $this->context);
144 | expect($results)->toHaveCount(2);
145 | expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe('Part 1 of many RC');
146 | expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('pngdata'));
147 | });
148 |
149 | it('formats array of EmbeddedResources by extracting their inner resource', function () {
150 | $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnArrayOfEmbeddedResources']);
151 | $results = $resource->read($this->container, $this->testUri, $this->context);
152 | expect($results)->toHaveCount(2);
153 | expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe('<doc1/>');
154 | expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('fontdata'));
155 | });
156 |
157 | it('formats mixed array with ResourceContent/EmbeddedResource by processing each item', function () {
158 | $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnMixedArrayWithResourceTypes']);
159 | $results = $resource->read($this->container, $this->testUri, $this->context);
160 |
161 | expect($results)->toBeArray()->toHaveCount(4);
162 | expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe("A raw string piece");
163 | expect($results[1])->toBeInstanceOf(TextResourceContents::class)->text->toBe("**Markdown!**");
164 | expect($results[2])->toBeInstanceOf(TextResourceContents::class);
165 | expect(json_decode($results[2]->text, true))->toEqual(['nested_array_data' => 'value', 'for_uri' => $this->testUri]);
166 | expect($results[3])->toBeInstanceOf(TextResourceContents::class)->text->toBe("col1,col2");
167 | });
168 |
169 |
170 | it('propagates McpServerException from handler during read', function () {
171 | $resource = RegisteredResource::make(
172 | $this->resourceSchema,
173 | [ResourceHandlerFixture::class, 'resourceHandlerNeedsUri']
174 | );
175 | $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn(
176 | Mockery::mock(ResourceHandlerFixture::class, function (Mockery\MockInterface $mock) {
177 | $mock->shouldReceive('resourceHandlerNeedsUri')->andThrow(McpServerException::invalidParams("Test error"));
178 | })
179 | );
180 | $resource->read($this->container, $this->testUri, $this->context);
181 | })->throws(McpServerException::class, "Test error");
182 |
183 | it('propagates other exceptions from handler during read', function () {
184 | $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'handlerThrowsException']);
185 | $resource->read($this->container, $this->testUri, $this->context);
186 | })->throws(\DomainException::class, "Cannot read resource");
187 |
188 | it('throws RuntimeException for unformattable handler result', function () {
189 | $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnUnformattableType']);
190 | $resource->read($this->container, $this->testUri, $this->context);
191 | })->throws(\RuntimeException::class, "Cannot format resource read result for URI");
192 |
193 |
194 | it('can be serialized to array and deserialized', function () {
195 | $original = RegisteredResource::make(
196 | ResourceSchema::make(
197 | 'uri://test',
198 | 'my-resource',
199 | 'desc',
200 | 'app/foo',
201 | ),
202 | [ResourceHandlerFixture::class, 'getStaticText'],
203 | true
204 | );
205 |
206 | $array = $original->toArray();
207 |
208 | expect($array['schema']['uri'])->toBe('uri://test');
209 | expect($array['schema']['name'])->toBe('my-resource');
210 | expect($array['schema']['description'])->toBe('desc');
211 | expect($array['schema']['mimeType'])->toBe('app/foo');
212 | expect($array['handler'])->toBe([ResourceHandlerFixture::class, 'getStaticText']);
213 | expect($array['isManual'])->toBeTrue();
214 |
215 | $rehydrated = RegisteredResource::fromArray($array);
216 | expect($rehydrated)->toBeInstanceOf(RegisteredResource::class);
217 | expect($rehydrated->schema->uri)->toEqual($original->schema->uri);
218 | expect($rehydrated->schema->name)->toEqual($original->schema->name);
219 | expect($rehydrated->isManual)->toBeTrue();
220 | });
221 |
222 | it('fromArray returns false on failure', function () {
223 | $badData = ['schema' => ['uri' => 'fail']];
224 | expect(RegisteredResource::fromArray($badData))->toBeFalse();
225 | });
226 |
```
--------------------------------------------------------------------------------
/tests/Unit/Session/CacheSessionHandlerTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Unit\Session;
4 |
5 | use Mockery;
6 | use Mockery\MockInterface;
7 | use PhpMcp\Server\Session\CacheSessionHandler;
8 | use PhpMcp\Server\Contracts\SessionHandlerInterface;
9 | use Psr\SimpleCache\CacheInterface;
10 | use PhpMcp\Server\Tests\Mocks\Clock\FixedClock;
11 |
12 | const SESSION_ID_CACHE_1 = 'cache-session-id-1';
13 | const SESSION_ID_CACHE_2 = 'cache-session-id-2';
14 | const SESSION_ID_CACHE_3 = 'cache-session-id-3';
15 | const SESSION_DATA_CACHE_1 = '{"id":"cs1","data":{"a":1,"b":"foo"}}';
16 | const SESSION_DATA_CACHE_2 = '{"id":"cs2","data":{"x":true,"y":null}}';
17 | const SESSION_DATA_CACHE_3 = '{"id":"cs3","data":"simple string data"}';
18 | const DEFAULT_TTL_CACHE = 3600;
19 | const SESSION_INDEX_KEY_CACHE = 'mcp_session_index';
20 |
21 | beforeEach(function () {
22 | $this->fixedClock = new FixedClock();
23 | /** @var MockInterface&CacheInterface $cache */
24 | $this->cache = Mockery::mock(CacheInterface::class);
25 |
26 | $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault();
27 | $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true)->byDefault();
28 |
29 | $this->handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
30 | });
31 |
32 | it('implements SessionHandlerInterface', function () {
33 | expect($this->handler)->toBeInstanceOf(SessionHandlerInterface::class);
34 | });
35 |
36 | it('constructs with default TTL and SystemClock if no clock provided', function () {
37 | $cacheMock = Mockery::mock(CacheInterface::class);
38 | $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault();
39 | $handler = new CacheSessionHandler($cacheMock);
40 |
41 | expect($handler->ttl)->toBe(DEFAULT_TTL_CACHE);
42 | $reflection = new \ReflectionClass($handler);
43 | $clockProp = $reflection->getProperty('clock');
44 | $clockProp->setAccessible(true);
45 | expect($clockProp->getValue($handler))->toBeInstanceOf(\PhpMcp\Server\Defaults\SystemClock::class);
46 | });
47 |
48 | it('constructs with a custom TTL and injected clock', function () {
49 | $customTtl = 7200;
50 | $clock = new FixedClock();
51 | $cacheMock = Mockery::mock(CacheInterface::class);
52 | $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault();
53 | $handler = new CacheSessionHandler($cacheMock, $customTtl, $clock);
54 | expect($handler->ttl)->toBe($customTtl);
55 |
56 | $reflection = new \ReflectionClass($handler);
57 | $clockProp = $reflection->getProperty('clock');
58 | $clockProp->setAccessible(true);
59 | expect($clockProp->getValue($handler))->toBe($clock);
60 | });
61 |
62 | it('loads session index from cache on construction', function () {
63 | $initialTimestamp = $this->fixedClock->now()->modify('-100 seconds')->getTimestamp();
64 | $initialIndex = [SESSION_ID_CACHE_1 => $initialTimestamp];
65 |
66 | $cacheMock = Mockery::mock(CacheInterface::class);
67 | $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn($initialIndex);
68 |
69 | new CacheSessionHandler($cacheMock, DEFAULT_TTL_CACHE, $this->fixedClock);
70 | });
71 |
72 | it('reads session data from cache', function () {
73 | $sessionIndex = [SESSION_ID_CACHE_1 => $this->fixedClock->now()->modify('-100 seconds')->getTimestamp()];
74 | $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn($sessionIndex);
75 | $this->cache->shouldReceive('get')->with(SESSION_ID_CACHE_1, false)->once()->andReturn(SESSION_DATA_CACHE_1);
76 |
77 | $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
78 | $readData = $handler->read(SESSION_ID_CACHE_1);
79 | expect($readData)->toBe(SESSION_DATA_CACHE_1);
80 | });
81 |
82 | it('returns false when reading non-existent session (cache get returns default)', function () {
83 | $this->cache->shouldReceive('get')->with('non-existent-id', false)->once()->andReturn(false);
84 | $readData = $this->handler->read('non-existent-id');
85 | expect($readData)->toBeFalse();
86 | });
87 |
88 | it('writes session data to cache with correct key and TTL, and updates session index', function () {
89 | $expectedTimestamp = $this->fixedClock->now()->getTimestamp(); // 15:00:00
90 |
91 | $this->cache->shouldReceive('set')
92 | ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_1 => $expectedTimestamp])
93 | ->once()->andReturn(true);
94 | $this->cache->shouldReceive('set')
95 | ->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1)
96 | ->once()->andReturn(true);
97 |
98 | $writeResult = $this->handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1);
99 | expect($writeResult)->toBeTrue();
100 | });
101 |
102 | it('updates timestamp in session index for existing session on write', function () {
103 | $initialWriteTime = $this->fixedClock->now()->modify('-60 seconds')->getTimestamp(); // 14:59:00
104 | $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([SESSION_ID_CACHE_1 => $initialWriteTime]);
105 | $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
106 |
107 | $this->fixedClock->addSeconds(90);
108 | $expectedNewTimestamp = $this->fixedClock->now()->getTimestamp();
109 |
110 | $this->cache->shouldReceive('set')
111 | ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_1 => $expectedNewTimestamp])
112 | ->once()->andReturn(true);
113 | $this->cache->shouldReceive('set')
114 | ->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1)
115 | ->once()->andReturn(true);
116 |
117 | $handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1);
118 | });
119 |
120 | it('returns false if cache set for session data fails', function () {
121 | $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true);
122 | $this->cache->shouldReceive('set')->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1)
123 | ->once()->andReturn(false);
124 |
125 | $writeResult = $this->handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1);
126 | expect($writeResult)->toBeFalse();
127 | });
128 |
129 | it('destroys session by removing from cache and updating index', function () {
130 | $initialTimestamp = $this->fixedClock->now()->getTimestamp();
131 | $initialIndex = [SESSION_ID_CACHE_1 => $initialTimestamp, SESSION_ID_CACHE_2 => $initialTimestamp];
132 | $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
133 | $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
134 |
135 | $this->cache->shouldReceive('set')
136 | ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_2 => $initialTimestamp])
137 | ->once()->andReturn(true);
138 | $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(true);
139 |
140 | $handler->destroy(SESSION_ID_CACHE_1);
141 | });
142 |
143 | it('destroy returns true if session ID not in index (cache delete still called)', function () {
144 | $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([]); // Empty index
145 | $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE);
146 |
147 | $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true); // Index remains empty
148 | $this->cache->shouldReceive('delete')->with('non-existent-id')->once()->andReturn(true); // Cache delete for data
149 |
150 | $destroyResult = $handler->destroy('non-existent-id');
151 | expect($destroyResult)->toBeTrue();
152 | });
153 |
154 | it('destroy returns false if cache delete for session data fails', function () {
155 | $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([SESSION_ID_CACHE_1 => time()]);
156 | $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE);
157 |
158 | $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true); // Index update
159 | $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(false); // Data delete fails
160 |
161 | $destroyResult = $handler->destroy(SESSION_ID_CACHE_1);
162 | expect($destroyResult)->toBeFalse();
163 | });
164 |
165 | it('garbage collects only sessions older than maxLifetime from cache and index', function () {
166 | $maxLifetime = 120;
167 |
168 | $initialIndex = [
169 | SESSION_ID_CACHE_1 => $this->fixedClock->now()->modify('-60 seconds')->getTimestamp(),
170 | SESSION_ID_CACHE_2 => $this->fixedClock->now()->modify("-{$maxLifetime} seconds -10 seconds")->getTimestamp(),
171 | SESSION_ID_CACHE_3 => $this->fixedClock->now()->modify('-1000 seconds')->getTimestamp(),
172 | ];
173 | $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
174 | $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
175 |
176 | $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_2)->once()->andReturn(true);
177 | $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_3)->once()->andReturn(true);
178 | $this->cache->shouldNotReceive('delete')->with(SESSION_ID_CACHE_1);
179 |
180 | $expectedFinalIndex = [SESSION_ID_CACHE_1 => $initialIndex[SESSION_ID_CACHE_1]];
181 | $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $expectedFinalIndex)->once()->andReturn(true);
182 |
183 | $deletedSessionIds = $handler->gc($maxLifetime);
184 |
185 | expect($deletedSessionIds)->toBeArray()->toHaveCount(2)
186 | ->and($deletedSessionIds)->toContain(SESSION_ID_CACHE_2)
187 | ->and($deletedSessionIds)->toContain(SESSION_ID_CACHE_3);
188 | });
189 |
190 | it('garbage collection respects maxLifetime precisely for cache handler', function () {
191 | $maxLifetime = 60;
192 |
193 | $sessionTimestamp = $this->fixedClock->now()->modify("-{$maxLifetime} seconds")->getTimestamp();
194 | $initialIndex = [SESSION_ID_CACHE_1 => $sessionTimestamp];
195 | $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
196 | $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
197 |
198 | $this->cache->shouldNotReceive('delete');
199 | $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $initialIndex)->once()->andReturn(true);
200 | $deleted = $handler->gc($maxLifetime);
201 | expect($deleted)->toBeEmpty();
202 |
203 | $this->fixedClock->addSeconds(1);
204 | $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
205 | $handlerAfterTimeAdvance = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
206 |
207 | $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(true);
208 | $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true);
209 | $deleted2 = $handlerAfterTimeAdvance->gc($maxLifetime);
210 | expect($deleted2)->toEqual([SESSION_ID_CACHE_1]);
211 | });
212 |
213 |
214 | it('garbage collection handles an empty session index', function () {
215 | $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([]);
216 | $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE);
217 |
218 | $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true);
219 | $this->cache->shouldNotReceive('delete');
220 |
221 | $deletedSessions = $handler->gc(DEFAULT_TTL_CACHE);
222 | expect($deletedSessions)->toBeArray()->toBeEmpty();
223 | });
224 |
225 | it('garbage collection continues updating index even if a cache delete fails', function () {
226 | $maxLifetime = 60;
227 |
228 | $initialIndex = [
229 | 'expired_deleted_ok' => $this->fixedClock->now()->modify("-70 seconds")->getTimestamp(),
230 | 'expired_delete_fails' => $this->fixedClock->now()->modify("-80 seconds")->getTimestamp(),
231 | 'survivor' => $this->fixedClock->now()->modify('-30 seconds')->getTimestamp(),
232 | ];
233 | $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
234 | $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
235 |
236 | $this->cache->shouldReceive('delete')->with('expired_deleted_ok')->once()->andReturn(true);
237 | $this->cache->shouldReceive('delete')->with('expired_delete_fails')->once()->andReturn(false);
238 |
239 | $expectedFinalIndex = ['survivor' => $initialIndex['survivor']];
240 | $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $expectedFinalIndex)->once()->andReturn(true);
241 |
242 | $deletedSessionIds = $handler->gc($maxLifetime);
243 | expect($deletedSessionIds)->toHaveCount(2)->toContain('expired_deleted_ok')->toContain('expired_delete_fails');
244 | });
245 |
```