├── 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