This is page 1 of 7. Use http://codebase.md/php-mcp/server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .github
│ └── workflows
│ ├── changelog.yml
│ └── tests.yml
├── .gitignore
├── .php-cs-fixer.php
├── CHANGELOG.md
├── composer.json
├── CONTRIBUTING.md
├── examples
│ ├── 01-discovery-stdio-calculator
│ │ ├── McpElements.php
│ │ └── server.php
│ ├── 02-discovery-http-userprofile
│ │ ├── McpElements.php
│ │ ├── server.php
│ │ └── UserIdCompletionProvider.php
│ ├── 03-manual-registration-stdio
│ │ ├── server.php
│ │ └── SimpleHandlers.php
│ ├── 04-combined-registration-http
│ │ ├── DiscoveredElements.php
│ │ ├── ManualHandlers.php
│ │ └── server.php
│ ├── 05-stdio-env-variables
│ │ ├── EnvToolHandler.php
│ │ └── server.php
│ ├── 06-custom-dependencies-stdio
│ │ ├── McpTaskHandlers.php
│ │ ├── server.php
│ │ └── Services.php
│ ├── 07-complex-tool-schema-http
│ │ ├── EventTypes.php
│ │ ├── McpEventScheduler.php
│ │ └── server.php
│ └── 08-schema-showcase-streamable
│ ├── SchemaShowcaseElements.php
│ └── server.php
├── LICENSE
├── phpunit.xml
├── README.md
├── src
│ ├── Attributes
│ │ ├── CompletionProvider.php
│ │ ├── McpPrompt.php
│ │ ├── McpResource.php
│ │ ├── McpResourceTemplate.php
│ │ ├── McpTool.php
│ │ └── Schema.php
│ ├── Configuration.php
│ ├── Context.php
│ ├── Contracts
│ │ ├── CompletionProviderInterface.php
│ │ ├── EventStoreInterface.php
│ │ ├── LoggerAwareInterface.php
│ │ ├── LoopAwareInterface.php
│ │ ├── ServerTransportInterface.php
│ │ ├── SessionHandlerInterface.php
│ │ └── SessionInterface.php
│ ├── Defaults
│ │ ├── ArrayCache.php
│ │ ├── BasicContainer.php
│ │ ├── DefaultUuidSessionIdGenerator.php
│ │ ├── EnumCompletionProvider.php
│ │ ├── FileCache.php
│ │ ├── InMemoryEventStore.php
│ │ ├── ListCompletionProvider.php
│ │ └── SystemClock.php
│ ├── Dispatcher.php
│ ├── Elements
│ │ ├── RegisteredElement.php
│ │ ├── RegisteredPrompt.php
│ │ ├── RegisteredResource.php
│ │ ├── RegisteredResourceTemplate.php
│ │ └── RegisteredTool.php
│ ├── Exception
│ │ ├── ConfigurationException.php
│ │ ├── DiscoveryException.php
│ │ ├── McpServerException.php
│ │ ├── ProtocolException.php
│ │ └── TransportException.php
│ ├── Protocol.php
│ ├── Registry.php
│ ├── Server.php
│ ├── ServerBuilder.php
│ ├── Session
│ │ ├── ArraySessionHandler.php
│ │ ├── CacheSessionHandler.php
│ │ ├── Session.php
│ │ ├── SessionManager.php
│ │ └── SubscriptionManager.php
│ ├── Transports
│ │ ├── HttpServerTransport.php
│ │ ├── StdioServerTransport.php
│ │ └── StreamableHttpServerTransport.php
│ └── Utils
│ ├── Discoverer.php
│ ├── DocBlockParser.php
│ ├── HandlerResolver.php
│ ├── SchemaGenerator.php
│ └── SchemaValidator.php
└── tests
├── Fixtures
│ ├── Discovery
│ │ ├── DiscoverablePromptHandler.php
│ │ ├── DiscoverableResourceHandler.php
│ │ ├── DiscoverableTemplateHandler.php
│ │ ├── DiscoverableToolHandler.php
│ │ ├── EnhancedCompletionHandler.php
│ │ ├── InvocablePromptFixture.php
│ │ ├── InvocableResourceFixture.php
│ │ ├── InvocableResourceTemplateFixture.php
│ │ ├── InvocableToolFixture.php
│ │ ├── NonDiscoverableClass.php
│ │ └── SubDir
│ │ └── HiddenTool.php
│ ├── Enums
│ │ ├── BackedIntEnum.php
│ │ ├── BackedStringEnum.php
│ │ ├── PriorityEnum.php
│ │ ├── StatusEnum.php
│ │ └── UnitEnum.php
│ ├── General
│ │ ├── CompletionProviderFixture.php
│ │ ├── DocBlockTestFixture.php
│ │ ├── InvokableHandlerFixture.php
│ │ ├── PromptHandlerFixture.php
│ │ ├── RequestAttributeChecker.php
│ │ ├── ResourceHandlerFixture.php
│ │ ├── ToolHandlerFixture.php
│ │ └── VariousTypesHandler.php
│ ├── Middlewares
│ │ ├── ErrorMiddleware.php
│ │ ├── FirstMiddleware.php
│ │ ├── HeaderMiddleware.php
│ │ ├── RequestAttributeMiddleware.php
│ │ ├── SecondMiddleware.php
│ │ ├── ShortCircuitMiddleware.php
│ │ └── ThirdMiddleware.php
│ ├── Schema
│ │ └── SchemaGenerationTarget.php
│ ├── ServerScripts
│ │ ├── HttpTestServer.php
│ │ ├── StdioTestServer.php
│ │ └── StreamableHttpTestServer.php
│ └── Utils
│ ├── AttributeFixtures.php
│ ├── DockBlockParserFixture.php
│ └── SchemaGeneratorFixture.php
├── Integration
│ ├── DiscoveryTest.php
│ ├── HttpServerTransportTest.php
│ ├── SchemaGenerationTest.php
│ ├── StdioServerTransportTest.php
│ └── StreamableHttpServerTransportTest.php
├── Mocks
│ ├── Clients
│ │ ├── MockJsonHttpClient.php
│ │ ├── MockSseClient.php
│ │ └── MockStreamHttpClient.php
│ └── Clock
│ └── FixedClock.php
├── Pest.php
├── TestCase.php
└── Unit
├── Attributes
│ ├── CompletionProviderTest.php
│ ├── McpPromptTest.php
│ ├── McpResourceTemplateTest.php
│ ├── McpResourceTest.php
│ └── McpToolTest.php
├── ConfigurationTest.php
├── Defaults
│ ├── EnumCompletionProviderTest.php
│ └── ListCompletionProviderTest.php
├── DispatcherTest.php
├── Elements
│ ├── RegisteredElementTest.php
│ ├── RegisteredPromptTest.php
│ ├── RegisteredResourceTemplateTest.php
│ ├── RegisteredResourceTest.php
│ └── RegisteredToolTest.php
├── ProtocolTest.php
├── RegistryTest.php
├── ServerBuilderTest.php
├── ServerTest.php
├── Session
│ ├── ArraySessionHandlerTest.php
│ ├── CacheSessionHandlerTest.php
│ ├── SessionManagerTest.php
│ └── SessionTest.php
└── Utils
├── DocBlockParserTest.php
├── HandlerResolverTest.php
└── SchemaValidatorTest.php
```
# Files
--------------------------------------------------------------------------------
/.php-cs-fixer.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | $finder = PhpCsFixer\Finder::create()
4 | ->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 |
```
--------------------------------------------------------------------------------
/.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
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # PHP MCP Server SDK
2 |
3 | [](https://packagist.org/packages/php-mcp/server)
4 | [](https://packagist.org/packages/php-mcp/server)
5 | [](https://github.com/php-mcp/server/actions/workflows/tests.yml)
6 | [](LICENSE)
7 |
8 | **A comprehensive PHP SDK for building [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) servers. Create production-ready MCP servers in PHP with modern architecture, extensive testing, and flexible transport options.**
9 |
10 | This SDK enables you to expose your PHP application's functionality as standardized MCP **Tools**, **Resources**, and **Prompts**, allowing AI assistants (like Anthropic's Claude, Cursor IDE, OpenAI's ChatGPT, etc.) to interact with your backend using the MCP standard.
11 |
12 | ## 🚀 Key Features
13 |
14 | - **🏗️ Modern Architecture**: Built with PHP 8.1+ features, PSR standards, and modular design
15 | - **📡 Multiple Transports**: Supports `stdio`, `http+sse`, and new **streamable HTTP** with resumability
16 | - **🎯 Attribute-Based Definition**: Use PHP 8 Attributes (`#[McpTool]`, `#[McpResource]`, etc.) for zero-config element registration
17 | - **🔧 Flexible Handlers**: Support for closures, class methods, static methods, and invokable classes
18 | - **📝 Smart Schema Generation**: Automatic JSON schema generation from method signatures with optional `#[Schema]` attribute enhancements
19 | - **⚡ Session Management**: Advanced session handling with multiple storage backends
20 | - **🔄 Event-Driven**: ReactPHP-based for high concurrency and non-blocking operations
21 | - **📊 Batch Processing**: Full support for JSON-RPC batch requests
22 | - **💾 Smart Caching**: Intelligent caching of discovered elements with manual override precedence
23 | - **🧪 Completion Providers**: Built-in support for argument completion in tools and prompts
24 | - **🔌 Dependency Injection**: Full PSR-11 container support with auto-wiring
25 | - **📋 Comprehensive Testing**: Extensive test suite with integration tests for all transports
26 |
27 | This package supports the **2025-03-26** version of the Model Context Protocol with backward compatibility.
28 |
29 | ## 📋 Requirements
30 |
31 | - **PHP** >= 8.1
32 | - **Composer**
33 | - **For HTTP Transport**: An event-driven PHP environment (CLI recommended)
34 | - **Extensions**: `json`, `mbstring`, `pcre` (typically enabled by default)
35 |
36 | ## 📦 Installation
37 |
38 | ```bash
39 | composer require php-mcp/server
40 | ```
41 |
42 | > **💡 Laravel Users**: Consider using [`php-mcp/laravel`](https://github.com/php-mcp/laravel) for enhanced framework integration, configuration management, and Artisan commands.
43 |
44 | ## ⚡ Quick Start: Stdio Server with Discovery
45 |
46 | This example demonstrates the most common usage pattern - a `stdio` server using attribute discovery.
47 |
48 | **1. Define Your MCP Elements**
49 |
50 | Create `src/CalculatorElements.php`:
51 |
52 | ```php
53 | <?php
54 |
55 | namespace App;
56 |
57 | use PhpMcp\Server\Attributes\McpTool;
58 | use PhpMcp\Server\Attributes\Schema;
59 |
60 | class CalculatorElements
61 | {
62 | /**
63 | * Adds two numbers together.
64 | *
65 | * @param int $a The first number
66 | * @param int $b The second number
67 | * @return int The sum of the two numbers
68 | */
69 | #[McpTool(name: 'add_numbers')]
70 | public function add(int $a, int $b): int
71 | {
72 | return $a + $b;
73 | }
74 |
75 | /**
76 | * Calculates power with validation.
77 | */
78 | #[McpTool(name: 'calculate_power')]
79 | public function power(
80 | #[Schema(type: 'number', minimum: 0, maximum: 1000)]
81 | float $base,
82 |
83 | #[Schema(type: 'integer', minimum: 0, maximum: 10)]
84 | int $exponent
85 | ): float {
86 | return pow($base, $exponent);
87 | }
88 | }
89 | ```
90 |
91 | **2. Create the Server Script**
92 |
93 | Create `mcp-server.php`:
94 |
95 | ```php
96 | #!/usr/bin/env php
97 | <?php
98 |
99 | declare(strict_types=1);
100 |
101 | require_once __DIR__ . '/vendor/autoload.php';
102 |
103 | use PhpMcp\Server\Server;
104 | use PhpMcp\Server\Transports\StdioServerTransport;
105 |
106 | try {
107 | // Build server configuration
108 | $server = Server::make()
109 | ->withServerInfo('PHP Calculator Server', '1.0.0')
110 | ->build();
111 |
112 | // Discover MCP elements via attributes
113 | $server->discover(
114 | basePath: __DIR__,
115 | scanDirs: ['src']
116 | );
117 |
118 | // Start listening via stdio transport
119 | $transport = new StdioServerTransport();
120 | $server->listen($transport);
121 |
122 | } catch (\Throwable $e) {
123 | fwrite(STDERR, "[CRITICAL ERROR] " . $e->getMessage() . "\n");
124 | exit(1);
125 | }
126 | ```
127 |
128 | **3. Configure Your MCP Client**
129 |
130 | Add to your client configuration (e.g., `.cursor/mcp.json`):
131 |
132 | ```json
133 | {
134 | "mcpServers": {
135 | "php-calculator": {
136 | "command": "php",
137 | "args": ["/absolute/path/to/your/mcp-server.php"]
138 | }
139 | }
140 | }
141 | ```
142 |
143 | **4. Test the Server**
144 |
145 | Your AI assistant can now call:
146 | - `add_numbers` - Add two integers
147 | - `calculate_power` - Calculate power with validation constraints
148 |
149 | ## 🏗️ Architecture Overview
150 |
151 | The PHP MCP Server uses a modern, decoupled architecture:
152 |
153 | ```
154 | ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
155 | │ MCP Client │◄──►│ Transport │◄──►│ Protocol │
156 | │ (Claude, etc.) │ │ (Stdio/HTTP/SSE) │ │ (JSON-RPC) │
157 | └─────────────────┘ └──────────────────┘ └─────────────────┘
158 | │
159 | ┌─────────────────┐ │
160 | │ Session Manager │◄──────────────┤
161 | │ (Multi-backend) │ │
162 | └─────────────────┘ │
163 | │
164 | ┌─────────────────┐ ┌──────────────────┐ │
165 | │ Dispatcher │◄───│ Server Core │◄─────────────┤
166 | │ (Method Router) │ │ Configuration │ │
167 | └─────────────────┘ └──────────────────┘ │
168 | │ │
169 | ▼ │
170 | ┌─────────────────┐ ┌──────────────────┐ │
171 | │ Registry │ │ Elements │◄─────────────┘
172 | │ (Element Store)│◄──►│ (Tools/Resources │
173 | └─────────────────┘ │ Prompts/etc.) │
174 | └──────────────────┘
175 | ```
176 |
177 | ### Core Components
178 |
179 | - **`ServerBuilder`**: Fluent configuration interface (`Server::make()->...->build()`)
180 | - **`Server`**: Central coordinator containing all configured components
181 | - **`Protocol`**: JSON-RPC 2.0 handler bridging transports and core logic
182 | - **`SessionManager`**: Multi-backend session storage (array, cache, custom)
183 | - **`Dispatcher`**: Method routing and request processing
184 | - **`Registry`**: Element storage with smart caching and precedence rules
185 | - **`Elements`**: Registered MCP components (Tools, Resources, Prompts, Templates)
186 |
187 | ### Transport Options
188 |
189 | 1. **`StdioServerTransport`**: Standard I/O for direct client launches
190 | 2. **`HttpServerTransport`**: HTTP + Server-Sent Events for web integration
191 | 3. **`StreamableHttpServerTransport`**: Enhanced HTTP with resumability and event sourcing
192 |
193 | ## ⚙️ Server Configuration
194 |
195 | ### Basic Configuration
196 |
197 | ```php
198 | use PhpMcp\Server\Server;
199 | use PhpMcp\Schema\ServerCapabilities;
200 |
201 | $server = Server::make()
202 | ->withServerInfo('My App Server', '2.1.0')
203 | ->withCapabilities(ServerCapabilities::make(
204 | resources: true,
205 | resourcesSubscribe: true,
206 | prompts: true,
207 | tools: true
208 | ))
209 | ->withPaginationLimit(100)
210 | ->build();
211 | ```
212 |
213 | ### Advanced Configuration with Dependencies
214 |
215 | ```php
216 | use Psr\Log\Logger;
217 | use Psr\SimpleCache\CacheInterface;
218 | use Psr\Container\ContainerInterface;
219 |
220 | $server = Server::make()
221 | ->withServerInfo('Production Server', '1.0.0')
222 | ->withLogger($myPsrLogger) // PSR-3 Logger
223 | ->withCache($myPsrCache) // PSR-16 Cache
224 | ->withContainer($myPsrContainer) // PSR-11 Container
225 | ->withSession('cache', 7200) // Cache-backed sessions, 2hr TTL
226 | ->withPaginationLimit(50) // Limit list responses
227 | ->build();
228 | ```
229 |
230 | ### Session Management Options
231 |
232 | ```php
233 | // In-memory sessions (default, not persistent)
234 | ->withSession('array', 3600)
235 |
236 | // Cache-backed sessions (persistent across restarts)
237 | ->withSession('cache', 7200)
238 |
239 | // Custom session handler (implement SessionHandlerInterface)
240 | ->withSessionHandler(new MyCustomSessionHandler(), 1800)
241 | ```
242 |
243 | ## 🎯 Defining MCP Elements
244 |
245 | The server provides two powerful ways to define MCP elements: **Attribute-Based Discovery** (recommended) and **Manual Registration**. Both can be combined, with manual registrations taking precedence.
246 |
247 | ### Element Types
248 |
249 | - **🔧 Tools**: Executable functions/actions (e.g., `calculate`, `send_email`, `query_database`)
250 | - **📄 Resources**: Static content/data (e.g., `config://settings`, `file://readme.txt`)
251 | - **📋 Resource Templates**: Dynamic resources with URI patterns (e.g., `user://{id}/profile`)
252 | - **💬 Prompts**: Conversation starters/templates (e.g., `summarize`, `translate`)
253 |
254 | ### 1. 🏷️ Attribute-Based Discovery (Recommended)
255 |
256 | Use PHP 8 attributes to mark methods or invokable classes as MCP elements. The server will discover them via filesystem scanning.
257 |
258 | ```php
259 | use PhpMcp\Server\Attributes\{McpTool, McpResource, McpResourceTemplate, McpPrompt};
260 |
261 | class UserManager
262 | {
263 | /**
264 | * Creates a new user account.
265 | */
266 | #[McpTool(name: 'create_user')]
267 | public function createUser(string $email, string $password, string $role = 'user'): array
268 | {
269 | // Create user logic
270 | return ['id' => 123, 'email' => $email, 'role' => $role];
271 | }
272 |
273 | /**
274 | * Get user configuration.
275 | */
276 | #[McpResource(
277 | uri: 'config://user/settings',
278 | mimeType: 'application/json'
279 | )]
280 | public function getUserConfig(): array
281 | {
282 | return ['theme' => 'dark', 'notifications' => true];
283 | }
284 |
285 | /**
286 | * Get user profile by ID.
287 | */
288 | #[McpResourceTemplate(
289 | uriTemplate: 'user://{userId}/profile',
290 | mimeType: 'application/json'
291 | )]
292 | public function getUserProfile(string $userId): array
293 | {
294 | return ['id' => $userId, 'name' => 'John Doe'];
295 | }
296 |
297 | /**
298 | * Generate welcome message prompt.
299 | */
300 | #[McpPrompt(name: 'welcome_user')]
301 | public function welcomeUserPrompt(string $username, string $role): array
302 | {
303 | return [
304 | ['role' => 'user', 'content' => "Create a welcome message for {$username} with role {$role}"]
305 | ];
306 | }
307 | }
308 | ```
309 |
310 | **Discovery Process:**
311 |
312 | ```php
313 | // Build server first
314 | $server = Server::make()
315 | ->withServerInfo('My App Server', '1.0.0')
316 | ->build();
317 |
318 | // Then discover elements
319 | $server->discover(
320 | basePath: __DIR__,
321 | scanDirs: ['src/Handlers', 'src/Services'], // Directories to scan
322 | excludeDirs: ['src/Tests'], // Directories to skip
323 | saveToCache: true // Cache results (default: true)
324 | );
325 | ```
326 |
327 | **Available Attributes:**
328 |
329 | - **`#[McpTool]`**: Executable actions
330 | - **`#[McpResource]`**: Static content accessible via URI
331 | - **`#[McpResourceTemplate]`**: Dynamic resources with URI templates
332 | - **`#[McpPrompt]`**: Conversation templates and prompt generators
333 |
334 | ### 2. 🔧 Manual Registration
335 |
336 | Register elements programmatically using the `ServerBuilder` before calling `build()`. Useful for dynamic registration, closures, or when you prefer explicit control.
337 |
338 | ```php
339 | use App\Handlers\{EmailHandler, ConfigHandler, UserHandler, PromptHandler};
340 | use PhpMcp\Schema\{ToolAnnotations, Annotations};
341 |
342 | $server = Server::make()
343 | ->withServerInfo('Manual Registration Server', '1.0.0')
344 |
345 | // Register a tool with handler method
346 | ->withTool(
347 | [EmailHandler::class, 'sendEmail'], // Handler: [class, method]
348 | name: 'send_email', // Tool name (optional)
349 | description: 'Send email to user', // Description (optional)
350 | annotations: ToolAnnotations::make( // Annotations (optional)
351 | title: 'Send Email Tool'
352 | )
353 | )
354 |
355 | // Register invokable class as tool
356 | ->withTool(UserHandler::class) // Handler: Invokable class
357 |
358 | // Register a closure as tool
359 | ->withTool(
360 | function(int $a, int $b): int { // Handler: Closure
361 | return $a + $b;
362 | },
363 | name: 'add_numbers',
364 | description: 'Add two numbers together'
365 | )
366 |
367 | // Register a resource with closure
368 | ->withResource(
369 | function(): array { // Handler: Closure
370 | return ['timestamp' => time(), 'server' => 'php-mcp'];
371 | },
372 | uri: 'config://runtime/status', // URI (required)
373 | mimeType: 'application/json' // MIME type (optional)
374 | )
375 |
376 | // Register a resource template
377 | ->withResourceTemplate(
378 | [UserHandler::class, 'getUserProfile'],
379 | uriTemplate: 'user://{userId}/profile' // URI template (required)
380 | )
381 |
382 | // Register a prompt with closure
383 | ->withPrompt(
384 | function(string $topic, string $tone = 'professional'): array {
385 | return [
386 | ['role' => 'user', 'content' => "Write about {$topic} in a {$tone} tone"]
387 | ];
388 | },
389 | name: 'writing_prompt' // Prompt name (optional)
390 | )
391 |
392 | ->build();
393 | ```
394 |
395 | The server supports three flexible handler formats: `[ClassName::class, 'methodName']` for class method handlers, `InvokableClass::class` for invokable class handlers (classes with `__invoke` method), and any PHP callable including closures, static methods like `[SomeClass::class, 'staticMethod']`, or function names. Class-based handlers are resolved via the configured PSR-11 container for dependency injection. Manual registrations are never cached and take precedence over discovered elements with the same identifier.
396 |
397 | > [!IMPORTANT]
398 | > When using closures as handlers, the server generates minimal JSON schemas based only on PHP type hints since there are no docblocks or class context available. For more detailed schemas with validation constraints, descriptions, and formats, you have two options:
399 | >
400 | > - Use the [`#[Schema]` attribute](#-schema-generation-and-validation) for enhanced schema generation
401 | > - Provide a custom `$inputSchema` parameter when registering tools with `->withTool()`
402 |
403 | ### 🏆 Element Precedence & Discovery
404 |
405 | **Precedence Rules:**
406 | - Manual registrations **always** override discovered/cached elements with the same identifier
407 | - Discovered elements are cached for performance (configurable)
408 | - Cache is automatically invalidated on fresh discovery runs
409 |
410 | **Discovery Process:**
411 |
412 | ```php
413 | $server->discover(
414 | basePath: __DIR__,
415 | scanDirs: ['src/Handlers', 'src/Services'], // Scan these directories
416 | excludeDirs: ['tests', 'vendor'], // Skip these directories
417 | force: false, // Force re-scan (default: false)
418 | saveToCache: true // Save to cache (default: true)
419 | );
420 | ```
421 |
422 | **Caching Behavior:**
423 | - Only **discovered** elements are cached (never manual registrations)
424 | - Cache loaded automatically during `build()` if available
425 | - Fresh `discover()` calls clear and rebuild cache
426 | - Use `force: true` to bypass discovery-already-ran check
427 |
428 | ## 🚀 Running the Server (Transports)
429 |
430 | The server core is transport-agnostic. Choose a transport based on your deployment needs:
431 |
432 | ### 1. 📟 Stdio Transport
433 |
434 | **Best for**: Direct client execution, command-line tools, simple deployments
435 |
436 | ```php
437 | use PhpMcp\Server\Transports\StdioServerTransport;
438 |
439 | $server = Server::make()
440 | ->withServerInfo('Stdio Server', '1.0.0')
441 | ->build();
442 |
443 | $server->discover(__DIR__, ['src']);
444 |
445 | // Create stdio transport (uses STDIN/STDOUT by default)
446 | $transport = new StdioServerTransport();
447 |
448 | // Start listening (blocking call)
449 | $server->listen($transport);
450 | ```
451 |
452 | **Client Configuration:**
453 | ```json
454 | {
455 | "mcpServers": {
456 | "my-php-server": {
457 | "command": "php",
458 | "args": ["/absolute/path/to/server.php"]
459 | }
460 | }
461 | }
462 | ```
463 |
464 | > ⚠️ **Important**: When using stdio transport, **never** write to `STDOUT` in your handlers (use `STDERR` for debugging). `STDOUT` is reserved for JSON-RPC communication.
465 |
466 | ### 2. 🌐 HTTP + Server-Sent Events Transport (Deprecated)
467 |
468 | > ⚠️ **Note**: This transport is deprecated in the latest MCP protocol version but remains available for backwards compatibility. For new projects, use the [StreamableHttpServerTransport](#3--streamable-http-transport-new) which provides enhanced features and better protocol compliance.
469 |
470 | **Best for**: Legacy applications requiring backwards compatibility
471 |
472 | ```php
473 | use PhpMcp\Server\Transports\HttpServerTransport;
474 |
475 | $server = Server::make()
476 | ->withServerInfo('HTTP Server', '1.0.0')
477 | ->withLogger($logger) // Recommended for HTTP
478 | ->build();
479 |
480 | $server->discover(__DIR__, ['src']);
481 |
482 | // Create HTTP transport
483 | $transport = new HttpServerTransport(
484 | host: '127.0.0.1', // MCP protocol prohibits 0.0.0.0
485 | port: 8080, // Port number
486 | mcpPathPrefix: 'mcp' // URL prefix (/mcp/sse, /mcp/message)
487 | );
488 |
489 | $server->listen($transport);
490 | ```
491 |
492 | **Client Configuration:**
493 | ```json
494 | {
495 | "mcpServers": {
496 | "my-http-server": {
497 | "url": "http://localhost:8080/mcp/sse"
498 | }
499 | }
500 | }
501 | ```
502 |
503 | **Endpoints:**
504 | - **SSE Connection**: `GET /mcp/sse`
505 | - **Message Sending**: `POST /mcp/message?clientId={clientId}`
506 |
507 | ### 3. 🔄 Streamable HTTP Transport (Recommended)
508 |
509 | **Best for**: Production deployments, remote MCP servers, multiple clients, resumable connections
510 |
511 | ```php
512 | use PhpMcp\Server\Transports\StreamableHttpServerTransport;
513 |
514 | $server = Server::make()
515 | ->withServerInfo('Streamable Server', '1.0.0')
516 | ->withLogger($logger)
517 | ->withCache($cache) // Required for resumability
518 | ->build();
519 |
520 | $server->discover(__DIR__, ['src']);
521 |
522 | // Create streamable transport with resumability
523 | $transport = new StreamableHttpServerTransport(
524 | host: '127.0.0.1', // MCP protocol prohibits 0.0.0.0
525 | port: 8080,
526 | mcpPathPrefix: 'mcp',
527 | enableJsonResponse: false, // Use SSE streaming (default)
528 | stateless: false // Enable stateless mode for session-less clients
529 | );
530 |
531 | $server->listen($transport);
532 | ```
533 |
534 | **JSON Response Mode:**
535 |
536 | The `enableJsonResponse` option controls how responses are delivered:
537 |
538 | - **`false` (default)**: Uses Server-Sent Events (SSE) streams for responses. Best for tools that may take time to process.
539 | - **`true`**: Returns immediate JSON responses without opening SSE streams. Use this when your tools execute quickly and don't need streaming.
540 |
541 | ```php
542 | // For fast-executing tools, enable JSON mode
543 | $transport = new StreamableHttpServerTransport(
544 | host: '127.0.0.1',
545 | port: 8080,
546 | enableJsonResponse: true // Immediate JSON responses
547 | );
548 | ```
549 |
550 | **Stateless Mode:**
551 |
552 | For clients that have issues with session management, enable stateless mode:
553 |
554 | ```php
555 | $transport = new StreamableHttpServerTransport(
556 | host: '127.0.0.1',
557 | port: 8080,
558 | stateless: true // Each request is independent
559 | );
560 | ```
561 |
562 | In stateless mode, session IDs are generated internally but not exposed to clients, and each request is treated as independent without persistent session state.
563 |
564 | **Features:**
565 | - **Resumable connections** - clients can reconnect and replay missed events
566 | - **Event sourcing** - all events are stored for replay
567 | - **JSON mode** - optional JSON-only responses for fast tools
568 | - **Enhanced session management** - persistent session state
569 | - **Multiple client support** - designed for concurrent clients
570 | - **Stateless mode** - session-less operation for simple clients
571 |
572 | ## 📋 Schema Generation and Validation
573 |
574 | The server automatically generates JSON schemas for tool parameters using a sophisticated priority system that combines PHP type hints, docblock information, and the optional `#[Schema]` attribute. These generated schemas are used both for input validation and for providing schema information to MCP clients.
575 |
576 | ### Schema Generation Priority
577 |
578 | The server follows this order of precedence when generating schemas:
579 |
580 | 1. **`#[Schema]` attribute with `definition`** - Complete schema override (highest precedence)
581 | 2. **Parameter-level `#[Schema]` attribute** - Parameter-specific schema enhancements
582 | 3. **Method-level `#[Schema]` attribute** - Method-wide schema configuration
583 | 4. **PHP type hints + docblocks** - Automatic inference from code (lowest precedence)
584 |
585 | When a `definition` is provided in the Schema attribute, all automatic inference is bypassed and the complete definition is used as-is.
586 |
587 | ### Parameter-Level Schema Attributes
588 |
589 | ```php
590 | use PhpMcp\Server\Attributes\{McpTool, Schema};
591 |
592 | #[McpTool(name: 'validate_user')]
593 | public function validateUser(
594 | #[Schema(format: 'email')] // PHP already knows it's string
595 | string $email,
596 |
597 | #[Schema(
598 | pattern: '^[A-Z][a-z]+$',
599 | description: 'Capitalized name'
600 | )]
601 | string $name,
602 |
603 | #[Schema(minimum: 18, maximum: 120)] // PHP already knows it's integer
604 | int $age
605 | ): bool {
606 | return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
607 | }
608 | ```
609 |
610 | ### Method-Level Schema
611 |
612 | ```php
613 | /**
614 | * Process user data with nested validation.
615 | */
616 | #[McpTool(name: 'create_user')]
617 | #[Schema(
618 | properties: [
619 | 'profile' => [
620 | 'type' => 'object',
621 | 'properties' => [
622 | 'name' => ['type' => 'string', 'minLength' => 2],
623 | 'age' => ['type' => 'integer', 'minimum' => 18],
624 | 'email' => ['type' => 'string', 'format' => 'email']
625 | ],
626 | 'required' => ['name', 'email']
627 | ]
628 | ],
629 | required: ['profile']
630 | )]
631 | public function createUser(array $userData): array
632 | {
633 | // PHP type hint provides base 'array' type
634 | // Method-level Schema adds object structure validation
635 | return ['id' => 123, 'status' => 'created'];
636 | }
637 | ```
638 |
639 | ### Complete Schema Override (Method-Level Only)
640 |
641 | ```php
642 | #[McpTool(name: 'process_api_request')]
643 | #[Schema(definition: [
644 | 'type' => 'object',
645 | 'properties' => [
646 | 'endpoint' => ['type' => 'string', 'format' => 'uri'],
647 | 'method' => ['type' => 'string', 'enum' => ['GET', 'POST', 'PUT', 'DELETE']],
648 | 'headers' => [
649 | 'type' => 'object',
650 | 'patternProperties' => [
651 | '^[A-Za-z0-9-]+$' => ['type' => 'string']
652 | ]
653 | ]
654 | ],
655 | 'required' => ['endpoint', 'method']
656 | ])]
657 | public function processApiRequest(string $endpoint, string $method, array $headers): array
658 | {
659 | // PHP type hints are completely ignored when definition is provided
660 | // The schema definition above takes full precedence
661 | return ['status' => 'processed', 'endpoint' => $endpoint];
662 | }
663 | ```
664 |
665 | > ⚠️ **Important**: Complete schema definition override should rarely be used. It bypasses all automatic schema inference and requires you to define the entire JSON schema manually. Only use this if you're well-versed with JSON Schema specification and have complex validation requirements that cannot be achieved through the priority system. In most cases, parameter-level and method-level `#[Schema]` attributes provide sufficient flexibility.
666 |
667 | ## 🎨 Return Value Formatting
668 |
669 | The server automatically formats return values from your handlers into appropriate MCP content types:
670 |
671 | ### Automatic Formatting
672 |
673 | ```php
674 | // Simple values are auto-wrapped in TextContent
675 | public function getString(): string { return "Hello World"; } // → TextContent
676 | public function getNumber(): int { return 42; } // → TextContent
677 | public function getBool(): bool { return true; } // → TextContent
678 | public function getArray(): array { return ['key' => 'value']; } // → TextContent (JSON)
679 |
680 | // Null handling
681 | public function getNull(): ?string { return null; } // → TextContent("(null)")
682 | public function returnVoid(): void { /* no return */ } // → Empty content
683 | ```
684 |
685 | ### Advanced Content Types
686 |
687 | ```php
688 | use PhpMcp\Schema\Content\{TextContent, ImageContent, AudioContent, ResourceContent};
689 |
690 | public function getFormattedCode(): TextContent
691 | {
692 | return TextContent::code('<?php echo "Hello";', 'php');
693 | }
694 |
695 | public function getMarkdown(): TextContent
696 | {
697 | return TextContent::make('# Title\n\nContent here');
698 | }
699 |
700 | public function getImage(): ImageContent
701 | {
702 | return ImageContent::make(
703 | data: base64_encode(file_get_contents('image.png')),
704 | mimeType: 'image/png'
705 | );
706 | }
707 |
708 | public function getAudio(): AudioContent
709 | {
710 | return AudioContent::make(
711 | data: base64_encode(file_get_contents('audio.mp3')),
712 | mimeType: 'audio/mpeg'
713 | );
714 | }
715 | ```
716 |
717 | ### File and Stream Handling
718 |
719 | ```php
720 | // File objects are automatically read and formatted
721 | public function getFileContent(): \SplFileInfo
722 | {
723 | return new \SplFileInfo('/path/to/file.txt'); // Auto-detects MIME type
724 | }
725 |
726 | // Stream resources are read completely
727 | public function getStreamContent()
728 | {
729 | $stream = fopen('/path/to/data.json', 'r');
730 | return $stream; // Will be read and closed automatically
731 | }
732 |
733 | // Structured resource responses
734 | public function getStructuredResource(): array
735 | {
736 | return [
737 | 'text' => 'File content here',
738 | 'mimeType' => 'text/plain'
739 | ];
740 |
741 | // Or for binary data:
742 | // return [
743 | // 'blob' => base64_encode($binaryData),
744 | // 'mimeType' => 'application/octet-stream'
745 | // ];
746 | }
747 | ```
748 |
749 | ## 🔄 Batch Processing
750 |
751 | The server automatically handles JSON-RPC batch requests:
752 |
753 | ```php
754 | // Client can send multiple requests in a single HTTP call:
755 | [
756 | {"jsonrpc": "2.0", "id": "1", "method": "tools/call", "params": {...}},
757 | {"jsonrpc": "2.0", "method": "notifications/ping"}, // notification
758 | {"jsonrpc": "2.0", "id": "2", "method": "tools/call", "params": {...}}
759 | ]
760 |
761 | // Server returns batch response (excluding notifications):
762 | [
763 | {"jsonrpc": "2.0", "id": "1", "result": {...}},
764 | {"jsonrpc": "2.0", "id": "2", "result": {...}}
765 | ]
766 | ```
767 |
768 | ## 🔧 Advanced Features
769 |
770 | ### Completion Providers
771 |
772 | Completion providers enable MCP clients to offer auto-completion suggestions in their user interfaces. They are specifically designed for **Resource Templates** and **Prompts** to help users discover available options for dynamic parts like template variables or prompt arguments.
773 |
774 | > **Note**: Tools and resources can be discovered via standard MCP commands (`tools/list`, `resources/list`), so completion providers are not needed for them. Completion providers are used only for resource templates (URI variables) and prompt arguments.
775 |
776 | The `#[CompletionProvider]` attribute supports three types of completion sources:
777 |
778 | #### 1. Custom Provider Classes
779 |
780 | For complex completion logic, implement the `CompletionProviderInterface`:
781 |
782 | ```php
783 | use PhpMcp\Server\Contracts\CompletionProviderInterface;
784 | use PhpMcp\Server\Contracts\SessionInterface;
785 | use PhpMcp\Server\Attributes\{McpResourceTemplate, CompletionProvider};
786 |
787 | class UserIdCompletionProvider implements CompletionProviderInterface
788 | {
789 | public function __construct(private DatabaseService $db) {}
790 |
791 | public function getCompletions(string $currentValue, SessionInterface $session): array
792 | {
793 | // Dynamic completion from database
794 | return $this->db->searchUsers($currentValue);
795 | }
796 | }
797 |
798 | class UserService
799 | {
800 | #[McpResourceTemplate(uriTemplate: 'user://{userId}/profile')]
801 | public function getUserProfile(
802 | #[CompletionProvider(provider: UserIdCompletionProvider::class)] // Class string - resolved from container
803 | string $userId
804 | ): array {
805 | return ['id' => $userId, 'name' => 'John Doe'];
806 | }
807 | }
808 | ```
809 |
810 | You can also pass pre-configured provider instances:
811 |
812 | ```php
813 | class DocumentService
814 | {
815 | #[McpPrompt(name: 'document_prompt')]
816 | public function generatePrompt(
817 | #[CompletionProvider(provider: new UserIdCompletionProvider($database))] // Pre-configured instance
818 | string $userId,
819 |
820 | #[CompletionProvider(provider: $this->categoryProvider)] // Instance from property
821 | string $category
822 | ): array {
823 | return [['role' => 'user', 'content' => "Generate document for user {$userId} in {$category}"]];
824 | }
825 | }
826 | ```
827 |
828 | #### 2. Simple List Completions
829 |
830 | For static completion lists, use the `values` parameter:
831 |
832 | ```php
833 | use PhpMcp\Server\Attributes\{McpPrompt, CompletionProvider};
834 |
835 | class ContentService
836 | {
837 | #[McpPrompt(name: 'content_generator')]
838 | public function generateContent(
839 | #[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide', 'documentation'])]
840 | string $contentType,
841 |
842 | #[CompletionProvider(values: ['beginner', 'intermediate', 'advanced', 'expert'])]
843 | string $difficulty
844 | ): array {
845 | return [['role' => 'user', 'content' => "Create a {$difficulty} level {$contentType}"]];
846 | }
847 | }
848 | ```
849 |
850 | #### 3. Enum-Based Completions
851 |
852 | For enum classes, use the `enum` parameter:
853 |
854 | ```php
855 | enum Priority: string
856 | {
857 | case LOW = 'low';
858 | case MEDIUM = 'medium';
859 | case HIGH = 'high';
860 | case CRITICAL = 'critical';
861 | }
862 |
863 | enum Status // Unit enum (no backing values)
864 | {
865 | case DRAFT;
866 | case PUBLISHED;
867 | case ARCHIVED;
868 | }
869 |
870 | class TaskService
871 | {
872 | #[McpTool(name: 'create_task')]
873 | public function createTask(
874 | string $title,
875 |
876 | #[CompletionProvider(enum: Priority::class)] // String-backed enum uses values
877 | string $priority,
878 |
879 | #[CompletionProvider(enum: Status::class)] // Unit enum uses case names
880 | string $status
881 | ): array {
882 | return ['id' => 123, 'title' => $title, 'priority' => $priority, 'status' => $status];
883 | }
884 | }
885 | ```
886 |
887 | #### Manual Registration with Completion Providers
888 |
889 | ```php
890 | $server = Server::make()
891 | ->withServerInfo('Completion Demo', '1.0.0')
892 |
893 | // Using provider class (resolved from container)
894 | ->withPrompt(
895 | [DocumentHandler::class, 'generateReport'],
896 | name: 'document_report'
897 | // Completion providers are auto-discovered from method attributes
898 | )
899 |
900 | // Using closure with inline completion providers
901 | ->withPrompt(
902 | function(
903 | #[CompletionProvider(values: ['json', 'xml', 'csv', 'yaml'])]
904 | string $format,
905 |
906 | #[CompletionProvider(enum: Priority::class)]
907 | string $priority
908 | ): array {
909 | return [['role' => 'user', 'content' => "Export data in {$format} format with {$priority} priority"]];
910 | },
911 | name: 'export_data'
912 | )
913 |
914 | ->build();
915 | ```
916 |
917 | #### Completion Provider Resolution
918 |
919 | The server automatically handles provider resolution:
920 |
921 | - **Class strings** (`MyProvider::class`) → Resolved from PSR-11 container with dependency injection
922 | - **Instances** (`new MyProvider()`) → Used directly as-is
923 | - **Values arrays** (`['a', 'b', 'c']`) → Automatically wrapped in `ListCompletionProvider`
924 | - **Enum classes** (`MyEnum::class`) → Automatically wrapped in `EnumCompletionProvider`
925 |
926 | > **Important**: Completion providers only offer suggestions to users in the MCP client interface. Users can still input any value, so always validate parameters in your handlers regardless of completion provider constraints.
927 |
928 | ### Custom Dependency Injection
929 |
930 | Your MCP element handlers can use constructor dependency injection to access services like databases, APIs, or other business logic. When handlers have constructor dependencies, you must provide a pre-configured PSR-11 container that contains those dependencies.
931 |
932 | By default, the server uses a `BasicContainer` - a simple implementation that attempts to auto-wire dependencies by instantiating classes with parameterless constructors. For dependencies that require configuration (like database connections), you can either manually add them to the BasicContainer or use a more advanced PSR-11 container like PHP-DI or Laravel's container.
933 |
934 | ```php
935 | use Psr\Container\ContainerInterface;
936 |
937 | class DatabaseService
938 | {
939 | public function __construct(private \PDO $pdo) {}
940 |
941 | #[McpTool(name: 'query_users')]
942 | public function queryUsers(): array
943 | {
944 | $stmt = $this->pdo->query('SELECT * FROM users');
945 | return $stmt->fetchAll();
946 | }
947 | }
948 |
949 | // Option 1: Use the basic container and manually add dependencies
950 | $basicContainer = new \PhpMcp\Server\Defaults\BasicContainer();
951 | $basicContainer->set(\PDO::class, new \PDO('sqlite::memory:'));
952 |
953 | // Option 2: Use any PSR-11 compatible container (PHP-DI, Laravel, etc.)
954 | $container = new \DI\Container();
955 | $container->set(\PDO::class, new \PDO('mysql:host=localhost;dbname=app', $user, $pass));
956 |
957 | $server = Server::make()
958 | ->withContainer($basicContainer) // Handlers get dependencies auto-injected
959 | ->build();
960 | ```
961 |
962 | ### Resource Subscriptions
963 |
964 | ```php
965 | use PhpMcp\Schema\ServerCapabilities;
966 |
967 | $server = Server::make()
968 | ->withCapabilities(ServerCapabilities::make(
969 | resourcesSubscribe: true, // Enable resource subscriptions
970 | prompts: true,
971 | tools: true
972 | ))
973 | ->build();
974 |
975 | // In your resource handler, you can notify clients of changes:
976 | #[McpResource(uri: 'file://config.json')]
977 | public function getConfig(): array
978 | {
979 | // When config changes, notify subscribers
980 | $this->notifyResourceChange('file://config.json');
981 | return ['setting' => 'value'];
982 | }
983 | ```
984 |
985 | ### Resumability and Event Store
986 |
987 | For production deployments using `StreamableHttpServerTransport`, you can implement resumability with event sourcing by providing a custom event store:
988 |
989 | ```php
990 | use PhpMcp\Server\Contracts\EventStoreInterface;
991 | use PhpMcp\Server\Defaults\InMemoryEventStore;
992 | use PhpMcp\Server\Transports\StreamableHttpServerTransport;
993 |
994 | // Use the built-in in-memory event store (for development/testing)
995 | $eventStore = new InMemoryEventStore();
996 |
997 | // Or implement your own persistent event store
998 | class DatabaseEventStore implements EventStoreInterface
999 | {
1000 | public function storeEvent(string $streamId, string $message): string
1001 | {
1002 | // Store event in database and return unique event ID
1003 | return $this->database->insert('events', [
1004 | 'stream_id' => $streamId,
1005 | 'message' => $message,
1006 | 'created_at' => now()
1007 | ]);
1008 | }
1009 |
1010 | public function replayEventsAfter(string $lastEventId, callable $sendCallback): void
1011 | {
1012 | // Replay events for resumability
1013 | $events = $this->database->getEventsAfter($lastEventId);
1014 | foreach ($events as $event) {
1015 | $sendCallback($event['id'], $event['message']);
1016 | }
1017 | }
1018 | }
1019 |
1020 | // Configure transport with event store
1021 | $transport = new StreamableHttpServerTransport(
1022 | host: '127.0.0.1',
1023 | port: 8080,
1024 | eventStore: new DatabaseEventStore() // Enable resumability
1025 | );
1026 | ```
1027 |
1028 | ### Custom Session Handlers
1029 |
1030 | Implement custom session storage by creating a class that implements `SessionHandlerInterface`:
1031 |
1032 | ```php
1033 | use PhpMcp\Server\Contracts\SessionHandlerInterface;
1034 |
1035 | class DatabaseSessionHandler implements SessionHandlerInterface
1036 | {
1037 | public function __construct(private \PDO $db) {}
1038 |
1039 | public function read(string $id): string|false
1040 | {
1041 | $stmt = $this->db->prepare('SELECT data FROM sessions WHERE id = ?');
1042 | $stmt->execute([$id]);
1043 | $session = $stmt->fetch(\PDO::FETCH_ASSOC);
1044 | return $session ? $session['data'] : false;
1045 | }
1046 |
1047 | public function write(string $id, string $data): bool
1048 | {
1049 | $stmt = $this->db->prepare(
1050 | 'INSERT OR REPLACE INTO sessions (id, data, updated_at) VALUES (?, ?, ?)'
1051 | );
1052 | return $stmt->execute([$id, $data, time()]);
1053 | }
1054 |
1055 | public function destroy(string $id): bool
1056 | {
1057 | $stmt = $this->db->prepare('DELETE FROM sessions WHERE id = ?');
1058 | return $stmt->execute([$id]);
1059 | }
1060 |
1061 | public function gc(int $maxLifetime): array
1062 | {
1063 | $cutoff = time() - $maxLifetime;
1064 | $stmt = $this->db->prepare('DELETE FROM sessions WHERE updated_at < ?');
1065 | $stmt->execute([$cutoff]);
1066 | return []; // Return array of cleaned session IDs if needed
1067 | }
1068 | }
1069 |
1070 | // Use custom session handler
1071 | $server = Server::make()
1072 | ->withSessionHandler(new DatabaseSessionHandler(), 3600)
1073 | ->build();
1074 | ```
1075 |
1076 | ### Middleware Support
1077 |
1078 | Both `HttpServerTransport` and `StreamableHttpServerTransport` support PSR-7 compatible middleware for intercepting and modifying HTTP requests and responses. Middleware allows you to extract common functionality like authentication, logging, CORS handling, and request validation into reusable components.
1079 |
1080 | Middleware must be a valid PHP callable that accepts a PSR-7 `ServerRequestInterface` as the first argument and a `callable` as the second argument.
1081 |
1082 | ```php
1083 | use Psr\Http\Message\ServerRequestInterface;
1084 | use Psr\Http\Message\ResponseInterface;
1085 | use React\Promise\PromiseInterface;
1086 |
1087 | class AuthMiddleware
1088 | {
1089 | public function __invoke(ServerRequestInterface $request, callable $next)
1090 | {
1091 | $apiKey = $request->getHeaderLine('Authorization');
1092 | if (empty($apiKey)) {
1093 | return new Response(401, [], 'Authorization required');
1094 | }
1095 |
1096 | $request = $request->withAttribute('user_id', $this->validateApiKey($apiKey));
1097 | $result = $next($request);
1098 |
1099 | return match (true) {
1100 | $result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)),
1101 | $result instanceof ResponseInterface => $this->handle($result),
1102 | default => $result
1103 | };
1104 | }
1105 |
1106 | private function handle($response)
1107 | {
1108 | return $response instanceof ResponseInterface
1109 | ? $response->withHeader('X-Auth-Provider', 'mcp-server')
1110 | : $response;
1111 | }
1112 | }
1113 |
1114 | $middlewares = [
1115 | new AuthMiddleware(),
1116 | new LoggingMiddleware(),
1117 | function(ServerRequestInterface $request, callable $next) {
1118 | $result = $next($request);
1119 | return match (true) {
1120 | $result instanceof PromiseInterface => $result->then(function($response) {
1121 | return $response instanceof ResponseInterface
1122 | ? $response->withHeader('Access-Control-Allow-Origin', '*')
1123 | : $response;
1124 | }),
1125 | $result instanceof ResponseInterface => $result->withHeader('Access-Control-Allow-Origin', '*'),
1126 | default => $result
1127 | };
1128 | }
1129 | ];
1130 |
1131 | $transport = new StreamableHttpServerTransport(
1132 | host: '127.0.0.1',
1133 | port: 8080,
1134 | middlewares: $middlewares
1135 | );
1136 | ```
1137 |
1138 | **Important Considerations:**
1139 |
1140 | - **Response Handling**: Middleware must handle both synchronous `ResponseInterface` and asynchronous `PromiseInterface` returns from `$next($request)`, since ReactPHP operates asynchronously
1141 | - **Invokable Pattern**: The recommended pattern is to use invokable classes with a separate `handle()` method to process responses, making the async logic reusable
1142 | - **Execution Order**: Middleware executes in the order provided, with the last middleware being closest to your MCP handlers
1143 |
1144 | ### SSL Context Configuration
1145 |
1146 | For HTTPS deployments of `StreamableHttpServerTransport`, configure SSL context options:
1147 |
1148 | ```php
1149 | $sslContext = [
1150 | 'ssl' => [
1151 | 'local_cert' => '/path/to/certificate.pem',
1152 | 'local_pk' => '/path/to/private-key.pem',
1153 | 'verify_peer' => false,
1154 | 'allow_self_signed' => true,
1155 | ]
1156 | ];
1157 |
1158 | $transport = new StreamableHttpServerTransport(
1159 | host: '0.0.0.0',
1160 | port: 8443,
1161 | sslContext: $sslContext
1162 | );
1163 | ```
1164 |
1165 | > **SSL Context Reference**: For complete SSL context options, see the [PHP SSL Context Options documentation](https://www.php.net/manual/en/context.ssl.php).
1166 | ## 🔍 Error Handling & Debugging
1167 |
1168 | The server provides comprehensive error handling and debugging capabilities:
1169 |
1170 | ### Exception Handling
1171 |
1172 | Tool handlers can throw any PHP exception when errors occur. The server automatically converts these exceptions into proper JSON-RPC error responses for MCP clients.
1173 |
1174 | ```php
1175 | #[McpTool(name: 'divide_numbers')]
1176 | public function divideNumbers(float $dividend, float $divisor): float
1177 | {
1178 | if ($divisor === 0.0) {
1179 | // Any exception with descriptive message will be sent to client
1180 | throw new \InvalidArgumentException('Division by zero is not allowed');
1181 | }
1182 |
1183 | return $dividend / $divisor;
1184 | }
1185 |
1186 | #[McpTool(name: 'calculate_factorial')]
1187 | public function calculateFactorial(int $number): int
1188 | {
1189 | if ($number < 0) {
1190 | throw new \InvalidArgumentException('Factorial is not defined for negative numbers');
1191 | }
1192 |
1193 | if ($number > 20) {
1194 | throw new \OverflowException('Number too large, factorial would cause overflow');
1195 | }
1196 |
1197 | // Implementation continues...
1198 | return $this->factorial($number);
1199 | }
1200 | ```
1201 |
1202 | The server will convert these exceptions into appropriate JSON-RPC error responses that MCP clients can understand and display to users.
1203 |
1204 | ### Logging and Debugging
1205 |
1206 | ```php
1207 | use Psr\Log\LoggerInterface;
1208 |
1209 | class DebugAwareHandler
1210 | {
1211 | public function __construct(private LoggerInterface $logger) {}
1212 |
1213 | #[McpTool(name: 'debug_tool')]
1214 | public function debugTool(string $data): array
1215 | {
1216 | $this->logger->info('Processing debug tool', ['input' => $data]);
1217 |
1218 | // For stdio transport, use STDERR for debug output
1219 | fwrite(STDERR, "Debug: Processing data length: " . strlen($data) . "\n");
1220 |
1221 | return ['processed' => true];
1222 | }
1223 | }
1224 | ```
1225 |
1226 | ## 🚀 Production Deployment
1227 |
1228 | Since `$server->listen()` runs a persistent process, you can deploy it using any strategy that suits your infrastructure needs. The server can be deployed on VPS, cloud instances, containers, or any environment that supports long-running processes.
1229 |
1230 | Here are two popular deployment approaches to consider:
1231 |
1232 | ### Option 1: VPS with Supervisor + Nginx (Recommended)
1233 |
1234 | **Best for**: Most production deployments, cost-effective, full control
1235 |
1236 | ```bash
1237 | # 1. Install your application on VPS
1238 | git clone https://github.com/yourorg/your-mcp-server.git /var/www/mcp-server
1239 | cd /var/www/mcp-server
1240 | composer install --no-dev --optimize-autoloader
1241 |
1242 | # 2. Install Supervisor
1243 | sudo apt-get install supervisor
1244 |
1245 | # 3. Create Supervisor configuration
1246 | sudo nano /etc/supervisor/conf.d/mcp-server.conf
1247 | ```
1248 |
1249 | **Supervisor Configuration:**
1250 | ```ini
1251 | [program:mcp-server]
1252 | process_name=%(program_name)s_%(process_num)02d
1253 | command=php /var/www/mcp-server/server.php --transport=http --host=127.0.0.1 --port=8080
1254 | autostart=true
1255 | autorestart=true
1256 | stopasgroup=true
1257 | killasgroup=true
1258 | user=www-data
1259 | numprocs=1
1260 | redirect_stderr=true
1261 | stdout_logfile=/var/log/mcp-server.log
1262 | stdout_logfile_maxbytes=10MB
1263 | stdout_logfile_backups=3
1264 | ```
1265 |
1266 | **Nginx Configuration with SSL:**
1267 | ```nginx
1268 | # /etc/nginx/sites-available/mcp-server
1269 | server {
1270 | listen 443 ssl http2;
1271 | listen [::]:443 ssl http2;
1272 | server_name mcp.yourdomain.com;
1273 |
1274 | # SSL configuration
1275 | ssl_certificate /etc/letsencrypt/live/mcp.yourdomain.com/fullchain.pem;
1276 | ssl_certificate_key /etc/letsencrypt/live/mcp.yourdomain.com/privkey.pem;
1277 |
1278 | # Security headers
1279 | add_header X-Frame-Options "SAMEORIGIN" always;
1280 | add_header X-Content-Type-Options "nosniff" always;
1281 | add_header X-XSS-Protection "1; mode=block" always;
1282 |
1283 | # MCP Server proxy
1284 | location / {
1285 | proxy_http_version 1.1;
1286 | proxy_set_header Host $http_host;
1287 | proxy_set_header X-Real-IP $remote_addr;
1288 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1289 | proxy_set_header X-Forwarded-Proto $scheme;
1290 | proxy_set_header Upgrade $http_upgrade;
1291 | proxy_set_header Connection "upgrade";
1292 |
1293 | # Important for SSE connections
1294 | proxy_buffering off;
1295 | proxy_cache off;
1296 |
1297 | proxy_pass http://127.0.0.1:8080/;
1298 | }
1299 | }
1300 |
1301 | # Redirect HTTP to HTTPS
1302 | server {
1303 | listen 80;
1304 | listen [::]:80;
1305 | server_name mcp.yourdomain.com;
1306 | return 301 https://$server_name$request_uri;
1307 | }
1308 | ```
1309 |
1310 | **Start Services:**
1311 | ```bash
1312 | # Enable and start supervisor
1313 | sudo supervisorctl reread
1314 | sudo supervisorctl update
1315 | sudo supervisorctl start mcp-server:*
1316 |
1317 | # Enable and start nginx
1318 | sudo systemctl enable nginx
1319 | sudo systemctl restart nginx
1320 |
1321 | # Check status
1322 | sudo supervisorctl status
1323 | ```
1324 |
1325 | **Client Configuration:**
1326 | ```json
1327 | {
1328 | "mcpServers": {
1329 | "my-server": {
1330 | "url": "https://mcp.yourdomain.com/mcp"
1331 | }
1332 | }
1333 | }
1334 | ```
1335 |
1336 | ### Option 2: Docker Deployment
1337 |
1338 | **Best for**: Containerized environments, Kubernetes, cloud platforms
1339 |
1340 | **Production Dockerfile:**
1341 | ```dockerfile
1342 | FROM php:8.3-fpm-alpine
1343 |
1344 | # Install system dependencies
1345 | RUN apk --no-cache add \
1346 | nginx \
1347 | supervisor \
1348 | && docker-php-ext-enable opcache
1349 |
1350 | # Install PHP extensions for MCP
1351 | RUN docker-php-ext-install pdo_mysql pdo_sqlite opcache
1352 |
1353 | # Create application directory
1354 | WORKDIR /var/www/mcp
1355 |
1356 | # Copy application code
1357 | COPY . /var/www/mcp
1358 | COPY docker/nginx.conf /etc/nginx/nginx.conf
1359 | COPY docker/supervisord.conf /etc/supervisord.conf
1360 | COPY docker/php.ini /usr/local/etc/php/conf.d/production.ini
1361 |
1362 | # Install Composer dependencies
1363 | RUN composer install --no-dev --optimize-autoloader --no-interaction
1364 |
1365 | # Set permissions
1366 | RUN chown -R www-data:www-data /var/www/mcp
1367 |
1368 | # Expose port
1369 | EXPOSE 80
1370 |
1371 | # Start supervisor
1372 | CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
1373 | ```
1374 |
1375 | **docker-compose.yml:**
1376 | ```yaml
1377 | services:
1378 | mcp-server:
1379 | build: .
1380 | ports:
1381 | - "8080:80"
1382 | environment:
1383 | - MCP_ENV=production
1384 | - MCP_LOG_LEVEL=info
1385 | volumes:
1386 | - ./storage:/var/www/mcp/storage
1387 | restart: unless-stopped
1388 | healthcheck:
1389 | test: ["CMD", "curl", "-f", "http://localhost/health"]
1390 | interval: 30s
1391 | timeout: 10s
1392 | retries: 3
1393 |
1394 | # Optional: Add database if needed
1395 | database:
1396 | image: mysql:8.0
1397 | environment:
1398 | MYSQL_ROOT_PASSWORD: secure_password
1399 | MYSQL_DATABASE: mcp_server
1400 | volumes:
1401 | - mysql_data:/var/lib/mysql
1402 | restart: unless-stopped
1403 |
1404 | volumes:
1405 | mysql_data:
1406 | ```
1407 |
1408 | ### Security Best Practices
1409 |
1410 | 1. **Firewall Configuration:**
1411 | ```bash
1412 | # Only allow necessary ports
1413 | sudo ufw allow ssh
1414 | sudo ufw allow 80
1415 | sudo ufw allow 443
1416 | sudo ufw deny 8080 # MCP port should not be publicly accessible
1417 | sudo ufw enable
1418 | ```
1419 |
1420 | 2. **SSL/TLS Setup:**
1421 | ```bash
1422 | # Install Certbot for Let's Encrypt
1423 | sudo apt install certbot python3-certbot-nginx
1424 |
1425 | # Generate SSL certificate
1426 | sudo certbot --nginx -d mcp.yourdomain.com
1427 | ```
1428 |
1429 | ## 📚 Examples & Use Cases
1430 |
1431 | Explore comprehensive examples in the [`examples/`](./examples/) directory:
1432 |
1433 | ### Available Examples
1434 |
1435 | - **`01-discovery-stdio-calculator/`** - Basic stdio calculator with attribute discovery
1436 | - **`02-discovery-http-userprofile/`** - HTTP server with user profile management
1437 | - **`03-manual-registration-stdio/`** - Manual element registration patterns
1438 | - **`04-combined-registration-http/`** - Combining manual and discovered elements
1439 | - **`05-stdio-env-variables/`** - Environment variable handling
1440 | - **`06-custom-dependencies-stdio/`** - Dependency injection with task management
1441 | - **`07-complex-tool-schema-http/`** - Advanced schema validation examples
1442 | - **`08-schema-showcase-streamable/`** - Comprehensive schema feature showcase
1443 |
1444 | ### Running Examples
1445 |
1446 | ```bash
1447 | # Navigate to an example directory
1448 | cd examples/01-discovery-stdio-calculator/
1449 |
1450 | # Make the server executable
1451 | chmod +x server.php
1452 |
1453 | # Run the server (or configure it in your MCP client)
1454 | ./server.php
1455 | ```
1456 |
1457 | ## 🚧 Migration from v2.x
1458 |
1459 | If migrating from version 2.x, note these key changes:
1460 |
1461 | ### Schema Updates
1462 | - Uses `php-mcp/schema` package for DTOs instead of internal classes
1463 | - Content types moved to `PhpMcp\Schema\Content\*` namespace
1464 | - Updated method signatures for better type safety
1465 |
1466 | ### Session Management
1467 | - New session management with multiple backends
1468 | - Use `->withSession()` or `->withSessionHandler()` for configuration
1469 | - Sessions are now persistent across reconnections (with cache backend)
1470 |
1471 | ### Transport Changes
1472 | - New `StreamableHttpServerTransport` with resumability
1473 | - Enhanced error handling and event sourcing
1474 | - Better batch request processing
1475 |
1476 | ## 🧪 Testing
1477 |
1478 | ```bash
1479 | # Install development dependencies
1480 | composer install --dev
1481 |
1482 | # Run the test suite
1483 | composer test
1484 |
1485 | # Run tests with coverage (requires Xdebug)
1486 | composer test:coverage
1487 |
1488 | # Run code style checks
1489 | composer lint
1490 | ```
1491 |
1492 | ## 🤝 Contributing
1493 |
1494 | We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
1495 |
1496 | ## 📄 License
1497 |
1498 | The MIT License (MIT). See [LICENSE](LICENSE) for details.
1499 |
1500 | ## 🙏 Acknowledgments
1501 |
1502 | - Built on the [Model Context Protocol](https://modelcontextprotocol.io/) specification
1503 | - Powered by [ReactPHP](https://reactphp.org/) for async operations
1504 | - Uses [PSR standards](https://www.php-fig.org/) for maximum interoperability
1505 |
```
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
```markdown
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 [email protected]: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!
```
--------------------------------------------------------------------------------
/tests/Fixtures/Enums/UnitEnum.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Fixtures\Enums;
4 |
5 | enum UnitEnum
6 | {
7 | case Yes;
8 | case No;
9 | }
10 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Enums/BackedIntEnum.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Fixtures\Enums;
4 |
5 | enum BackedIntEnum: int
6 | {
7 | case First = 1;
8 | case Second = 2;
9 | }
10 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Enums/BackedStringEnum.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Fixtures\Enums;
4 |
5 | enum BackedStringEnum: string
6 | {
7 | case OptionA = 'A';
8 | case OptionB = 'B';
9 | }
10 |
```
--------------------------------------------------------------------------------
/tests/TestCase.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests;
4 |
5 | use PHPUnit\Framework\TestCase as BaseTestCase;
6 |
7 | abstract class TestCase extends BaseTestCase
8 | {
9 | //
10 | }
11 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Enums/PriorityEnum.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Enums;
6 |
7 | enum PriorityEnum: int
8 | {
9 | case LOW = 1;
10 | case MEDIUM = 2;
11 | case HIGH = 3;
12 | }
13 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Enums/StatusEnum.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Enums;
6 |
7 | enum StatusEnum: string
8 | {
9 | case DRAFT = 'draft';
10 | case PUBLISHED = 'published';
11 | case ARCHIVED = 'archived';
12 | }
13 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Discovery/SubDir/HiddenTool.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Discovery\SubDir;
6 |
7 | use PhpMcp\Server\Attributes\McpTool;
8 |
9 | class HiddenTool
10 | {
11 | #[McpTool(name: 'hidden_subdir_tool')]
12 | public function run()
13 | {
14 | }
15 | }
16 |
```
--------------------------------------------------------------------------------
/src/Exception/DiscoveryException.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Exception;
6 |
7 | /**
8 | * Exception related to errors during the attribute discovery process.
9 | */
10 | class DiscoveryException extends McpServerException
11 | {
12 | // No specific JSON-RPC code, internal server issue.
13 | }
14 |
```
--------------------------------------------------------------------------------
/src/Defaults/SystemClock.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Defaults;
6 |
7 | use DateTimeImmutable;
8 | use Psr\Clock\ClockInterface;
9 |
10 | class SystemClock implements ClockInterface
11 | {
12 | public function now(): \DateTimeImmutable
13 | {
14 | return new \DateTimeImmutable();
15 | }
16 | }
17 |
```
--------------------------------------------------------------------------------
/examples/07-complex-tool-schema-http/EventTypes.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace Mcp\ComplexSchemaHttpExample\Model;
4 |
5 | enum EventType: string
6 | {
7 | case Meeting = 'meeting';
8 | case Reminder = 'reminder';
9 | case Call = 'call';
10 | case Other = 'other';
11 | }
12 |
13 | enum EventPriority: int
14 | {
15 | case Low = 0;
16 | case Normal = 1;
17 | case High = 2;
18 | }
19 |
```
--------------------------------------------------------------------------------
/src/Context.php:
--------------------------------------------------------------------------------
```php
1 | <?php declare(strict_types = 1);
2 | namespace PhpMcp\Server;
3 |
4 | use PhpMcp\Server\Contracts\SessionInterface;
5 | use Psr\Http\Message\ServerRequestInterface;
6 |
7 | final class Context
8 | {
9 | public function __construct(
10 | public readonly SessionInterface $session,
11 | public readonly ?ServerRequestInterface $request = null,
12 | )
13 | {
14 | }
15 | }
```
--------------------------------------------------------------------------------
/src/Exception/ConfigurationException.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Exception;
6 |
7 | /**
8 | * Exception related to invalid server configuration.
9 | *
10 | * Typically thrown during ServerBuilder::build().
11 | */
12 | class ConfigurationException extends McpServerException
13 | {
14 | // No specific JSON-RPC code, usually an internal setup issue.
15 | // Code 0 is appropriate.
16 | }
17 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Discovery/InvocableResourceFixture.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Discovery;
6 |
7 | use PhpMcp\Server\Attributes\McpResource;
8 |
9 | #[McpResource(uri: "invokable://config/status", name: "invokable_app_status")]
10 | class InvocableResourceFixture
11 | {
12 | public function __invoke(): array
13 | {
14 | return ["status" => "OK", "load" => rand(1, 100) / 100.0];
15 | }
16 | }
17 |
```
--------------------------------------------------------------------------------
/src/Contracts/LoggerAwareInterface.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Contracts;
6 |
7 | use Psr\Log\LoggerInterface;
8 |
9 | /**
10 | * Interface for components that can accept a PSR-3 Logger instance.
11 | *
12 | * Primarily used for injecting the configured logger into transport implementations.
13 | */
14 | interface LoggerAwareInterface
15 | {
16 | public function setLogger(LoggerInterface $logger): void;
17 | }
18 |
```
--------------------------------------------------------------------------------
/src/Contracts/LoopAwareInterface.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Contracts;
6 |
7 | use React\EventLoop\LoopInterface;
8 |
9 | /**
10 | * Interface for components that require a ReactPHP event loop instance.
11 | *
12 | * Primarily used for injecting the configured loop into transport implementations.
13 | */
14 | interface LoopAwareInterface
15 | {
16 | public function setLoop(LoopInterface $loop): void;
17 | }
18 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Discovery/NonDiscoverableClass.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Discovery;
6 |
7 | class NonDiscoverableClass
8 | {
9 | public function someMethod(): string
10 | {
11 | return "Just a regular method.";
12 | }
13 | }
14 |
15 | interface MyDiscoverableInterface
16 | {
17 | }
18 |
19 | trait MyDiscoverableTrait
20 | {
21 | public function traitMethod()
22 | {
23 | }
24 | }
25 |
26 | enum MyDiscoverableEnum
27 | {
28 | case Alpha;
29 | }
30 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Middlewares/RequestAttributeMiddleware.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Middlewares;
6 |
7 | use Psr\Http\Message\ServerRequestInterface;
8 |
9 | class RequestAttributeMiddleware
10 | {
11 | public function __invoke(ServerRequestInterface $request, callable $next)
12 | {
13 | $request = $request->withAttribute('middleware-attr', 'middleware-value');
14 | return $next($request);
15 | }
16 | }
17 |
```
--------------------------------------------------------------------------------
/src/Defaults/DefaultUuidSessionIdGenerator.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Defaults;
6 |
7 | use PhpMcp\Server\Contracts\SessionIdGeneratorInterface;
8 |
9 | class DefaultUuidSessionIdGenerator implements SessionIdGeneratorInterface
10 | {
11 | public function generateId(): string
12 | {
13 | return bin2hex(random_bytes(16));
14 | }
15 |
16 | public function onSessionInitialized(string $sessionId): void
17 | {
18 | // no-op
19 | }
20 | }
21 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Middlewares/ErrorMiddleware.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Middlewares;
6 |
7 | use Psr\Http\Message\ServerRequestInterface;
8 |
9 | class ErrorMiddleware
10 | {
11 | public function __invoke(ServerRequestInterface $request, callable $next)
12 | {
13 | if (str_contains($request->getUri()->getPath(), '/error-middleware')) {
14 | throw new \Exception('Middleware error');
15 | }
16 | return $next($request);
17 | }
18 | }
19 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Discovery/InvocablePromptFixture.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Discovery;
6 |
7 | use PhpMcp\Server\Attributes\McpPrompt;
8 |
9 | #[McpPrompt(name: "InvokableGreeterPrompt")]
10 | class InvocablePromptFixture
11 | {
12 | /**
13 | * @param string $personName
14 | * @return array
15 | */
16 | public function __invoke(string $personName): array
17 | {
18 | return [['role' => 'user', 'content' => "Generate a short greeting for {$personName}."]];
19 | }
20 | }
21 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/General/InvokableHandlerFixture.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Fixtures\General;
4 |
5 | class InvokableHandlerFixture
6 | {
7 | public string $type;
8 | public array $argsReceived;
9 |
10 | public function __construct(string $type = "default")
11 | {
12 | $this->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 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Discovery/InvocableResourceTemplateFixture.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Discovery;
6 |
7 | use PhpMcp\Server\Attributes\McpResourceTemplate;
8 |
9 | #[McpResourceTemplate(uriTemplate: "invokable://user-profile/{userId}")]
10 | class InvocableResourceTemplateFixture
11 | {
12 | /**
13 | * @param string $userId
14 | * @return array
15 | */
16 | public function __invoke(string $userId): array
17 | {
18 | return ["id" => $userId, "email" => "user{$userId}@example-invokable.com"];
19 | }
20 | }
21 |
```
--------------------------------------------------------------------------------
/src/Contracts/CompletionProviderInterface.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Contracts;
6 |
7 | interface CompletionProviderInterface
8 | {
9 | /**
10 | * Get completions for a given current value.
11 | *
12 | * @param string $currentValue The current value to get completions for.
13 | * @param SessionInterface $session The session to get completions for.
14 | * @return array The completions.
15 | */
16 | public function getCompletions(string $currentValue, SessionInterface $session): array;
17 | }
18 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Middlewares/ShortCircuitMiddleware.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Middlewares;
6 |
7 | use Psr\Http\Message\ServerRequestInterface;
8 | use React\Http\Message\Response;
9 |
10 | class ShortCircuitMiddleware
11 | {
12 | public function __invoke(ServerRequestInterface $request, callable $next)
13 | {
14 | if (str_contains($request->getUri()->getPath(), '/short-circuit')) {
15 | return new Response(418, [], 'Short-circuited by middleware');
16 | }
17 | return $next($request);
18 | }
19 | }
20 |
```
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
```
1 | <?xml version="1.0" encoding="UTF-8"?>
2 | <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3 | xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
4 | bootstrap="vendor/autoload.php"
5 | colors="true"
6 | >
7 | <testsuites>
8 | <testsuite name="Test Suite">
9 | <directory suffix="Test.php">./tests</directory>
10 | </testsuite>
11 | </testsuites>
12 | <source>
13 | <include>
14 | <directory>src</directory>
15 | </include>
16 | </source>
17 | </phpunit>
18 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/General/RequestAttributeChecker.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\General;
6 |
7 | use PhpMcp\Schema\Content\TextContent;
8 | use PhpMcp\Server\Context;
9 |
10 | class RequestAttributeChecker
11 | {
12 | public function checkAttribute(Context $context): TextContent
13 | {
14 | $attribute = $context->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:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace Mcp\HttpUserProfileExample;
6 |
7 | use PhpMcp\Server\Contracts\CompletionProviderInterface;
8 | use PhpMcp\Server\Contracts\SessionInterface;
9 |
10 | class UserIdCompletionProvider implements CompletionProviderInterface
11 | {
12 | public function getCompletions(string $currentValue, SessionInterface $session): array
13 | {
14 | $availableUserIds = ['101', '102', '103'];
15 | $filteredUserIds = array_filter($availableUserIds, fn(string $userId) => str_contains($userId, $currentValue));
16 |
17 | return $filteredUserIds;
18 | }
19 | }
20 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Discovery/InvocableToolFixture.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Discovery;
6 |
7 | use PhpMcp\Server\Attributes\McpTool;
8 | use PhpMcp\Server\Attributes\McpResource;
9 | use PhpMcp\Server\Attributes\McpPrompt;
10 | use PhpMcp\Server\Attributes\McpResourceTemplate;
11 |
12 | #[McpTool(name: "InvokableCalculator", description: "An invokable calculator tool.")]
13 | class InvocableToolFixture
14 | {
15 | /**
16 | * Adds two numbers.
17 | * @param int $a
18 | * @param int $b
19 | * @return int
20 | */
21 | public function __invoke(int $a, int $b): int
22 | {
23 | return $a + $b;
24 | }
25 | }
26 |
```
--------------------------------------------------------------------------------
/src/Exception/TransportException.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Exception;
6 |
7 | use PhpMcp\Schema\JsonRpc\Error as JsonRpcError;
8 |
9 | /**
10 | * Exception related to errors in the underlying transport layer
11 | * (e.g., socket errors, process management issues, SSE stream errors).
12 | */
13 | class TransportException extends McpServerException
14 | {
15 | public function toJsonRpcError(string|int $id): JsonRpcError
16 | {
17 | return new JsonRpcError(
18 | jsonrpc: '2.0',
19 | id: $id,
20 | code: JsonRpcError::CODE_INTERNAL_ERROR,
21 | message: 'Transport layer error: ' . $this->getMessage(),
22 | data: null
23 | );
24 | }
25 | }
26 |
```
--------------------------------------------------------------------------------
/src/Defaults/ListCompletionProvider.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Defaults;
6 |
7 | use PhpMcp\Server\Contracts\CompletionProviderInterface;
8 | use PhpMcp\Server\Contracts\SessionInterface;
9 |
10 | class ListCompletionProvider implements CompletionProviderInterface
11 | {
12 | public function __construct(private array $values) {}
13 |
14 | public function getCompletions(string $currentValue, SessionInterface $session): array
15 | {
16 | if (empty($currentValue)) {
17 | return $this->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 |
```
--------------------------------------------------------------------------------
/src/Attributes/McpPrompt.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Attributes;
4 |
5 | use Attribute;
6 |
7 | /**
8 | * Marks a PHP method as an MCP Prompt generator.
9 | * The method should return the prompt messages, potentially using arguments for templating.
10 | */
11 | #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
12 | final class McpPrompt
13 | {
14 | /**
15 | * @param ?string $name Overrides the prompt name (defaults to method name).
16 | * @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary.
17 | */
18 | public function __construct(
19 | public ?string $name = null,
20 | public ?string $description = null,
21 | ) {
22 | }
23 | }
24 |
```
--------------------------------------------------------------------------------
/src/Attributes/McpTool.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Attributes;
4 |
5 | use Attribute;
6 | use PhpMcp\Schema\ToolAnnotations;
7 |
8 | #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
9 | class McpTool
10 | {
11 | /**
12 | * @param string|null $name The name of the tool (defaults to the method name)
13 | * @param string|null $description The description of the tool (defaults to the DocBlock/inferred)
14 | * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior
15 | */
16 | public function __construct(
17 | public ?string $name = null,
18 | public ?string $description = null,
19 | public ?ToolAnnotations $annotations = null,
20 | ) {
21 | }
22 | }
23 |
```
--------------------------------------------------------------------------------
/.github/workflows/changelog.yml:
--------------------------------------------------------------------------------
```yaml
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:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Fixtures\General;
4 |
5 | use PhpMcp\Server\Contracts\CompletionProviderInterface;
6 | use PhpMcp\Server\Contracts\SessionInterface;
7 |
8 | class CompletionProviderFixture implements CompletionProviderInterface
9 | {
10 | public static array $completions = ['alpha', 'beta', 'gamma'];
11 | public static string $lastCurrentValue = '';
12 | public static ?SessionInterface $lastSession = null;
13 |
14 | public function getCompletions(string $currentValue, SessionInterface $session): array
15 | {
16 | self::$lastCurrentValue = $currentValue;
17 | self::$lastSession = $session;
18 |
19 | return array_filter(self::$completions, fn ($item) => str_starts_with($item, $currentValue));
20 | }
21 | }
22 |
```
--------------------------------------------------------------------------------
/src/Exception/ProtocolException.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Exception;
6 |
7 | use PhpMcp\Schema\JsonRpc\Error as JsonRpcError;
8 |
9 | /**
10 | * Exception related to violations of the JSON-RPC 2.0 or MCP structure
11 | * in incoming messages or outgoing responses (e.g., missing required fields,
12 | * invalid types within the protocol itself).
13 | */
14 | class ProtocolException extends McpServerException
15 | {
16 | public function toJsonRpcError(string|int $id): JsonRpcError
17 | {
18 | $code = ($this->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:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace Mcp\CombinedHttpExample\Discovered;
4 |
5 | use PhpMcp\Server\Attributes\McpResource;
6 | use PhpMcp\Server\Attributes\McpTool;
7 |
8 | class DiscoveredElements
9 | {
10 | /**
11 | * A tool discovered via attributes.
12 | *
13 | * @return string A status message.
14 | */
15 | #[McpTool(name: 'discovered_status_check')]
16 | public function checkSystemStatus(): string
17 | {
18 | return 'System status: OK (discovered)';
19 | }
20 |
21 | /**
22 | * A resource discovered via attributes.
23 | * This will be overridden by a manual registration with the same URI.
24 | *
25 | * @return string Content.
26 | */
27 | #[McpResource(uri: 'config://priority', name: 'priority_config_discovered')]
28 | public function getPriorityConfigDiscovered(): string
29 | {
30 | return 'Discovered Priority Config: Low';
31 | }
32 | }
33 |
```
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
```yaml
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 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Middlewares/HeaderMiddleware.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Middlewares;
6 |
7 | use Psr\Http\Message\ServerRequestInterface;
8 | use Psr\Http\Message\ResponseInterface;
9 | use React\Promise\PromiseInterface;
10 |
11 | class HeaderMiddleware
12 | {
13 | public function __invoke(ServerRequestInterface $request, callable $next)
14 | {
15 | $result = $next($request);
16 |
17 | return match (true) {
18 | $result instanceof PromiseInterface => $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 |
```
--------------------------------------------------------------------------------
/src/Attributes/CompletionProvider.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Attributes;
6 |
7 | use Attribute;
8 | use PhpMcp\Server\Contracts\CompletionProviderInterface;
9 |
10 | #[Attribute(Attribute::TARGET_PARAMETER)]
11 | class CompletionProvider
12 | {
13 | /**
14 | * @param class-string<CompletionProviderInterface>|null $providerClass
15 | * @param class-string<CompletionProviderInterface>|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 |
```
--------------------------------------------------------------------------------
/examples/04-combined-registration-http/ManualHandlers.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace Mcp\CombinedHttpExample\Manual;
4 |
5 | use Psr\Log\LoggerInterface;
6 |
7 | class ManualHandlers
8 | {
9 | private LoggerInterface $logger;
10 |
11 | public function __construct(LoggerInterface $logger)
12 | {
13 | $this->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 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Discovery/DiscoverableResourceHandler.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Discovery;
6 |
7 | use PhpMcp\Schema\Annotations;
8 | use PhpMcp\Server\Attributes\McpResource;
9 |
10 | class DiscoverableResourceHandler
11 | {
12 | /**
13 | * Provides the application's current version.
14 | * @return string The version string.
15 | */
16 | #[McpResource(
17 | uri: "app://info/version",
18 | name: "app_version",
19 | description: "The current version of the application.",
20 | mimeType: "text/plain",
21 | size: 10
22 | )]
23 | public function getAppVersion(): string
24 | {
25 | return "1.2.3-discovered";
26 | }
27 |
28 | #[McpResource(
29 | uri: "config://settings/ui",
30 | name: "ui_settings_discovered",
31 | mimeType: "application/json",
32 | annotations: new Annotations(priority: 0.5)
33 | )]
34 | public function getUiSettings(): array
35 | {
36 | return ["theme" => "dark", "fontSize" => 14];
37 | }
38 |
39 | public function someOtherMethod(): void
40 | {
41 | }
42 | }
43 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Middlewares/FirstMiddleware.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Middlewares;
6 |
7 | use Psr\Http\Message\ServerRequestInterface;
8 | use Psr\Http\Message\ResponseInterface;
9 | use React\Promise\PromiseInterface;
10 |
11 | class FirstMiddleware
12 | {
13 | public function __invoke(ServerRequestInterface $request, callable $next)
14 | {
15 | $result = $next($request);
16 |
17 | return match (true) {
18 | $result instanceof PromiseInterface => $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:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Middlewares;
6 |
7 | use Psr\Http\Message\ServerRequestInterface;
8 | use Psr\Http\Message\ResponseInterface;
9 | use React\Promise\PromiseInterface;
10 |
11 | class ThirdMiddleware
12 | {
13 | public function __invoke(ServerRequestInterface $request, callable $next)
14 | {
15 | $result = $next($request);
16 |
17 | return match (true) {
18 | $result instanceof PromiseInterface => $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 |
```
--------------------------------------------------------------------------------
/src/Contracts/SessionHandlerInterface.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Contracts;
6 |
7 | interface SessionHandlerInterface
8 | {
9 | /**
10 | * Read session data
11 | *
12 | * Returns an encoded string of the read data.
13 | * If nothing was read, it must return false.
14 | * @param string $id The session id to read data for.
15 | */
16 | public function read(string $id): string|false;
17 |
18 | /**
19 | * Write session data
20 | * @param string $id The session id.
21 | * @param string $data The encoded session data.
22 | */
23 | public function write(string $id, string $data): bool;
24 |
25 | /**
26 | * Destroy a session
27 | * @param string $id The session ID being destroyed.
28 | * The return value (usually TRUE on success, FALSE on failure).
29 | */
30 | public function destroy(string $id): bool;
31 |
32 | /**
33 | * Cleanup old sessions
34 | * Sessions that have not updated for
35 | * the last maxlifetime seconds will be removed.
36 | */
37 | public function gc(int $maxLifetime): array;
38 | }
39 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Middlewares/SecondMiddleware.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Middlewares;
6 |
7 | use Psr\Http\Message\ServerRequestInterface;
8 | use Psr\Http\Message\ResponseInterface;
9 | use React\Promise\PromiseInterface;
10 |
11 | class SecondMiddleware
12 | {
13 | public function __invoke(ServerRequestInterface $request, callable $next)
14 | {
15 | $result = $next($request);
16 |
17 | return match (true) {
18 | $result instanceof PromiseInterface => $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 |
```
--------------------------------------------------------------------------------
/tests/Unit/Attributes/McpToolTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Unit\Attributes;
4 |
5 | use PhpMcp\Server\Attributes\McpTool;
6 |
7 | it('instantiates with correct properties', function () {
8 | // Arrange
9 | $name = 'test-tool-name';
10 | $description = 'This is a test description.';
11 |
12 | // Act
13 | $attribute = new McpTool(name: $name, description: $description);
14 |
15 | // Assert
16 | expect($attribute->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:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Defaults;
6 |
7 | use PhpMcp\Server\Contracts\CompletionProviderInterface;
8 | use PhpMcp\Server\Contracts\SessionInterface;
9 |
10 | class EnumCompletionProvider implements CompletionProviderInterface
11 | {
12 | private array $values;
13 |
14 | public function __construct(string $enumClass)
15 | {
16 | if (!enum_exists($enumClass)) {
17 | throw new \InvalidArgumentException("Class {$enumClass} is not an enum");
18 | }
19 |
20 | $this->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:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Unit\Attributes;
4 |
5 | use PhpMcp\Server\Attributes\McpPrompt;
6 |
7 | it('instantiates with name and description', function () {
8 | // Arrange
9 | $name = 'test-prompt-name';
10 | $description = 'This is a test prompt description.';
11 |
12 | // Act
13 | $attribute = new McpPrompt(name: $name, description: $description);
14 |
15 | // Assert
16 | expect($attribute->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 |
```
--------------------------------------------------------------------------------
/src/Contracts/EventStoreInterface.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Contracts;
6 |
7 | /**
8 | * Interface for resumability support via event storage
9 | */
10 | interface EventStoreInterface
11 | {
12 | /**
13 | * Stores a message associated with a specific stream and returns a unique event ID.
14 | *
15 | * @param string $streamId The ID of the stream the event belongs to.
16 | * @param string $message The framed JSON-RPC message to store.
17 | * @return string The generated event ID for the stored event
18 | */
19 | public function storeEvent(string $streamId, string $message): string;
20 |
21 | /**
22 | * Replays events for a given stream that occurred after a specific event ID.
23 | *
24 | * @param string $lastEventId The last event ID the client received for this specific stream.
25 | * @param callable $sendCallback A function to call for each replayed message.
26 | * The callback will receive: `function(string $eventId, Message $message): void`
27 | */
28 | public function replayEventsAfter(string $lastEventId, callable $sendCallback): void;
29 | }
30 |
```
--------------------------------------------------------------------------------
/src/Attributes/McpResourceTemplate.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Attributes;
4 |
5 | use Attribute;
6 | use PhpMcp\Schema\Annotations;
7 |
8 | /**
9 | * Marks a PHP class definition as representing an MCP Resource Template.
10 | * This is informational, used for 'resources/templates/list'.
11 | */
12 | #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
13 | final class McpResourceTemplate
14 | {
15 | /**
16 | * @param string $uriTemplate The URI template string (RFC 6570).
17 | * @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name.
18 | * @param ?string $description Optional description. Defaults to class DocBlock summary.
19 | * @param ?string $mimeType Optional default MIME type for matching resources.
20 | * @param ?Annotations $annotations Optional annotations describing the resource template.
21 | */
22 | public function __construct(
23 | public string $uriTemplate,
24 | public ?string $name = null,
25 | public ?string $description = null,
26 | public ?string $mimeType = null,
27 | public ?Annotations $annotations = null,
28 | ) {
29 | }
30 | }
31 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Discovery/DiscoverablePromptHandler.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Discovery;
6 |
7 | use PhpMcp\Server\Attributes\McpPrompt;
8 | use PhpMcp\Server\Attributes\CompletionProvider;
9 | use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture;
10 |
11 | class DiscoverablePromptHandler
12 | {
13 | /**
14 | * Generates a creative story prompt.
15 | * @param string $genre The genre of the story.
16 | * @param int $lengthWords Approximate length in words.
17 | * @return array The prompt messages.
18 | */
19 | #[McpPrompt(name: "creative_story_prompt")]
20 | public function generateStoryPrompt(
21 | #[CompletionProvider(provider: CompletionProviderFixture::class)]
22 | string $genre,
23 | int $lengthWords = 200
24 | ): array {
25 | return [
26 | ["role" => "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 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Utils/AttributeFixtures.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Fixtures\Utils;
4 |
5 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_PARAMETER)]
6 | class TestAttributeOne
7 | {
8 | public function __construct(public string $value)
9 | {
10 | }
11 | }
12 |
13 | #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER)]
14 | class TestAttributeTwo
15 | {
16 | public function __construct(public int $number)
17 | {
18 | }
19 | }
20 |
21 | #[\Attribute(\Attribute::TARGET_CLASS)]
22 | class TestClassOnlyAttribute
23 | {
24 | }
25 |
26 |
27 | // --- Test Class ---
28 |
29 | #[TestClassOnlyAttribute]
30 | #[TestAttributeOne(value: 'class-level')]
31 | class AttributeFixtures
32 | {
33 | #[TestAttributeOne(value: 'prop-level')]
34 | public string $propertyOne = 'default';
35 |
36 | #[TestAttributeOne(value: 'method-one')]
37 | public function methodOne(
38 | #[TestAttributeOne(value: 'param-one')]
39 | #[TestAttributeTwo(number: 1)]
40 | string $param1
41 | ): void {
42 | }
43 |
44 | #[TestAttributeOne(value: 'method-two')]
45 | #[TestAttributeTwo(number: 2)]
46 | public function methodTwo(
47 | #[TestAttributeTwo(number: 3)]
48 | int $paramA
49 | ): void {
50 | }
51 |
52 | // Method with no attributes
53 | public function methodThree(string $unattributedParam): void
54 | {
55 | }
56 | }
57 |
```
--------------------------------------------------------------------------------
/src/Attributes/McpResource.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Attributes;
4 |
5 | use Attribute;
6 | use PhpMcp\Schema\Annotations;
7 |
8 | /**
9 | * Marks a PHP class as representing or handling a specific MCP Resource instance.
10 | * Used primarily for the 'resources/list' discovery.
11 | */
12 | #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
13 | final class McpResource
14 | {
15 | /**
16 | * @param string $uri The specific URI identifying this resource instance. Must be unique within the server.
17 | * @param ?string $name A human-readable name for this resource. If null, a default might be generated from the method name.
18 | * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary.
19 | * @param ?string $mimeType The MIME type, if known and constant for this resource.
20 | * @param ?int $size The size in bytes, if known and constant.
21 | * @param Annotations|null $annotations Optional annotations describing the resource.
22 | */
23 | public function __construct(
24 | public string $uri,
25 | public ?string $name = null,
26 | public ?string $description = null,
27 | public ?string $mimeType = null,
28 | public ?int $size = null,
29 | public ?Annotations $annotations = null,
30 | ) {
31 | }
32 | }
33 |
```
--------------------------------------------------------------------------------
/examples/05-stdio-env-variables/EnvToolHandler.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace Mcp\EnvExample;
4 |
5 | use PhpMcp\Server\Attributes\McpTool;
6 |
7 | class EnvToolHandler
8 | {
9 | public function __construct()
10 | {
11 | }
12 |
13 | /**
14 | * Performs an action that can be modified by an environment variable.
15 | * The MCP client should set 'APP_MODE' in its 'env' config for this server.
16 | *
17 | * @param string $input Some input data.
18 | * @return array The result, varying by APP_MODE.
19 | */
20 | #[McpTool(name: 'process_data_by_mode')]
21 | public function processData(string $input): array
22 | {
23 | $appMode = getenv('APP_MODE'); // Read from environment
24 |
25 | if ($appMode === 'debug') {
26 | return [
27 | 'mode' => '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:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Discovery;
6 |
7 | use PhpMcp\Server\Attributes\McpResourceTemplate;
8 | use PhpMcp\Server\Attributes\CompletionProvider;
9 | use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture;
10 |
11 | class DiscoverableTemplateHandler
12 | {
13 | /**
14 | * Retrieves product details based on ID and region.
15 | * @param string $productId The ID of the product.
16 | * @param string $region The sales region.
17 | * @return array Product details.
18 | */
19 | #[McpResourceTemplate(
20 | uriTemplate: "product://{region}/details/{productId}",
21 | name: "product_details_template",
22 | mimeType: "application/json"
23 | )]
24 | public function getProductDetails(
25 | string $productId,
26 | #[CompletionProvider(provider: CompletionProviderFixture::class)]
27 | string $region
28 | ): array {
29 | return [
30 | "id" => $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:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Discovery;
6 |
7 | use PhpMcp\Server\Attributes\McpPrompt;
8 | use PhpMcp\Server\Attributes\McpResourceTemplate;
9 | use PhpMcp\Server\Attributes\CompletionProvider;
10 | use PhpMcp\Server\Tests\Fixtures\Enums\StatusEnum;
11 | use PhpMcp\Server\Tests\Fixtures\Enums\PriorityEnum;
12 |
13 | class EnhancedCompletionHandler
14 | {
15 | /**
16 | * Create content with list and enum completion providers.
17 | */
18 | #[McpPrompt(name: 'content_creator')]
19 | public function createContent(
20 | #[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide'])]
21 | string $type,
22 | #[CompletionProvider(enum: StatusEnum::class)]
23 | string $status,
24 | #[CompletionProvider(enum: PriorityEnum::class)]
25 | string $priority
26 | ): array {
27 | return [
28 | ['role' => '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 |
```
--------------------------------------------------------------------------------
/tests/Unit/Attributes/McpResourceTemplateTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Unit\Attributes;
4 |
5 | use PhpMcp\Server\Attributes\McpResourceTemplate;
6 |
7 | it('instantiates with correct properties', function () {
8 | // Arrange
9 | $uriTemplate = 'file:///{path}/data';
10 | $name = 'test-template-name';
11 | $description = 'This is a test template description.';
12 | $mimeType = 'application/json';
13 |
14 | // Act
15 | $attribute = new McpResourceTemplate(
16 | uriTemplate: $uriTemplate,
17 | name: $name,
18 | description: $description,
19 | mimeType: $mimeType,
20 | );
21 |
22 | // Assert
23 | expect($attribute->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/Fixtures/ServerScripts/StdioTestServer.php:
--------------------------------------------------------------------------------
```php
1 | #!/usr/bin/env php
2 | <?php
3 |
4 | declare(strict_types=1);
5 |
6 | require_once __DIR__ . '/../../../vendor/autoload.php';
7 |
8 | use PhpMcp\Server\Server;
9 | use PhpMcp\Server\Transports\StdioServerTransport;
10 | use PhpMcp\Server\Tests\Fixtures\General\ToolHandlerFixture;
11 | use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture;
12 | use PhpMcp\Server\Tests\Fixtures\General\PromptHandlerFixture;
13 | use Psr\Log\AbstractLogger;
14 | use Psr\Log\NullLogger;
15 |
16 | class StdErrLogger extends AbstractLogger
17 | {
18 | public function log($level, \Stringable|string $message, array $context = []): void
19 | {
20 | fwrite(STDERR, sprintf("[%s] SERVER_LOG: %s %s\n", strtoupper((string)$level), $message, empty($context) ? '' : json_encode($context)));
21 | }
22 | }
23 |
24 | try {
25 | $logger = new NullLogger();
26 | $logger->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 |
```
--------------------------------------------------------------------------------
/src/Configuration.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server;
6 |
7 | use PhpMcp\Schema\Implementation;
8 | use PhpMcp\Schema\ServerCapabilities;
9 | use Psr\Container\ContainerInterface;
10 | use Psr\Log\LoggerInterface;
11 | use Psr\SimpleCache\CacheInterface;
12 | use React\EventLoop\LoopInterface;
13 |
14 | /**
15 | * Value Object holding core configuration and shared dependencies for the MCP Server instance.
16 | *
17 | * This object is typically assembled by the ServerBuilder and passed to the Server constructor.
18 | */
19 | class Configuration
20 | {
21 | /**
22 | * @param Implementation $serverInfo Info about this MCP server application.
23 | * @param ServerCapabilities $capabilities Capabilities of this MCP server application.
24 | * @param LoggerInterface $logger PSR-3 Logger instance.
25 | * @param LoopInterface $loop ReactPHP Event Loop instance.
26 | * @param CacheInterface|null $cache Optional PSR-16 Cache instance for registry/state.
27 | * @param ContainerInterface $container PSR-11 DI Container for resolving handlers/dependencies.
28 | * @param int $paginationLimit Maximum number of items to return for list methods.
29 | * @param string|null $instructions Instructions describing how to use the server and its features.
30 | */
31 | public function __construct(
32 | public readonly Implementation $serverInfo,
33 | public readonly ServerCapabilities $capabilities,
34 | public readonly LoggerInterface $logger,
35 | public readonly LoopInterface $loop,
36 | public readonly ?CacheInterface $cache,
37 | public readonly ContainerInterface $container,
38 | public readonly int $paginationLimit = 50,
39 | public readonly ?string $instructions = null,
40 | ) {}
41 | }
42 |
```
--------------------------------------------------------------------------------
/tests/Unit/Attributes/McpResourceTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Unit\Attributes;
4 |
5 | use PhpMcp\Server\Attributes\McpResource;
6 |
7 | it('instantiates with correct properties', function () {
8 | // Arrange
9 | $uri = 'file:///test/resource';
10 | $name = 'test-resource-name';
11 | $description = 'This is a test resource description.';
12 | $mimeType = 'text/plain';
13 | $size = 1024;
14 |
15 | // Act
16 | $attribute = new McpResource(
17 | uri: $uri,
18 | name: $name,
19 | description: $description,
20 | mimeType: $mimeType,
21 | size: $size,
22 | );
23 |
24 | // Assert
25 | expect($attribute->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:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Fixtures\Discovery;
6 |
7 | use PhpMcp\Schema\ToolAnnotations;
8 | use PhpMcp\Server\Attributes\McpTool;
9 | use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum;
10 |
11 | class DiscoverableToolHandler
12 | {
13 | /**
14 | * A basic discoverable tool.
15 | * @param string $name The name to greet.
16 | * @return string The greeting.
17 | */
18 | #[McpTool(name: "greet_user", description: "Greets a user by name.")]
19 | public function greet(string $name): string
20 | {
21 | return "Hello, {$name}!";
22 | }
23 |
24 | /**
25 | * A tool with more complex parameters and inferred name/description.
26 | * @param int $count The number of times to repeat.
27 | * @param bool $loudly Should it be loud?
28 | * @param BackedStringEnum $mode The mode of operation.
29 | * @return array An array with results.
30 | */
31 | #[McpTool(annotations: new ToolAnnotations(readOnlyHint: true))]
32 | public function repeatAction(int $count, bool $loudly = false, BackedStringEnum $mode = BackedStringEnum::OptionA): array
33 | {
34 | return ['count' => $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:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Session;
6 |
7 | use PhpMcp\Server\Contracts\SessionHandlerInterface;
8 | use PhpMcp\Server\Defaults\SystemClock;
9 | use Psr\Clock\ClockInterface;
10 |
11 | class ArraySessionHandler implements SessionHandlerInterface
12 | {
13 | /**
14 | * @var array<string, array{ data: array, timestamp: int }>
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 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/General/DocBlockTestFixture.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Fixtures\General;
4 |
5 | /**
6 | * A stub class for testing DocBlock parsing.
7 | */
8 | class DocBlockTestFixture
9 | {
10 | /**
11 | * Simple summary line.
12 | */
13 | public function methodWithSummaryOnly(): void
14 | {
15 | }
16 |
17 | /**
18 | * Summary line here.
19 | *
20 | * This is a longer description spanning
21 | * multiple lines.
22 | * It might contain *markdown* or `code`.
23 | *
24 | * @since 1.0
25 | */
26 | public function methodWithSummaryAndDescription(): void
27 | {
28 | }
29 |
30 | /**
31 | * Method with various parameter tags.
32 | *
33 | * @param string $param1 Description for string param.
34 | * @param int|null $param2 Description for nullable int param.
35 | * @param bool $param3
36 | * @param $param4 Missing type.
37 | * @param array<string, mixed> $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 |
```
--------------------------------------------------------------------------------
/examples/03-manual-registration-stdio/SimpleHandlers.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace Mcp\ManualStdioExample;
4 |
5 | use Psr\Log\LoggerInterface;
6 |
7 | class SimpleHandlers
8 | {
9 | private LoggerInterface $logger;
10 |
11 | private string $appVersion = '1.0-manual';
12 |
13 | public function __construct(LoggerInterface $logger)
14 | {
15 | $this->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/Mocks/Clock/FixedClock.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Mocks\Clock;
6 |
7 | use DateTimeImmutable;
8 | use DateTimeZone;
9 | use Psr\Clock\ClockInterface;
10 | use DateInterval;
11 |
12 | class FixedClock implements ClockInterface
13 | {
14 | private DateTimeImmutable $currentTime;
15 |
16 | public function __construct(string|DateTimeImmutable $initialTime = 'now', ?DateTimeZone $timezone = null)
17 | {
18 | if ($initialTime instanceof DateTimeImmutable) {
19 | $this->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 |
```
--------------------------------------------------------------------------------
/tests/Unit/Defaults/ListCompletionProviderTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Unit\Defaults;
6 |
7 | use PhpMcp\Server\Defaults\ListCompletionProvider;
8 | use PhpMcp\Server\Contracts\SessionInterface;
9 | use Mockery;
10 |
11 | beforeEach(function () {
12 | $this->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 |
```
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
```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": "[email protected]"
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 | }
```
--------------------------------------------------------------------------------
/src/Contracts/SessionInterface.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Contracts;
6 |
7 | use JsonSerializable;
8 |
9 | interface SessionInterface extends JsonSerializable
10 | {
11 | /**
12 | * Get the session ID.
13 | */
14 | public function getId(): string;
15 |
16 | /**
17 | * Save the session.
18 | */
19 | public function save(): void;
20 |
21 | /**
22 | * Get a specific attribute from the session.
23 | * Supports dot notation for nested access.
24 | */
25 | public function get(string $key, mixed $default = null): mixed;
26 |
27 | /**
28 | * Set a specific attribute in the session.
29 | * Supports dot notation for nested access.
30 | */
31 | public function set(string $key, mixed $value, bool $overwrite = true): void;
32 |
33 | /**
34 | * Check if an attribute exists in the session.
35 | * Supports dot notation for nested access.
36 | */
37 | public function has(string $key): bool;
38 |
39 | /**
40 | * Remove an attribute from the session.
41 | * Supports dot notation for nested access.
42 | */
43 | public function forget(string $key): void;
44 |
45 | /**
46 | * Remove all attributes from the session.
47 | */
48 | public function clear(): void;
49 |
50 | /**
51 | * Get an attribute's value and then remove it from the session.
52 | * Supports dot notation for nested access.
53 | */
54 | public function pull(string $key, mixed $default = null): mixed;
55 |
56 | /**
57 | * Get all attributes of the session.
58 | */
59 | public function all(): array;
60 |
61 | /**
62 | * Set all attributes of the session, typically for hydration.
63 | * This will overwrite existing attributes.
64 | */
65 | public function hydrate(array $attributes): void;
66 |
67 | /**
68 | * Add a message to the session's queue.
69 | */
70 | public function queueMessage(string $message): void;
71 |
72 | /**
73 | * Retrieve and remove all messages from the queue.
74 | * @return array<string>
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 |
```
--------------------------------------------------------------------------------
/src/Defaults/InMemoryEventStore.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Defaults;
6 |
7 | use PhpMcp\Server\Contracts\EventStoreInterface;
8 |
9 | /**
10 | * Simple in-memory implementation of the EventStore interface for resumability
11 | * This is primarily intended for examples and testing, not for production use
12 | * where a persistent storage solution would be more appropriate.
13 | */
14 | class InMemoryEventStore implements EventStoreInterface
15 | {
16 | public const DEFAULT_MAX_EVENTS_PER_STREAM = 1000;
17 |
18 | /**
19 | * @var array<string, array{streamId: string, message: string}>
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 |
```
--------------------------------------------------------------------------------
/src/Contracts/ServerTransportInterface.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Contracts;
6 |
7 | use Evenement\EventEmitterInterface;
8 | use PhpMcp\Server\Exception\TransportException;
9 | use PhpMcp\Schema\JsonRpc\Message;
10 | use React\Promise\PromiseInterface;
11 |
12 | /**
13 | * Interface for server-side MCP transports.
14 | *
15 | * Implementations handle listening for connections/data and sending raw messages.
16 | * MUST emit events for lifecycle and messages.
17 | *
18 | * --- Expected Emitted Events ---
19 | * 'ready': () - Optional: Fired when listening starts successfully.
20 | * 'client_connected': (string $sessionId) - New client connection
21 | * 'message': (Message $message, string $sessionId, array $context) - Complete message received from a client.
22 | * 'client_disconnected': (string $sessionId, ?string $reason) - Client connection closed.
23 | * 'error': (Throwable $error, ?string $sessionId) - Error occurred (general transport error if sessionId is null).
24 | * 'close': (?string $reason) - Transport listener stopped completely.
25 | */
26 | interface ServerTransportInterface extends EventEmitterInterface
27 | {
28 | /**
29 | * Starts the transport listener (e.g., listens on STDIN, starts HTTP server).
30 | * Does NOT run the event loop itself. Prepares transport to emit events when loop runs.
31 | *
32 | * @throws TransportException on immediate setup failure (e.g., port binding).
33 | */
34 | public function listen(): void;
35 |
36 | /**
37 | * Sends a message to a connected client session with optional context.
38 | *
39 | * @param Message $message Message to send.
40 | * @param string $sessionId Target session identifier.
41 | * @param array $context Optional context for the message. Eg. streamId for SSE.
42 | * @return PromiseInterface<void> 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 |
```
--------------------------------------------------------------------------------
/examples/05-stdio-env-variables/server.php:
--------------------------------------------------------------------------------
```php
1 | #!/usr/bin/env php
2 | <?php
3 |
4 | declare(strict_types=1);
5 |
6 | chdir(__DIR__);
7 | require_once '../../vendor/autoload.php';
8 | require_once './EnvToolHandler.php';
9 |
10 | use PhpMcp\Server\Server;
11 | use PhpMcp\Server\Transports\StdioServerTransport;
12 | use Psr\Log\AbstractLogger;
13 |
14 | /*
15 | |--------------------------------------------------------------------------
16 | | MCP Stdio Environment Variable Example Server
17 | |--------------------------------------------------------------------------
18 | |
19 | | This server demonstrates how to use environment variables to modify tool
20 | | behavior. The MCP client can set the APP_MODE environment variable to
21 | | control the server's behavior.
22 | |
23 | | Configure your MCP Client (eg. Cursor) for this server like this:
24 | |
25 | | {
26 | | "mcpServers": {
27 | | "my-php-env-server": {
28 | | "command": "php",
29 | | "args": ["/full/path/to/examples/05-stdio-env-variables/server.php"],
30 | | "env": {
31 | | "APP_MODE": "debug" // or "production", or leave it out
32 | | }
33 | | }
34 | | }
35 | | }
36 | |
37 | | The server will read the APP_MODE environment variable and use it to
38 | | modify the behavior of the tools.
39 | |
40 | | If the APP_MODE environment variable is not set, the server will use the
41 | | default behavior.
42 | |
43 | */
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 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 |
```
--------------------------------------------------------------------------------
/examples/08-schema-showcase-streamable/server.php:
--------------------------------------------------------------------------------
```php
1 | #!/usr/bin/env php
2 | <?php
3 |
4 | /*
5 | |--------------------------------------------------------------------------
6 | | MCP Schema Showcase Server (Attribute Discovery)
7 | |--------------------------------------------------------------------------
8 | |
9 | | This server demonstrates various ways to use the Schema attribute to
10 | | validate tool inputs. It showcases string constraints, numeric validation,
11 | | object schemas, array handling, enums, and format validation.
12 | |
13 | | To Use:
14 | | 1. Ensure 'SchemaShowcaseElements.php' defines classes with MCP attributes.
15 | | 2. Configure your MCP Client (e.g., Cursor) for this server:
16 | |
17 | | {
18 | | "mcpServers": {
19 | | "php-schema-showcase": {
20 | | "command": "php",
21 | | "args": ["/full/path/to/examples/08-schema-showcase-stdio/server.php"]
22 | | }
23 | | }
24 | | }
25 | |
26 | | This example focuses specifically on demonstrating different Schema
27 | | attribute capabilities for robust input validation.
28 | |
29 | */
30 |
31 | declare(strict_types=1);
32 |
33 | chdir(__DIR__);
34 | require_once '../../vendor/autoload.php';
35 | require_once 'SchemaShowcaseElements.php';
36 |
37 | use PhpMcp\Server\Server;
38 | use PhpMcp\Server\Transports\StreamableHttpServerTransport;
39 | use Psr\Log\AbstractLogger;
40 |
41 | class StderrLogger extends AbstractLogger
42 | {
43 | public function log($level, \Stringable|string $message, array $context = []): void
44 | {
45 | fwrite(STDERR, sprintf(
46 | "[%s] %s %s\n",
47 | strtoupper($level),
48 | $message,
49 | empty($context) ? '' : json_encode($context)
50 | ));
51 | }
52 | }
53 |
54 | try {
55 | $logger = new StderrLogger();
56 | $logger->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 |
```
--------------------------------------------------------------------------------
/tests/Unit/Attributes/CompletionProviderTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Unit\Attributes;
6 |
7 | use PhpMcp\Server\Attributes\CompletionProvider;
8 | use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture;
9 | use PhpMcp\Server\Defaults\ListCompletionProvider;
10 | use PhpMcp\Server\Defaults\EnumCompletionProvider;
11 | use PhpMcp\Server\Tests\Fixtures\Enums\StatusEnum;
12 |
13 | it('can be constructed with provider class', function () {
14 | $attribute = new CompletionProvider(provider: CompletionProviderFixture::class);
15 |
16 | expect($attribute->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 |
```
--------------------------------------------------------------------------------
/tests/Unit/Defaults/EnumCompletionProviderTest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | declare(strict_types=1);
4 |
5 | namespace PhpMcp\Server\Tests\Unit\Defaults;
6 |
7 | use PhpMcp\Server\Defaults\EnumCompletionProvider;
8 | use PhpMcp\Server\Contracts\SessionInterface;
9 | use Mockery;
10 |
11 | enum StringEnum: string
12 | {
13 | case DRAFT = 'draft';
14 | case PUBLISHED = 'published';
15 | case ARCHIVED = 'archived';
16 | }
17 |
18 | enum IntEnum: int
19 | {
20 | case LOW = 1;
21 | case MEDIUM = 2;
22 | case HIGH = 3;
23 | }
24 |
25 | enum UnitEnum
26 | {
27 | case ALPHA;
28 | case BETA;
29 | case GAMMA;
30 | }
31 |
32 | beforeEach(function () {
33 | $this->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 |
```
--------------------------------------------------------------------------------
/examples/07-complex-tool-schema-http/McpEventScheduler.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace Mcp\ComplexSchemaHttpExample;
4 |
5 | use Mcp\ComplexSchemaHttpExample\Model\EventPriority;
6 | use Mcp\ComplexSchemaHttpExample\Model\EventType;
7 | use PhpMcp\Server\Attributes\McpTool;
8 | use Psr\Log\LoggerInterface;
9 |
10 | class McpEventScheduler
11 | {
12 | private LoggerInterface $logger;
13 |
14 | public function __construct(LoggerInterface $logger)
15 | {
16 | $this->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 |
```
--------------------------------------------------------------------------------
/src/Defaults/ArrayCache.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Defaults;
4 |
5 | use DateInterval;
6 | use DateTime;
7 | use Psr\SimpleCache\CacheInterface;
8 |
9 | /**
10 | * Very basic PSR-16 array cache implementation (not for production).
11 | */
12 | class ArrayCache implements CacheInterface
13 | {
14 | private array $store = [];
15 |
16 | private array $expiries = [];
17 |
18 | public function get(string $key, mixed $default = null): mixed
19 | {
20 | if (! $this->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 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/ServerScripts/HttpTestServer.php:
--------------------------------------------------------------------------------
```php
1 | #!/usr/bin/env php
2 | <?php
3 |
4 | declare(strict_types=1);
5 |
6 | require_once __DIR__ . '/../../../vendor/autoload.php';
7 |
8 | use PhpMcp\Server\Server;
9 | use PhpMcp\Server\Transports\HttpServerTransport;
10 | use PhpMcp\Server\Tests\Fixtures\General\ToolHandlerFixture;
11 | use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture;
12 | use PhpMcp\Server\Tests\Fixtures\General\PromptHandlerFixture;
13 | use PhpMcp\Server\Tests\Fixtures\General\RequestAttributeChecker;
14 | use PhpMcp\Server\Tests\Fixtures\Middlewares\HeaderMiddleware;
15 | use PhpMcp\Server\Tests\Fixtures\Middlewares\RequestAttributeMiddleware;
16 | use PhpMcp\Server\Tests\Fixtures\Middlewares\ShortCircuitMiddleware;
17 | use PhpMcp\Server\Tests\Fixtures\Middlewares\FirstMiddleware;
18 | use PhpMcp\Server\Tests\Fixtures\Middlewares\SecondMiddleware;
19 | use PhpMcp\Server\Tests\Fixtures\Middlewares\ThirdMiddleware;
20 | use PhpMcp\Server\Tests\Fixtures\Middlewares\ErrorMiddleware;
21 | use Psr\Log\AbstractLogger;
22 | use Psr\Log\NullLogger;
23 |
24 | class StdErrLogger extends AbstractLogger
25 | {
26 | public function log($level, \Stringable|string $message, array $context = []): void
27 | {
28 | fwrite(STDERR, sprintf("[%s] HTTP_SERVER_LOG: %s %s\n", strtoupper((string)$level), $message, empty($context) ? '' : json_encode($context)));
29 | }
30 | }
31 |
32 | $host = $argv[1] ?? '127.0.0.1';
33 | $port = (int)($argv[2] ?? 8990);
34 | $mcpPathPrefix = $argv[3] ?? 'mcp_http_test';
35 |
36 | try {
37 | $logger = new NullLogger();
38 |
39 | $server = Server::make()
40 | ->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:
--------------------------------------------------------------------------------
```php
1 | #!/usr/bin/env php
2 | <?php
3 |
4 | /*
5 | |--------------------------------------------------------------------------
6 | | MCP Stdio Calculator Server (Attribute Discovery)
7 | |--------------------------------------------------------------------------
8 | |
9 | | This server demonstrates using attribute-based discovery to find MCP
10 | | elements (Tools, Resources) in the 'McpElements.php' file within this
11 | | directory. It runs via the STDIO transport.
12 | |
13 | | To Use:
14 | | 1. Ensure 'McpElements.php' defines classes with MCP attributes.
15 | | 2. Configure your MCP Client (e.g., Cursor) for this server:
16 | |
17 | | {
18 | | "mcpServers": {
19 | | "php-stdio-calculator": {
20 | | "command": "php",
21 | | "args": ["/full/path/to/examples/01-discovery-stdio-calculator/server.php"]
22 | | }
23 | | }
24 | | }
25 | |
26 | | The ServerBuilder builds the server instance, then $server->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/Pest.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | use React\EventLoop\Loop;
4 | use React\EventLoop\LoopInterface;
5 | use React\EventLoop\TimerInterface;
6 | use React\Promise\Promise;
7 | use React\Promise\PromiseInterface;
8 | use React\Socket\SocketServer;
9 |
10 | function getPrivateProperty(object $object, string $propertyName)
11 | {
12 | $reflector = new ReflectionClass($object);
13 | $property = $reflector->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 |
```
--------------------------------------------------------------------------------
/tests/Fixtures/Utils/DockBlockParserFixture.php:
--------------------------------------------------------------------------------
```php
1 | <?php
2 |
3 | namespace PhpMcp\Server\Tests\Fixtures\Utils;
4 |
5 | /**
6 | * Test stub for DocBlock array type parsing
7 | */
8 | class DockBlockParserFixture
9 | {
10 | /**
11 | * Method with simple array[] syntax
12 | *
13 | * @param string[] $strings Array of strings using [] syntax
14 | * @param int[] $integers Array of integers using [] syntax
15 | * @param bool[] $booleans Array of booleans using [] syntax
16 | * @param float[] $floats Array of floats using [] syntax
17 | * @param object[] $objects Array of objects using [] syntax
18 | * @param \DateTime[] $dateTimeInstances Array of DateTime objects
19 | */
20 | public function simpleArraySyntax(
21 | array $strings,
22 | array $integers,
23 | array $booleans,
24 | array $floats,
25 | array $objects,
26 | array $dateTimeInstances
27 | ): void {
28 | }
29 |
30 | /**
31 | * Method with array<T> generic syntax
32 | *
33 | * @param array<string> $strings Array of strings using generic syntax
34 | * @param array<int> $integers Array of integers using generic syntax
35 | * @param array<bool> $booleans Array of booleans using generic syntax
36 | * @param array<float> $floats Array of floats using generic syntax
37 | * @param array<object> $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<array<string>> $nestedStringArrays Array of arrays of strings
54 | * @param array<array<int>> $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<int>} $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 |
```