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