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 | ```