├── tests ├── Fixtures │ ├── Enums │ │ ├── UnitEnum.php │ │ ├── BackedIntEnum.php │ │ ├── BackedStringEnum.php │ │ ├── PriorityEnum.php │ │ └── StatusEnum.php │ ├── Discovery │ │ ├── SubDir │ │ │ └── HiddenTool.php │ │ ├── InvocableResourceFixture.php │ │ ├── NonDiscoverableClass.php │ │ ├── InvocablePromptFixture.php │ │ ├── InvocableResourceTemplateFixture.php │ │ ├── InvocableToolFixture.php │ │ ├── DiscoverableResourceHandler.php │ │ ├── DiscoverablePromptHandler.php │ │ ├── DiscoverableTemplateHandler.php │ │ ├── EnhancedCompletionHandler.php │ │ └── DiscoverableToolHandler.php │ ├── Middlewares │ │ ├── RequestAttributeMiddleware.php │ │ ├── ErrorMiddleware.php │ │ ├── ShortCircuitMiddleware.php │ │ ├── HeaderMiddleware.php │ │ ├── FirstMiddleware.php │ │ ├── ThirdMiddleware.php │ │ └── SecondMiddleware.php │ ├── General │ │ ├── InvokableHandlerFixture.php │ │ ├── RequestAttributeChecker.php │ │ ├── CompletionProviderFixture.php │ │ ├── DocBlockTestFixture.php │ │ ├── ToolHandlerFixture.php │ │ ├── ResourceHandlerFixture.php │ │ └── VariousTypesHandler.php │ ├── Utils │ │ ├── AttributeFixtures.php │ │ └── DockBlockParserFixture.php │ ├── ServerScripts │ │ ├── StdioTestServer.php │ │ ├── HttpTestServer.php │ │ └── StreamableHttpTestServer.php │ └── Schema │ │ └── SchemaGenerationTarget.php ├── TestCase.php ├── Unit │ ├── Attributes │ │ ├── McpToolTest.php │ │ ├── McpPromptTest.php │ │ ├── McpResourceTemplateTest.php │ │ ├── McpResourceTest.php │ │ └── CompletionProviderTest.php │ ├── Defaults │ │ ├── ListCompletionProviderTest.php │ │ └── EnumCompletionProviderTest.php │ └── ConfigurationTest.php ├── Mocks │ ├── Clock │ │ └── FixedClock.php │ └── Clients │ │ └── MockJsonHttpClient.php └── Pest.php ├── src ├── Exception │ ├── DiscoveryException.php │ ├── ConfigurationException.php │ ├── TransportException.php │ ├── ProtocolException.php │ └── McpServerException.php ├── Defaults │ ├── SystemClock.php │ ├── DefaultUuidSessionIdGenerator.php │ ├── ListCompletionProvider.php │ ├── EnumCompletionProvider.php │ ├── InMemoryEventStore.php │ └── ArrayCache.php ├── Context.php ├── Contracts │ ├── LoggerAwareInterface.php │ ├── LoopAwareInterface.php │ ├── CompletionProviderInterface.php │ ├── SessionHandlerInterface.php │ ├── EventStoreInterface.php │ ├── ServerTransportInterface.php │ └── SessionInterface.php ├── Attributes │ ├── McpPrompt.php │ ├── McpTool.php │ ├── CompletionProvider.php │ ├── McpResourceTemplate.php │ └── McpResource.php ├── Configuration.php ├── Session │ ├── ArraySessionHandler.php │ ├── CacheSessionHandler.php │ ├── SubscriptionManager.php │ ├── SessionManager.php │ └── Session.php ├── Utils │ ├── HandlerResolver.php │ └── DocBlockParser.php └── Elements │ └── RegisteredTool.php ├── .php-cs-fixer.php ├── examples ├── 07-complex-tool-schema-http │ ├── EventTypes.php │ ├── McpEventScheduler.php │ └── server.php ├── 02-discovery-http-userprofile │ ├── UserIdCompletionProvider.php │ ├── McpElements.php │ └── server.php ├── 04-combined-registration-http │ ├── DiscoveredElements.php │ ├── ManualHandlers.php │ └── server.php ├── 05-stdio-env-variables │ ├── EnvToolHandler.php │ └── server.php ├── 03-manual-registration-stdio │ ├── SimpleHandlers.php │ └── server.php ├── 08-schema-showcase-streamable │ └── server.php ├── 01-discovery-stdio-calculator │ ├── server.php │ └── McpElements.php └── 06-custom-dependencies-stdio │ ├── McpTaskHandlers.php │ ├── Services.php │ └── server.php ├── phpunit.xml ├── .github └── workflows │ ├── changelog.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── composer.json └── CONTRIBUTING.md /tests/Fixtures/Enums/UnitEnum.php: -------------------------------------------------------------------------------- 1 | exclude([ 5 | 'examples', 6 | 'vendor', 7 | 'tests/Mocks', 8 | ]) 9 | ->in(__DIR__); 10 | 11 | return (new PhpCsFixer\Config) 12 | ->setRules([ 13 | '@PSR12' => true, 14 | 'array_syntax' => ['syntax' => 'short'], 15 | ]) 16 | ->setFinder($finder); 17 | -------------------------------------------------------------------------------- /examples/07-complex-tool-schema-http/EventTypes.php: -------------------------------------------------------------------------------- 1 | "OK", "load" => rand(1, 100) / 100.0]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/Fixtures/Middlewares/RequestAttributeMiddleware.php: -------------------------------------------------------------------------------- 1 | withAttribute('middleware-attr', 'middleware-value'); 14 | return $next($request); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Defaults/DefaultUuidSessionIdGenerator.php: -------------------------------------------------------------------------------- 1 | getUri()->getPath(), '/error-middleware')) { 14 | throw new \Exception('Middleware error'); 15 | } 16 | return $next($request); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/NonDiscoverableClass.php: -------------------------------------------------------------------------------- 1 | 'user', 'content' => "Generate a short greeting for {$personName}."]]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Fixtures/General/InvokableHandlerFixture.php: -------------------------------------------------------------------------------- 1 | type = $type; 13 | } 14 | 15 | public function __invoke(string $arg1, int $arg2 = 0): array 16 | { 17 | $this->argsReceived = func_get_args(); 18 | return ['invoked' => $this->type, 'arg1' => $arg1, 'arg2' => $arg2]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Contracts/CompletionProviderInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests 10 | 11 | 12 | 13 | 14 | src 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/InvocableResourceTemplateFixture.php: -------------------------------------------------------------------------------- 1 | $userId, "email" => "user{$userId}@example-invokable.com"]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Fixtures/Middlewares/ShortCircuitMiddleware.php: -------------------------------------------------------------------------------- 1 | getUri()->getPath(), '/short-circuit')) { 15 | return new Response(418, [], 'Short-circuited by middleware'); 16 | } 17 | return $next($request); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Fixtures/General/RequestAttributeChecker.php: -------------------------------------------------------------------------------- 1 | request->getAttribute('middleware-attr'); 15 | if ($attribute === 'middleware-value') { 16 | return TextContent::make('middleware-value-found: ' . $attribute); 17 | } 18 | 19 | return TextContent::make('middleware-value-not-found: ' . $attribute); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/02-discovery-http-userprofile/UserIdCompletionProvider.php: -------------------------------------------------------------------------------- 1 | str_contains($userId, $currentValue)); 16 | 17 | return $filteredUserIds; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/InvocableToolFixture.php: -------------------------------------------------------------------------------- 1 | getMessage(), 22 | data: null 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Attributes/McpTool.php: -------------------------------------------------------------------------------- 1 | values; 18 | } 19 | 20 | return array_values(array_filter( 21 | $this->values, 22 | fn(string $value) => str_starts_with($value, $currentValue) 23 | )); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: "Update Changelog" 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | update: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | with: 18 | ref: main 19 | 20 | - name: Update Changelog 21 | uses: stefanzweifel/changelog-updater-action@v1 22 | with: 23 | latest-version: ${{ github.event.release.name }} 24 | release-notes: ${{ github.event.release.body }} 25 | 26 | - name: Commit updated CHANGELOG 27 | uses: stefanzweifel/git-auto-commit-action@v5 28 | with: 29 | branch: main 30 | commit_message: Update CHANGELOG 31 | file_pattern: CHANGELOG.md -------------------------------------------------------------------------------- /tests/Fixtures/General/CompletionProviderFixture.php: -------------------------------------------------------------------------------- 1 | str_starts_with($item, $currentValue)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Exception/ProtocolException.php: -------------------------------------------------------------------------------- 1 | code >= -32700 && $this->code <= -32600) ? $this->code : self::CODE_INVALID_REQUEST; 19 | 20 | return new JsonRpcError( 21 | jsonrpc: '2.0', 22 | id: $id, 23 | code: $code, 24 | message: $this->getMessage(), 25 | data: $this->getData() 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/04-combined-registration-http/DiscoveredElements.php: -------------------------------------------------------------------------------- 1 | $result->then(fn($response) => $this->handle($response)), 19 | $result instanceof ResponseInterface => $this->handle($result), 20 | default => $result 21 | }; 22 | } 23 | 24 | private function handle($response) 25 | { 26 | return $response instanceof ResponseInterface 27 | ? $response->withHeader('X-Test-Middleware', 'header-added') 28 | : $response; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: ['push', 'pull_request'] 4 | 5 | jobs: 6 | ci: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | php: [8.1, 8.2, 8.3, 8.4] 13 | max-parallel: 2 14 | 15 | name: Tests PHP${{ matrix.php }} 16 | 17 | steps: 18 | 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Cache dependencies 23 | uses: actions/cache@v4 24 | with: 25 | path: ~/.composer/cache/files 26 | key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 27 | 28 | - name: Setup PHP 29 | uses: shivammathur/setup-php@v2 30 | with: 31 | php-version: ${{ matrix.php }} 32 | coverage: none 33 | 34 | - name: Install Composer dependencies 35 | run: composer update --no-interaction --prefer-dist 36 | 37 | - name: Run Tests 38 | run: composer test 39 | -------------------------------------------------------------------------------- /src/Attributes/CompletionProvider.php: -------------------------------------------------------------------------------- 1 | |null $providerClass 15 | * @param class-string|CompletionProviderInterface|null $provider If a class-string, it will be resolved from the container at the point of use. 16 | */ 17 | public function __construct( 18 | public ?string $providerClass = null, 19 | public string|CompletionProviderInterface|null $provider = null, 20 | public ?array $values = null, 21 | public ?string $enum = null, 22 | ) { 23 | if (count(array_filter([$provider, $values, $enum])) !== 1) { 24 | throw new \InvalidArgumentException('Only one of provider, values, or enum can be set'); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Composer dependencies 2 | /vendor/ 3 | /composer.lock 4 | 5 | # PHPUnit 6 | .phpunit.result.cache 7 | 8 | # PHP CS Fixer 9 | /.php-cs-fixer.cache 10 | 11 | # Editor directories and files 12 | /.idea 13 | /.vscode 14 | *.sublime-project 15 | *.sublime-workspace 16 | 17 | # Operating system files 18 | .DS_Store 19 | Thumbs.db 20 | 21 | # Local environment files 22 | /.env 23 | /.env.backup 24 | /.env.local 25 | 26 | # PHP CodeSniffer 27 | /.phpcs.xml 28 | /.phpcs.xml.dist 29 | /phpcs.xml 30 | /phpcs.xml.dist 31 | 32 | # PHPStan 33 | /phpstan.neon 34 | /phpstan.neon.dist 35 | 36 | # Local development tools 37 | /.php_cs 38 | /.php_cs.cache 39 | /.php_cs.dist 40 | /_ide_helper.php 41 | 42 | # Build artifacts 43 | /build/ 44 | /coverage/ 45 | 46 | # PHPUnit coverage reports 47 | /clover.xml 48 | /coverage.xml 49 | /coverage/ 50 | 51 | # Laravel generated files 52 | bootstrap/cache/ 53 | .phpunit.result.cache 54 | 55 | # Local Composer dependencies 56 | composer.phar 57 | 58 | workbench 59 | playground 60 | 61 | # Log files 62 | *.log 63 | 64 | # Cache files 65 | cache -------------------------------------------------------------------------------- /tests/Fixtures/Middlewares/FirstMiddleware.php: -------------------------------------------------------------------------------- 1 | $result->then(fn($response) => $this->handle($response)), 19 | $result instanceof ResponseInterface => $this->handle($result), 20 | default => $result 21 | }; 22 | } 23 | 24 | private function handle($response) 25 | { 26 | if ($response instanceof ResponseInterface) { 27 | $existing = $response->getHeaderLine('X-Middleware-Order'); 28 | $new = $existing ? $existing . ',first' : 'first'; 29 | return $response->withHeader('X-Middleware-Order', $new); 30 | } 31 | return $response; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Fixtures/Middlewares/ThirdMiddleware.php: -------------------------------------------------------------------------------- 1 | $result->then(fn($response) => $this->handle($response)), 19 | $result instanceof ResponseInterface => $this->handle($result), 20 | default => $result 21 | }; 22 | } 23 | 24 | private function handle($response) 25 | { 26 | if ($response instanceof ResponseInterface) { 27 | $existing = $response->getHeaderLine('X-Middleware-Order'); 28 | $new = $existing ? $existing . ',third' : 'third'; 29 | return $response->withHeader('X-Middleware-Order', $new); 30 | } 31 | return $response; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kyrian Obikwelu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tests/Fixtures/Middlewares/SecondMiddleware.php: -------------------------------------------------------------------------------- 1 | $result->then(fn($response) => $this->handle($response)), 19 | $result instanceof ResponseInterface => $this->handle($result), 20 | default => $result 21 | }; 22 | } 23 | 24 | private function handle($response) 25 | { 26 | if ($response instanceof ResponseInterface) { 27 | $existing = $response->getHeaderLine('X-Middleware-Order'); 28 | $new = $existing ? $existing . ',second' : 'second'; 29 | return $response->withHeader('X-Middleware-Order', $new); 30 | } 31 | return $response; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/04-combined-registration-http/ManualHandlers.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 14 | } 15 | 16 | /** 17 | * A manually registered tool. 18 | * 19 | * @param string $user The user to greet. 20 | * @return string Greeting. 21 | */ 22 | public function manualGreeter(string $user): string 23 | { 24 | $this->logger->info("Manual tool 'manual_greeter' called for {$user}"); 25 | 26 | return "Hello {$user}, from manual registration!"; 27 | } 28 | 29 | /** 30 | * Manually registered resource that overrides a discovered one. 31 | * 32 | * @return string Content. 33 | */ 34 | public function getPriorityConfigManual(): string 35 | { 36 | $this->logger->info("Manual resource 'config://priority' read."); 37 | 38 | return 'Manual Priority Config: HIGH (overrides discovered)'; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Contracts/SessionHandlerInterface.php: -------------------------------------------------------------------------------- 1 | name)->toBe($name); 17 | expect($attribute->description)->toBe($description); 18 | }); 19 | 20 | it('instantiates with null values for name and description', function () { 21 | // Arrange & Act 22 | $attribute = new McpTool(name: null, description: null); 23 | 24 | // Assert 25 | expect($attribute->name)->toBeNull(); 26 | expect($attribute->description)->toBeNull(); 27 | }); 28 | 29 | it('instantiates with missing optional arguments', function () { 30 | // Arrange & Act 31 | $attribute = new McpTool(); // Use default constructor values 32 | 33 | // Assert 34 | expect($attribute->name)->toBeNull(); 35 | expect($attribute->description)->toBeNull(); 36 | }); 37 | -------------------------------------------------------------------------------- /src/Defaults/EnumCompletionProvider.php: -------------------------------------------------------------------------------- 1 | values = array_map( 21 | fn($case) => isset($case->value) && is_string($case->value) ? $case->value : $case->name, 22 | $enumClass::cases() 23 | ); 24 | } 25 | 26 | public function getCompletions(string $currentValue, SessionInterface $session): array 27 | { 28 | if (empty($currentValue)) { 29 | return $this->values; 30 | } 31 | 32 | return array_values(array_filter( 33 | $this->values, 34 | fn(string $value) => str_starts_with($value, $currentValue) 35 | )); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Unit/Attributes/McpPromptTest.php: -------------------------------------------------------------------------------- 1 | name)->toBe($name); 17 | expect($attribute->description)->toBe($description); 18 | }); 19 | 20 | it('instantiates with null values for name and description', function () { 21 | // Arrange & Act 22 | $attribute = new McpPrompt(name: null, description: null); 23 | 24 | // Assert 25 | expect($attribute->name)->toBeNull(); 26 | expect($attribute->description)->toBeNull(); 27 | }); 28 | 29 | it('instantiates with missing optional arguments', function () { 30 | // Arrange & Act 31 | $attribute = new McpPrompt(); // Use default constructor values 32 | 33 | // Assert 34 | expect($attribute->name)->toBeNull(); 35 | expect($attribute->description)->toBeNull(); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/DiscoverableResourceHandler.php: -------------------------------------------------------------------------------- 1 | "dark", "fontSize" => 14]; 37 | } 38 | 39 | public function someOtherMethod(): void 40 | { 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Contracts/EventStoreInterface.php: -------------------------------------------------------------------------------- 1 | "user", "content" => "Write a {$genre} story about a lost robot, approximately {$lengthWords} words long."] 27 | ]; 28 | } 29 | 30 | #[McpPrompt] 31 | public function simpleQuestionPrompt(string $question): array 32 | { 33 | return [ 34 | ["role" => "user", "content" => $question], 35 | ["role" => "assistant", "content" => "I will try to answer that."] 36 | ]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Attributes/McpResource.php: -------------------------------------------------------------------------------- 1 | 'debug', 28 | 'processed_input' => strtoupper($input), 29 | 'message' => 'Processed in DEBUG mode.', 30 | ]; 31 | } elseif ($appMode === 'production') { 32 | return [ 33 | 'mode' => 'production', 34 | 'processed_input_length' => strlen($input), 35 | 'message' => 'Processed in PRODUCTION mode (summary only).', 36 | ]; 37 | } else { 38 | return [ 39 | 'mode' => $appMode ?: 'default', 40 | 'original_input' => $input, 41 | 'message' => 'Processed in default mode (APP_MODE not recognized or not set).', 42 | ]; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/DiscoverableTemplateHandler.php: -------------------------------------------------------------------------------- 1 | $productId, 31 | "name" => "Product " . $productId, 32 | "region" => $region, 33 | "price" => ($region === "EU" ? "€" : "$") . (hexdec(substr(md5($productId), 0, 4)) / 100) 34 | ]; 35 | } 36 | 37 | #[McpResourceTemplate(uriTemplate: "file://{path}/{filename}.{extension}")] 38 | public function getFileContent(string $path, string $filename, string $extension): string 39 | { 40 | return "Content of {$path}/{$filename}.{$extension}"; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/EnhancedCompletionHandler.php: -------------------------------------------------------------------------------- 1 | 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority}"] 29 | ]; 30 | } 31 | 32 | /** 33 | * Resource template with list completion for categories. 34 | */ 35 | #[McpResourceTemplate( 36 | uriTemplate: 'content://{category}/{slug}', 37 | name: 'content_template' 38 | )] 39 | public function getContent( 40 | #[CompletionProvider(values: ['news', 'blog', 'docs', 'api'])] 41 | string $category, 42 | string $slug 43 | ): array { 44 | return [ 45 | 'category' => $category, 46 | 'slug' => $slug, 47 | 'url' => "https://example.com/{$category}/{$slug}" 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Configuration.php: -------------------------------------------------------------------------------- 1 | info('StdioTestServer listener starting.'); 27 | 28 | $server = Server::make() 29 | ->withServerInfo('StdioIntegrationTestServer', '0.1.0') 30 | ->withLogger($logger) 31 | ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_stdio_tool') 32 | ->withTool([ToolHandlerFixture::class, 'toolReadsContext'], 'tool_reads_context') // for Context testing 33 | ->withResource([ResourceHandlerFixture::class, 'getStaticText'], 'test://stdio/static', 'static_stdio_resource') 34 | ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_stdio_prompt') 35 | ->build(); 36 | 37 | $transport = new StdioServerTransport(); 38 | $server->listen($transport); 39 | 40 | $logger->info('StdioTestServer listener stopped.'); 41 | exit(0); 42 | } catch (\Throwable $e) { 43 | fwrite(STDERR, "[STDIO_SERVER_CRITICAL_ERROR]\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); 44 | exit(1); 45 | } 46 | -------------------------------------------------------------------------------- /tests/Unit/Attributes/McpResourceTemplateTest.php: -------------------------------------------------------------------------------- 1 | uriTemplate)->toBe($uriTemplate); 24 | expect($attribute->name)->toBe($name); 25 | expect($attribute->description)->toBe($description); 26 | expect($attribute->mimeType)->toBe($mimeType); 27 | }); 28 | 29 | it('instantiates with null values for name and description', function () { 30 | // Arrange & Act 31 | $attribute = new McpResourceTemplate( 32 | uriTemplate: 'test://{id}', // uriTemplate is required 33 | name: null, 34 | description: null, 35 | mimeType: null, 36 | ); 37 | 38 | // Assert 39 | expect($attribute->uriTemplate)->toBe('test://{id}'); 40 | expect($attribute->name)->toBeNull(); 41 | expect($attribute->description)->toBeNull(); 42 | expect($attribute->mimeType)->toBeNull(); 43 | }); 44 | 45 | it('instantiates with missing optional arguments', function () { 46 | // Arrange & Act 47 | $uriTemplate = 'tmpl://{key}'; 48 | $attribute = new McpResourceTemplate(uriTemplate: $uriTemplate); 49 | 50 | // Assert 51 | expect($attribute->uriTemplate)->toBe($uriTemplate); 52 | expect($attribute->name)->toBeNull(); 53 | expect($attribute->description)->toBeNull(); 54 | expect($attribute->mimeType)->toBeNull(); 55 | }); 56 | -------------------------------------------------------------------------------- /tests/Unit/Attributes/McpResourceTest.php: -------------------------------------------------------------------------------- 1 | uri)->toBe($uri); 26 | expect($attribute->name)->toBe($name); 27 | expect($attribute->description)->toBe($description); 28 | expect($attribute->mimeType)->toBe($mimeType); 29 | expect($attribute->size)->toBe($size); 30 | }); 31 | 32 | it('instantiates with null values for name and description', function () { 33 | // Arrange & Act 34 | $attribute = new McpResource( 35 | uri: 'file:///test', // URI is required 36 | name: null, 37 | description: null, 38 | mimeType: null, 39 | size: null, 40 | ); 41 | 42 | // Assert 43 | expect($attribute->uri)->toBe('file:///test'); 44 | expect($attribute->name)->toBeNull(); 45 | expect($attribute->description)->toBeNull(); 46 | expect($attribute->mimeType)->toBeNull(); 47 | expect($attribute->size)->toBeNull(); 48 | }); 49 | 50 | it('instantiates with missing optional arguments', function () { 51 | // Arrange & Act 52 | $uri = 'file:///only-uri'; 53 | $attribute = new McpResource(uri: $uri); 54 | 55 | // Assert 56 | expect($attribute->uri)->toBe($uri); 57 | expect($attribute->name)->toBeNull(); 58 | expect($attribute->description)->toBeNull(); 59 | expect($attribute->mimeType)->toBeNull(); 60 | expect($attribute->size)->toBeNull(); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/DiscoverableToolHandler.php: -------------------------------------------------------------------------------- 1 | $count, 'loudly' => $loudly, 'mode' => $mode->value, 'message' => "Action repeated."]; 35 | } 36 | 37 | // This method should NOT be discovered as a tool 38 | public function internalHelperMethod(int $value): int 39 | { 40 | return $value * 2; 41 | } 42 | 43 | #[McpTool(name: "private_tool_should_be_ignored")] // On private method 44 | private function aPrivateTool(): void 45 | { 46 | } 47 | 48 | #[McpTool(name: "protected_tool_should_be_ignored")] // On protected method 49 | protected function aProtectedTool(): void 50 | { 51 | } 52 | 53 | #[McpTool(name: "static_tool_should_be_ignored")] // On static method 54 | public static function aStaticTool(): void 55 | { 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Session/ArraySessionHandler.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | protected array $store = []; 17 | 18 | private ClockInterface $clock; 19 | 20 | public function __construct( 21 | public readonly int $ttl = 3600, 22 | ?ClockInterface $clock = null 23 | ) { 24 | $this->clock = $clock ?? new SystemClock(); 25 | } 26 | 27 | public function read(string $sessionId): string|false 28 | { 29 | $session = $this->store[$sessionId] ?? ''; 30 | if ($session === '') { 31 | return false; 32 | } 33 | 34 | $currentTimestamp = $this->clock->now()->getTimestamp(); 35 | 36 | if ($currentTimestamp - $session['timestamp'] > $this->ttl) { 37 | unset($this->store[$sessionId]); 38 | return false; 39 | } 40 | 41 | return $session['data']; 42 | } 43 | 44 | public function write(string $sessionId, string $data): bool 45 | { 46 | $this->store[$sessionId] = [ 47 | 'data' => $data, 48 | 'timestamp' => $this->clock->now()->getTimestamp(), 49 | ]; 50 | 51 | return true; 52 | } 53 | 54 | public function destroy(string $sessionId): bool 55 | { 56 | if (isset($this->store[$sessionId])) { 57 | unset($this->store[$sessionId]); 58 | } 59 | 60 | return true; 61 | } 62 | 63 | public function gc(int $maxLifetime): array 64 | { 65 | $currentTimestamp = $this->clock->now()->getTimestamp(); 66 | $deletedSessions = []; 67 | 68 | foreach ($this->store as $sessionId => $session) { 69 | if ($currentTimestamp - $session['timestamp'] > $maxLifetime) { 70 | unset($this->store[$sessionId]); 71 | $deletedSessions[] = $sessionId; 72 | } 73 | } 74 | 75 | return $deletedSessions; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /examples/03-manual-registration-stdio/SimpleHandlers.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 16 | $this->logger->info('SimpleHandlers instantiated for manual registration example.'); 17 | } 18 | 19 | /** 20 | * A manually registered tool to echo input. 21 | * 22 | * @param string $text The text to echo. 23 | * @return string The echoed text. 24 | */ 25 | public function echoText(string $text): string 26 | { 27 | $this->logger->info("Manual tool 'echo_text' called.", ['text' => $text]); 28 | 29 | return 'Echo: '.$text; 30 | } 31 | 32 | /** 33 | * A manually registered resource providing app version. 34 | * 35 | * @return string The application version. 36 | */ 37 | public function getAppVersion(): string 38 | { 39 | $this->logger->info("Manual resource 'app://version' read."); 40 | 41 | return $this->appVersion; 42 | } 43 | 44 | /** 45 | * A manually registered prompt template. 46 | * 47 | * @param string $userName The name of the user. 48 | * @return array The prompt messages. 49 | */ 50 | public function greetingPrompt(string $userName): array 51 | { 52 | $this->logger->info("Manual prompt 'personalized_greeting' called.", ['userName' => $userName]); 53 | 54 | return [ 55 | ['role' => 'user', 'content' => "Craft a personalized greeting for {$userName}."], 56 | ]; 57 | } 58 | 59 | /** 60 | * A manually registered resource template. 61 | * 62 | * @param string $itemId The ID of the item. 63 | * @return array Item details. 64 | */ 65 | public function getItemDetails(string $itemId): array 66 | { 67 | $this->logger->info("Manual template 'item://{itemId}' resolved.", ['itemId' => $itemId]); 68 | 69 | return ['id' => $itemId, 'name' => "Item {$itemId}", 'description' => "Details for item {$itemId} from manual template."]; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Fixtures/General/DocBlockTestFixture.php: -------------------------------------------------------------------------------- 1 | $param5 Array description. 38 | * @param \stdClass $param6 Object param. 39 | */ 40 | public function methodWithParams(string $param1, ?int $param2, bool $param3, $param4, array $param5, \stdClass $param6): void 41 | { 42 | } 43 | 44 | /** 45 | * Method with return tag. 46 | * 47 | * @return string The result of the operation. 48 | */ 49 | public function methodWithReturn(): string 50 | { 51 | return ''; 52 | } 53 | 54 | /** 55 | * Method with multiple tags. 56 | * 57 | * @param float $value The value to process. 58 | * @return bool Status of the operation. 59 | * @throws \RuntimeException If processing fails. 60 | * @deprecated Use newMethod() instead. 61 | * @see \PhpMcp\Server\Tests\Fixtures\General\DocBlockTestFixture::newMethod() 62 | */ 63 | public function methodWithMultipleTags(float $value): bool 64 | { 65 | return true; 66 | } 67 | 68 | /** 69 | * Malformed docblock - missing closing 70 | */ 71 | public function methodWithMalformedDocBlock(): void 72 | { 73 | } 74 | 75 | public function methodWithNoDocBlock(): void 76 | { 77 | } 78 | 79 | // Some other method needed for a @see tag perhaps 80 | public function newMethod(): void 81 | { 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/Mocks/Clock/FixedClock.php: -------------------------------------------------------------------------------- 1 | currentTime = $initialTime; 20 | } else { 21 | $this->currentTime = new DateTimeImmutable($initialTime, $timezone); 22 | } 23 | } 24 | 25 | public function now(): DateTimeImmutable 26 | { 27 | return $this->currentTime; 28 | } 29 | 30 | public function setCurrentTime(string|DateTimeImmutable $newTime, ?DateTimeZone $timezone = null): void 31 | { 32 | if ($newTime instanceof DateTimeImmutable) { 33 | $this->currentTime = $newTime; 34 | } else { 35 | $this->currentTime = new DateTimeImmutable($newTime, $timezone); 36 | } 37 | } 38 | 39 | public function advance(DateInterval $interval): void 40 | { 41 | $this->currentTime = $this->currentTime->add($interval); 42 | } 43 | 44 | public function rewind(DateInterval $interval): void 45 | { 46 | $this->currentTime = $this->currentTime->sub($interval); 47 | } 48 | 49 | public function addSecond(): void 50 | { 51 | $this->advance(new DateInterval("PT1S")); 52 | } 53 | 54 | public function addSeconds(int $seconds): void 55 | { 56 | $this->advance(new DateInterval("PT{$seconds}S")); 57 | } 58 | 59 | public function addMinutes(int $minutes): void 60 | { 61 | $this->advance(new DateInterval("PT{$minutes}M")); 62 | } 63 | 64 | public function addHours(int $hours): void 65 | { 66 | $this->advance(new DateInterval("PT{$hours}H")); 67 | } 68 | 69 | public function subSeconds(int $seconds): void 70 | { 71 | $this->rewind(new DateInterval("PT{$seconds}S")); 72 | } 73 | 74 | public function subMinutes(int $minutes): void 75 | { 76 | $this->rewind(new DateInterval("PT{$minutes}M")); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Contracts/ServerTransportInterface.php: -------------------------------------------------------------------------------- 1 | Resolves on successful send/queue, rejects on specific send error. 43 | */ 44 | public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface; 45 | 46 | /** 47 | * Stops the transport listener gracefully and closes all active connections. 48 | * MUST eventually emit a 'close' event for the transport itself. 49 | * Individual client disconnects should emit 'client_disconnected' events. 50 | */ 51 | public function close(): void; 52 | } 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "php-mcp/server", 3 | "description": "PHP SDK for building Model Context Protocol (MCP) servers - Create MCP tools, resources, and prompts", 4 | "keywords": [ 5 | "mcp", 6 | "model context protocol", 7 | "server", 8 | "php", 9 | "php mcp", 10 | "php mcp sdk", 11 | "php mcp server", 12 | "php mcp tools", 13 | "php mcp resources", 14 | "php mcp prompts", 15 | "php model context protocol" 16 | ], 17 | "type": "library", 18 | "license": "MIT", 19 | "authors": [ 20 | { 21 | "name": "Kyrian Obikwelu", 22 | "email": "koshnawaza@gmail.com" 23 | } 24 | ], 25 | "require": { 26 | "php": ">=8.1", 27 | "opis/json-schema": "^2.4", 28 | "php-mcp/schema": "^1.0", 29 | "phpdocumentor/reflection-docblock": "^5.6", 30 | "psr/clock": "^1.0", 31 | "psr/container": "^1.0 || ^2.0", 32 | "psr/log": "^1.0 || ^2.0 || ^3.0", 33 | "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", 34 | "react/event-loop": "^1.5", 35 | "react/http": "^1.11", 36 | "react/promise": "^3.0", 37 | "react/stream": "^1.4", 38 | "symfony/finder": "^6.4 || ^7.2" 39 | }, 40 | "require-dev": { 41 | "friendsofphp/php-cs-fixer": "^3.75", 42 | "mockery/mockery": "^1.6", 43 | "pestphp/pest": "^2.36.0|^3.5.0", 44 | "react/async": "^4.0", 45 | "react/child-process": "^0.6.6", 46 | "symfony/var-dumper": "^6.4.11|^7.1.5" 47 | }, 48 | "suggest": { 49 | "ext-pcntl": "For signal handling support when using StdioServerTransport with StreamSelectLoop" 50 | }, 51 | "autoload": { 52 | "psr-4": { 53 | "PhpMcp\\Server\\": "src/" 54 | } 55 | }, 56 | "autoload-dev": { 57 | "psr-4": { 58 | "PhpMcp\\Server\\Tests\\": "tests/" 59 | } 60 | }, 61 | "scripts": { 62 | "test": "vendor/bin/pest", 63 | "test:coverage": "XDEBUG_MODE=coverage ./vendor/bin/pest --coverage", 64 | "lint": "vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php" 65 | }, 66 | "config": { 67 | "sort-packages": true, 68 | "allow-plugins": { 69 | "pestphp/pest-plugin": true 70 | } 71 | }, 72 | "minimum-stability": "dev", 73 | "prefer-stable": true 74 | } -------------------------------------------------------------------------------- /tests/Unit/Defaults/ListCompletionProviderTest.php: -------------------------------------------------------------------------------- 1 | session = Mockery::mock(SessionInterface::class); 13 | }); 14 | 15 | it('returns all values when current value is empty', function () { 16 | $values = ['apple', 'banana', 'cherry']; 17 | $provider = new ListCompletionProvider($values); 18 | 19 | $result = $provider->getCompletions('', $this->session); 20 | 21 | expect($result)->toBe($values); 22 | }); 23 | 24 | it('filters values based on current value prefix', function () { 25 | $values = ['apple', 'apricot', 'banana', 'cherry']; 26 | $provider = new ListCompletionProvider($values); 27 | 28 | $result = $provider->getCompletions('ap', $this->session); 29 | 30 | expect($result)->toBe(['apple', 'apricot']); 31 | }); 32 | 33 | it('returns empty array when no values match', function () { 34 | $values = ['apple', 'banana', 'cherry']; 35 | $provider = new ListCompletionProvider($values); 36 | 37 | $result = $provider->getCompletions('xyz', $this->session); 38 | 39 | expect($result)->toBe([]); 40 | }); 41 | 42 | it('works with single character prefix', function () { 43 | $values = ['apple', 'banana', 'cherry']; 44 | $provider = new ListCompletionProvider($values); 45 | 46 | $result = $provider->getCompletions('a', $this->session); 47 | 48 | expect($result)->toBe(['apple']); 49 | }); 50 | 51 | it('is case sensitive by default', function () { 52 | $values = ['Apple', 'apple', 'APPLE']; 53 | $provider = new ListCompletionProvider($values); 54 | 55 | $result = $provider->getCompletions('A', $this->session); 56 | 57 | expect($result)->toEqual(['Apple', 'APPLE']); 58 | }); 59 | 60 | it('handles empty values array', function () { 61 | $provider = new ListCompletionProvider([]); 62 | 63 | $result = $provider->getCompletions('test', $this->session); 64 | 65 | expect($result)->toBe([]); 66 | }); 67 | 68 | it('preserves array order', function () { 69 | $values = ['zebra', 'apple', 'banana']; 70 | $provider = new ListCompletionProvider($values); 71 | 72 | $result = $provider->getCompletions('', $this->session); 73 | 74 | expect($result)->toBe(['zebra', 'apple', 'banana']); 75 | }); 76 | -------------------------------------------------------------------------------- /src/Defaults/InMemoryEventStore.php: -------------------------------------------------------------------------------- 1 | 20 | * Example: [eventId1 => ['streamId' => 'abc', 'message' => '...']] 21 | */ 22 | private array $events = []; 23 | 24 | private function generateEventId(string $streamId): string 25 | { 26 | return $streamId . '_' . (int)(microtime(true) * 1000) . '_' . bin2hex(random_bytes(4)); 27 | } 28 | 29 | private function getStreamIdFromEventId(string $eventId): ?string 30 | { 31 | $parts = explode('_', $eventId); 32 | return $parts[0] ?? null; 33 | } 34 | 35 | public function storeEvent(string $streamId, string $message): string 36 | { 37 | $eventId = $this->generateEventId($streamId); 38 | 39 | $this->events[$eventId] = [ 40 | 'streamId' => $streamId, 41 | 'message' => $message, 42 | ]; 43 | 44 | return $eventId; 45 | } 46 | 47 | public function replayEventsAfter(string $lastEventId, callable $sendCallback): void 48 | { 49 | if (!isset($this->events[$lastEventId])) { 50 | return; 51 | } 52 | 53 | $streamId = $this->getStreamIdFromEventId($lastEventId); 54 | if ($streamId === null) { 55 | return; 56 | } 57 | 58 | $foundLastEvent = false; 59 | 60 | // Sort by eventId for deterministic ordering 61 | ksort($this->events); 62 | 63 | foreach ($this->events as $eventId => ['streamId' => $eventStreamId, 'message' => $message]) { 64 | if ($eventStreamId !== $streamId) { 65 | continue; 66 | } 67 | 68 | if ($eventId === $lastEventId) { 69 | $foundLastEvent = true; 70 | continue; 71 | } 72 | 73 | if ($foundLastEvent) { 74 | $sendCallback($eventId, $message); 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /examples/05-stdio-env-variables/server.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | info('Starting MCP Stdio Environment Variable Example Server...'); 56 | 57 | $server = Server::make() 58 | ->withServerInfo('Env Var Server', '1.0.0') 59 | ->withLogger($logger) 60 | ->build(); 61 | 62 | $server->discover(__DIR__, ['.']); 63 | 64 | $transport = new StdioServerTransport(); 65 | $server->listen($transport); 66 | 67 | $logger->info('Server listener stopped gracefully.'); 68 | exit(0); 69 | 70 | } catch (\Throwable $e) { 71 | fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); 72 | exit(1); 73 | } 74 | -------------------------------------------------------------------------------- /src/Contracts/SessionInterface.php: -------------------------------------------------------------------------------- 1 | 75 | */ 76 | public function dequeueMessages(): array; 77 | 78 | /** 79 | * Check if there are any messages in the queue. 80 | */ 81 | public function hasQueuedMessages(): bool; 82 | 83 | /** 84 | * Get the session handler instance. 85 | * 86 | * @return SessionHandlerInterface 87 | */ 88 | public function getHandler(): SessionHandlerInterface; 89 | } 90 | -------------------------------------------------------------------------------- /tests/Unit/Attributes/CompletionProviderTest.php: -------------------------------------------------------------------------------- 1 | provider)->toBe(CompletionProviderFixture::class); 17 | expect($attribute->values)->toBeNull(); 18 | expect($attribute->enum)->toBeNull(); 19 | }); 20 | 21 | it('can be constructed with provider instance', function () { 22 | $instance = new CompletionProviderFixture(); 23 | $attribute = new CompletionProvider(provider: $instance); 24 | 25 | expect($attribute->provider)->toBe($instance); 26 | expect($attribute->values)->toBeNull(); 27 | expect($attribute->enum)->toBeNull(); 28 | }); 29 | 30 | it('can be constructed with values array', function () { 31 | $values = ['draft', 'published', 'archived']; 32 | $attribute = new CompletionProvider(values: $values); 33 | 34 | expect($attribute->provider)->toBeNull(); 35 | expect($attribute->values)->toBe($values); 36 | expect($attribute->enum)->toBeNull(); 37 | }); 38 | 39 | it('can be constructed with enum class', function () { 40 | $attribute = new CompletionProvider(enum: StatusEnum::class); 41 | 42 | expect($attribute->provider)->toBeNull(); 43 | expect($attribute->values)->toBeNull(); 44 | expect($attribute->enum)->toBe(StatusEnum::class); 45 | }); 46 | 47 | it('throws exception when no parameters provided', function () { 48 | new CompletionProvider(); 49 | })->throws(\InvalidArgumentException::class, 'Only one of provider, values, or enum can be set'); 50 | 51 | it('throws exception when multiple parameters provided', function () { 52 | new CompletionProvider( 53 | provider: CompletionProviderFixture::class, 54 | values: ['test'] 55 | ); 56 | })->throws(\InvalidArgumentException::class, 'Only one of provider, values, or enum can be set'); 57 | 58 | it('throws exception when all parameters provided', function () { 59 | new CompletionProvider( 60 | provider: CompletionProviderFixture::class, 61 | values: ['test'], 62 | enum: StatusEnum::class 63 | ); 64 | })->throws(\InvalidArgumentException::class, 'Only one of provider, values, or enum can be set'); 65 | -------------------------------------------------------------------------------- /examples/08-schema-showcase-streamable/server.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | info('Starting MCP Schema Showcase Server...'); 57 | 58 | $server = Server::make() 59 | ->withServerInfo('Schema Showcase', '1.0.0') 60 | ->withLogger($logger) 61 | ->build(); 62 | 63 | $server->discover(__DIR__, ['.']); 64 | 65 | $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); 66 | 67 | $server->listen($transport); 68 | 69 | $logger->info('Server listener stopped gracefully.'); 70 | exit(0); 71 | } catch (\Throwable $e) { 72 | fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); 73 | fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); 74 | fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); 75 | fwrite(STDERR, $e->getTraceAsString() . "\n"); 76 | exit(1); 77 | } 78 | -------------------------------------------------------------------------------- /examples/07-complex-tool-schema-http/McpEventScheduler.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 17 | } 18 | 19 | /** 20 | * Schedules a new event. 21 | * The inputSchema for this tool will reflect all parameter types and defaults. 22 | * 23 | * @param string $title The title of the event. 24 | * @param string $date The date of the event (YYYY-MM-DD). 25 | * @param EventType $type The type of event. 26 | * @param string|null $time The time of the event (HH:MM), optional. 27 | * @param EventPriority $priority The priority of the event. Defaults to Normal. 28 | * @param string[]|null $attendees An optional list of attendee email addresses. 29 | * @param bool $sendInvites Send calendar invites to attendees? Defaults to true if attendees are provided. 30 | * @return array Confirmation of the scheduled event. 31 | */ 32 | #[McpTool(name: 'schedule_event')] 33 | public function scheduleEvent( 34 | string $title, 35 | string $date, 36 | EventType $type, 37 | ?string $time = null, // Optional, nullable 38 | EventPriority $priority = EventPriority::Normal, // Optional with enum default 39 | ?array $attendees = null, // Optional array of strings, nullable 40 | bool $sendInvites = true // Optional with default 41 | ): array { 42 | $this->logger->info("Tool 'schedule_event' called", compact('title', 'date', 'type', 'time', 'priority', 'attendees', 'sendInvites')); 43 | 44 | // Simulate scheduling logic 45 | $eventDetails = [ 46 | 'title' => $title, 47 | 'date' => $date, 48 | 'type' => $type->value, // Use enum value 49 | 'time' => $time ?? 'All day', 50 | 'priority' => $priority->name, // Use enum name 51 | 'attendees' => $attendees ?? [], 52 | 'invites_will_be_sent' => ($attendees && $sendInvites), 53 | ]; 54 | 55 | // In a real app, this would interact with a calendar service 56 | $this->logger->info('Event scheduled', ['details' => $eventDetails]); 57 | 58 | return [ 59 | 'success' => true, 60 | 'message' => "Event '{$title}' scheduled successfully for {$date}.", 61 | 'event_details' => $eventDetails, 62 | ]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/Unit/Defaults/EnumCompletionProviderTest.php: -------------------------------------------------------------------------------- 1 | session = Mockery::mock(SessionInterface::class); 34 | }); 35 | 36 | it('creates provider from string-backed enum', function () { 37 | $provider = new EnumCompletionProvider(StringEnum::class); 38 | 39 | $result = $provider->getCompletions('', $this->session); 40 | 41 | expect($result)->toBe(['draft', 'published', 'archived']); 42 | }); 43 | 44 | it('creates provider from int-backed enum using names', function () { 45 | $provider = new EnumCompletionProvider(IntEnum::class); 46 | 47 | $result = $provider->getCompletions('', $this->session); 48 | 49 | expect($result)->toBe(['LOW', 'MEDIUM', 'HIGH']); 50 | }); 51 | 52 | it('creates provider from unit enum using names', function () { 53 | $provider = new EnumCompletionProvider(UnitEnum::class); 54 | 55 | $result = $provider->getCompletions('', $this->session); 56 | 57 | expect($result)->toBe(['ALPHA', 'BETA', 'GAMMA']); 58 | }); 59 | 60 | it('filters string enum values by prefix', function () { 61 | $provider = new EnumCompletionProvider(StringEnum::class); 62 | 63 | $result = $provider->getCompletions('ar', $this->session); 64 | 65 | expect($result)->toEqual(['archived']); 66 | }); 67 | 68 | it('filters unit enum values by prefix', function () { 69 | $provider = new EnumCompletionProvider(UnitEnum::class); 70 | 71 | $result = $provider->getCompletions('A', $this->session); 72 | 73 | expect($result)->toBe(['ALPHA']); 74 | }); 75 | 76 | it('returns empty array when no values match prefix', function () { 77 | $provider = new EnumCompletionProvider(StringEnum::class); 78 | 79 | $result = $provider->getCompletions('xyz', $this->session); 80 | 81 | expect($result)->toBe([]); 82 | }); 83 | 84 | it('throws exception for non-enum class', function () { 85 | new EnumCompletionProvider(\stdClass::class); 86 | })->throws(\InvalidArgumentException::class, 'Class stdClass is not an enum'); 87 | 88 | it('throws exception for non-existent class', function () { 89 | new EnumCompletionProvider('NonExistentClass'); 90 | })->throws(\InvalidArgumentException::class, 'Class NonExistentClass is not an enum'); 91 | -------------------------------------------------------------------------------- /tests/Fixtures/ServerScripts/HttpTestServer.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | withServerInfo('HttpIntegrationTestServer', '0.1.0') 41 | ->withLogger($logger) 42 | ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_http_tool') 43 | ->withTool([RequestAttributeChecker::class, 'checkAttribute'], 'check_request_attribute_tool') 44 | ->withResource([ResourceHandlerFixture::class, 'getStaticText'], "test://http/static", 'static_http_resource') 45 | ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_http_prompt') 46 | ->build(); 47 | 48 | $middlewares = [ 49 | new HeaderMiddleware(), 50 | new RequestAttributeMiddleware(), 51 | new ShortCircuitMiddleware(), 52 | new FirstMiddleware(), 53 | new SecondMiddleware(), 54 | new ThirdMiddleware(), 55 | new ErrorMiddleware() 56 | ]; 57 | 58 | $transport = new HttpServerTransport($host, $port, $mcpPathPrefix, null, $middlewares); 59 | $server->listen($transport); 60 | 61 | exit(0); 62 | } catch (\Throwable $e) { 63 | fwrite(STDERR, "[HTTP_SERVER_CRITICAL_ERROR]\nHost:{$host} Port:{$port} Prefix:{$mcpPathPrefix}\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); 64 | exit(1); 65 | } 66 | -------------------------------------------------------------------------------- /examples/01-discovery-stdio-calculator/server.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | discover() 27 | | scans the current directory (specified by basePath: __DIR__, scanDirs: ['.']) 28 | | to find and register elements before listening on STDIN/STDOUT. 29 | | 30 | | If you provided a `CacheInterface` implementation to the ServerBuilder, 31 | | the discovery process will be cached, so you can comment out the 32 | | discovery call after the first run to speed up subsequent runs. 33 | | 34 | */ 35 | declare(strict_types=1); 36 | 37 | chdir(__DIR__); 38 | require_once '../../vendor/autoload.php'; 39 | require_once 'McpElements.php'; 40 | 41 | use PhpMcp\Server\Server; 42 | use PhpMcp\Server\Transports\StdioServerTransport; 43 | use Psr\Log\AbstractLogger; 44 | 45 | class StderrLogger extends AbstractLogger 46 | { 47 | public function log($level, \Stringable|string $message, array $context = []): void 48 | { 49 | fwrite(STDERR, sprintf( 50 | "[%s] %s %s\n", 51 | strtoupper($level), 52 | $message, 53 | empty($context) ? '' : json_encode($context) 54 | )); 55 | } 56 | } 57 | 58 | try { 59 | $logger = new StderrLogger(); 60 | $logger->info('Starting MCP Stdio Calculator Server...'); 61 | 62 | $server = Server::make() 63 | ->withServerInfo('Stdio Calculator', '1.1.0') 64 | ->withLogger($logger) 65 | ->build(); 66 | 67 | $server->discover(__DIR__, ['.']); 68 | 69 | $transport = new StdioServerTransport(); 70 | 71 | $server->listen($transport); 72 | 73 | $logger->info('Server listener stopped gracefully.'); 74 | exit(0); 75 | 76 | } catch (\Throwable $e) { 77 | fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); 78 | fwrite(STDERR, 'Error: '.$e->getMessage()."\n"); 79 | fwrite(STDERR, 'File: '.$e->getFile().':'.$e->getLine()."\n"); 80 | fwrite(STDERR, $e->getTraceAsString()."\n"); 81 | exit(1); 82 | } 83 | -------------------------------------------------------------------------------- /tests/Fixtures/Utils/DockBlockParserFixture.php: -------------------------------------------------------------------------------- 1 | generic syntax 32 | * 33 | * @param array $strings Array of strings using generic syntax 34 | * @param array $integers Array of integers using generic syntax 35 | * @param array $booleans Array of booleans using generic syntax 36 | * @param array $floats Array of floats using generic syntax 37 | * @param array $objects Array of objects using generic syntax 38 | * @param array<\DateTime> $dateTimeInstances Array of DateTime objects using generic syntax 39 | */ 40 | public function genericArraySyntax( 41 | array $strings, 42 | array $integers, 43 | array $booleans, 44 | array $floats, 45 | array $objects, 46 | array $dateTimeInstances 47 | ): void { 48 | } 49 | 50 | /** 51 | * Method with nested array syntax 52 | * 53 | * @param array> $nestedStringArrays Array of arrays of strings 54 | * @param array> $nestedIntArrays Array of arrays of integers 55 | * @param string[][] $doubleStringArrays Array of arrays of strings using double [] 56 | * @param int[][] $doubleIntArrays Array of arrays of integers using double [] 57 | */ 58 | public function nestedArraySyntax( 59 | array $nestedStringArrays, 60 | array $nestedIntArrays, 61 | array $doubleStringArrays, 62 | array $doubleIntArrays 63 | ): void { 64 | } 65 | 66 | /** 67 | * Method with object-like array syntax 68 | * 69 | * @param array{name: string, age: int} $person Simple object array with name and age 70 | * @param array{id: int, title: string, tags: string[]} $article Article with array of tags 71 | * @param array{user: array{id: int, name: string}, items: array} $order Order with nested user object and array of item IDs 72 | */ 73 | public function objectArraySyntax( 74 | array $person, 75 | array $article, 76 | array $order 77 | ): void { 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | getProperty($propertyName); 14 | $property->setAccessible(true); 15 | return $property->getValue($object); 16 | } 17 | 18 | function delay($time, ?LoopInterface $loop = null) 19 | { 20 | if ($loop === null) { 21 | $loop = Loop::get(); 22 | } 23 | 24 | /** @var TimerInterface $timer */ 25 | $timer = null; 26 | return new Promise(function ($resolve) use ($loop, $time, &$timer) { 27 | $timer = $loop->addTimer($time, function () use ($resolve) { 28 | $resolve(null); 29 | }); 30 | }, function () use (&$timer, $loop) { 31 | $loop->cancelTimer($timer); 32 | $timer = null; 33 | 34 | throw new \RuntimeException('Timer cancelled'); 35 | }); 36 | } 37 | 38 | function timeout(PromiseInterface $promise, $time, ?LoopInterface $loop = null) 39 | { 40 | $canceller = null; 41 | if (\method_exists($promise, 'cancel')) { 42 | $canceller = function () use (&$promise) { 43 | $promise->cancel(); 44 | $promise = null; 45 | }; 46 | } 47 | 48 | if ($loop === null) { 49 | $loop = Loop::get(); 50 | } 51 | 52 | return new Promise(function ($resolve, $reject) use ($loop, $time, $promise) { 53 | $timer = null; 54 | $promise = $promise->then(function ($v) use (&$timer, $loop, $resolve) { 55 | if ($timer) { 56 | $loop->cancelTimer($timer); 57 | } 58 | $timer = false; 59 | $resolve($v); 60 | }, function ($v) use (&$timer, $loop, $reject) { 61 | if ($timer) { 62 | $loop->cancelTimer($timer); 63 | } 64 | $timer = false; 65 | $reject($v); 66 | }); 67 | 68 | if ($timer === false) { 69 | return; 70 | } 71 | 72 | // start timeout timer which will cancel the input promise 73 | $timer = $loop->addTimer($time, function () use ($time, &$promise, $reject) { 74 | $reject(new \RuntimeException('Timed out after ' . $time . ' seconds')); 75 | 76 | if (\method_exists($promise, 'cancel')) { 77 | $promise->cancel(); 78 | } 79 | $promise = null; 80 | }); 81 | }, $canceller); 82 | } 83 | 84 | function findFreePort() 85 | { 86 | $server = new SocketServer('127.0.0.1:0'); 87 | $address = $server->getAddress(); 88 | $port = $address ? parse_url($address, PHP_URL_PORT) : null; 89 | $server->close(); 90 | if (!$port) { 91 | throw new \RuntimeException("Could not find a free port for testing."); 92 | } 93 | return (int)$port; 94 | } 95 | -------------------------------------------------------------------------------- /src/Session/CacheSessionHandler.php: -------------------------------------------------------------------------------- 1 | sessionIndex = $this->cache->get(self::SESSION_INDEX_KEY, []); 24 | $this->clock = $clock ?? new SystemClock(); 25 | } 26 | 27 | public function read(string $sessionId): string|false 28 | { 29 | $session = $this->cache->get($sessionId, false); 30 | if ($session === false) { 31 | if (isset($this->sessionIndex[$sessionId])) { 32 | unset($this->sessionIndex[$sessionId]); 33 | $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); 34 | } 35 | return false; 36 | } 37 | 38 | if (!isset($this->sessionIndex[$sessionId])) { 39 | $this->sessionIndex[$sessionId] = $this->clock->now()->getTimestamp(); 40 | $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); 41 | return $session; 42 | } 43 | 44 | if ($this->clock->now()->getTimestamp() - $this->sessionIndex[$sessionId] > $this->ttl) { 45 | $this->cache->delete($sessionId); 46 | return false; 47 | } 48 | 49 | return $session; 50 | } 51 | 52 | public function write(string $sessionId, string $data): bool 53 | { 54 | $this->sessionIndex[$sessionId] = $this->clock->now()->getTimestamp(); 55 | $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); 56 | return $this->cache->set($sessionId, $data); 57 | } 58 | 59 | public function destroy(string $sessionId): bool 60 | { 61 | unset($this->sessionIndex[$sessionId]); 62 | $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); 63 | return $this->cache->delete($sessionId); 64 | } 65 | 66 | public function gc(int $maxLifetime): array 67 | { 68 | $currentTime = $this->clock->now()->getTimestamp(); 69 | $deletedSessions = []; 70 | 71 | foreach ($this->sessionIndex as $sessionId => $timestamp) { 72 | if ($currentTime - $timestamp > $maxLifetime) { 73 | $this->cache->delete($sessionId); 74 | unset($this->sessionIndex[$sessionId]); 75 | $deletedSessions[] = $sessionId; 76 | } 77 | } 78 | 79 | $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); 80 | 81 | return $deletedSessions; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /examples/06-custom-dependencies-stdio/McpTaskHandlers.php: -------------------------------------------------------------------------------- 1 | taskRepo = $taskRepo; 26 | $this->statsService = $statsService; 27 | $this->logger = $logger; 28 | $this->logger->info('McpTaskHandlers instantiated with dependencies.'); 29 | } 30 | 31 | /** 32 | * Adds a new task for a given user. 33 | * 34 | * @param string $userId The ID of the user. 35 | * @param string $description The task description. 36 | * @return array The created task details. 37 | */ 38 | #[McpTool(name: 'add_task')] 39 | public function addTask(string $userId, string $description): array 40 | { 41 | $this->logger->info("Tool 'add_task' invoked", ['userId' => $userId]); 42 | 43 | return $this->taskRepo->addTask($userId, $description); 44 | } 45 | 46 | /** 47 | * Lists pending tasks for a specific user. 48 | * 49 | * @param string $userId The ID of the user. 50 | * @return array A list of tasks. 51 | */ 52 | #[McpTool(name: 'list_user_tasks')] 53 | public function listUserTasks(string $userId): array 54 | { 55 | $this->logger->info("Tool 'list_user_tasks' invoked", ['userId' => $userId]); 56 | 57 | return $this->taskRepo->getTasksForUser($userId); 58 | } 59 | 60 | /** 61 | * Marks a task as complete. 62 | * 63 | * @param int $taskId The ID of the task to complete. 64 | * @return array Status of the operation. 65 | */ 66 | #[McpTool(name: 'complete_task')] 67 | public function completeTask(int $taskId): array 68 | { 69 | $this->logger->info("Tool 'complete_task' invoked", ['taskId' => $taskId]); 70 | $success = $this->taskRepo->completeTask($taskId); 71 | 72 | return ['success' => $success, 'message' => $success ? "Task {$taskId} completed." : "Task {$taskId} not found."]; 73 | } 74 | 75 | /** 76 | * Provides current system statistics. 77 | * 78 | * @return array System statistics. 79 | */ 80 | #[McpResource(uri: 'stats://system/overview', name: 'system_stats', mimeType: 'application/json')] 81 | public function getSystemStatistics(): array 82 | { 83 | $this->logger->info("Resource 'stats://system/overview' invoked"); 84 | 85 | return $this->statsService->getSystemStats(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /examples/03-manual-registration-stdio/server.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | discover() method is NOT called. 29 | | 30 | */ 31 | 32 | declare(strict_types=1); 33 | 34 | chdir(__DIR__); 35 | require_once '../../vendor/autoload.php'; 36 | require_once './SimpleHandlers.php'; 37 | 38 | use Mcp\ManualStdioExample\SimpleHandlers; 39 | use PhpMcp\Server\Defaults\BasicContainer; 40 | use PhpMcp\Server\Server; 41 | use PhpMcp\Server\Transports\StdioServerTransport; 42 | use Psr\Log\AbstractLogger; 43 | use Psr\Log\LoggerInterface; 44 | 45 | class StderrLogger extends AbstractLogger 46 | { 47 | public function log($level, \Stringable|string $message, array $context = []): void 48 | { 49 | fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); 50 | } 51 | } 52 | 53 | try { 54 | $logger = new StderrLogger(); 55 | $logger->info('Starting MCP Manual Registration (Stdio) Server...'); 56 | 57 | $container = new BasicContainer(); 58 | $container->set(LoggerInterface::class, $logger); 59 | 60 | $server = Server::make() 61 | ->withServerInfo('Manual Reg Server', '1.0.0') 62 | ->withLogger($logger) 63 | ->withContainer($container) 64 | ->withTool([SimpleHandlers::class, 'echoText'], 'echo_text') 65 | ->withResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain') 66 | ->withPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') 67 | ->withResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json') 68 | ->build(); 69 | 70 | $transport = new StdioServerTransport(); 71 | $server->listen($transport); 72 | 73 | $logger->info('Server listener stopped gracefully.'); 74 | exit(0); 75 | 76 | } catch (\Throwable $e) { 77 | fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); 78 | exit(1); 79 | } 80 | -------------------------------------------------------------------------------- /src/Defaults/ArrayCache.php: -------------------------------------------------------------------------------- 1 | has($key)) { 21 | return $default; 22 | } 23 | 24 | return $this->store[$key]; 25 | } 26 | 27 | public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool 28 | { 29 | $this->store[$key] = $value; 30 | $this->expiries[$key] = $this->calculateExpiry($ttl); 31 | 32 | return true; 33 | } 34 | 35 | public function delete(string $key): bool 36 | { 37 | unset($this->store[$key], $this->expiries[$key]); 38 | 39 | return true; 40 | } 41 | 42 | public function clear(): bool 43 | { 44 | $this->store = []; 45 | $this->expiries = []; 46 | 47 | return true; 48 | } 49 | 50 | public function getMultiple(iterable $keys, mixed $default = null): iterable 51 | { 52 | $result = []; 53 | foreach ($keys as $key) { 54 | $result[$key] = $this->get($key, $default); 55 | } 56 | 57 | return $result; 58 | } 59 | 60 | public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool 61 | { 62 | $expiry = $this->calculateExpiry($ttl); 63 | foreach ($values as $key => $value) { 64 | $this->store[$key] = $value; 65 | $this->expiries[$key] = $expiry; 66 | } 67 | 68 | return true; 69 | } 70 | 71 | public function deleteMultiple(iterable $keys): bool 72 | { 73 | foreach ($keys as $key) { 74 | unset($this->store[$key], $this->expiries[$key]); 75 | } 76 | 77 | return true; 78 | } 79 | 80 | public function has(string $key): bool 81 | { 82 | if (! isset($this->store[$key])) { 83 | return false; 84 | } 85 | // Check expiry 86 | if (isset($this->expiries[$key]) && $this->expiries[$key] !== null && time() >= $this->expiries[$key]) { 87 | $this->delete($key); 88 | 89 | return false; 90 | } 91 | 92 | return true; 93 | } 94 | 95 | private function calculateExpiry(DateInterval|int|null $ttl): ?int 96 | { 97 | if ($ttl === null) { 98 | return null; // No expiry 99 | } 100 | if (is_int($ttl)) { 101 | return time() + $ttl; 102 | } 103 | if ($ttl instanceof DateInterval) { 104 | return (new DateTime())->add($ttl)->getTimestamp(); 105 | } 106 | 107 | // Invalid TTL type, treat as no expiry 108 | return null; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Session/SubscriptionManager.php: -------------------------------------------------------------------------------- 1 | > Key: URI, Value: array of session IDs */ 10 | private array $resourceSubscribers = []; 11 | 12 | /** @var array> Key: Session ID, Value: array of URIs */ 13 | private array $sessionSubscriptions = []; 14 | 15 | public function __construct( 16 | private readonly LoggerInterface $logger 17 | ) { 18 | } 19 | 20 | /** 21 | * Subscribe a session to a resource 22 | */ 23 | public function subscribe(string $sessionId, string $uri): void 24 | { 25 | // Add to both mappings for efficient lookup 26 | $this->resourceSubscribers[$uri][$sessionId] = true; 27 | $this->sessionSubscriptions[$sessionId][$uri] = true; 28 | 29 | $this->logger->debug('Session subscribed to resource', [ 30 | 'sessionId' => $sessionId, 31 | 'uri' => $uri 32 | ]); 33 | } 34 | 35 | /** 36 | * Unsubscribe a session from a resource 37 | */ 38 | public function unsubscribe(string $sessionId, string $uri): void 39 | { 40 | unset($this->resourceSubscribers[$uri][$sessionId]); 41 | unset($this->sessionSubscriptions[$sessionId][$uri]); 42 | 43 | // Clean up empty arrays 44 | if (empty($this->resourceSubscribers[$uri])) { 45 | unset($this->resourceSubscribers[$uri]); 46 | } 47 | 48 | $this->logger->debug('Session unsubscribed from resource', [ 49 | 'sessionId' => $sessionId, 50 | 'uri' => $uri 51 | ]); 52 | } 53 | 54 | /** 55 | * Get all sessions subscribed to a resource 56 | */ 57 | public function getSubscribers(string $uri): array 58 | { 59 | return array_keys($this->resourceSubscribers[$uri] ?? []); 60 | } 61 | 62 | /** 63 | * Check if a session is subscribed to a resource 64 | */ 65 | public function isSubscribed(string $sessionId, string $uri): bool 66 | { 67 | return isset($this->sessionSubscriptions[$sessionId][$uri]); 68 | } 69 | 70 | /** 71 | * Clean up all subscriptions for a session 72 | */ 73 | public function cleanupSession(string $sessionId): void 74 | { 75 | if (!isset($this->sessionSubscriptions[$sessionId])) { 76 | return; 77 | } 78 | 79 | $uris = array_keys($this->sessionSubscriptions[$sessionId]); 80 | foreach ($uris as $uri) { 81 | unset($this->resourceSubscribers[$uri][$sessionId]); 82 | 83 | // Clean up empty arrays 84 | if (empty($this->resourceSubscribers[$uri])) { 85 | unset($this->resourceSubscribers[$uri]); 86 | } 87 | } 88 | 89 | unset($this->sessionSubscriptions[$sessionId]); 90 | 91 | $this->logger->debug('Cleaned up all subscriptions for session', [ 92 | 'sessionId' => $sessionId, 93 | 'count' => count($uris) 94 | ]); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/Unit/ConfigurationTest.php: -------------------------------------------------------------------------------- 1 | serverInfo = Implementation::make('TestServer', '1.1.0'); 16 | $this->logger = Mockery::mock(LoggerInterface::class); 17 | $this->loop = Mockery::mock(LoopInterface::class); 18 | $this->cache = Mockery::mock(CacheInterface::class); 19 | $this->container = Mockery::mock(ContainerInterface::class); 20 | $this->capabilities = ServerCapabilities::make(); 21 | }); 22 | 23 | afterEach(function () { 24 | Mockery::close(); 25 | }); 26 | 27 | it('constructs configuration object with all properties', function () { 28 | $paginationLimit = 100; 29 | $config = new Configuration( 30 | serverInfo: $this->serverInfo, 31 | capabilities: $this->capabilities, 32 | logger: $this->logger, 33 | loop: $this->loop, 34 | cache: $this->cache, 35 | container: $this->container, 36 | paginationLimit: $paginationLimit 37 | ); 38 | 39 | expect($config->serverInfo)->toBe($this->serverInfo); 40 | expect($config->capabilities)->toBe($this->capabilities); 41 | expect($config->logger)->toBe($this->logger); 42 | expect($config->loop)->toBe($this->loop); 43 | expect($config->cache)->toBe($this->cache); 44 | expect($config->container)->toBe($this->container); 45 | expect($config->paginationLimit)->toBe($paginationLimit); 46 | }); 47 | 48 | it('constructs configuration object with default pagination limit', function () { 49 | $config = new Configuration( 50 | serverInfo: $this->serverInfo, 51 | capabilities: $this->capabilities, 52 | logger: $this->logger, 53 | loop: $this->loop, 54 | cache: $this->cache, 55 | container: $this->container 56 | ); 57 | 58 | expect($config->paginationLimit)->toBe(50); // Default value 59 | }); 60 | 61 | it('constructs configuration object with null cache', function () { 62 | $config = new Configuration( 63 | serverInfo: $this->serverInfo, 64 | capabilities: $this->capabilities, 65 | logger: $this->logger, 66 | loop: $this->loop, 67 | cache: null, 68 | container: $this->container 69 | ); 70 | 71 | expect($config->cache)->toBeNull(); 72 | }); 73 | 74 | it('constructs configuration object with specific capabilities', function () { 75 | $customCaps = ServerCapabilities::make( 76 | resourcesSubscribe: true, 77 | logging: true, 78 | ); 79 | 80 | $config = new Configuration( 81 | serverInfo: $this->serverInfo, 82 | capabilities: $customCaps, 83 | logger: $this->logger, 84 | loop: $this->loop, 85 | cache: null, 86 | container: $this->container 87 | ); 88 | 89 | expect($config->capabilities)->toBe($customCaps); 90 | expect($config->capabilities->resourcesSubscribe)->toBeTrue(); 91 | expect($config->capabilities->logging)->toBeTrue(); 92 | }); 93 | -------------------------------------------------------------------------------- /examples/06-custom-dependencies-stdio/Services.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 31 | // Add some initial tasks 32 | $this->addTask('user1', 'Buy groceries'); 33 | $this->addTask('user1', 'Write MCP example'); 34 | $this->addTask('user2', 'Review PR'); 35 | } 36 | 37 | public function addTask(string $userId, string $description): array 38 | { 39 | $task = [ 40 | 'id' => $this->nextTaskId++, 41 | 'userId' => $userId, 42 | 'description' => $description, 43 | 'completed' => false, 44 | 'createdAt' => date('c'), 45 | ]; 46 | $this->tasks[$task['id']] = $task; 47 | $this->logger->info('Task added', ['id' => $task['id'], 'user' => $userId]); 48 | 49 | return $task; 50 | } 51 | 52 | public function getTasksForUser(string $userId): array 53 | { 54 | return array_values(array_filter($this->tasks, fn ($task) => $task['userId'] === $userId && ! $task['completed'])); 55 | } 56 | 57 | public function getAllTasks(): array 58 | { 59 | return array_values($this->tasks); 60 | } 61 | 62 | public function completeTask(int $taskId): bool 63 | { 64 | if (isset($this->tasks[$taskId])) { 65 | $this->tasks[$taskId]['completed'] = true; 66 | $this->logger->info('Task completed', ['id' => $taskId]); 67 | 68 | return true; 69 | } 70 | 71 | return false; 72 | } 73 | } 74 | 75 | interface StatsServiceInterface 76 | { 77 | public function getSystemStats(): array; 78 | } 79 | 80 | class SystemStatsService implements StatsServiceInterface 81 | { 82 | private TaskRepositoryInterface $taskRepository; 83 | 84 | public function __construct(TaskRepositoryInterface $taskRepository) 85 | { 86 | $this->taskRepository = $taskRepository; 87 | } 88 | 89 | public function getSystemStats(): array 90 | { 91 | $allTasks = $this->taskRepository->getAllTasks(); 92 | $completed = count(array_filter($allTasks, fn ($task) => $task['completed'])); 93 | $pending = count($allTasks) - $completed; 94 | 95 | return [ 96 | 'total_tasks' => count($allTasks), 97 | 'completed_tasks' => $completed, 98 | 'pending_tasks' => $pending, 99 | 'server_uptime_seconds' => time() - $_SERVER['REQUEST_TIME_FLOAT'], // Approx uptime for CLI script 100 | ]; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /examples/04-combined-registration-http/server.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | build(). 34 | | Then, $server->discover() scans for attributed elements. 35 | | 36 | */ 37 | 38 | declare(strict_types=1); 39 | 40 | chdir(__DIR__); 41 | require_once '../../vendor/autoload.php'; 42 | require_once './DiscoveredElements.php'; 43 | require_once './ManualHandlers.php'; 44 | 45 | use Mcp\CombinedHttpExample\Manual\ManualHandlers; 46 | use PhpMcp\Server\Defaults\BasicContainer; 47 | use PhpMcp\Server\Server; 48 | use PhpMcp\Server\Transports\HttpServerTransport; 49 | use Psr\Log\AbstractLogger; 50 | use Psr\Log\LoggerInterface; 51 | 52 | class StderrLogger extends AbstractLogger 53 | { 54 | public function log($level, \Stringable|string $message, array $context = []): void 55 | { 56 | fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); 57 | } 58 | } 59 | 60 | try { 61 | $logger = new StderrLogger(); 62 | $logger->info('Starting MCP Combined Registration (HTTP) Server...'); 63 | 64 | $container = new BasicContainer(); 65 | $container->set(LoggerInterface::class, $logger); // ManualHandlers needs LoggerInterface 66 | 67 | $server = Server::make() 68 | ->withServerInfo('Combined HTTP Server', '1.0.0') 69 | ->withLogger($logger) 70 | ->withContainer($container) 71 | ->withTool([ManualHandlers::class, 'manualGreeter']) 72 | ->withResource( 73 | [ManualHandlers::class, 'getPriorityConfigManual'], 74 | 'config://priority', 75 | 'priority_config_manual', 76 | ) 77 | ->build(); 78 | 79 | // Now, run discovery. Discovered elements will be added. 80 | // If 'config://priority' was discovered, the manual one takes precedence. 81 | $server->discover(__DIR__, scanDirs: ['.']); 82 | 83 | $transport = new HttpServerTransport('127.0.0.1', 8081, 'mcp_combined'); 84 | 85 | $server->listen($transport); 86 | 87 | $logger->info('Server listener stopped gracefully.'); 88 | exit(0); 89 | 90 | } catch (\Throwable $e) { 91 | fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); 92 | exit(1); 93 | } 94 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to php-mcp/server 2 | 3 | First off, thank you for considering contributing to `php-mcp/server`! We appreciate your time and effort. This project aims to provide a robust and easy-to-use PHP server for the Model Context Protocol. 4 | 5 | Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open-source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests. 6 | 7 | ## How Can I Contribute? 8 | 9 | There are several ways you can contribute: 10 | 11 | * **Reporting Bugs:** If you find a bug, please open an issue on the GitHub repository. Include steps to reproduce, expected behavior, and actual behavior. Specify your PHP version, operating system, and relevant package versions. 12 | * **Suggesting Enhancements:** Open an issue to suggest new features or improvements to existing functionality. Explain the use case and why the enhancement would be valuable. 13 | * **Improving Documentation:** If you find errors, omissions, or areas that could be clearer in the README or code comments, please submit a pull request or open an issue. 14 | * **Writing Code:** Submit pull requests to fix bugs or add new features. 15 | 16 | ## Development Setup 17 | 18 | 1. **Fork the repository:** Click the "Fork" button on the [php-mcp/server GitHub page](https://github.com/php-mcp/server). 19 | 2. **Clone your fork:** `git clone git@github.com:YOUR_USERNAME/server.git` 20 | 3. **Navigate into the directory:** `cd server` 21 | 4. **Install dependencies:** `composer install` (This installs runtime and development dependencies). 22 | 23 | ## Submitting Changes (Pull Requests) 24 | 25 | 1. **Create a new branch:** `git checkout -b feature/your-feature-name` or `git checkout -b fix/issue-number`. 26 | 2. **Make your changes:** Write your code and accompanying tests. 27 | 3. **Ensure Code Style:** Run the code style fixer (if configured, e.g., PHP CS Fixer): 28 | ```bash 29 | composer lint # Or ./vendor/bin/php-cs-fixer fix 30 | ``` 31 | Adhere to PSR-12 coding standards. 32 | 4. **Run Tests:** Ensure all tests pass: 33 | ```bash 34 | composer test # Or ./vendor/bin/pest 35 | ``` 36 | Consider adding new tests for your changes. Aim for good test coverage. 37 | 5. **Update Documentation:** If your changes affect the public API or usage, update the `README.md` and relevant PHPDoc blocks. 38 | 6. **Commit your changes:** Use clear and descriptive commit messages. `git commit -m "feat: Add support for resource subscriptions"` or `git commit -m "fix: Correct handling of transport errors"` 39 | 7. **Push to your fork:** `git push origin feature/your-feature-name` 40 | 8. **Open a Pull Request:** Go to the original `php-mcp/server` repository on GitHub and open a pull request from your branch to the `main` branch (or the appropriate development branch). 41 | 9. **Describe your changes:** Provide a clear description of the problem and solution in the pull request. Link to any relevant issues (`Closes #123`). 42 | 43 | ## Coding Standards 44 | 45 | * Follow **PSR-12** coding standards. 46 | * Use **strict types:** `declare(strict_types=1);` at the top of PHP files. 47 | * Use **PHP 8.1+ features** where appropriate (readonly properties, enums, etc.). 48 | * Add **PHPDoc blocks** for all public classes, methods, and properties. 49 | * Write clear and concise code. Add comments only where necessary to explain complex logic. 50 | 51 | ## Reporting Issues 52 | 53 | * Use the GitHub issue tracker. 54 | * Check if the issue already exists. 55 | * Provide a clear title and description. 56 | * Include steps to reproduce the issue, code examples, error messages, and stack traces if applicable. 57 | * Specify relevant environment details (PHP version, OS, package version). 58 | 59 | Thank you for contributing! -------------------------------------------------------------------------------- /examples/07-complex-tool-schema-http/server.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | info('Starting MCP Complex Schema HTTP Server...'); 73 | 74 | $container = new BasicContainer(); 75 | $container->set(LoggerInterface::class, $logger); 76 | 77 | $server = Server::make() 78 | ->withServerInfo('Event Scheduler Server', '1.0.0') 79 | ->withLogger($logger) 80 | ->withContainer($container) 81 | ->build(); 82 | 83 | $server->discover(__DIR__, ['.']); 84 | 85 | $transport = new HttpServerTransport('127.0.0.1', 8082, 'mcp_scheduler'); 86 | $server->listen($transport); 87 | 88 | $logger->info('Server listener stopped gracefully.'); 89 | exit(0); 90 | 91 | } catch (\Throwable $e) { 92 | fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); 93 | exit(1); 94 | } 95 | -------------------------------------------------------------------------------- /tests/Fixtures/ServerScripts/StreamableHttpTestServer.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | info("Starting StreamableHttpTestServer on {$host}:{$port}/{$mcpPath}, JSON Mode: " . ($enableJsonResponse ? 'ON' : 'OFF') . ", Stateless: " . ($stateless ? 'ON' : 'OFF')); 43 | 44 | $eventStore = $useEventStore ? new InMemoryEventStore() : null; 45 | 46 | $server = Server::make() 47 | ->withServerInfo('StreamableHttpIntegrationServer', '0.1.0') 48 | ->withLogger($logger) 49 | ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_streamable_tool') 50 | ->withTool([ToolHandlerFixture::class, 'sum'], 'sum_streamable_tool') // For batch testing 51 | ->withTool([ToolHandlerFixture::class, 'toolReadsContext'], 'tool_reads_context') // for Context testing 52 | ->withTool([RequestAttributeChecker::class, 'checkAttribute'], 'check_request_attribute_tool') 53 | ->withResource([ResourceHandlerFixture::class, 'getStaticText'], "test://streamable/static", 'static_streamable_resource') 54 | ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_streamable_prompt') 55 | ->build(); 56 | 57 | $middlewares = [ 58 | new HeaderMiddleware(), 59 | new RequestAttributeMiddleware(), 60 | new ShortCircuitMiddleware(), 61 | new FirstMiddleware(), 62 | new SecondMiddleware(), 63 | new ThirdMiddleware(), 64 | new ErrorMiddleware() 65 | ]; 66 | 67 | $transport = new StreamableHttpServerTransport( 68 | host: $host, 69 | port: $port, 70 | mcpPath: $mcpPath, 71 | enableJsonResponse: $enableJsonResponse, 72 | stateless: $stateless, 73 | eventStore: $eventStore, 74 | middlewares: $middlewares 75 | ); 76 | 77 | $server->listen($transport); 78 | 79 | $logger->info("StreamableHttpTestServer listener stopped on {$host}:{$port}."); 80 | exit(0); 81 | } catch (\Throwable $e) { 82 | fwrite(STDERR, "[STREAMABLE_HTTP_SERVER_CRITICAL_ERROR]\nHost:{$host} Port:{$port} Prefix:{$mcpPath}\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); 83 | exit(1); 84 | } 85 | -------------------------------------------------------------------------------- /tests/Fixtures/General/ToolHandlerFixture.php: -------------------------------------------------------------------------------- 1 | 'ok', 'timestamp' => time()]; 36 | } 37 | 38 | public function processBackedEnum(BackedStringEnum $status): string 39 | { 40 | return "Status processed: " . $status->value; 41 | } 42 | 43 | public function returnString(): string 44 | { 45 | return "This is a string result."; 46 | } 47 | 48 | public function returnInteger(): int 49 | { 50 | return 12345; 51 | } 52 | 53 | public function returnFloat(): float 54 | { 55 | return 67.89; 56 | } 57 | 58 | public function returnBooleanTrue(): bool 59 | { 60 | return true; 61 | } 62 | 63 | public function returnBooleanFalse(): bool 64 | { 65 | return false; 66 | } 67 | 68 | public function returnNull(): ?string 69 | { 70 | return null; 71 | } 72 | 73 | public function returnArray(): array 74 | { 75 | return ['message' => 'Array result', 'data' => [1, 2, 3]]; 76 | } 77 | 78 | public function returnStdClass(): \stdClass 79 | { 80 | $obj = new \stdClass(); 81 | $obj->property = "value"; 82 | return $obj; 83 | } 84 | 85 | public function returnTextContent(): TextContent 86 | { 87 | return TextContent::make("Pre-formatted TextContent."); 88 | } 89 | 90 | public function returnImageContent(): ImageContent 91 | { 92 | return ImageContent::make("base64data==", "image/png"); 93 | } 94 | 95 | public function returnAudioContent(): AudioContent 96 | { 97 | return AudioContent::make("base64audio==", "audio/mp3"); 98 | } 99 | 100 | public function returnArrayOfContent(): array 101 | { 102 | return [ 103 | TextContent::make("Part 1"), 104 | ImageContent::make("imgdata", "image/jpeg") 105 | ]; 106 | } 107 | 108 | public function returnMixedArray(): array 109 | { 110 | return [ 111 | "A raw string", 112 | TextContent::make("A TextContent object"), 113 | 123, 114 | true, 115 | null, 116 | ['nested_key' => 'nested_value', 'sub_array' => [4, 5]], 117 | ImageContent::make("img_data_mixed", "image/gif"), 118 | (object)['obj_prop' => 'obj_val'] 119 | ]; 120 | } 121 | 122 | public function returnEmptyArray(): array 123 | { 124 | return []; 125 | } 126 | 127 | public function toolThatThrows(): void 128 | { 129 | throw new \InvalidArgumentException("Something went wrong in the tool."); 130 | } 131 | 132 | public function toolUnencodableResult() 133 | { 134 | return fopen('php://memory', 'r'); 135 | } 136 | 137 | public function toolReadsContext(Context $context): string 138 | { 139 | if (!$context->request) { 140 | return "No request instance present"; 141 | } 142 | 143 | return $context->request->getHeaderLine('X-Test-Header') ?: "No X-Test-Header"; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Utils/HandlerResolver.php: -------------------------------------------------------------------------------- 1 | isPublic()) { 68 | throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' must be public."); 69 | } 70 | if ($reflectionMethod->isAbstract()) { 71 | throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be abstract."); 72 | } 73 | if ($reflectionMethod->isConstructor() || $reflectionMethod->isDestructor()) { 74 | throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be a constructor or destructor."); 75 | } 76 | 77 | return $reflectionMethod; 78 | } catch (ReflectionException $e) { 79 | // This typically occurs if class_exists passed but ReflectionMethod still fails (rare) 80 | throw new InvalidArgumentException("Reflection error for handler '{$className}::{$methodName}': {$e->getMessage()}", 0, $e); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /examples/06-custom-dependencies-stdio/server.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | withContainer($container)`. 23 | | - The DI container is set up with bindings for service interfaces to 24 | | their concrete implementations (e.g., TaskRepositoryInterface -> InMemoryTaskRepository). 25 | | - The `McpTaskHandlers` class receives its dependencies (TaskRepositoryInterface, 26 | | StatsServiceInterface, LoggerInterface) via constructor injection, resolved by 27 | | the DI container when the Processor needs an instance of McpTaskHandlers. 28 | | - This example uses attribute-based discovery via `$server->discover()`. 29 | | - It runs using the STDIO transport. 30 | | 31 | | To Use: 32 | | 1. Run this script: `php server.php` (from this directory) 33 | | 2. Configure your MCP Client (e.g., Cursor) for this server: 34 | | 35 | | { 36 | | "mcpServers": { 37 | | "php-stdio-deps-taskmgr": { 38 | | "command": "php", 39 | | "args": ["/full/path/to/examples/06-custom-dependencies-stdio/server.php"] 40 | | } 41 | | } 42 | | } 43 | | 44 | | Interact with tools like 'add_task', 'list_user_tasks', 'complete_task' 45 | | and read the resource 'stats://system/overview'. 46 | | 47 | */ 48 | 49 | declare(strict_types=1); 50 | 51 | chdir(__DIR__); 52 | require_once '../../vendor/autoload.php'; 53 | require_once './Services.php'; 54 | require_once './McpTaskHandlers.php'; 55 | 56 | use Mcp\DependenciesStdioExample\Services; 57 | use PhpMcp\Server\Defaults\BasicContainer; 58 | use PhpMcp\Server\Server; 59 | use PhpMcp\Server\Transports\StdioServerTransport; 60 | use Psr\Log\AbstractLogger; 61 | use Psr\Log\LoggerInterface; 62 | 63 | class StderrLogger extends AbstractLogger 64 | { 65 | public function log($level, \Stringable|string $message, array $context = []): void 66 | { 67 | fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); 68 | } 69 | } 70 | 71 | try { 72 | $logger = new StderrLogger(); 73 | $logger->info('Starting MCP Custom Dependencies (Stdio) Server...'); 74 | 75 | $container = new BasicContainer(); 76 | $container->set(LoggerInterface::class, $logger); 77 | 78 | $taskRepo = new Services\InMemoryTaskRepository($logger); 79 | $container->set(Services\TaskRepositoryInterface::class, $taskRepo); 80 | 81 | $statsService = new Services\SystemStatsService($taskRepo); 82 | $container->set(Services\StatsServiceInterface::class, $statsService); 83 | 84 | $server = Server::make() 85 | ->withServerInfo('Task Manager Server', '1.0.0') 86 | ->withLogger($logger) 87 | ->withContainer($container) 88 | ->build(); 89 | 90 | $server->discover(__DIR__, ['.']); 91 | 92 | $transport = new StdioServerTransport(); 93 | $server->listen($transport); 94 | 95 | $logger->info('Server listener stopped gracefully.'); 96 | exit(0); 97 | } catch (\Throwable $e) { 98 | fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); 99 | fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); 100 | fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); 101 | fwrite(STDERR, $e->getTraceAsString() . "\n"); 102 | exit(1); 103 | } 104 | -------------------------------------------------------------------------------- /src/Session/SessionManager.php: -------------------------------------------------------------------------------- 1 | loop ??= Loop::get(); 30 | } 31 | 32 | /** 33 | * Start the garbage collection timer 34 | */ 35 | public function startGcTimer(): void 36 | { 37 | if ($this->gcTimer !== null) { 38 | return; 39 | } 40 | 41 | $this->gcTimer = $this->loop->addPeriodicTimer($this->gcInterval, [$this, 'gc']); 42 | } 43 | 44 | public function gc(): array 45 | { 46 | $deletedSessions = $this->handler->gc($this->ttl); 47 | 48 | foreach ($deletedSessions as $sessionId) { 49 | $this->emit('session_deleted', [$sessionId]); 50 | } 51 | 52 | if (count($deletedSessions) > 0) { 53 | $this->logger->debug('Session garbage collection complete', [ 54 | 'purged_sessions' => count($deletedSessions), 55 | ]); 56 | } 57 | 58 | return $deletedSessions; 59 | } 60 | 61 | /** 62 | * Stop the garbage collection timer 63 | */ 64 | public function stopGcTimer(): void 65 | { 66 | if ($this->gcTimer !== null) { 67 | $this->loop->cancelTimer($this->gcTimer); 68 | $this->gcTimer = null; 69 | } 70 | } 71 | 72 | /** 73 | * Create a new session 74 | */ 75 | public function createSession(string $sessionId): SessionInterface 76 | { 77 | $session = new Session($this->handler, $sessionId); 78 | 79 | $session->hydrate([ 80 | 'initialized' => false, 81 | 'client_info' => null, 82 | 'protocol_version' => null, 83 | 'subscriptions' => [], // [uri => true] 84 | 'message_queue' => [], // string[] (raw JSON-RPC frames) 85 | 'log_level' => null, 86 | ]); 87 | 88 | $session->save(); 89 | 90 | $this->logger->info('Session created', ['sessionId' => $sessionId]); 91 | $this->emit('session_created', [$sessionId, $session]); 92 | 93 | return $session; 94 | } 95 | 96 | /** 97 | * Get an existing session 98 | */ 99 | public function getSession(string $sessionId): ?SessionInterface 100 | { 101 | return Session::retrieve($sessionId, $this->handler); 102 | } 103 | 104 | public function hasSession(string $sessionId): bool 105 | { 106 | return $this->getSession($sessionId) !== null; 107 | } 108 | 109 | /** 110 | * Delete a session completely 111 | */ 112 | public function deleteSession(string $sessionId): bool 113 | { 114 | $success = $this->handler->destroy($sessionId); 115 | 116 | if ($success) { 117 | $this->emit('session_deleted', [$sessionId]); 118 | $this->logger->info('Session deleted', ['sessionId' => $sessionId]); 119 | } else { 120 | $this->logger->warning('Failed to delete session', ['sessionId' => $sessionId]); 121 | } 122 | 123 | return $success; 124 | } 125 | 126 | public function queueMessage(string $sessionId, string $message): void 127 | { 128 | $session = $this->getSession($sessionId); 129 | if ($session === null) { 130 | return; 131 | } 132 | 133 | $session->queueMessage($message); 134 | $session->save(); 135 | } 136 | 137 | public function dequeueMessages(string $sessionId): array 138 | { 139 | $session = $this->getSession($sessionId); 140 | if ($session === null) { 141 | return []; 142 | } 143 | 144 | $messages = $session->dequeueMessages(); 145 | $session->save(); 146 | 147 | return $messages; 148 | } 149 | 150 | public function hasQueuedMessages(string $sessionId): bool 151 | { 152 | $session = $this->getSession($sessionId, true); 153 | if ($session === null) { 154 | return false; 155 | } 156 | 157 | return $session->hasQueuedMessages(); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Exception/McpServerException.php: -------------------------------------------------------------------------------- 1 | data = $data; 43 | } 44 | 45 | /** 46 | * Get additional error data. 47 | * 48 | * @return mixed|null 49 | */ 50 | public function getData(): mixed 51 | { 52 | return $this->data; 53 | } 54 | 55 | /** 56 | * Formats the exception into a JSON-RPC 2.0 error object structure. 57 | * Specific exceptions should override this or provide factories with correct codes. 58 | */ 59 | public function toJsonRpcError(string|int $id): JsonRpcError 60 | { 61 | $code = ($this->code >= -32768 && $this->code <= -32000) ? $this->code : Constants::INTERNAL_ERROR; 62 | 63 | return new JsonRpcError( 64 | jsonrpc: '2.0', 65 | id: $id, 66 | code: $code, 67 | message: $this->getMessage(), 68 | data: $this->getData() 69 | ); 70 | } 71 | 72 | public static function parseError(string $details, ?Throwable $previous = null): self 73 | { 74 | return new ProtocolException('Parse error: ' . $details, Constants::PARSE_ERROR, null, $previous); 75 | } 76 | 77 | public static function invalidRequest(?string $details = 'Invalid Request', ?Throwable $previous = null): self 78 | { 79 | return new ProtocolException($details, Constants::INVALID_REQUEST, null, $previous); 80 | } 81 | 82 | public static function methodNotFound(string $methodName, ?string $message = null, ?Throwable $previous = null): self 83 | { 84 | return new ProtocolException($message ?? "Method not found: {$methodName}", Constants::METHOD_NOT_FOUND, null, $previous); 85 | } 86 | 87 | public static function invalidParams(string $message = 'Invalid params', $data = null, ?Throwable $previous = null): self 88 | { 89 | // Pass data (e.g., validation errors) through 90 | return new ProtocolException($message, Constants::INVALID_PARAMS, $data, $previous); 91 | } 92 | 93 | public static function internalError(?string $details = 'Internal server error', ?Throwable $previous = null): self 94 | { 95 | $message = 'Internal error'; 96 | if ($details && is_string($details)) { 97 | $message .= ': ' . $details; 98 | } elseif ($previous && $details === null) { 99 | $message .= ' (See server logs)'; 100 | } 101 | 102 | return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); 103 | } 104 | 105 | public static function toolExecutionFailed(string $toolName, ?Throwable $previous = null): self 106 | { 107 | $message = "Execution failed for tool '{$toolName}'"; 108 | if ($previous) { 109 | $message .= ': ' . $previous->getMessage(); 110 | } 111 | 112 | return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); 113 | } 114 | 115 | public static function resourceReadFailed(string $uri, ?Throwable $previous = null): self 116 | { 117 | $message = "Failed to read resource '{$uri}'"; 118 | if ($previous) { 119 | $message .= ': ' . $previous->getMessage(); 120 | } 121 | 122 | return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); 123 | } 124 | 125 | public static function promptGenerationFailed(string $promptName, ?Throwable $previous = null): self 126 | { 127 | $message = "Failed to generate prompt '{$promptName}'"; 128 | if ($previous) { 129 | $message .= ': ' . $previous->getMessage(); 130 | } 131 | 132 | return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Elements/RegisteredTool.php: -------------------------------------------------------------------------------- 1 | handle($container, $arguments, $context); 37 | 38 | return $this->formatResult($result); 39 | } 40 | 41 | /** 42 | * Formats the result of a tool execution into an array of MCP Content items. 43 | * 44 | * - If the result is already a Content object, it's wrapped in an array. 45 | * - If the result is an array: 46 | * - If all elements are Content objects, the array is returned as is. 47 | * - If it's a mixed array (Content and non-Content items), non-Content items are 48 | * individually formatted (scalars to TextContent, others to JSON TextContent). 49 | * - If it's an array with no Content items, the entire array is JSON-encoded into a single TextContent. 50 | * - Scalars (string, int, float, bool) are wrapped in TextContent. 51 | * - null is represented as TextContent('(null)'). 52 | * - Other objects are JSON-encoded and wrapped in TextContent. 53 | * 54 | * @param mixed $toolExecutionResult The raw value returned by the tool's PHP method. 55 | * @return Content[] The content items for CallToolResult. 56 | * @throws JsonException if JSON encoding fails for non-Content array/object results. 57 | */ 58 | protected function formatResult(mixed $toolExecutionResult): array 59 | { 60 | if ($toolExecutionResult instanceof Content) { 61 | return [$toolExecutionResult]; 62 | } 63 | 64 | if (is_array($toolExecutionResult)) { 65 | if (empty($toolExecutionResult)) { 66 | return [TextContent::make('[]')]; 67 | } 68 | 69 | $allAreContent = true; 70 | $hasContent = false; 71 | 72 | foreach ($toolExecutionResult as $item) { 73 | if ($item instanceof Content) { 74 | $hasContent = true; 75 | } else { 76 | $allAreContent = false; 77 | } 78 | } 79 | 80 | if ($allAreContent && $hasContent) { 81 | return $toolExecutionResult; 82 | } 83 | 84 | if ($hasContent) { 85 | $result = []; 86 | foreach ($toolExecutionResult as $item) { 87 | if ($item instanceof Content) { 88 | $result[] = $item; 89 | } else { 90 | $result = array_merge($result, $this->formatResult($item)); 91 | } 92 | } 93 | return $result; 94 | } 95 | } 96 | 97 | if ($toolExecutionResult === null) { 98 | return [TextContent::make('(null)')]; 99 | } 100 | 101 | if (is_bool($toolExecutionResult)) { 102 | return [TextContent::make($toolExecutionResult ? 'true' : 'false')]; 103 | } 104 | 105 | if (is_scalar($toolExecutionResult)) { 106 | return [TextContent::make($toolExecutionResult)]; 107 | } 108 | 109 | $jsonResult = json_encode( 110 | $toolExecutionResult, 111 | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE 112 | ); 113 | 114 | return [TextContent::make($jsonResult)]; 115 | } 116 | 117 | public function toArray(): array 118 | { 119 | return [ 120 | 'schema' => $this->schema->toArray(), 121 | ...parent::toArray(), 122 | ]; 123 | } 124 | 125 | public static function fromArray(array $data): self|false 126 | { 127 | try { 128 | if (! isset($data['schema']) || ! isset($data['handler'])) { 129 | return false; 130 | } 131 | 132 | return new self( 133 | Tool::fromArray($data['schema']), 134 | $data['handler'], 135 | $data['isManual'] ?? false, 136 | ); 137 | } catch (Throwable $e) { 138 | return false; 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /tests/Fixtures/Schema/SchemaGenerationTarget.php: -------------------------------------------------------------------------------- 1 | $pIntArrayGeneric Array of integers (generic style) 74 | * @param array $pAssocArray Associative array 75 | * @param BackedIntEnum[] $pEnumArray Array of enums 76 | * @param array{name: string, age: int} $pShapeArray Typed array shape 77 | * @param array $pArrayOfShapes Array of shapes 78 | */ 79 | public function arrayTypes( 80 | array $pStringArray, 81 | array $pIntArrayGeneric, 82 | array $pAssocArray, 83 | array $pEnumArray, 84 | array $pShapeArray, 85 | array $pArrayOfShapes 86 | ): void { 87 | } 88 | 89 | /** 90 | * Enum types. 91 | * @param BackedStringEnum $pBackedStringEnum Backed string enum 92 | * @param BackedIntEnum $pBackedIntEnum Backed int enum 93 | * @param UnitEnum $pUnitEnum Unit enum 94 | */ 95 | public function enumTypes(BackedStringEnum $pBackedStringEnum, BackedIntEnum $pBackedIntEnum, UnitEnum $pUnitEnum): void 96 | { 97 | } 98 | 99 | /** 100 | * Variadic parameters. 101 | * @param string ...$pVariadicStrings Variadic strings 102 | */ 103 | public function variadicParams(string ...$pVariadicStrings): void 104 | { 105 | } 106 | 107 | /** 108 | * Mixed type. 109 | * @param mixed $pMixed Mixed type 110 | */ 111 | public function mixedType(mixed $pMixed): void 112 | { 113 | } 114 | 115 | /** 116 | * With #[Schema] attributes for enhanced validation. 117 | * @param string $email With email format. 118 | * @param int $quantity With numeric constraints. 119 | * @param string[] $tags With array constraints. 120 | * @param array $userProfile With object property constraints. 121 | */ 122 | public function withSchemaAttributes( 123 | #[Schema(format: Format::EMAIL)] 124 | string $email, 125 | #[Schema(minimum: 1, maximum: 100, multipleOf: 5)] 126 | int $quantity, 127 | #[Schema(minItems: 1, maxItems: 5, uniqueItems: true, items: new ArrayItems(minLength: 3))] 128 | array $tags, 129 | #[Schema( 130 | properties: [ 131 | new Property(name: 'id', minimum: 1), 132 | new Property(name: 'username', pattern: '^[a-z0-9_]{3,16}$'), 133 | ], 134 | required: ['id', 'username'], 135 | additionalProperties: false 136 | )] 137 | array $userProfile 138 | ): void { 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /examples/01-discovery-stdio-calculator/McpElements.php: -------------------------------------------------------------------------------- 1 | 2, 12 | 'allow_negative' => true, 13 | ]; 14 | 15 | /** 16 | * Performs a calculation based on the operation. 17 | * 18 | * Supports 'add', 'subtract', 'multiply', 'divide'. 19 | * Obeys the 'precision' and 'allow_negative' settings from the config resource. 20 | * 21 | * @param float $a The first operand. 22 | * @param float $b The second operand. 23 | * @param string $operation The operation ('add', 'subtract', 'multiply', 'divide'). 24 | * @return float|string The result of the calculation, or an error message string. 25 | */ 26 | #[McpTool(name: 'calculate')] 27 | public function calculate(float $a, float $b, string $operation): float|string 28 | { 29 | // Use STDERR for logs 30 | fwrite(STDERR, "Calculate tool called: a=$a, b=$b, op=$operation\n"); 31 | 32 | $op = strtolower($operation); 33 | $result = null; 34 | 35 | switch ($op) { 36 | case 'add': 37 | $result = $a + $b; 38 | break; 39 | case 'subtract': 40 | $result = $a - $b; 41 | break; 42 | case 'multiply': 43 | $result = $a * $b; 44 | break; 45 | case 'divide': 46 | if ($b == 0) { 47 | return 'Error: Division by zero.'; 48 | } 49 | $result = $a / $b; 50 | break; 51 | default: 52 | return "Error: Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide."; 53 | } 54 | 55 | if (! $this->config['allow_negative'] && $result < 0) { 56 | return 'Error: Negative results are disabled.'; 57 | } 58 | 59 | return round($result, $this->config['precision']); 60 | } 61 | 62 | /** 63 | * Provides the current calculator configuration. 64 | * Can be read by clients to understand precision etc. 65 | * 66 | * @return array The configuration array. 67 | */ 68 | #[McpResource( 69 | uri: 'config://calculator/settings', 70 | name: 'calculator_config', 71 | description: 'Current settings for the calculator tool (precision, allow_negative).', 72 | mimeType: 'application/json' // Return as JSON 73 | )] 74 | public function getConfiguration(): array 75 | { 76 | fwrite(STDERR, "Resource config://calculator/settings read.\n"); 77 | 78 | return $this->config; 79 | } 80 | 81 | /** 82 | * Updates a specific configuration setting. 83 | * Note: This requires more robust validation in a real app. 84 | * 85 | * @param string $setting The setting key ('precision' or 'allow_negative'). 86 | * @param mixed $value The new value (int for precision, bool for allow_negative). 87 | * @return array Success message or error. 88 | */ 89 | #[McpTool(name: 'update_setting')] 90 | public function updateSetting(string $setting, mixed $value): array 91 | { 92 | fwrite(STDERR, "Update Setting tool called: setting=$setting, value=".var_export($value, true)."\n"); 93 | if (! array_key_exists($setting, $this->config)) { 94 | return ['success' => false, 'error' => "Unknown setting '{$setting}'."]; 95 | } 96 | 97 | if ($setting === 'precision') { 98 | if (! is_int($value) || $value < 0 || $value > 10) { 99 | return ['success' => false, 'error' => 'Invalid precision value. Must be integer between 0 and 10.']; 100 | } 101 | $this->config['precision'] = $value; 102 | 103 | // In real app, notify subscribers of config://calculator/settings change 104 | // $registry->notifyResourceChanged('config://calculator/settings'); 105 | return ['success' => true, 'message' => "Precision updated to {$value}."]; 106 | } 107 | 108 | if ($setting === 'allow_negative') { 109 | if (! is_bool($value)) { 110 | // Attempt basic cast for flexibility 111 | if (in_array(strtolower((string) $value), ['true', '1', 'yes', 'on'])) { 112 | $value = true; 113 | } elseif (in_array(strtolower((string) $value), ['false', '0', 'no', 'off'])) { 114 | $value = false; 115 | } else { 116 | return ['success' => false, 'error' => 'Invalid allow_negative value. Must be boolean (true/false).']; 117 | } 118 | } 119 | $this->config['allow_negative'] = $value; 120 | 121 | // $registry->notifyResourceChanged('config://calculator/settings'); 122 | return ['success' => true, 'message' => 'Allow negative results set to '.($value ? 'true' : 'false').'.']; 123 | } 124 | 125 | return ['success' => false, 'error' => 'Internal error handling setting.']; // Should not happen 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /examples/02-discovery-http-userprofile/McpElements.php: -------------------------------------------------------------------------------- 1 | ['name' => 'Alice', 'email' => 'alice@example.com', 'role' => 'admin'], 18 | '102' => ['name' => 'Bob', 'email' => 'bob@example.com', 'role' => 'user'], 19 | '103' => ['name' => 'Charlie', 'email' => 'charlie@example.com', 'role' => 'user'], 20 | ]; 21 | 22 | private LoggerInterface $logger; 23 | 24 | public function __construct(LoggerInterface $logger) 25 | { 26 | $this->logger = $logger; 27 | $this->logger->debug('HttpUserProfileExample McpElements instantiated.'); 28 | } 29 | 30 | /** 31 | * Retrieves the profile data for a specific user. 32 | * 33 | * @param string $userId The ID of the user (from URI). 34 | * @return array User profile data. 35 | * 36 | * @throws McpServerException If the user is not found. 37 | */ 38 | #[McpResourceTemplate( 39 | uriTemplate: 'user://{userId}/profile', 40 | name: 'user_profile', 41 | description: 'Get profile information for a specific user ID.', 42 | mimeType: 'application/json' 43 | )] 44 | 45 | public function getUserProfile( 46 | #[CompletionProvider(values: ['101', '102', '103'])] 47 | string $userId 48 | ): array { 49 | $this->logger->info('Reading resource: user profile', ['userId' => $userId]); 50 | if (! isset($this->users[$userId])) { 51 | // Throwing an exception that Processor can turn into an error response 52 | throw McpServerException::invalidParams("User profile not found for ID: {$userId}"); 53 | } 54 | 55 | return $this->users[$userId]; 56 | } 57 | 58 | /** 59 | * Retrieves a list of all known user IDs. 60 | * 61 | * @return array List of user IDs. 62 | */ 63 | #[McpResource( 64 | uri: 'user://list/ids', 65 | name: 'user_id_list', 66 | description: 'Provides a list of all available user IDs.', 67 | mimeType: 'application/json' 68 | )] 69 | public function listUserIds(): array 70 | { 71 | $this->logger->info('Reading resource: user ID list'); 72 | 73 | return array_keys($this->users); 74 | } 75 | 76 | /** 77 | * Sends a welcome message to a user. 78 | * (This is a placeholder - in a real app, it might queue an email) 79 | * 80 | * @param string $userId The ID of the user to message. 81 | * @param string|null $customMessage An optional custom message part. 82 | * @return array Status of the operation. 83 | */ 84 | #[McpTool(name: 'send_welcome')] 85 | public function sendWelcomeMessage(string $userId, ?string $customMessage = null): array 86 | { 87 | $this->logger->info('Executing tool: send_welcome', ['userId' => $userId]); 88 | if (! isset($this->users[$userId])) { 89 | return ['success' => false, 'error' => "User ID {$userId} not found."]; 90 | } 91 | $user = $this->users[$userId]; 92 | $message = "Welcome, {$user['name']}!"; 93 | if ($customMessage) { 94 | $message .= ' ' . $customMessage; 95 | } 96 | // Simulate sending 97 | $this->logger->info("Simulated sending message to {$user['email']}: {$message}"); 98 | 99 | return ['success' => true, 'message_sent' => $message]; 100 | } 101 | 102 | #[McpTool(name: 'test_tool_without_params')] 103 | public function testToolWithoutParams() 104 | { 105 | return ['success' => true, 'message' => 'Test tool without params']; 106 | } 107 | 108 | /** 109 | * Generates a prompt to write a bio for a user. 110 | * 111 | * @param string $userId The user ID to generate the bio for. 112 | * @param string $tone Desired tone (e.g., 'formal', 'casual'). 113 | * @return array Prompt messages. 114 | * 115 | * @throws McpServerException If user not found. 116 | */ 117 | #[McpPrompt(name: 'generate_bio_prompt')] 118 | public function generateBio( 119 | #[CompletionProvider(provider: UserIdCompletionProvider::class)] 120 | string $userId, 121 | string $tone = 'professional' 122 | ): array { 123 | $this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]); 124 | if (! isset($this->users[$userId])) { 125 | throw McpServerException::invalidParams("User not found for bio prompt: {$userId}"); 126 | } 127 | $user = $this->users[$userId]; 128 | 129 | return [ 130 | ['role' => 'user', 'content' => "Write a short, {$tone} biography for {$user['name']} (Role: {$user['role']}, Email: {$user['email']}). Highlight their role within the system."], 131 | ]; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Utils/DocBlockParser.php: -------------------------------------------------------------------------------- 1 | docBlockFactory = DocBlockFactory::createInstance(); 24 | $this->logger = $logger ?? new NullLogger(); 25 | } 26 | 27 | /** 28 | * Safely parses a DocComment string into a DocBlock object. 29 | */ 30 | public function parseDocBlock(string|null|false $docComment): ?DocBlock 31 | { 32 | if ($docComment === false || $docComment === null || empty($docComment)) { 33 | return null; 34 | } 35 | try { 36 | return $this->docBlockFactory->create($docComment); 37 | } catch (Throwable $e) { 38 | // Log error or handle gracefully if invalid DocBlock syntax is encountered 39 | $this->logger->warning('Failed to parse DocBlock', [ 40 | 'error' => $e->getMessage(), 41 | 'exception_trace' => $e->getTraceAsString(), 42 | ]); 43 | 44 | return null; 45 | } 46 | } 47 | 48 | /** 49 | * Gets the summary line from a DocBlock. 50 | */ 51 | public function getSummary(?DocBlock $docBlock): ?string 52 | { 53 | if (! $docBlock) { 54 | return null; 55 | } 56 | $summary = trim($docBlock->getSummary()); 57 | 58 | return $summary ?: null; // Return null if empty after trimming 59 | } 60 | 61 | /** 62 | * Gets the description from a DocBlock (summary + description body). 63 | */ 64 | public function getDescription(?DocBlock $docBlock): ?string 65 | { 66 | if (! $docBlock) { 67 | return null; 68 | } 69 | $summary = trim($docBlock->getSummary()); 70 | $descriptionBody = trim((string) $docBlock->getDescription()); 71 | 72 | if ($summary && $descriptionBody) { 73 | return $summary . "\n\n" . $descriptionBody; 74 | } 75 | if ($summary) { 76 | return $summary; 77 | } 78 | if ($descriptionBody) { 79 | return $descriptionBody; 80 | } 81 | 82 | return null; 83 | } 84 | 85 | /** 86 | * Extracts @param tag information from a DocBlock, keyed by variable name (e.g., '$paramName'). 87 | * 88 | * @return array 89 | */ 90 | public function getParamTags(?DocBlock $docBlock): array 91 | { 92 | if (! $docBlock) { 93 | return []; 94 | } 95 | 96 | /** @var array $paramTags */ 97 | $paramTags = []; 98 | foreach ($docBlock->getTagsByName('param') as $tag) { 99 | if ($tag instanceof Param && $tag->getVariableName()) { 100 | $paramTags['$' . $tag->getVariableName()] = $tag; 101 | } 102 | } 103 | 104 | return $paramTags; 105 | } 106 | 107 | /** 108 | * Gets the @return tag information from a DocBlock. 109 | */ 110 | public function getReturnTag(?DocBlock $docBlock): ?Return_ 111 | { 112 | if (! $docBlock) { 113 | return null; 114 | } 115 | /** @var Return_|null $returnTag */ 116 | $returnTag = $docBlock->getTagsByName('return')[0] ?? null; 117 | 118 | return $returnTag; 119 | } 120 | 121 | /** 122 | * Gets the description string from a Param tag. 123 | */ 124 | public function getParamDescription(?Param $paramTag): ?string 125 | { 126 | return $paramTag ? (trim((string) $paramTag->getDescription()) ?: null) : null; 127 | } 128 | 129 | /** 130 | * Gets the type string from a Param tag. 131 | */ 132 | public function getParamTypeString(?Param $paramTag): ?string 133 | { 134 | if ($paramTag && $paramTag->getType()) { 135 | $typeFromTag = trim((string) $paramTag->getType()); 136 | if (! empty($typeFromTag)) { 137 | return ltrim($typeFromTag, '\\'); 138 | } 139 | } 140 | 141 | return null; 142 | } 143 | 144 | /** 145 | * Gets the description string from a Return_ tag. 146 | */ 147 | public function getReturnDescription(?Return_ $returnTag): ?string 148 | { 149 | return $returnTag ? (trim((string) $returnTag->getDescription()) ?: null) : null; 150 | } 151 | 152 | /** 153 | * Gets the type string from a Return_ tag. 154 | */ 155 | public function getReturnTypeString(?Return_ $returnTag): ?string 156 | { 157 | if ($returnTag && $returnTag->getType()) { 158 | $typeFromTag = trim((string) $returnTag->getType()); 159 | if (! empty($typeFromTag)) { 160 | return ltrim($typeFromTag, '\\'); 161 | } 162 | } 163 | 164 | return null; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /examples/02-discovery-http-userprofile/server.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | discover() scans for elements, 29 | | and then $server->listen() starts the ReactPHP HTTP server. 30 | | 31 | | If you provided a `CacheInterface` implementation to the ServerBuilder, 32 | | the discovery process will be cached, so you can comment out the 33 | | discovery call after the first run to speed up subsequent runs. 34 | | 35 | */ 36 | 37 | declare(strict_types=1); 38 | 39 | chdir(__DIR__); 40 | require_once '../../vendor/autoload.php'; 41 | require_once 'McpElements.php'; 42 | require_once 'UserIdCompletionProvider.php'; 43 | 44 | use PhpMcp\Schema\ServerCapabilities; 45 | use PhpMcp\Server\Defaults\BasicContainer; 46 | use PhpMcp\Server\Server; 47 | use PhpMcp\Server\Transports\HttpServerTransport; 48 | use PhpMcp\Server\Transports\StreamableHttpServerTransport; 49 | use Psr\Log\AbstractLogger; 50 | use Psr\Log\LoggerInterface; 51 | 52 | class StderrLogger extends AbstractLogger 53 | { 54 | public function log($level, \Stringable|string $message, array $context = []): void 55 | { 56 | fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); 57 | } 58 | } 59 | 60 | try { 61 | $logger = new StderrLogger(); 62 | $logger->info('Starting MCP HTTP User Profile Server...'); 63 | 64 | // --- Setup DI Container for DI in McpElements class --- 65 | $container = new BasicContainer(); 66 | $container->set(LoggerInterface::class, $logger); 67 | 68 | $server = Server::make() 69 | ->withServerInfo('HTTP User Profiles', '1.0.0') 70 | ->withCapabilities(ServerCapabilities::make(completions: true, logging: true)) 71 | ->withLogger($logger) 72 | ->withContainer($container) 73 | ->withTool( 74 | function (float $a, float $b, string $operation = 'add'): array { 75 | $result = match ($operation) { 76 | 'add' => $a + $b, 77 | 'subtract' => $a - $b, 78 | 'multiply' => $a * $b, 79 | 'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Cannot divide by zero'), 80 | default => throw new \InvalidArgumentException("Unknown operation: {$operation}") 81 | }; 82 | 83 | return [ 84 | 'operation' => $operation, 85 | 'operands' => [$a, $b], 86 | 'result' => $result 87 | ]; 88 | }, 89 | name: 'calculator', 90 | description: 'Perform basic math operations (add, subtract, multiply, divide)' 91 | ) 92 | ->withResource( 93 | function (): array { 94 | $memoryUsage = memory_get_usage(true); 95 | $memoryPeak = memory_get_peak_usage(true); 96 | $uptime = time() - $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); 97 | $serverSoftware = $_SERVER['SERVER_SOFTWARE'] ?? 'CLI'; 98 | 99 | return [ 100 | 'server_time' => date('Y-m-d H:i:s'), 101 | 'uptime_seconds' => $uptime, 102 | 'memory_usage_mb' => round($memoryUsage / 1024 / 1024, 2), 103 | 'memory_peak_mb' => round($memoryPeak / 1024 / 1024, 2), 104 | 'php_version' => PHP_VERSION, 105 | 'server_software' => $serverSoftware, 106 | 'operating_system' => PHP_OS_FAMILY, 107 | 'status' => 'healthy' 108 | ]; 109 | }, 110 | uri: 'system://status', 111 | name: 'system_status', 112 | description: 'Current system status and runtime information', 113 | mimeType: 'application/json' 114 | ) 115 | ->build(); 116 | 117 | $server->discover(__DIR__, ['.']); 118 | 119 | // $transport = new HttpServerTransport('127.0.0.1', 8080, 'mcp'); 120 | $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); 121 | 122 | $server->listen($transport); 123 | 124 | $logger->info('Server listener stopped gracefully.'); 125 | exit(0); 126 | } catch (\Throwable $e) { 127 | fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); 128 | fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); 129 | fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); 130 | fwrite(STDERR, $e->getTraceAsString() . "\n"); 131 | exit(1); 132 | } 133 | -------------------------------------------------------------------------------- /tests/Fixtures/General/ResourceHandlerFixture.php: -------------------------------------------------------------------------------- 1 | dynamicContentStore['dynamic://data/item1'] = "Content for item 1"; 20 | } 21 | 22 | public function returnStringText(string $uri): string 23 | { 24 | return "Plain string content for {$uri}"; 25 | } 26 | 27 | public function returnStringJson(string $uri): string 28 | { 29 | return json_encode(['uri_in_json' => $uri, 'data' => 'some json string']); 30 | } 31 | 32 | public function returnStringHtml(string $uri): string 33 | { 34 | return "{$uri}Content"; 35 | } 36 | 37 | public function returnArrayJson(string $uri): array 38 | { 39 | return ['uri_in_array' => $uri, 'message' => 'This is JSON data from array', 'timestamp' => time()]; 40 | } 41 | 42 | public function returnEmptyArray(string $uri): array 43 | { 44 | return []; 45 | } 46 | 47 | public function returnStream(string $uri) // Returns a stream resource 48 | { 49 | $stream = fopen('php://memory', 'r+'); 50 | fwrite($stream, "Streamed content for {$uri}"); 51 | rewind($stream); 52 | return $stream; 53 | } 54 | 55 | public function returnSplFileInfo(string $uri): SplFileInfo 56 | { 57 | self::$unlinkableSplFile = tempnam(sys_get_temp_dir(), 'res_fixture_spl_'); 58 | file_put_contents(self::$unlinkableSplFile, "Content from SplFileInfo for {$uri}"); 59 | return new SplFileInfo(self::$unlinkableSplFile); 60 | } 61 | 62 | public function returnEmbeddedResource(string $uri): EmbeddedResource 63 | { 64 | return EmbeddedResource::make( 65 | TextResourceContents::make($uri, 'application/vnd.custom-embedded', 'Direct EmbeddedResource content') 66 | ); 67 | } 68 | 69 | public function returnTextResourceContents(string $uri): TextResourceContents 70 | { 71 | return TextResourceContents::make($uri, 'text/special-contents', 'Direct TextResourceContents'); 72 | } 73 | 74 | public function returnBlobResourceContents(string $uri): BlobResourceContents 75 | { 76 | return BlobResourceContents::make($uri, 'application/custom-blob-contents', base64_encode('blobbycontents')); 77 | } 78 | 79 | public function returnArrayForBlobSchema(string $uri): array 80 | { 81 | return ['blob' => base64_encode("Blob for {$uri} via array"), 'mimeType' => 'application/x-custom-blob-array']; 82 | } 83 | 84 | public function returnArrayForTextSchema(string $uri): array 85 | { 86 | return ['text' => "Text from array for {$uri} via array", 'mimeType' => 'text/vnd.custom-array-text']; 87 | } 88 | 89 | public function returnArrayOfResourceContents(string $uri): array 90 | { 91 | return [ 92 | TextResourceContents::make($uri . "_part1", 'text/plain', 'Part 1 of many RC'), 93 | BlobResourceContents::make($uri . "_part2", 'image/png', base64_encode('pngdata')), 94 | ]; 95 | } 96 | 97 | public function returnArrayOfEmbeddedResources(string $uri): array 98 | { 99 | return [ 100 | EmbeddedResource::make(TextResourceContents::make($uri . "_emb1", 'text/xml', '')), 101 | EmbeddedResource::make(BlobResourceContents::make($uri . "_emb2", 'font/woff2', base64_encode('fontdata'))), 102 | ]; 103 | } 104 | 105 | public function returnMixedArrayWithResourceTypes(string $uri): array 106 | { 107 | return [ 108 | "A raw string piece", // Will be formatted 109 | TextResourceContents::make($uri . "_rc1", 'text/markdown', '**Markdown!**'), // Used as is 110 | ['nested_array_data' => 'value', 'for_uri' => $uri], // Will be formatted (JSON) 111 | EmbeddedResource::make(TextResourceContents::make($uri . "_emb1", 'text/csv', 'col1,col2')), // Extracted 112 | ]; 113 | } 114 | 115 | public function handlerThrowsException(string $uri): void 116 | { 117 | throw new \DomainException("Cannot read resource {$uri} - handler error."); 118 | } 119 | 120 | public function returnUnformattableType(string $uri) 121 | { 122 | return new \DateTimeImmutable(); 123 | } 124 | 125 | public function resourceHandlerNeedsUri(string $uri): string 126 | { 127 | return "Handler received URI: " . $uri; 128 | } 129 | 130 | public function resourceHandlerDoesNotNeedUri(): string 131 | { 132 | return "Handler did not need or receive URI parameter."; 133 | } 134 | 135 | public function getTemplatedContent( 136 | string $category, 137 | string $itemId, 138 | string $format, 139 | ): array { 140 | return [ 141 | 'message' => "Content for item {$itemId} in category {$category}, format {$format}.", 142 | 'category_received' => $category, 143 | 'itemId_received' => $itemId, 144 | 'format_received' => $format, 145 | ]; 146 | } 147 | 148 | public function getStaticText(): string 149 | { 150 | return self::$staticTextContent; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /tests/Mocks/Clients/MockJsonHttpClient.php: -------------------------------------------------------------------------------- 1 | browser = (new Browser())->withTimeout($timeout); 21 | $this->baseUrl = "http://{$host}:{$port}/{$mcpPath}"; 22 | } 23 | 24 | public function sendRequest(string $method, array $params = [], ?string $id = null, array $additionalHeaders = []): PromiseInterface 25 | { 26 | $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]; 27 | if ($id !== null) { 28 | $payload['id'] = $id; 29 | } 30 | 31 | $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream']; 32 | if ($this->sessionId && $method !== 'initialize') { 33 | $headers['Mcp-Session-Id'] = $this->sessionId; 34 | } 35 | $headers += $additionalHeaders; 36 | 37 | $body = json_encode($payload); 38 | 39 | return $this->browser->post($this->baseUrl, $headers, $body) 40 | ->then(function (ResponseInterface $response) use ($method) { 41 | // Store response headers for testing 42 | $this->lastResponseHeaders = []; 43 | foreach ($response->getHeaders() as $name => $values) { 44 | foreach ($values as $value) { 45 | $this->lastResponseHeaders[] = "{$name}: {$value}"; 46 | } 47 | } 48 | 49 | $bodyContent = (string) $response->getBody()->getContents(); 50 | $statusCode = $response->getStatusCode(); 51 | 52 | if ($method === 'initialize' && $statusCode === 200) { 53 | $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); 54 | } 55 | 56 | if ($statusCode === 202) { 57 | if ($bodyContent !== '') { 58 | throw new \RuntimeException("Expected empty body for 202 response, got: {$bodyContent}"); 59 | } 60 | return ['statusCode' => $statusCode, 'body' => null, 'headers' => $response->getHeaders()]; 61 | } 62 | 63 | try { 64 | $decoded = json_decode($bodyContent, true, 512, JSON_THROW_ON_ERROR); 65 | return ['statusCode' => $statusCode, 'body' => $decoded, 'headers' => $response->getHeaders()]; 66 | } catch (\JsonException $e) { 67 | throw new \RuntimeException("Failed to decode JSON response body: {$bodyContent} Error: {$e->getMessage()}", $statusCode, $e); 68 | } 69 | }); 70 | } 71 | 72 | public function sendBatchRequest(array $batchRequestObjects): PromiseInterface 73 | { 74 | $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; 75 | if ($this->sessionId) { 76 | $headers['Mcp-Session-Id'] = $this->sessionId; 77 | } 78 | $body = json_encode($batchRequestObjects); 79 | 80 | return $this->browser->post($this->baseUrl, $headers, $body) 81 | ->then(function (ResponseInterface $response) { 82 | $bodyContent = (string) $response->getBody()->getContents(); 83 | $statusCode = $response->getStatusCode(); 84 | if ($statusCode === 202) { 85 | if ($bodyContent !== '') { 86 | throw new \RuntimeException("Expected empty body for 202 response, got: {$bodyContent}"); 87 | } 88 | return ['statusCode' => $statusCode, 'body' => null, 'headers' => $response->getHeaders()]; 89 | } 90 | 91 | try { 92 | $decoded = json_decode($bodyContent, true, 512, JSON_THROW_ON_ERROR); 93 | return ['statusCode' => $statusCode, 'body' => $decoded, 'headers' => $response->getHeaders()]; 94 | } catch (\JsonException $e) { 95 | throw new \RuntimeException("Failed to decode JSON response body: {$bodyContent} Error: {$e->getMessage()}", $statusCode, $e); 96 | } 97 | }); 98 | } 99 | 100 | public function sendDeleteRequest(): PromiseInterface 101 | { 102 | $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; 103 | if ($this->sessionId) { 104 | $headers['Mcp-Session-Id'] = $this->sessionId; 105 | } 106 | 107 | return $this->browser->delete($this->baseUrl, $headers) 108 | ->then(function (ResponseInterface $response) { 109 | $bodyContent = (string) $response->getBody()->getContents(); 110 | $statusCode = $response->getStatusCode(); 111 | return ['statusCode' => $statusCode, 'body' => $bodyContent, 'headers' => $response->getHeaders()]; 112 | }); 113 | } 114 | 115 | public function sendNotification(string $method, array $params = []): PromiseInterface 116 | { 117 | return $this->sendRequest($method, $params, null); 118 | } 119 | 120 | public function connectSseForNotifications(): PromiseInterface 121 | { 122 | return resolve(null); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/Fixtures/General/VariousTypesHandler.php: -------------------------------------------------------------------------------- 1 | $context->session->get('testKey'), 150 | 'request' => $context->request->getHeaderLine('testHeader'), 151 | ]; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Session/Session.php: -------------------------------------------------------------------------------- 1 | Stores all session data. 14 | * Keys are snake_case by convention for MCP-specific data. 15 | * 16 | * Official keys are: 17 | * - initialized: bool 18 | * - client_info: array|null 19 | * - protocol_version: string|null 20 | * - subscriptions: array 21 | * - message_queue: array 22 | * - log_level: string|null 23 | */ 24 | protected array $data = []; 25 | 26 | public function __construct( 27 | protected SessionHandlerInterface $handler, 28 | protected string $id = '', 29 | ?array $data = null 30 | ) { 31 | if (empty($this->id)) { 32 | $this->id = $this->generateId(); 33 | } 34 | 35 | if ($data !== null) { 36 | $this->hydrate($data); 37 | } elseif ($sessionData = $this->handler->read($this->id)) { 38 | $this->data = json_decode($sessionData, true) ?? []; 39 | } 40 | } 41 | 42 | /** 43 | * Retrieve an existing session instance from handler or return null if session doesn't exist 44 | */ 45 | public static function retrieve(string $id, SessionHandlerInterface $handler): ?SessionInterface 46 | { 47 | $sessionData = $handler->read($id); 48 | 49 | if (!$sessionData) { 50 | return null; 51 | } 52 | 53 | $data = json_decode($sessionData, true); 54 | if ($data === null) { 55 | return null; 56 | } 57 | 58 | return new static($handler, $id, $data); 59 | } 60 | 61 | public function getId(): string 62 | { 63 | return $this->id; 64 | } 65 | 66 | public function getHandler(): SessionHandlerInterface 67 | { 68 | return $this->handler; 69 | } 70 | 71 | public function generateId(): string 72 | { 73 | return bin2hex(random_bytes(16)); 74 | } 75 | 76 | public function save(): void 77 | { 78 | $this->handler->write($this->id, json_encode($this->data)); 79 | } 80 | 81 | public function get(string $key, mixed $default = null): mixed 82 | { 83 | $key = explode('.', $key); 84 | $data = $this->data; 85 | 86 | foreach ($key as $segment) { 87 | if (is_array($data) && array_key_exists($segment, $data)) { 88 | $data = $data[$segment]; 89 | } else { 90 | return $default; 91 | } 92 | } 93 | 94 | return $data; 95 | } 96 | 97 | public function set(string $key, mixed $value, bool $overwrite = true): void 98 | { 99 | $segments = explode('.', $key); 100 | $data = &$this->data; 101 | 102 | while (count($segments) > 1) { 103 | $segment = array_shift($segments); 104 | if (!isset($data[$segment]) || !is_array($data[$segment])) { 105 | $data[$segment] = []; 106 | } 107 | $data = &$data[$segment]; 108 | } 109 | 110 | $lastKey = array_shift($segments); 111 | if ($overwrite || !isset($data[$lastKey])) { 112 | $data[$lastKey] = $value; 113 | } 114 | } 115 | 116 | public function has(string $key): bool 117 | { 118 | $key = explode('.', $key); 119 | $data = $this->data; 120 | 121 | foreach ($key as $segment) { 122 | if (is_array($data) && array_key_exists($segment, $data)) { 123 | $data = $data[$segment]; 124 | } elseif (is_object($data) && isset($data->{$segment})) { 125 | $data = $data->{$segment}; 126 | } else { 127 | return false; 128 | } 129 | } 130 | 131 | return true; 132 | } 133 | 134 | public function forget(string $key): void 135 | { 136 | $segments = explode('.', $key); 137 | $data = &$this->data; 138 | 139 | while (count($segments) > 1) { 140 | $segment = array_shift($segments); 141 | if (!isset($data[$segment]) || !is_array($data[$segment])) { 142 | $data[$segment] = []; 143 | } 144 | $data = &$data[$segment]; 145 | } 146 | 147 | $lastKey = array_shift($segments); 148 | if (isset($data[$lastKey])) { 149 | unset($data[$lastKey]); 150 | } 151 | } 152 | 153 | public function clear(): void 154 | { 155 | $this->data = []; 156 | } 157 | 158 | public function pull(string $key, mixed $default = null): mixed 159 | { 160 | $value = $this->get($key, $default); 161 | $this->forget($key); 162 | return $value; 163 | } 164 | 165 | public function all(): array 166 | { 167 | return $this->data; 168 | } 169 | 170 | public function hydrate(array $attributes): void 171 | { 172 | $this->data = array_merge( 173 | [ 174 | 'initialized' => false, 175 | 'client_info' => null, 176 | 'protocol_version' => null, 177 | 'message_queue' => [], 178 | 'log_level' => null, 179 | ], 180 | $attributes 181 | ); 182 | unset($this->data['id']); 183 | } 184 | 185 | public function queueMessage(string $rawFramedMessage): void 186 | { 187 | $this->data['message_queue'][] = $rawFramedMessage; 188 | } 189 | 190 | public function dequeueMessages(): array 191 | { 192 | $messages = $this->data['message_queue'] ?? []; 193 | $this->data['message_queue'] = []; 194 | return $messages; 195 | } 196 | 197 | public function hasQueuedMessages(): bool 198 | { 199 | return !empty($this->data['message_queue']); 200 | } 201 | 202 | public function jsonSerialize(): array 203 | { 204 | return $this->all(); 205 | } 206 | } 207 | --------------------------------------------------------------------------------