# Directory Structure
```
├── .gitignore
├── CHANGELOG.md
├── composer.json
├── examples
│ └── echo.php
├── LICENSE.md
├── README.md
├── src
│ ├── McpServerException.php
│ └── Server.php
└── tests
├── ArchTest.php
└── BasicInteractionTest.php
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Composer Related
2 | composer.lock
3 | /vendor
4 |
5 | # Frontend Assets
6 | /node_modules
7 |
8 | # Logs
9 | npm-debug.log
10 | yarn-error.log
11 |
12 | # Caches
13 | .phpunit.cache
14 | .phpunit.result.cache
15 | /build
16 |
17 | # IDE Helper
18 | _ide_helper.php
19 | _ide_helper_models.php
20 |
21 | # Editors
22 | /.vscode
23 |
24 | # Misc
25 | phpunit.xml
26 | phpstan.neon
27 | testbench.yaml
28 | /docs
29 | /coverage
30 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # 🐉 The fast, PHP way to build MCP servers
2 |
3 | The Model Context Protocol (MCP) is a new, standardized way to provide context and tools to your LLMs, and `pronskiy/mcp` makes building MCP servers simple and intuitive.
4 |
5 | Create tools, expose resources, define prompts, and connect components with clean PHP code.
6 |
7 | ## Installation
8 |
9 | With composer:
10 |
11 | ```bash
12 | composer require pronskiy/mcp
13 | ```
14 |
15 | ## Usage
16 |
17 | ```php
18 | require 'vendor/autoload.php';
19 |
20 | $server = new \Pronskiy\Mcp\Server('simple-mcp-server');
21 |
22 | $server
23 | ->tool(
24 | 'add-numbers',
25 | 'Adds two numbers together',
26 | fn(float $num1, float $num2) => "The sum of {$num1} and {$num2} is " . ($num1 + $num2)
27 | )
28 | ->tool(
29 | 'multiply-numbers',
30 | 'Multiplies two numbers',
31 | fn(float $num1, float $num2) => "The product of {$num1} and {$num2} is " . ($num1 * $num2)
32 | )
33 | ;
34 |
35 | $server->run();
36 | ```
37 |
38 | ## Credits
39 |
40 | - https://github.com/logiscape/mcp-sdk-php
41 |
42 | ## License
43 |
44 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
45 |
```
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
```markdown
1 | The MIT License (MIT)
2 |
3 | Copyright (c) pronskiy <[email protected]>
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
13 | all 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
21 | THE SOFTWARE.
22 |
```
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # Changelog
2 |
3 | All notable changes to `Mcp` will be documented in this file.
4 |
```
--------------------------------------------------------------------------------
/tests/ArchTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | arch('it will not use debugging functions')
4 | ->expect(['dd', 'dump', 'ray', 'var_dump'])
5 | ->each->not->toBeUsed();
6 |
```
--------------------------------------------------------------------------------
/examples/echo.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | require __DIR__ . '/../vendor/autoload.php';
4 |
5 | (new \Pronskiy\Mcp\Server('echo-server'))
6 | ->tool('echo', 'Echoes text', function(string $text) {
7 | return $text;
8 | })
9 | ->run();
10 |
```
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "pronskiy/mcp",
3 | "description": "🐉 The fast, PHP way to build MCP servers",
4 | "version": "0.1.1",
5 | "keywords": [
6 | "pronskiy",
7 | "mcp"
8 | ],
9 | "homepage": "https://github.com/pronskiy/mcp",
10 | "license": "MIT",
11 | "authors": [
12 | {
13 | "name": "Roman Pronskiy",
14 | "email": "[email protected]"
15 | }
16 | ],
17 | "require": {
18 | "php": "^8.3",
19 | "mcp/sdk": "dev-main"
20 | },
21 | "require-dev": {
22 | "phpstan/phpstan": "^2.1.12",
23 | "pestphp/pest": "^3.0",
24 | "pestphp/pest-plugin-arch": "^3.0",
25 | "pestphp/pest-plugin-laravel": "^3.0",
26 | "phpstan/extension-installer": "^1.3||^2.0",
27 | "phpstan/phpstan-deprecation-rules": "^1.1||^2.0",
28 | "phpstan/phpstan-phpunit": "^1.3||^2.0",
29 | "symfony/console": "^v7.2.5"
30 | },
31 | "autoload": {
32 | "psr-4": {
33 | "Pronskiy\\Mcp\\": "src/"
34 | }
35 | },
36 | "autoload-dev": {
37 | "psr-4": {
38 | "Pronskiy\\Mcp\\Tests\\": "tests/"
39 | }
40 | },
41 | "scripts": {
42 | "analyse": "vendor/bin/phpstan analyse",
43 | "test": "vendor/bin/pest",
44 | "test-coverage": "vendor/bin/pest --coverage"
45 | },
46 | "config": {
47 | "sort-packages": true,
48 | "allow-plugins": {
49 | "pestphp/pest-plugin": true,
50 | "phpstan/extension-installer": true
51 | }
52 | },
53 | "extra": {
54 | },
55 | "minimum-stability": "dev",
56 | "prefer-stable": true
57 | }
58 |
```
--------------------------------------------------------------------------------
/src/McpServerException.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace Pronskiy\Mcp;
4 |
5 | class McpServerException extends \Exception
6 | {
7 | /**
8 | * Create a new exception for an invalid tool handler result
9 | */
10 | public static function invalidToolResult(mixed $result): self
11 | {
12 | $type = is_object($result) ? get_class($result) : gettype($result);
13 | return new self("Invalid tool handler result: expected string or CallToolResult, got {$type}");
14 | }
15 |
16 | /**
17 | * Create a new exception for an invalid prompt handler result
18 | */
19 | public static function invalidPromptResult(mixed $result): self
20 | {
21 | $type = is_object($result) ? get_class($result) : gettype($result);
22 | return new self("Invalid prompt handler result: expected string, array, or GetPromptResult, got {$type}");
23 | }
24 |
25 | /**
26 | * Create a new exception for an invalid resource handler result
27 | */
28 | public static function invalidResourceResult(mixed $result): self
29 | {
30 | $type = is_object($result) ? get_class($result) : gettype($result);
31 | return new self("Invalid resource handler result: expected string, SplFileObject, resource, or ReadResourceResult, got {$type}");
32 | }
33 |
34 | /**
35 | * Create a new exception for an unknown tool
36 | */
37 | public static function unknownTool(string $name): self
38 | {
39 | return new self("Unknown tool: {$name}");
40 | }
41 |
42 | /**
43 | * Create a new exception for an unknown prompt
44 | */
45 | public static function unknownPrompt(string $name): self
46 | {
47 | return new self("Unknown prompt: {$name}");
48 | }
49 |
50 | /**
51 | * Create a new exception for an unknown resource
52 | */
53 | public static function unknownResource(string $uri): self
54 | {
55 | return new self("Unknown resource: {$uri}");
56 | }
57 | }
58 |
```
--------------------------------------------------------------------------------
/tests/BasicInteractionTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | use Symfony\Component\Process\InputStream;
4 | use Symfony\Component\Process\Process;
5 |
6 | function sendRequest(Process $process, InputStream $input, array $request, $waitForResponse = true)
7 | {
8 | $requestStr = json_encode($request) . "\n";
9 |
10 | // Send the request
11 | $input->write($requestStr);
12 |
13 | // Return immediately for notifications
14 | if (!$waitForResponse) {
15 | return null;
16 | }
17 |
18 | // Read the response
19 | $responseStr = '';
20 | $startTime = microtime(true);
21 |
22 | while (empty($responseStr) && (microtime(true) - $startTime) < 5.0) {
23 | $responseStr = $process->getIncrementalOutput();
24 | if (empty($responseStr)) {
25 | usleep(100000); // 100ms
26 | }
27 | }
28 |
29 | $responseStr = trim($responseStr);
30 |
31 | // Parse and return the response
32 | return json_decode($responseStr, true, 512, JSON_THROW_ON_ERROR);
33 | }
34 |
35 | it('can initialize', function () {
36 | $process = new Process([
37 | 'php',
38 | '-r',
39 | /** @lang PHP */
40 | 'require "vendor/autoload.php";
41 | $server = new \Pronskiy\Mcp\Server("test-mcp-server");
42 | $server->run();'
43 | ]);
44 | $input = new InputStream();
45 | $process->setInput($input);
46 | $process->setTty(false);
47 | $process->setTimeout(null);
48 | $process->start();
49 |
50 | // @todo test with different protocol versions
51 | $protocolVersion = '2025';
52 | if ($protocolVersion === '2024-11-05') {
53 | $capabilities = [
54 | 'supports' => [
55 | 'filesystem' => true,
56 | 'resources' => true,
57 | 'utilities' => true,
58 | 'prompt' => true
59 | ]
60 | ];
61 | } else { // 2025-03-26
62 | $capabilities = [
63 | 'tools' => [
64 | 'listChanged' => true
65 | ],
66 | 'resources' => true,
67 | 'prompt' => [
68 | 'streaming' => true
69 | ],
70 | 'utilities' => true
71 | ];
72 | }
73 |
74 | $initializeRequest = [
75 | 'jsonrpc' => '2.0',
76 | 'id' => 'test-init',
77 | 'method' => 'initialize',
78 | 'params' => [
79 | 'protocolVersion' => $protocolVersion,
80 | 'capabilities' => $capabilities,
81 | 'clientInfo' => [
82 | 'name' => 'TestClient',
83 | 'version' => '1.0.0'
84 | ]
85 | ]
86 | ];
87 |
88 | $initResponse = sendRequest($process,$input, $initializeRequest);
89 |
90 | expect($initResponse)
91 | ->toBeArray()
92 | ->and(isset($initResponse['result']))->toBeTrue()
93 | ->and(isset($initResponse['result']['protocolVersion']))->toBeTrue()
94 | ;
95 | });
96 |
```
--------------------------------------------------------------------------------
/src/Server.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace Pronskiy\Mcp;
6 |
7 | use Mcp\Server as SdkServer;
8 | use Mcp\Server\ServerBuilder;
9 | use Mcp\Server\Transport\StdioTransport;
10 | use Psr\Log\NullLogger;
11 |
12 | /**
13 | * Simple MCP Server implementation (using the official mcp/sdk under the hood)
14 | *
15 | * This class provides a fluent interface for creating MCP servers.
16 | * It stores user-defined tools, prompts, and resources, and registers them
17 | * with the mcp/sdk ServerBuilder when run() is called.
18 | */
19 | class Server
20 | {
21 | protected static ?self $instance = null;
22 |
23 | /**
24 | * Get the singleton instance of the server
25 | */
26 | public static function getInstance(string $name = 'mcp-server'): self
27 | {
28 | if (self::$instance === null) {
29 | self::$instance = new self($name);
30 | }
31 | return self::$instance;
32 | }
33 |
34 | public static function addTool(string $name, string $description, callable $callback): self
35 | {
36 | return self::getInstance()->tool($name, $description, $callback);
37 | }
38 |
39 | public static function addPrompt(string $name, string $description, callable $callback): self
40 | {
41 | return self::getInstance()->prompt($name, $description, $callback);
42 | }
43 |
44 | public static function addResource(string $uri, string $name, string $description = '', string $mimeType = 'text/plain', ?callable $callback = null): self
45 | {
46 | return self::getInstance()->resource($uri, $name, $description, $mimeType, $callback);
47 | }
48 |
49 | public static function start(bool $resourcesChanged = true, bool $toolsChanged = true, bool $promptsChanged = true): void
50 | {
51 | // Flags kept for API compatibility, but no-op with the new SDK here.
52 | self::getInstance()->run($resourcesChanged, $toolsChanged, $promptsChanged);
53 | }
54 |
55 | private string $name;
56 |
57 | /** @var array<int, array{name:string, description:string, callback:callable}> */
58 | private array $tools = [];
59 |
60 | /** @var array<int, array{name:string, description:string, callback:callable}> */
61 | private array $prompts = [];
62 |
63 | /** @var array<int, array{uri:string, name:string, description:string, mimeType:string, callback:?callable}> */
64 | private array $resources = [];
65 |
66 | public function __construct(string $name)
67 | {
68 | $this->name = $name;
69 | }
70 |
71 | /**
72 | * Define a new tool
73 | */
74 | public function tool(string $name, string $description, callable $callback): self
75 | {
76 | $this->tools[] = compact('name', 'description', 'callback');
77 | return $this;
78 | }
79 |
80 | /**
81 | * Define a new prompt
82 | */
83 | public function prompt(string $name, string $description, callable $callback): self
84 | {
85 | $this->prompts[] = compact('name', 'description', 'callback');
86 | return $this;
87 | }
88 |
89 | /**
90 | * Define a new resource
91 | */
92 | public function resource(string $uri, string $name, string $description = '', string $mimeType = 'text/plain', ?callable $callback = null): self
93 | {
94 | $this->resources[] = compact('uri', 'name', 'description', 'mimeType', 'callback');
95 | return $this;
96 | }
97 |
98 | /**
99 | * Run the server using mcp/sdk's ServerBuilder and StdioTransport
100 | */
101 | public function run(bool $resourcesChanged = true, bool $toolsChanged = true, bool $promptsChanged = true): void
102 | {
103 | // Build with official SDK
104 | /** @var ServerBuilder $builder */
105 | $builder = SdkServer::make()
106 | ->withServerInfo($this->name, '0.1.1') // @TODO: keep version in sync with package when possible
107 | ->withLogger(new NullLogger());
108 |
109 | // Register tools
110 | foreach ($this->tools as $tool) {
111 | $builder->withTool($tool['callback'], $tool['name'], $tool['description']);
112 | }
113 |
114 | // Register prompts
115 | foreach ($this->prompts as $prompt) {
116 | $builder->withPrompt($prompt['callback'], $prompt['name'], $prompt['description']);
117 | }
118 |
119 | // Register resources
120 | foreach ($this->resources as $res) {
121 | $builder->withResource($res['callback'] ?? fn() => null, $res['uri'], $res['name'], $res['description'], $res['mimeType']);
122 | }
123 |
124 | $server = $builder->build();
125 |
126 | // Connect to stdio transport (JSON-RPC over STDIN/STDOUT)
127 | $server->connect(new StdioTransport());
128 | }
129 | }
130 |
```