This is page 1 of 5. Use http://codebase.md/php-mcp/server?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 <?php $finder = PhpCsFixer\Finder::create() ->exclude([ 'examples', 'vendor', 'tests/Mocks', ]) ->in(__DIR__); return (new PhpCsFixer\Config) ->setRules([ '@PSR12' => true, 'array_syntax' => ['syntax' => 'short'], ]) ->setFinder($finder); ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Composer dependencies /vendor/ /composer.lock # PHPUnit .phpunit.result.cache # PHP CS Fixer /.php-cs-fixer.cache # Editor directories and files /.idea /.vscode *.sublime-project *.sublime-workspace # Operating system files .DS_Store Thumbs.db # Local environment files /.env /.env.backup /.env.local # PHP CodeSniffer /.phpcs.xml /.phpcs.xml.dist /phpcs.xml /phpcs.xml.dist # PHPStan /phpstan.neon /phpstan.neon.dist # Local development tools /.php_cs /.php_cs.cache /.php_cs.dist /_ide_helper.php # Build artifacts /build/ /coverage/ # PHPUnit coverage reports /clover.xml /coverage.xml /coverage/ # Laravel generated files bootstrap/cache/ .phpunit.result.cache # Local Composer dependencies composer.phar workbench playground # Log files *.log # Cache files cache ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # PHP MCP Server SDK [](https://packagist.org/packages/php-mcp/server) [](https://packagist.org/packages/php-mcp/server) [](https://github.com/php-mcp/server/actions/workflows/tests.yml) [](LICENSE) **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.** 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. ## 🚀 Key Features - **🏗️ Modern Architecture**: Built with PHP 8.1+ features, PSR standards, and modular design - **📡 Multiple Transports**: Supports `stdio`, `http+sse`, and new **streamable HTTP** with resumability - **🎯 Attribute-Based Definition**: Use PHP 8 Attributes (`#[McpTool]`, `#[McpResource]`, etc.) for zero-config element registration - **🔧 Flexible Handlers**: Support for closures, class methods, static methods, and invokable classes - **📝 Smart Schema Generation**: Automatic JSON schema generation from method signatures with optional `#[Schema]` attribute enhancements - **⚡ Session Management**: Advanced session handling with multiple storage backends - **🔄 Event-Driven**: ReactPHP-based for high concurrency and non-blocking operations - **📊 Batch Processing**: Full support for JSON-RPC batch requests - **💾 Smart Caching**: Intelligent caching of discovered elements with manual override precedence - **🧪 Completion Providers**: Built-in support for argument completion in tools and prompts - **🔌 Dependency Injection**: Full PSR-11 container support with auto-wiring - **📋 Comprehensive Testing**: Extensive test suite with integration tests for all transports This package supports the **2025-03-26** version of the Model Context Protocol with backward compatibility. ## 📋 Requirements - **PHP** >= 8.1 - **Composer** - **For HTTP Transport**: An event-driven PHP environment (CLI recommended) - **Extensions**: `json`, `mbstring`, `pcre` (typically enabled by default) ## 📦 Installation ```bash composer require php-mcp/server ``` > **💡 Laravel Users**: Consider using [`php-mcp/laravel`](https://github.com/php-mcp/laravel) for enhanced framework integration, configuration management, and Artisan commands. ## ⚡ Quick Start: Stdio Server with Discovery This example demonstrates the most common usage pattern - a `stdio` server using attribute discovery. **1. Define Your MCP Elements** Create `src/CalculatorElements.php`: ```php <?php namespace App; use PhpMcp\Server\Attributes\McpTool; use PhpMcp\Server\Attributes\Schema; class CalculatorElements { /** * Adds two numbers together. * * @param int $a The first number * @param int $b The second number * @return int The sum of the two numbers */ #[McpTool(name: 'add_numbers')] public function add(int $a, int $b): int { return $a + $b; } /** * Calculates power with validation. */ #[McpTool(name: 'calculate_power')] public function power( #[Schema(type: 'number', minimum: 0, maximum: 1000)] float $base, #[Schema(type: 'integer', minimum: 0, maximum: 10)] int $exponent ): float { return pow($base, $exponent); } } ``` **2. Create the Server Script** Create `mcp-server.php`: ```php #!/usr/bin/env php <?php declare(strict_types=1); require_once __DIR__ . '/vendor/autoload.php'; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\StdioServerTransport; try { // Build server configuration $server = Server::make() ->withServerInfo('PHP Calculator Server', '1.0.0') ->build(); // Discover MCP elements via attributes $server->discover( basePath: __DIR__, scanDirs: ['src'] ); // Start listening via stdio transport $transport = new StdioServerTransport(); $server->listen($transport); } catch (\Throwable $e) { fwrite(STDERR, "[CRITICAL ERROR] " . $e->getMessage() . "\n"); exit(1); } ``` **3. Configure Your MCP Client** Add to your client configuration (e.g., `.cursor/mcp.json`): ```json { "mcpServers": { "php-calculator": { "command": "php", "args": ["/absolute/path/to/your/mcp-server.php"] } } } ``` **4. Test the Server** Your AI assistant can now call: - `add_numbers` - Add two integers - `calculate_power` - Calculate power with validation constraints ## 🏗️ Architecture Overview The PHP MCP Server uses a modern, decoupled architecture: ``` ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ MCP Client │◄──►│ Transport │◄──►│ Protocol │ │ (Claude, etc.) │ │ (Stdio/HTTP/SSE) │ │ (JSON-RPC) │ └─────────────────┘ └──────────────────┘ └─────────────────┘ │ ┌─────────────────┐ │ │ Session Manager │◄──────────────┤ │ (Multi-backend) │ │ └─────────────────┘ │ │ ┌─────────────────┐ ┌──────────────────┐ │ │ Dispatcher │◄───│ Server Core │◄─────────────┤ │ (Method Router) │ │ Configuration │ │ └─────────────────┘ └──────────────────┘ │ │ │ ▼ │ ┌─────────────────┐ ┌──────────────────┐ │ │ Registry │ │ Elements │◄─────────────┘ │ (Element Store)│◄──►│ (Tools/Resources │ └─────────────────┘ │ Prompts/etc.) │ └──────────────────┘ ``` ### Core Components - **`ServerBuilder`**: Fluent configuration interface (`Server::make()->...->build()`) - **`Server`**: Central coordinator containing all configured components - **`Protocol`**: JSON-RPC 2.0 handler bridging transports and core logic - **`SessionManager`**: Multi-backend session storage (array, cache, custom) - **`Dispatcher`**: Method routing and request processing - **`Registry`**: Element storage with smart caching and precedence rules - **`Elements`**: Registered MCP components (Tools, Resources, Prompts, Templates) ### Transport Options 1. **`StdioServerTransport`**: Standard I/O for direct client launches 2. **`HttpServerTransport`**: HTTP + Server-Sent Events for web integration 3. **`StreamableHttpServerTransport`**: Enhanced HTTP with resumability and event sourcing ## ⚙️ Server Configuration ### Basic Configuration ```php use PhpMcp\Server\Server; use PhpMcp\Schema\ServerCapabilities; $server = Server::make() ->withServerInfo('My App Server', '2.1.0') ->withCapabilities(ServerCapabilities::make( resources: true, resourcesSubscribe: true, prompts: true, tools: true )) ->withPaginationLimit(100) ->build(); ``` ### Advanced Configuration with Dependencies ```php use Psr\Log\Logger; use Psr\SimpleCache\CacheInterface; use Psr\Container\ContainerInterface; $server = Server::make() ->withServerInfo('Production Server', '1.0.0') ->withLogger($myPsrLogger) // PSR-3 Logger ->withCache($myPsrCache) // PSR-16 Cache ->withContainer($myPsrContainer) // PSR-11 Container ->withSession('cache', 7200) // Cache-backed sessions, 2hr TTL ->withPaginationLimit(50) // Limit list responses ->build(); ``` ### Session Management Options ```php // In-memory sessions (default, not persistent) ->withSession('array', 3600) // Cache-backed sessions (persistent across restarts) ->withSession('cache', 7200) // Custom session handler (implement SessionHandlerInterface) ->withSessionHandler(new MyCustomSessionHandler(), 1800) ``` ## 🎯 Defining MCP Elements 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. ### Element Types - **🔧 Tools**: Executable functions/actions (e.g., `calculate`, `send_email`, `query_database`) - **📄 Resources**: Static content/data (e.g., `config://settings`, `file://readme.txt`) - **📋 Resource Templates**: Dynamic resources with URI patterns (e.g., `user://{id}/profile`) - **💬 Prompts**: Conversation starters/templates (e.g., `summarize`, `translate`) ### 1. 🏷️ Attribute-Based Discovery (Recommended) Use PHP 8 attributes to mark methods or invokable classes as MCP elements. The server will discover them via filesystem scanning. ```php use PhpMcp\Server\Attributes\{McpTool, McpResource, McpResourceTemplate, McpPrompt}; class UserManager { /** * Creates a new user account. */ #[McpTool(name: 'create_user')] public function createUser(string $email, string $password, string $role = 'user'): array { // Create user logic return ['id' => 123, 'email' => $email, 'role' => $role]; } /** * Get user configuration. */ #[McpResource( uri: 'config://user/settings', mimeType: 'application/json' )] public function getUserConfig(): array { return ['theme' => 'dark', 'notifications' => true]; } /** * Get user profile by ID. */ #[McpResourceTemplate( uriTemplate: 'user://{userId}/profile', mimeType: 'application/json' )] public function getUserProfile(string $userId): array { return ['id' => $userId, 'name' => 'John Doe']; } /** * Generate welcome message prompt. */ #[McpPrompt(name: 'welcome_user')] public function welcomeUserPrompt(string $username, string $role): array { return [ ['role' => 'user', 'content' => "Create a welcome message for {$username} with role {$role}"] ]; } } ``` **Discovery Process:** ```php // Build server first $server = Server::make() ->withServerInfo('My App Server', '1.0.0') ->build(); // Then discover elements $server->discover( basePath: __DIR__, scanDirs: ['src/Handlers', 'src/Services'], // Directories to scan excludeDirs: ['src/Tests'], // Directories to skip saveToCache: true // Cache results (default: true) ); ``` **Available Attributes:** - **`#[McpTool]`**: Executable actions - **`#[McpResource]`**: Static content accessible via URI - **`#[McpResourceTemplate]`**: Dynamic resources with URI templates - **`#[McpPrompt]`**: Conversation templates and prompt generators ### 2. 🔧 Manual Registration Register elements programmatically using the `ServerBuilder` before calling `build()`. Useful for dynamic registration, closures, or when you prefer explicit control. ```php use App\Handlers\{EmailHandler, ConfigHandler, UserHandler, PromptHandler}; use PhpMcp\Schema\{ToolAnnotations, Annotations}; $server = Server::make() ->withServerInfo('Manual Registration Server', '1.0.0') // Register a tool with handler method ->withTool( [EmailHandler::class, 'sendEmail'], // Handler: [class, method] name: 'send_email', // Tool name (optional) description: 'Send email to user', // Description (optional) annotations: ToolAnnotations::make( // Annotations (optional) title: 'Send Email Tool' ) ) // Register invokable class as tool ->withTool(UserHandler::class) // Handler: Invokable class // Register a closure as tool ->withTool( function(int $a, int $b): int { // Handler: Closure return $a + $b; }, name: 'add_numbers', description: 'Add two numbers together' ) // Register a resource with closure ->withResource( function(): array { // Handler: Closure return ['timestamp' => time(), 'server' => 'php-mcp']; }, uri: 'config://runtime/status', // URI (required) mimeType: 'application/json' // MIME type (optional) ) // Register a resource template ->withResourceTemplate( [UserHandler::class, 'getUserProfile'], uriTemplate: 'user://{userId}/profile' // URI template (required) ) // Register a prompt with closure ->withPrompt( function(string $topic, string $tone = 'professional'): array { return [ ['role' => 'user', 'content' => "Write about {$topic} in a {$tone} tone"] ]; }, name: 'writing_prompt' // Prompt name (optional) ) ->build(); ``` 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. > [!IMPORTANT] > 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: > > - Use the [`#[Schema]` attribute](#-schema-generation-and-validation) for enhanced schema generation > - Provide a custom `$inputSchema` parameter when registering tools with `->withTool()` ### 🏆 Element Precedence & Discovery **Precedence Rules:** - Manual registrations **always** override discovered/cached elements with the same identifier - Discovered elements are cached for performance (configurable) - Cache is automatically invalidated on fresh discovery runs **Discovery Process:** ```php $server->discover( basePath: __DIR__, scanDirs: ['src/Handlers', 'src/Services'], // Scan these directories excludeDirs: ['tests', 'vendor'], // Skip these directories force: false, // Force re-scan (default: false) saveToCache: true // Save to cache (default: true) ); ``` **Caching Behavior:** - Only **discovered** elements are cached (never manual registrations) - Cache loaded automatically during `build()` if available - Fresh `discover()` calls clear and rebuild cache - Use `force: true` to bypass discovery-already-ran check ## 🚀 Running the Server (Transports) The server core is transport-agnostic. Choose a transport based on your deployment needs: ### 1. 📟 Stdio Transport **Best for**: Direct client execution, command-line tools, simple deployments ```php use PhpMcp\Server\Transports\StdioServerTransport; $server = Server::make() ->withServerInfo('Stdio Server', '1.0.0') ->build(); $server->discover(__DIR__, ['src']); // Create stdio transport (uses STDIN/STDOUT by default) $transport = new StdioServerTransport(); // Start listening (blocking call) $server->listen($transport); ``` **Client Configuration:** ```json { "mcpServers": { "my-php-server": { "command": "php", "args": ["/absolute/path/to/server.php"] } } } ``` > ⚠️ **Important**: When using stdio transport, **never** write to `STDOUT` in your handlers (use `STDERR` for debugging). `STDOUT` is reserved for JSON-RPC communication. ### 2. 🌐 HTTP + Server-Sent Events Transport (Deprecated) > ⚠️ **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. **Best for**: Legacy applications requiring backwards compatibility ```php use PhpMcp\Server\Transports\HttpServerTransport; $server = Server::make() ->withServerInfo('HTTP Server', '1.0.0') ->withLogger($logger) // Recommended for HTTP ->build(); $server->discover(__DIR__, ['src']); // Create HTTP transport $transport = new HttpServerTransport( host: '127.0.0.1', // MCP protocol prohibits 0.0.0.0 port: 8080, // Port number mcpPathPrefix: 'mcp' // URL prefix (/mcp/sse, /mcp/message) ); $server->listen($transport); ``` **Client Configuration:** ```json { "mcpServers": { "my-http-server": { "url": "http://localhost:8080/mcp/sse" } } } ``` **Endpoints:** - **SSE Connection**: `GET /mcp/sse` - **Message Sending**: `POST /mcp/message?clientId={clientId}` ### 3. 🔄 Streamable HTTP Transport (Recommended) **Best for**: Production deployments, remote MCP servers, multiple clients, resumable connections ```php use PhpMcp\Server\Transports\StreamableHttpServerTransport; $server = Server::make() ->withServerInfo('Streamable Server', '1.0.0') ->withLogger($logger) ->withCache($cache) // Required for resumability ->build(); $server->discover(__DIR__, ['src']); // Create streamable transport with resumability $transport = new StreamableHttpServerTransport( host: '127.0.0.1', // MCP protocol prohibits 0.0.0.0 port: 8080, mcpPathPrefix: 'mcp', enableJsonResponse: false, // Use SSE streaming (default) stateless: false // Enable stateless mode for session-less clients ); $server->listen($transport); ``` **JSON Response Mode:** The `enableJsonResponse` option controls how responses are delivered: - **`false` (default)**: Uses Server-Sent Events (SSE) streams for responses. Best for tools that may take time to process. - **`true`**: Returns immediate JSON responses without opening SSE streams. Use this when your tools execute quickly and don't need streaming. ```php // For fast-executing tools, enable JSON mode $transport = new StreamableHttpServerTransport( host: '127.0.0.1', port: 8080, enableJsonResponse: true // Immediate JSON responses ); ``` **Stateless Mode:** For clients that have issues with session management, enable stateless mode: ```php $transport = new StreamableHttpServerTransport( host: '127.0.0.1', port: 8080, stateless: true // Each request is independent ); ``` In stateless mode, session IDs are generated internally but not exposed to clients, and each request is treated as independent without persistent session state. **Features:** - **Resumable connections** - clients can reconnect and replay missed events - **Event sourcing** - all events are stored for replay - **JSON mode** - optional JSON-only responses for fast tools - **Enhanced session management** - persistent session state - **Multiple client support** - designed for concurrent clients - **Stateless mode** - session-less operation for simple clients ## 📋 Schema Generation and Validation 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. ### Schema Generation Priority The server follows this order of precedence when generating schemas: 1. **`#[Schema]` attribute with `definition`** - Complete schema override (highest precedence) 2. **Parameter-level `#[Schema]` attribute** - Parameter-specific schema enhancements 3. **Method-level `#[Schema]` attribute** - Method-wide schema configuration 4. **PHP type hints + docblocks** - Automatic inference from code (lowest precedence) When a `definition` is provided in the Schema attribute, all automatic inference is bypassed and the complete definition is used as-is. ### Parameter-Level Schema Attributes ```php use PhpMcp\Server\Attributes\{McpTool, Schema}; #[McpTool(name: 'validate_user')] public function validateUser( #[Schema(format: 'email')] // PHP already knows it's string string $email, #[Schema( pattern: '^[A-Z][a-z]+$', description: 'Capitalized name' )] string $name, #[Schema(minimum: 18, maximum: 120)] // PHP already knows it's integer int $age ): bool { return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; } ``` ### Method-Level Schema ```php /** * Process user data with nested validation. */ #[McpTool(name: 'create_user')] #[Schema( properties: [ 'profile' => [ 'type' => 'object', 'properties' => [ 'name' => ['type' => 'string', 'minLength' => 2], 'age' => ['type' => 'integer', 'minimum' => 18], 'email' => ['type' => 'string', 'format' => 'email'] ], 'required' => ['name', 'email'] ] ], required: ['profile'] )] public function createUser(array $userData): array { // PHP type hint provides base 'array' type // Method-level Schema adds object structure validation return ['id' => 123, 'status' => 'created']; } ``` ### Complete Schema Override (Method-Level Only) ```php #[McpTool(name: 'process_api_request')] #[Schema(definition: [ 'type' => 'object', 'properties' => [ 'endpoint' => ['type' => 'string', 'format' => 'uri'], 'method' => ['type' => 'string', 'enum' => ['GET', 'POST', 'PUT', 'DELETE']], 'headers' => [ 'type' => 'object', 'patternProperties' => [ '^[A-Za-z0-9-]+$' => ['type' => 'string'] ] ] ], 'required' => ['endpoint', 'method'] ])] public function processApiRequest(string $endpoint, string $method, array $headers): array { // PHP type hints are completely ignored when definition is provided // The schema definition above takes full precedence return ['status' => 'processed', 'endpoint' => $endpoint]; } ``` > ⚠️ **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. ## 🎨 Return Value Formatting The server automatically formats return values from your handlers into appropriate MCP content types: ### Automatic Formatting ```php // Simple values are auto-wrapped in TextContent public function getString(): string { return "Hello World"; } // → TextContent public function getNumber(): int { return 42; } // → TextContent public function getBool(): bool { return true; } // → TextContent public function getArray(): array { return ['key' => 'value']; } // → TextContent (JSON) // Null handling public function getNull(): ?string { return null; } // → TextContent("(null)") public function returnVoid(): void { /* no return */ } // → Empty content ``` ### Advanced Content Types ```php use PhpMcp\Schema\Content\{TextContent, ImageContent, AudioContent, ResourceContent}; public function getFormattedCode(): TextContent { return TextContent::code('<?php echo "Hello";', 'php'); } public function getMarkdown(): TextContent { return TextContent::make('# Title\n\nContent here'); } public function getImage(): ImageContent { return ImageContent::make( data: base64_encode(file_get_contents('image.png')), mimeType: 'image/png' ); } public function getAudio(): AudioContent { return AudioContent::make( data: base64_encode(file_get_contents('audio.mp3')), mimeType: 'audio/mpeg' ); } ``` ### File and Stream Handling ```php // File objects are automatically read and formatted public function getFileContent(): \SplFileInfo { return new \SplFileInfo('/path/to/file.txt'); // Auto-detects MIME type } // Stream resources are read completely public function getStreamContent() { $stream = fopen('/path/to/data.json', 'r'); return $stream; // Will be read and closed automatically } // Structured resource responses public function getStructuredResource(): array { return [ 'text' => 'File content here', 'mimeType' => 'text/plain' ]; // Or for binary data: // return [ // 'blob' => base64_encode($binaryData), // 'mimeType' => 'application/octet-stream' // ]; } ``` ## 🔄 Batch Processing The server automatically handles JSON-RPC batch requests: ```php // Client can send multiple requests in a single HTTP call: [ {"jsonrpc": "2.0", "id": "1", "method": "tools/call", "params": {...}}, {"jsonrpc": "2.0", "method": "notifications/ping"}, // notification {"jsonrpc": "2.0", "id": "2", "method": "tools/call", "params": {...}} ] // Server returns batch response (excluding notifications): [ {"jsonrpc": "2.0", "id": "1", "result": {...}}, {"jsonrpc": "2.0", "id": "2", "result": {...}} ] ``` ## 🔧 Advanced Features ### Completion Providers 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. > **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. The `#[CompletionProvider]` attribute supports three types of completion sources: #### 1. Custom Provider Classes For complex completion logic, implement the `CompletionProviderInterface`: ```php use PhpMcp\Server\Contracts\CompletionProviderInterface; use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Attributes\{McpResourceTemplate, CompletionProvider}; class UserIdCompletionProvider implements CompletionProviderInterface { public function __construct(private DatabaseService $db) {} public function getCompletions(string $currentValue, SessionInterface $session): array { // Dynamic completion from database return $this->db->searchUsers($currentValue); } } class UserService { #[McpResourceTemplate(uriTemplate: 'user://{userId}/profile')] public function getUserProfile( #[CompletionProvider(provider: UserIdCompletionProvider::class)] // Class string - resolved from container string $userId ): array { return ['id' => $userId, 'name' => 'John Doe']; } } ``` You can also pass pre-configured provider instances: ```php class DocumentService { #[McpPrompt(name: 'document_prompt')] public function generatePrompt( #[CompletionProvider(provider: new UserIdCompletionProvider($database))] // Pre-configured instance string $userId, #[CompletionProvider(provider: $this->categoryProvider)] // Instance from property string $category ): array { return [['role' => 'user', 'content' => "Generate document for user {$userId} in {$category}"]]; } } ``` #### 2. Simple List Completions For static completion lists, use the `values` parameter: ```php use PhpMcp\Server\Attributes\{McpPrompt, CompletionProvider}; class ContentService { #[McpPrompt(name: 'content_generator')] public function generateContent( #[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide', 'documentation'])] string $contentType, #[CompletionProvider(values: ['beginner', 'intermediate', 'advanced', 'expert'])] string $difficulty ): array { return [['role' => 'user', 'content' => "Create a {$difficulty} level {$contentType}"]]; } } ``` #### 3. Enum-Based Completions For enum classes, use the `enum` parameter: ```php enum Priority: string { case LOW = 'low'; case MEDIUM = 'medium'; case HIGH = 'high'; case CRITICAL = 'critical'; } enum Status // Unit enum (no backing values) { case DRAFT; case PUBLISHED; case ARCHIVED; } class TaskService { #[McpTool(name: 'create_task')] public function createTask( string $title, #[CompletionProvider(enum: Priority::class)] // String-backed enum uses values string $priority, #[CompletionProvider(enum: Status::class)] // Unit enum uses case names string $status ): array { return ['id' => 123, 'title' => $title, 'priority' => $priority, 'status' => $status]; } } ``` #### Manual Registration with Completion Providers ```php $server = Server::make() ->withServerInfo('Completion Demo', '1.0.0') // Using provider class (resolved from container) ->withPrompt( [DocumentHandler::class, 'generateReport'], name: 'document_report' // Completion providers are auto-discovered from method attributes ) // Using closure with inline completion providers ->withPrompt( function( #[CompletionProvider(values: ['json', 'xml', 'csv', 'yaml'])] string $format, #[CompletionProvider(enum: Priority::class)] string $priority ): array { return [['role' => 'user', 'content' => "Export data in {$format} format with {$priority} priority"]]; }, name: 'export_data' ) ->build(); ``` #### Completion Provider Resolution The server automatically handles provider resolution: - **Class strings** (`MyProvider::class`) → Resolved from PSR-11 container with dependency injection - **Instances** (`new MyProvider()`) → Used directly as-is - **Values arrays** (`['a', 'b', 'c']`) → Automatically wrapped in `ListCompletionProvider` - **Enum classes** (`MyEnum::class`) → Automatically wrapped in `EnumCompletionProvider` > **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. ### Custom Dependency Injection 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. 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. ```php use Psr\Container\ContainerInterface; class DatabaseService { public function __construct(private \PDO $pdo) {} #[McpTool(name: 'query_users')] public function queryUsers(): array { $stmt = $this->pdo->query('SELECT * FROM users'); return $stmt->fetchAll(); } } // Option 1: Use the basic container and manually add dependencies $basicContainer = new \PhpMcp\Server\Defaults\BasicContainer(); $basicContainer->set(\PDO::class, new \PDO('sqlite::memory:')); // Option 2: Use any PSR-11 compatible container (PHP-DI, Laravel, etc.) $container = new \DI\Container(); $container->set(\PDO::class, new \PDO('mysql:host=localhost;dbname=app', $user, $pass)); $server = Server::make() ->withContainer($basicContainer) // Handlers get dependencies auto-injected ->build(); ``` ### Resource Subscriptions ```php use PhpMcp\Schema\ServerCapabilities; $server = Server::make() ->withCapabilities(ServerCapabilities::make( resourcesSubscribe: true, // Enable resource subscriptions prompts: true, tools: true )) ->build(); // In your resource handler, you can notify clients of changes: #[McpResource(uri: 'file://config.json')] public function getConfig(): array { // When config changes, notify subscribers $this->notifyResourceChange('file://config.json'); return ['setting' => 'value']; } ``` ### Resumability and Event Store For production deployments using `StreamableHttpServerTransport`, you can implement resumability with event sourcing by providing a custom event store: ```php use PhpMcp\Server\Contracts\EventStoreInterface; use PhpMcp\Server\Defaults\InMemoryEventStore; use PhpMcp\Server\Transports\StreamableHttpServerTransport; // Use the built-in in-memory event store (for development/testing) $eventStore = new InMemoryEventStore(); // Or implement your own persistent event store class DatabaseEventStore implements EventStoreInterface { public function storeEvent(string $streamId, string $message): string { // Store event in database and return unique event ID return $this->database->insert('events', [ 'stream_id' => $streamId, 'message' => $message, 'created_at' => now() ]); } public function replayEventsAfter(string $lastEventId, callable $sendCallback): void { // Replay events for resumability $events = $this->database->getEventsAfter($lastEventId); foreach ($events as $event) { $sendCallback($event['id'], $event['message']); } } } // Configure transport with event store $transport = new StreamableHttpServerTransport( host: '127.0.0.1', port: 8080, eventStore: new DatabaseEventStore() // Enable resumability ); ``` ### Custom Session Handlers Implement custom session storage by creating a class that implements `SessionHandlerInterface`: ```php use PhpMcp\Server\Contracts\SessionHandlerInterface; class DatabaseSessionHandler implements SessionHandlerInterface { public function __construct(private \PDO $db) {} public function read(string $id): string|false { $stmt = $this->db->prepare('SELECT data FROM sessions WHERE id = ?'); $stmt->execute([$id]); $session = $stmt->fetch(\PDO::FETCH_ASSOC); return $session ? $session['data'] : false; } public function write(string $id, string $data): bool { $stmt = $this->db->prepare( 'INSERT OR REPLACE INTO sessions (id, data, updated_at) VALUES (?, ?, ?)' ); return $stmt->execute([$id, $data, time()]); } public function destroy(string $id): bool { $stmt = $this->db->prepare('DELETE FROM sessions WHERE id = ?'); return $stmt->execute([$id]); } public function gc(int $maxLifetime): array { $cutoff = time() - $maxLifetime; $stmt = $this->db->prepare('DELETE FROM sessions WHERE updated_at < ?'); $stmt->execute([$cutoff]); return []; // Return array of cleaned session IDs if needed } } // Use custom session handler $server = Server::make() ->withSessionHandler(new DatabaseSessionHandler(), 3600) ->build(); ``` ### Middleware Support 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. Middleware must be a valid PHP callable that accepts a PSR-7 `ServerRequestInterface` as the first argument and a `callable` as the second argument. ```php use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use React\Promise\PromiseInterface; class AuthMiddleware { public function __invoke(ServerRequestInterface $request, callable $next) { $apiKey = $request->getHeaderLine('Authorization'); if (empty($apiKey)) { return new Response(401, [], 'Authorization required'); } $request = $request->withAttribute('user_id', $this->validateApiKey($apiKey)); $result = $next($request); return match (true) { $result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)), $result instanceof ResponseInterface => $this->handle($result), default => $result }; } private function handle($response) { return $response instanceof ResponseInterface ? $response->withHeader('X-Auth-Provider', 'mcp-server') : $response; } } $middlewares = [ new AuthMiddleware(), new LoggingMiddleware(), function(ServerRequestInterface $request, callable $next) { $result = $next($request); return match (true) { $result instanceof PromiseInterface => $result->then(function($response) { return $response instanceof ResponseInterface ? $response->withHeader('Access-Control-Allow-Origin', '*') : $response; }), $result instanceof ResponseInterface => $result->withHeader('Access-Control-Allow-Origin', '*'), default => $result }; } ]; $transport = new StreamableHttpServerTransport( host: '127.0.0.1', port: 8080, middlewares: $middlewares ); ``` **Important Considerations:** - **Response Handling**: Middleware must handle both synchronous `ResponseInterface` and asynchronous `PromiseInterface` returns from `$next($request)`, since ReactPHP operates asynchronously - **Invokable Pattern**: The recommended pattern is to use invokable classes with a separate `handle()` method to process responses, making the async logic reusable - **Execution Order**: Middleware executes in the order provided, with the last middleware being closest to your MCP handlers ### SSL Context Configuration For HTTPS deployments of `StreamableHttpServerTransport`, configure SSL context options: ```php $sslContext = [ 'ssl' => [ 'local_cert' => '/path/to/certificate.pem', 'local_pk' => '/path/to/private-key.pem', 'verify_peer' => false, 'allow_self_signed' => true, ] ]; $transport = new StreamableHttpServerTransport( host: '0.0.0.0', port: 8443, sslContext: $sslContext ); ``` > **SSL Context Reference**: For complete SSL context options, see the [PHP SSL Context Options documentation](https://www.php.net/manual/en/context.ssl.php). ## 🔍 Error Handling & Debugging The server provides comprehensive error handling and debugging capabilities: ### Exception Handling 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. ```php #[McpTool(name: 'divide_numbers')] public function divideNumbers(float $dividend, float $divisor): float { if ($divisor === 0.0) { // Any exception with descriptive message will be sent to client throw new \InvalidArgumentException('Division by zero is not allowed'); } return $dividend / $divisor; } #[McpTool(name: 'calculate_factorial')] public function calculateFactorial(int $number): int { if ($number < 0) { throw new \InvalidArgumentException('Factorial is not defined for negative numbers'); } if ($number > 20) { throw new \OverflowException('Number too large, factorial would cause overflow'); } // Implementation continues... return $this->factorial($number); } ``` The server will convert these exceptions into appropriate JSON-RPC error responses that MCP clients can understand and display to users. ### Logging and Debugging ```php use Psr\Log\LoggerInterface; class DebugAwareHandler { public function __construct(private LoggerInterface $logger) {} #[McpTool(name: 'debug_tool')] public function debugTool(string $data): array { $this->logger->info('Processing debug tool', ['input' => $data]); // For stdio transport, use STDERR for debug output fwrite(STDERR, "Debug: Processing data length: " . strlen($data) . "\n"); return ['processed' => true]; } } ``` ## 🚀 Production Deployment 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. Here are two popular deployment approaches to consider: ### Option 1: VPS with Supervisor + Nginx (Recommended) **Best for**: Most production deployments, cost-effective, full control ```bash # 1. Install your application on VPS git clone https://github.com/yourorg/your-mcp-server.git /var/www/mcp-server cd /var/www/mcp-server composer install --no-dev --optimize-autoloader # 2. Install Supervisor sudo apt-get install supervisor # 3. Create Supervisor configuration sudo nano /etc/supervisor/conf.d/mcp-server.conf ``` **Supervisor Configuration:** ```ini [program:mcp-server] process_name=%(program_name)s_%(process_num)02d command=php /var/www/mcp-server/server.php --transport=http --host=127.0.0.1 --port=8080 autostart=true autorestart=true stopasgroup=true killasgroup=true user=www-data numprocs=1 redirect_stderr=true stdout_logfile=/var/log/mcp-server.log stdout_logfile_maxbytes=10MB stdout_logfile_backups=3 ``` **Nginx Configuration with SSL:** ```nginx # /etc/nginx/sites-available/mcp-server server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name mcp.yourdomain.com; # SSL configuration ssl_certificate /etc/letsencrypt/live/mcp.yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mcp.yourdomain.com/privkey.pem; # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; # MCP Server proxy location / { proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # Important for SSE connections proxy_buffering off; proxy_cache off; proxy_pass http://127.0.0.1:8080/; } } # Redirect HTTP to HTTPS server { listen 80; listen [::]:80; server_name mcp.yourdomain.com; return 301 https://$server_name$request_uri; } ``` **Start Services:** ```bash # Enable and start supervisor sudo supervisorctl reread sudo supervisorctl update sudo supervisorctl start mcp-server:* # Enable and start nginx sudo systemctl enable nginx sudo systemctl restart nginx # Check status sudo supervisorctl status ``` **Client Configuration:** ```json { "mcpServers": { "my-server": { "url": "https://mcp.yourdomain.com/mcp" } } } ``` ### Option 2: Docker Deployment **Best for**: Containerized environments, Kubernetes, cloud platforms **Production Dockerfile:** ```dockerfile FROM php:8.3-fpm-alpine # Install system dependencies RUN apk --no-cache add \ nginx \ supervisor \ && docker-php-ext-enable opcache # Install PHP extensions for MCP RUN docker-php-ext-install pdo_mysql pdo_sqlite opcache # Create application directory WORKDIR /var/www/mcp # Copy application code COPY . /var/www/mcp COPY docker/nginx.conf /etc/nginx/nginx.conf COPY docker/supervisord.conf /etc/supervisord.conf COPY docker/php.ini /usr/local/etc/php/conf.d/production.ini # Install Composer dependencies RUN composer install --no-dev --optimize-autoloader --no-interaction # Set permissions RUN chown -R www-data:www-data /var/www/mcp # Expose port EXPOSE 80 # Start supervisor CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] ``` **docker-compose.yml:** ```yaml services: mcp-server: build: . ports: - "8080:80" environment: - MCP_ENV=production - MCP_LOG_LEVEL=info volumes: - ./storage:/var/www/mcp/storage restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost/health"] interval: 30s timeout: 10s retries: 3 # Optional: Add database if needed database: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: secure_password MYSQL_DATABASE: mcp_server volumes: - mysql_data:/var/lib/mysql restart: unless-stopped volumes: mysql_data: ``` ### Security Best Practices 1. **Firewall Configuration:** ```bash # Only allow necessary ports sudo ufw allow ssh sudo ufw allow 80 sudo ufw allow 443 sudo ufw deny 8080 # MCP port should not be publicly accessible sudo ufw enable ``` 2. **SSL/TLS Setup:** ```bash # Install Certbot for Let's Encrypt sudo apt install certbot python3-certbot-nginx # Generate SSL certificate sudo certbot --nginx -d mcp.yourdomain.com ``` ## 📚 Examples & Use Cases Explore comprehensive examples in the [`examples/`](./examples/) directory: ### Available Examples - **`01-discovery-stdio-calculator/`** - Basic stdio calculator with attribute discovery - **`02-discovery-http-userprofile/`** - HTTP server with user profile management - **`03-manual-registration-stdio/`** - Manual element registration patterns - **`04-combined-registration-http/`** - Combining manual and discovered elements - **`05-stdio-env-variables/`** - Environment variable handling - **`06-custom-dependencies-stdio/`** - Dependency injection with task management - **`07-complex-tool-schema-http/`** - Advanced schema validation examples - **`08-schema-showcase-streamable/`** - Comprehensive schema feature showcase ### Running Examples ```bash # Navigate to an example directory cd examples/01-discovery-stdio-calculator/ # Make the server executable chmod +x server.php # Run the server (or configure it in your MCP client) ./server.php ``` ## 🚧 Migration from v2.x If migrating from version 2.x, note these key changes: ### Schema Updates - Uses `php-mcp/schema` package for DTOs instead of internal classes - Content types moved to `PhpMcp\Schema\Content\*` namespace - Updated method signatures for better type safety ### Session Management - New session management with multiple backends - Use `->withSession()` or `->withSessionHandler()` for configuration - Sessions are now persistent across reconnections (with cache backend) ### Transport Changes - New `StreamableHttpServerTransport` with resumability - Enhanced error handling and event sourcing - Better batch request processing ## 🧪 Testing ```bash # Install development dependencies composer install --dev # Run the test suite composer test # Run tests with coverage (requires Xdebug) composer test:coverage # Run code style checks composer lint ``` ## 🤝 Contributing We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. ## 📄 License The MIT License (MIT). See [LICENSE](LICENSE) for details. ## 🙏 Acknowledgments - Built on the [Model Context Protocol](https://modelcontextprotocol.io/) specification - Powered by [ReactPHP](https://reactphp.org/) for async operations - Uses [PSR standards](https://www.php-fig.org/) for maximum interoperability ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- ```markdown # Contributing to php-mcp/server 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. 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. ## How Can I Contribute? There are several ways you can contribute: * **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. * **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. * **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. * **Writing Code:** Submit pull requests to fix bugs or add new features. ## Development Setup 1. **Fork the repository:** Click the "Fork" button on the [php-mcp/server GitHub page](https://github.com/php-mcp/server). 2. **Clone your fork:** `git clone [email protected]:YOUR_USERNAME/server.git` 3. **Navigate into the directory:** `cd server` 4. **Install dependencies:** `composer install` (This installs runtime and development dependencies). ## Submitting Changes (Pull Requests) 1. **Create a new branch:** `git checkout -b feature/your-feature-name` or `git checkout -b fix/issue-number`. 2. **Make your changes:** Write your code and accompanying tests. 3. **Ensure Code Style:** Run the code style fixer (if configured, e.g., PHP CS Fixer): ```bash composer lint # Or ./vendor/bin/php-cs-fixer fix ``` Adhere to PSR-12 coding standards. 4. **Run Tests:** Ensure all tests pass: ```bash composer test # Or ./vendor/bin/pest ``` Consider adding new tests for your changes. Aim for good test coverage. 5. **Update Documentation:** If your changes affect the public API or usage, update the `README.md` and relevant PHPDoc blocks. 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"` 7. **Push to your fork:** `git push origin feature/your-feature-name` 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). 9. **Describe your changes:** Provide a clear description of the problem and solution in the pull request. Link to any relevant issues (`Closes #123`). ## Coding Standards * Follow **PSR-12** coding standards. * Use **strict types:** `declare(strict_types=1);` at the top of PHP files. * Use **PHP 8.1+ features** where appropriate (readonly properties, enums, etc.). * Add **PHPDoc blocks** for all public classes, methods, and properties. * Write clear and concise code. Add comments only where necessary to explain complex logic. ## Reporting Issues * Use the GitHub issue tracker. * Check if the issue already exists. * Provide a clear title and description. * Include steps to reproduce the issue, code examples, error messages, and stack traces if applicable. * Specify relevant environment details (PHP version, OS, package version). Thank you for contributing! ``` -------------------------------------------------------------------------------- /tests/Fixtures/Enums/UnitEnum.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Fixtures\Enums; enum UnitEnum { case Yes; case No; } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Enums/BackedIntEnum.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Fixtures\Enums; enum BackedIntEnum: int { case First = 1; case Second = 2; } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Enums/BackedStringEnum.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Fixtures\Enums; enum BackedStringEnum: string { case OptionA = 'A'; case OptionB = 'B'; } ``` -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests; use PHPUnit\Framework\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { // } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Enums/PriorityEnum.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Enums; enum PriorityEnum: int { case LOW = 1; case MEDIUM = 2; case HIGH = 3; } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Enums/StatusEnum.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Enums; enum StatusEnum: string { case DRAFT = 'draft'; case PUBLISHED = 'published'; case ARCHIVED = 'archived'; } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/SubDir/HiddenTool.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Discovery\SubDir; use PhpMcp\Server\Attributes\McpTool; class HiddenTool { #[McpTool(name: 'hidden_subdir_tool')] public function run() { } } ``` -------------------------------------------------------------------------------- /src/Exception/DiscoveryException.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Exception; /** * Exception related to errors during the attribute discovery process. */ class DiscoveryException extends McpServerException { // No specific JSON-RPC code, internal server issue. } ``` -------------------------------------------------------------------------------- /src/Defaults/SystemClock.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Defaults; use DateTimeImmutable; use Psr\Clock\ClockInterface; class SystemClock implements ClockInterface { public function now(): \DateTimeImmutable { return new \DateTimeImmutable(); } } ``` -------------------------------------------------------------------------------- /examples/07-complex-tool-schema-http/EventTypes.php: -------------------------------------------------------------------------------- ```php <?php namespace Mcp\ComplexSchemaHttpExample\Model; enum EventType: string { case Meeting = 'meeting'; case Reminder = 'reminder'; case Call = 'call'; case Other = 'other'; } enum EventPriority: int { case Low = 0; case Normal = 1; case High = 2; } ``` -------------------------------------------------------------------------------- /src/Context.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types = 1); namespace PhpMcp\Server; use PhpMcp\Server\Contracts\SessionInterface; use Psr\Http\Message\ServerRequestInterface; final class Context { public function __construct( public readonly SessionInterface $session, public readonly ?ServerRequestInterface $request = null, ) { } } ``` -------------------------------------------------------------------------------- /src/Exception/ConfigurationException.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Exception; /** * Exception related to invalid server configuration. * * Typically thrown during ServerBuilder::build(). */ class ConfigurationException extends McpServerException { // No specific JSON-RPC code, usually an internal setup issue. // Code 0 is appropriate. } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/InvocableResourceFixture.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Discovery; use PhpMcp\Server\Attributes\McpResource; #[McpResource(uri: "invokable://config/status", name: "invokable_app_status")] class InvocableResourceFixture { public function __invoke(): array { return ["status" => "OK", "load" => rand(1, 100) / 100.0]; } } ``` -------------------------------------------------------------------------------- /src/Contracts/LoggerAwareInterface.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Contracts; use Psr\Log\LoggerInterface; /** * Interface for components that can accept a PSR-3 Logger instance. * * Primarily used for injecting the configured logger into transport implementations. */ interface LoggerAwareInterface { public function setLogger(LoggerInterface $logger): void; } ``` -------------------------------------------------------------------------------- /src/Contracts/LoopAwareInterface.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Contracts; use React\EventLoop\LoopInterface; /** * Interface for components that require a ReactPHP event loop instance. * * Primarily used for injecting the configured loop into transport implementations. */ interface LoopAwareInterface { public function setLoop(LoopInterface $loop): void; } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/NonDiscoverableClass.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Discovery; class NonDiscoverableClass { public function someMethod(): string { return "Just a regular method."; } } interface MyDiscoverableInterface { } trait MyDiscoverableTrait { public function traitMethod() { } } enum MyDiscoverableEnum { case Alpha; } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Middlewares/RequestAttributeMiddleware.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Middlewares; use Psr\Http\Message\ServerRequestInterface; class RequestAttributeMiddleware { public function __invoke(ServerRequestInterface $request, callable $next) { $request = $request->withAttribute('middleware-attr', 'middleware-value'); return $next($request); } } ``` -------------------------------------------------------------------------------- /src/Defaults/DefaultUuidSessionIdGenerator.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Defaults; use PhpMcp\Server\Contracts\SessionIdGeneratorInterface; class DefaultUuidSessionIdGenerator implements SessionIdGeneratorInterface { public function generateId(): string { return bin2hex(random_bytes(16)); } public function onSessionInitialized(string $sessionId): void { // no-op } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Middlewares/ErrorMiddleware.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Middlewares; use Psr\Http\Message\ServerRequestInterface; class ErrorMiddleware { public function __invoke(ServerRequestInterface $request, callable $next) { if (str_contains($request->getUri()->getPath(), '/error-middleware')) { throw new \Exception('Middleware error'); } return $next($request); } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/InvocablePromptFixture.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Discovery; use PhpMcp\Server\Attributes\McpPrompt; #[McpPrompt(name: "InvokableGreeterPrompt")] class InvocablePromptFixture { /** * @param string $personName * @return array */ public function __invoke(string $personName): array { return [['role' => 'user', 'content' => "Generate a short greeting for {$personName}."]]; } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/General/InvokableHandlerFixture.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Fixtures\General; class InvokableHandlerFixture { public string $type; public array $argsReceived; public function __construct(string $type = "default") { $this->type = $type; } public function __invoke(string $arg1, int $arg2 = 0): array { $this->argsReceived = func_get_args(); return ['invoked' => $this->type, 'arg1' => $arg1, 'arg2' => $arg2]; } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/InvocableResourceTemplateFixture.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Discovery; use PhpMcp\Server\Attributes\McpResourceTemplate; #[McpResourceTemplate(uriTemplate: "invokable://user-profile/{userId}")] class InvocableResourceTemplateFixture { /** * @param string $userId * @return array */ public function __invoke(string $userId): array { return ["id" => $userId, "email" => "user{$userId}@example-invokable.com"]; } } ``` -------------------------------------------------------------------------------- /src/Contracts/CompletionProviderInterface.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Contracts; interface CompletionProviderInterface { /** * Get completions for a given current value. * * @param string $currentValue The current value to get completions for. * @param SessionInterface $session The session to get completions for. * @return array The completions. */ public function getCompletions(string $currentValue, SessionInterface $session): array; } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Middlewares/ShortCircuitMiddleware.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Middlewares; use Psr\Http\Message\ServerRequestInterface; use React\Http\Message\Response; class ShortCircuitMiddleware { public function __invoke(ServerRequestInterface $request, callable $next) { if (str_contains($request->getUri()->getPath(), '/short-circuit')) { return new Response(418, [], 'Short-circuited by middleware'); } return $next($request); } } ``` -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- ``` <?xml version="1.0" encoding="UTF-8"?> <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true" > <testsuites> <testsuite name="Test Suite"> <directory suffix="Test.php">./tests</directory> </testsuite> </testsuites> <source> <include> <directory>src</directory> </include> </source> </phpunit> ``` -------------------------------------------------------------------------------- /tests/Fixtures/General/RequestAttributeChecker.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\General; use PhpMcp\Schema\Content\TextContent; use PhpMcp\Server\Context; class RequestAttributeChecker { public function checkAttribute(Context $context): TextContent { $attribute = $context->request->getAttribute('middleware-attr'); if ($attribute === 'middleware-value') { return TextContent::make('middleware-value-found: ' . $attribute); } return TextContent::make('middleware-value-not-found: ' . $attribute); } } ``` -------------------------------------------------------------------------------- /examples/02-discovery-http-userprofile/UserIdCompletionProvider.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace Mcp\HttpUserProfileExample; use PhpMcp\Server\Contracts\CompletionProviderInterface; use PhpMcp\Server\Contracts\SessionInterface; class UserIdCompletionProvider implements CompletionProviderInterface { public function getCompletions(string $currentValue, SessionInterface $session): array { $availableUserIds = ['101', '102', '103']; $filteredUserIds = array_filter($availableUserIds, fn(string $userId) => str_contains($userId, $currentValue)); return $filteredUserIds; } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/InvocableToolFixture.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Discovery; use PhpMcp\Server\Attributes\McpTool; use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpPrompt; use PhpMcp\Server\Attributes\McpResourceTemplate; #[McpTool(name: "InvokableCalculator", description: "An invokable calculator tool.")] class InvocableToolFixture { /** * Adds two numbers. * @param int $a * @param int $b * @return int */ public function __invoke(int $a, int $b): int { return $a + $b; } } ``` -------------------------------------------------------------------------------- /src/Exception/TransportException.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Exception; use PhpMcp\Schema\JsonRpc\Error as JsonRpcError; /** * Exception related to errors in the underlying transport layer * (e.g., socket errors, process management issues, SSE stream errors). */ class TransportException extends McpServerException { public function toJsonRpcError(string|int $id): JsonRpcError { return new JsonRpcError( jsonrpc: '2.0', id: $id, code: JsonRpcError::CODE_INTERNAL_ERROR, message: 'Transport layer error: ' . $this->getMessage(), data: null ); } } ``` -------------------------------------------------------------------------------- /src/Defaults/ListCompletionProvider.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Defaults; use PhpMcp\Server\Contracts\CompletionProviderInterface; use PhpMcp\Server\Contracts\SessionInterface; class ListCompletionProvider implements CompletionProviderInterface { public function __construct(private array $values) {} public function getCompletions(string $currentValue, SessionInterface $session): array { if (empty($currentValue)) { return $this->values; } return array_values(array_filter( $this->values, fn(string $value) => str_starts_with($value, $currentValue) )); } } ``` -------------------------------------------------------------------------------- /src/Attributes/McpPrompt.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Attributes; use Attribute; /** * Marks a PHP method as an MCP Prompt generator. * The method should return the prompt messages, potentially using arguments for templating. */ #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] final class McpPrompt { /** * @param ?string $name Overrides the prompt name (defaults to method name). * @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary. */ public function __construct( public ?string $name = null, public ?string $description = null, ) { } } ``` -------------------------------------------------------------------------------- /src/Attributes/McpTool.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Attributes; use Attribute; use PhpMcp\Schema\ToolAnnotations; #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class McpTool { /** * @param string|null $name The name of the tool (defaults to the method name) * @param string|null $description The description of the tool (defaults to the DocBlock/inferred) * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior */ public function __construct( public ?string $name = null, public ?string $description = null, public ?ToolAnnotations $annotations = null, ) { } } ``` -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- ```yaml name: "Update Changelog" on: release: types: [released] permissions: contents: write jobs: update: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 with: ref: main - name: Update Changelog uses: stefanzweifel/changelog-updater-action@v1 with: latest-version: ${{ github.event.release.name }} release-notes: ${{ github.event.release.body }} - name: Commit updated CHANGELOG uses: stefanzweifel/git-auto-commit-action@v5 with: branch: main commit_message: Update CHANGELOG file_pattern: CHANGELOG.md ``` -------------------------------------------------------------------------------- /tests/Fixtures/General/CompletionProviderFixture.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Fixtures\General; use PhpMcp\Server\Contracts\CompletionProviderInterface; use PhpMcp\Server\Contracts\SessionInterface; class CompletionProviderFixture implements CompletionProviderInterface { public static array $completions = ['alpha', 'beta', 'gamma']; public static string $lastCurrentValue = ''; public static ?SessionInterface $lastSession = null; public function getCompletions(string $currentValue, SessionInterface $session): array { self::$lastCurrentValue = $currentValue; self::$lastSession = $session; return array_filter(self::$completions, fn ($item) => str_starts_with($item, $currentValue)); } } ``` -------------------------------------------------------------------------------- /src/Exception/ProtocolException.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Exception; use PhpMcp\Schema\JsonRpc\Error as JsonRpcError; /** * Exception related to violations of the JSON-RPC 2.0 or MCP structure * in incoming messages or outgoing responses (e.g., missing required fields, * invalid types within the protocol itself). */ class ProtocolException extends McpServerException { public function toJsonRpcError(string|int $id): JsonRpcError { $code = ($this->code >= -32700 && $this->code <= -32600) ? $this->code : self::CODE_INVALID_REQUEST; return new JsonRpcError( jsonrpc: '2.0', id: $id, code: $code, message: $this->getMessage(), data: $this->getData() ); } } ``` -------------------------------------------------------------------------------- /examples/04-combined-registration-http/DiscoveredElements.php: -------------------------------------------------------------------------------- ```php <?php namespace Mcp\CombinedHttpExample\Discovered; use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpTool; class DiscoveredElements { /** * A tool discovered via attributes. * * @return string A status message. */ #[McpTool(name: 'discovered_status_check')] public function checkSystemStatus(): string { return 'System status: OK (discovered)'; } /** * A resource discovered via attributes. * This will be overridden by a manual registration with the same URI. * * @return string Content. */ #[McpResource(uri: 'config://priority', name: 'priority_config_discovered')] public function getPriorityConfigDiscovered(): string { return 'Discovered Priority Config: Low'; } } ``` -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- ```yaml name: Tests on: ['push', 'pull_request'] jobs: ci: runs-on: ubuntu-latest strategy: fail-fast: false matrix: php: [8.1, 8.2, 8.3, 8.4] max-parallel: 2 name: Tests PHP${{ matrix.php }} steps: - name: Checkout uses: actions/checkout@v4 - name: Cache dependencies uses: actions/cache@v4 with: path: ~/.composer/cache/files key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: none - name: Install Composer dependencies run: composer update --no-interaction --prefer-dist - name: Run Tests run: composer test ``` -------------------------------------------------------------------------------- /tests/Fixtures/Middlewares/HeaderMiddleware.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Middlewares; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use React\Promise\PromiseInterface; class HeaderMiddleware { public function __invoke(ServerRequestInterface $request, callable $next) { $result = $next($request); return match (true) { $result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)), $result instanceof ResponseInterface => $this->handle($result), default => $result }; } private function handle($response) { return $response instanceof ResponseInterface ? $response->withHeader('X-Test-Middleware', 'header-added') : $response; } } ``` -------------------------------------------------------------------------------- /src/Attributes/CompletionProvider.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Attributes; use Attribute; use PhpMcp\Server\Contracts\CompletionProviderInterface; #[Attribute(Attribute::TARGET_PARAMETER)] class CompletionProvider { /** * @param class-string<CompletionProviderInterface>|null $providerClass * @param class-string<CompletionProviderInterface>|CompletionProviderInterface|null $provider If a class-string, it will be resolved from the container at the point of use. */ public function __construct( public ?string $providerClass = null, public string|CompletionProviderInterface|null $provider = null, public ?array $values = null, public ?string $enum = null, ) { if (count(array_filter([$provider, $values, $enum])) !== 1) { throw new \InvalidArgumentException('Only one of provider, values, or enum can be set'); } } } ``` -------------------------------------------------------------------------------- /examples/04-combined-registration-http/ManualHandlers.php: -------------------------------------------------------------------------------- ```php <?php namespace Mcp\CombinedHttpExample\Manual; use Psr\Log\LoggerInterface; class ManualHandlers { private LoggerInterface $logger; public function __construct(LoggerInterface $logger) { $this->logger = $logger; } /** * A manually registered tool. * * @param string $user The user to greet. * @return string Greeting. */ public function manualGreeter(string $user): string { $this->logger->info("Manual tool 'manual_greeter' called for {$user}"); return "Hello {$user}, from manual registration!"; } /** * Manually registered resource that overrides a discovered one. * * @return string Content. */ public function getPriorityConfigManual(): string { $this->logger->info("Manual resource 'config://priority' read."); return 'Manual Priority Config: HIGH (overrides discovered)'; } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/DiscoverableResourceHandler.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Discovery; use PhpMcp\Schema\Annotations; use PhpMcp\Server\Attributes\McpResource; class DiscoverableResourceHandler { /** * Provides the application's current version. * @return string The version string. */ #[McpResource( uri: "app://info/version", name: "app_version", description: "The current version of the application.", mimeType: "text/plain", size: 10 )] public function getAppVersion(): string { return "1.2.3-discovered"; } #[McpResource( uri: "config://settings/ui", name: "ui_settings_discovered", mimeType: "application/json", annotations: new Annotations(priority: 0.5) )] public function getUiSettings(): array { return ["theme" => "dark", "fontSize" => 14]; } public function someOtherMethod(): void { } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Middlewares/FirstMiddleware.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Middlewares; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use React\Promise\PromiseInterface; class FirstMiddleware { public function __invoke(ServerRequestInterface $request, callable $next) { $result = $next($request); return match (true) { $result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)), $result instanceof ResponseInterface => $this->handle($result), default => $result }; } private function handle($response) { if ($response instanceof ResponseInterface) { $existing = $response->getHeaderLine('X-Middleware-Order'); $new = $existing ? $existing . ',first' : 'first'; return $response->withHeader('X-Middleware-Order', $new); } return $response; } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Middlewares/ThirdMiddleware.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Middlewares; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use React\Promise\PromiseInterface; class ThirdMiddleware { public function __invoke(ServerRequestInterface $request, callable $next) { $result = $next($request); return match (true) { $result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)), $result instanceof ResponseInterface => $this->handle($result), default => $result }; } private function handle($response) { if ($response instanceof ResponseInterface) { $existing = $response->getHeaderLine('X-Middleware-Order'); $new = $existing ? $existing . ',third' : 'third'; return $response->withHeader('X-Middleware-Order', $new); } return $response; } } ``` -------------------------------------------------------------------------------- /src/Contracts/SessionHandlerInterface.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Contracts; interface SessionHandlerInterface { /** * Read session data * * Returns an encoded string of the read data. * If nothing was read, it must return false. * @param string $id The session id to read data for. */ public function read(string $id): string|false; /** * Write session data * @param string $id The session id. * @param string $data The encoded session data. */ public function write(string $id, string $data): bool; /** * Destroy a session * @param string $id The session ID being destroyed. * The return value (usually TRUE on success, FALSE on failure). */ public function destroy(string $id): bool; /** * Cleanup old sessions * Sessions that have not updated for * the last maxlifetime seconds will be removed. */ public function gc(int $maxLifetime): array; } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Middlewares/SecondMiddleware.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Middlewares; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use React\Promise\PromiseInterface; class SecondMiddleware { public function __invoke(ServerRequestInterface $request, callable $next) { $result = $next($request); return match (true) { $result instanceof PromiseInterface => $result->then(fn($response) => $this->handle($response)), $result instanceof ResponseInterface => $this->handle($result), default => $result }; } private function handle($response) { if ($response instanceof ResponseInterface) { $existing = $response->getHeaderLine('X-Middleware-Order'); $new = $existing ? $existing . ',second' : 'second'; return $response->withHeader('X-Middleware-Order', $new); } return $response; } } ``` -------------------------------------------------------------------------------- /tests/Unit/Attributes/McpToolTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Attributes; use PhpMcp\Server\Attributes\McpTool; it('instantiates with correct properties', function () { // Arrange $name = 'test-tool-name'; $description = 'This is a test description.'; // Act $attribute = new McpTool(name: $name, description: $description); // Assert expect($attribute->name)->toBe($name); expect($attribute->description)->toBe($description); }); it('instantiates with null values for name and description', function () { // Arrange & Act $attribute = new McpTool(name: null, description: null); // Assert expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); }); it('instantiates with missing optional arguments', function () { // Arrange & Act $attribute = new McpTool(); // Use default constructor values // Assert expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); }); ``` -------------------------------------------------------------------------------- /src/Defaults/EnumCompletionProvider.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Defaults; use PhpMcp\Server\Contracts\CompletionProviderInterface; use PhpMcp\Server\Contracts\SessionInterface; class EnumCompletionProvider implements CompletionProviderInterface { private array $values; public function __construct(string $enumClass) { if (!enum_exists($enumClass)) { throw new \InvalidArgumentException("Class {$enumClass} is not an enum"); } $this->values = array_map( fn($case) => isset($case->value) && is_string($case->value) ? $case->value : $case->name, $enumClass::cases() ); } public function getCompletions(string $currentValue, SessionInterface $session): array { if (empty($currentValue)) { return $this->values; } return array_values(array_filter( $this->values, fn(string $value) => str_starts_with($value, $currentValue) )); } } ``` -------------------------------------------------------------------------------- /tests/Unit/Attributes/McpPromptTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Attributes; use PhpMcp\Server\Attributes\McpPrompt; it('instantiates with name and description', function () { // Arrange $name = 'test-prompt-name'; $description = 'This is a test prompt description.'; // Act $attribute = new McpPrompt(name: $name, description: $description); // Assert expect($attribute->name)->toBe($name); expect($attribute->description)->toBe($description); }); it('instantiates with null values for name and description', function () { // Arrange & Act $attribute = new McpPrompt(name: null, description: null); // Assert expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); }); it('instantiates with missing optional arguments', function () { // Arrange & Act $attribute = new McpPrompt(); // Use default constructor values // Assert expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); }); ``` -------------------------------------------------------------------------------- /src/Contracts/EventStoreInterface.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Contracts; /** * Interface for resumability support via event storage */ interface EventStoreInterface { /** * Stores a message associated with a specific stream and returns a unique event ID. * * @param string $streamId The ID of the stream the event belongs to. * @param string $message The framed JSON-RPC message to store. * @return string The generated event ID for the stored event */ public function storeEvent(string $streamId, string $message): string; /** * Replays events for a given stream that occurred after a specific event ID. * * @param string $lastEventId The last event ID the client received for this specific stream. * @param callable $sendCallback A function to call for each replayed message. * The callback will receive: `function(string $eventId, Message $message): void` */ public function replayEventsAfter(string $lastEventId, callable $sendCallback): void; } ``` -------------------------------------------------------------------------------- /src/Attributes/McpResourceTemplate.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Attributes; use Attribute; use PhpMcp\Schema\Annotations; /** * Marks a PHP class definition as representing an MCP Resource Template. * This is informational, used for 'resources/templates/list'. */ #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] final class McpResourceTemplate { /** * @param string $uriTemplate The URI template string (RFC 6570). * @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name. * @param ?string $description Optional description. Defaults to class DocBlock summary. * @param ?string $mimeType Optional default MIME type for matching resources. * @param ?Annotations $annotations Optional annotations describing the resource template. */ public function __construct( public string $uriTemplate, public ?string $name = null, public ?string $description = null, public ?string $mimeType = null, public ?Annotations $annotations = null, ) { } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/DiscoverablePromptHandler.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Discovery; use PhpMcp\Server\Attributes\McpPrompt; use PhpMcp\Server\Attributes\CompletionProvider; use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture; class DiscoverablePromptHandler { /** * Generates a creative story prompt. * @param string $genre The genre of the story. * @param int $lengthWords Approximate length in words. * @return array The prompt messages. */ #[McpPrompt(name: "creative_story_prompt")] public function generateStoryPrompt( #[CompletionProvider(provider: CompletionProviderFixture::class)] string $genre, int $lengthWords = 200 ): array { return [ ["role" => "user", "content" => "Write a {$genre} story about a lost robot, approximately {$lengthWords} words long."] ]; } #[McpPrompt] public function simpleQuestionPrompt(string $question): array { return [ ["role" => "user", "content" => $question], ["role" => "assistant", "content" => "I will try to answer that."] ]; } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Utils/AttributeFixtures.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Fixtures\Utils; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_PARAMETER)] class TestAttributeOne { public function __construct(public string $value) { } } #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER)] class TestAttributeTwo { public function __construct(public int $number) { } } #[\Attribute(\Attribute::TARGET_CLASS)] class TestClassOnlyAttribute { } // --- Test Class --- #[TestClassOnlyAttribute] #[TestAttributeOne(value: 'class-level')] class AttributeFixtures { #[TestAttributeOne(value: 'prop-level')] public string $propertyOne = 'default'; #[TestAttributeOne(value: 'method-one')] public function methodOne( #[TestAttributeOne(value: 'param-one')] #[TestAttributeTwo(number: 1)] string $param1 ): void { } #[TestAttributeOne(value: 'method-two')] #[TestAttributeTwo(number: 2)] public function methodTwo( #[TestAttributeTwo(number: 3)] int $paramA ): void { } // Method with no attributes public function methodThree(string $unattributedParam): void { } } ``` -------------------------------------------------------------------------------- /src/Attributes/McpResource.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Attributes; use Attribute; use PhpMcp\Schema\Annotations; /** * Marks a PHP class as representing or handling a specific MCP Resource instance. * Used primarily for the 'resources/list' discovery. */ #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] final class McpResource { /** * @param string $uri The specific URI identifying this resource instance. Must be unique within the server. * @param ?string $name A human-readable name for this resource. If null, a default might be generated from the method name. * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary. * @param ?string $mimeType The MIME type, if known and constant for this resource. * @param ?int $size The size in bytes, if known and constant. * @param Annotations|null $annotations Optional annotations describing the resource. */ public function __construct( public string $uri, public ?string $name = null, public ?string $description = null, public ?string $mimeType = null, public ?int $size = null, public ?Annotations $annotations = null, ) { } } ``` -------------------------------------------------------------------------------- /examples/05-stdio-env-variables/EnvToolHandler.php: -------------------------------------------------------------------------------- ```php <?php namespace Mcp\EnvExample; use PhpMcp\Server\Attributes\McpTool; class EnvToolHandler { public function __construct() { } /** * Performs an action that can be modified by an environment variable. * The MCP client should set 'APP_MODE' in its 'env' config for this server. * * @param string $input Some input data. * @return array The result, varying by APP_MODE. */ #[McpTool(name: 'process_data_by_mode')] public function processData(string $input): array { $appMode = getenv('APP_MODE'); // Read from environment if ($appMode === 'debug') { return [ 'mode' => 'debug', 'processed_input' => strtoupper($input), 'message' => 'Processed in DEBUG mode.', ]; } elseif ($appMode === 'production') { return [ 'mode' => 'production', 'processed_input_length' => strlen($input), 'message' => 'Processed in PRODUCTION mode (summary only).', ]; } else { return [ 'mode' => $appMode ?: 'default', 'original_input' => $input, 'message' => 'Processed in default mode (APP_MODE not recognized or not set).', ]; } } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/DiscoverableTemplateHandler.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Discovery; use PhpMcp\Server\Attributes\McpResourceTemplate; use PhpMcp\Server\Attributes\CompletionProvider; use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture; class DiscoverableTemplateHandler { /** * Retrieves product details based on ID and region. * @param string $productId The ID of the product. * @param string $region The sales region. * @return array Product details. */ #[McpResourceTemplate( uriTemplate: "product://{region}/details/{productId}", name: "product_details_template", mimeType: "application/json" )] public function getProductDetails( string $productId, #[CompletionProvider(provider: CompletionProviderFixture::class)] string $region ): array { return [ "id" => $productId, "name" => "Product " . $productId, "region" => $region, "price" => ($region === "EU" ? "€" : "$") . (hexdec(substr(md5($productId), 0, 4)) / 100) ]; } #[McpResourceTemplate(uriTemplate: "file://{path}/{filename}.{extension}")] public function getFileContent(string $path, string $filename, string $extension): string { return "Content of {$path}/{$filename}.{$extension}"; } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/EnhancedCompletionHandler.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Discovery; use PhpMcp\Server\Attributes\McpPrompt; use PhpMcp\Server\Attributes\McpResourceTemplate; use PhpMcp\Server\Attributes\CompletionProvider; use PhpMcp\Server\Tests\Fixtures\Enums\StatusEnum; use PhpMcp\Server\Tests\Fixtures\Enums\PriorityEnum; class EnhancedCompletionHandler { /** * Create content with list and enum completion providers. */ #[McpPrompt(name: 'content_creator')] public function createContent( #[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide'])] string $type, #[CompletionProvider(enum: StatusEnum::class)] string $status, #[CompletionProvider(enum: PriorityEnum::class)] string $priority ): array { return [ ['role' => 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority}"] ]; } /** * Resource template with list completion for categories. */ #[McpResourceTemplate( uriTemplate: 'content://{category}/{slug}', name: 'content_template' )] public function getContent( #[CompletionProvider(values: ['news', 'blog', 'docs', 'api'])] string $category, string $slug ): array { return [ 'category' => $category, 'slug' => $slug, 'url' => "https://example.com/{$category}/{$slug}" ]; } } ``` -------------------------------------------------------------------------------- /tests/Unit/Attributes/McpResourceTemplateTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Attributes; use PhpMcp\Server\Attributes\McpResourceTemplate; it('instantiates with correct properties', function () { // Arrange $uriTemplate = 'file:///{path}/data'; $name = 'test-template-name'; $description = 'This is a test template description.'; $mimeType = 'application/json'; // Act $attribute = new McpResourceTemplate( uriTemplate: $uriTemplate, name: $name, description: $description, mimeType: $mimeType, ); // Assert expect($attribute->uriTemplate)->toBe($uriTemplate); expect($attribute->name)->toBe($name); expect($attribute->description)->toBe($description); expect($attribute->mimeType)->toBe($mimeType); }); it('instantiates with null values for name and description', function () { // Arrange & Act $attribute = new McpResourceTemplate( uriTemplate: 'test://{id}', // uriTemplate is required name: null, description: null, mimeType: null, ); // Assert expect($attribute->uriTemplate)->toBe('test://{id}'); expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); }); it('instantiates with missing optional arguments', function () { // Arrange & Act $uriTemplate = 'tmpl://{key}'; $attribute = new McpResourceTemplate(uriTemplate: $uriTemplate); // Assert expect($attribute->uriTemplate)->toBe($uriTemplate); expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); }); ``` -------------------------------------------------------------------------------- /tests/Fixtures/ServerScripts/StdioTestServer.php: -------------------------------------------------------------------------------- ```php #!/usr/bin/env php <?php declare(strict_types=1); require_once __DIR__ . '/../../../vendor/autoload.php'; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\StdioServerTransport; use PhpMcp\Server\Tests\Fixtures\General\ToolHandlerFixture; use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture; use PhpMcp\Server\Tests\Fixtures\General\PromptHandlerFixture; use Psr\Log\AbstractLogger; use Psr\Log\NullLogger; class StdErrLogger extends AbstractLogger { public function log($level, \Stringable|string $message, array $context = []): void { fwrite(STDERR, sprintf("[%s] SERVER_LOG: %s %s\n", strtoupper((string)$level), $message, empty($context) ? '' : json_encode($context))); } } try { $logger = new NullLogger(); $logger->info('StdioTestServer listener starting.'); $server = Server::make() ->withServerInfo('StdioIntegrationTestServer', '0.1.0') ->withLogger($logger) ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_stdio_tool') ->withTool([ToolHandlerFixture::class, 'toolReadsContext'], 'tool_reads_context') // for Context testing ->withResource([ResourceHandlerFixture::class, 'getStaticText'], 'test://stdio/static', 'static_stdio_resource') ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_stdio_prompt') ->build(); $transport = new StdioServerTransport(); $server->listen($transport); $logger->info('StdioTestServer listener stopped.'); exit(0); } catch (\Throwable $e) { fwrite(STDERR, "[STDIO_SERVER_CRITICAL_ERROR]\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); exit(1); } ``` -------------------------------------------------------------------------------- /src/Configuration.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server; use PhpMcp\Schema\Implementation; use PhpMcp\Schema\ServerCapabilities; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use React\EventLoop\LoopInterface; /** * Value Object holding core configuration and shared dependencies for the MCP Server instance. * * This object is typically assembled by the ServerBuilder and passed to the Server constructor. */ class Configuration { /** * @param Implementation $serverInfo Info about this MCP server application. * @param ServerCapabilities $capabilities Capabilities of this MCP server application. * @param LoggerInterface $logger PSR-3 Logger instance. * @param LoopInterface $loop ReactPHP Event Loop instance. * @param CacheInterface|null $cache Optional PSR-16 Cache instance for registry/state. * @param ContainerInterface $container PSR-11 DI Container for resolving handlers/dependencies. * @param int $paginationLimit Maximum number of items to return for list methods. * @param string|null $instructions Instructions describing how to use the server and its features. */ public function __construct( public readonly Implementation $serverInfo, public readonly ServerCapabilities $capabilities, public readonly LoggerInterface $logger, public readonly LoopInterface $loop, public readonly ?CacheInterface $cache, public readonly ContainerInterface $container, public readonly int $paginationLimit = 50, public readonly ?string $instructions = null, ) {} } ``` -------------------------------------------------------------------------------- /tests/Unit/Attributes/McpResourceTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Attributes; use PhpMcp\Server\Attributes\McpResource; it('instantiates with correct properties', function () { // Arrange $uri = 'file:///test/resource'; $name = 'test-resource-name'; $description = 'This is a test resource description.'; $mimeType = 'text/plain'; $size = 1024; // Act $attribute = new McpResource( uri: $uri, name: $name, description: $description, mimeType: $mimeType, size: $size, ); // Assert expect($attribute->uri)->toBe($uri); expect($attribute->name)->toBe($name); expect($attribute->description)->toBe($description); expect($attribute->mimeType)->toBe($mimeType); expect($attribute->size)->toBe($size); }); it('instantiates with null values for name and description', function () { // Arrange & Act $attribute = new McpResource( uri: 'file:///test', // URI is required name: null, description: null, mimeType: null, size: null, ); // Assert expect($attribute->uri)->toBe('file:///test'); expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); expect($attribute->size)->toBeNull(); }); it('instantiates with missing optional arguments', function () { // Arrange & Act $uri = 'file:///only-uri'; $attribute = new McpResource(uri: $uri); // Assert expect($attribute->uri)->toBe($uri); expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); expect($attribute->size)->toBeNull(); }); ``` -------------------------------------------------------------------------------- /tests/Fixtures/Discovery/DiscoverableToolHandler.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Fixtures\Discovery; use PhpMcp\Schema\ToolAnnotations; use PhpMcp\Server\Attributes\McpTool; use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; class DiscoverableToolHandler { /** * A basic discoverable tool. * @param string $name The name to greet. * @return string The greeting. */ #[McpTool(name: "greet_user", description: "Greets a user by name.")] public function greet(string $name): string { return "Hello, {$name}!"; } /** * A tool with more complex parameters and inferred name/description. * @param int $count The number of times to repeat. * @param bool $loudly Should it be loud? * @param BackedStringEnum $mode The mode of operation. * @return array An array with results. */ #[McpTool(annotations: new ToolAnnotations(readOnlyHint: true))] public function repeatAction(int $count, bool $loudly = false, BackedStringEnum $mode = BackedStringEnum::OptionA): array { return ['count' => $count, 'loudly' => $loudly, 'mode' => $mode->value, 'message' => "Action repeated."]; } // This method should NOT be discovered as a tool public function internalHelperMethod(int $value): int { return $value * 2; } #[McpTool(name: "private_tool_should_be_ignored")] // On private method private function aPrivateTool(): void { } #[McpTool(name: "protected_tool_should_be_ignored")] // On protected method protected function aProtectedTool(): void { } #[McpTool(name: "static_tool_should_be_ignored")] // On static method public static function aStaticTool(): void { } } ``` -------------------------------------------------------------------------------- /src/Session/ArraySessionHandler.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Session; use PhpMcp\Server\Contracts\SessionHandlerInterface; use PhpMcp\Server\Defaults\SystemClock; use Psr\Clock\ClockInterface; class ArraySessionHandler implements SessionHandlerInterface { /** * @var array<string, array{ data: array, timestamp: int }> */ protected array $store = []; private ClockInterface $clock; public function __construct( public readonly int $ttl = 3600, ?ClockInterface $clock = null ) { $this->clock = $clock ?? new SystemClock(); } public function read(string $sessionId): string|false { $session = $this->store[$sessionId] ?? ''; if ($session === '') { return false; } $currentTimestamp = $this->clock->now()->getTimestamp(); if ($currentTimestamp - $session['timestamp'] > $this->ttl) { unset($this->store[$sessionId]); return false; } return $session['data']; } public function write(string $sessionId, string $data): bool { $this->store[$sessionId] = [ 'data' => $data, 'timestamp' => $this->clock->now()->getTimestamp(), ]; return true; } public function destroy(string $sessionId): bool { if (isset($this->store[$sessionId])) { unset($this->store[$sessionId]); } return true; } public function gc(int $maxLifetime): array { $currentTimestamp = $this->clock->now()->getTimestamp(); $deletedSessions = []; foreach ($this->store as $sessionId => $session) { if ($currentTimestamp - $session['timestamp'] > $maxLifetime) { unset($this->store[$sessionId]); $deletedSessions[] = $sessionId; } } return $deletedSessions; } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/General/DocBlockTestFixture.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Fixtures\General; /** * A stub class for testing DocBlock parsing. */ class DocBlockTestFixture { /** * Simple summary line. */ public function methodWithSummaryOnly(): void { } /** * Summary line here. * * This is a longer description spanning * multiple lines. * It might contain *markdown* or `code`. * * @since 1.0 */ public function methodWithSummaryAndDescription(): void { } /** * Method with various parameter tags. * * @param string $param1 Description for string param. * @param int|null $param2 Description for nullable int param. * @param bool $param3 * @param $param4 Missing type. * @param array<string, mixed> $param5 Array description. * @param \stdClass $param6 Object param. */ public function methodWithParams(string $param1, ?int $param2, bool $param3, $param4, array $param5, \stdClass $param6): void { } /** * Method with return tag. * * @return string The result of the operation. */ public function methodWithReturn(): string { return ''; } /** * Method with multiple tags. * * @param float $value The value to process. * @return bool Status of the operation. * @throws \RuntimeException If processing fails. * @deprecated Use newMethod() instead. * @see \PhpMcp\Server\Tests\Fixtures\General\DocBlockTestFixture::newMethod() */ public function methodWithMultipleTags(float $value): bool { return true; } /** * Malformed docblock - missing closing */ public function methodWithMalformedDocBlock(): void { } public function methodWithNoDocBlock(): void { } // Some other method needed for a @see tag perhaps public function newMethod(): void { } } ``` -------------------------------------------------------------------------------- /examples/03-manual-registration-stdio/SimpleHandlers.php: -------------------------------------------------------------------------------- ```php <?php namespace Mcp\ManualStdioExample; use Psr\Log\LoggerInterface; class SimpleHandlers { private LoggerInterface $logger; private string $appVersion = '1.0-manual'; public function __construct(LoggerInterface $logger) { $this->logger = $logger; $this->logger->info('SimpleHandlers instantiated for manual registration example.'); } /** * A manually registered tool to echo input. * * @param string $text The text to echo. * @return string The echoed text. */ public function echoText(string $text): string { $this->logger->info("Manual tool 'echo_text' called.", ['text' => $text]); return 'Echo: '.$text; } /** * A manually registered resource providing app version. * * @return string The application version. */ public function getAppVersion(): string { $this->logger->info("Manual resource 'app://version' read."); return $this->appVersion; } /** * A manually registered prompt template. * * @param string $userName The name of the user. * @return array The prompt messages. */ public function greetingPrompt(string $userName): array { $this->logger->info("Manual prompt 'personalized_greeting' called.", ['userName' => $userName]); return [ ['role' => 'user', 'content' => "Craft a personalized greeting for {$userName}."], ]; } /** * A manually registered resource template. * * @param string $itemId The ID of the item. * @return array Item details. */ public function getItemDetails(string $itemId): array { $this->logger->info("Manual template 'item://{itemId}' resolved.", ['itemId' => $itemId]); return ['id' => $itemId, 'name' => "Item {$itemId}", 'description' => "Details for item {$itemId} from manual template."]; } } ``` -------------------------------------------------------------------------------- /tests/Mocks/Clock/FixedClock.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Mocks\Clock; use DateTimeImmutable; use DateTimeZone; use Psr\Clock\ClockInterface; use DateInterval; class FixedClock implements ClockInterface { private DateTimeImmutable $currentTime; public function __construct(string|DateTimeImmutable $initialTime = 'now', ?DateTimeZone $timezone = null) { if ($initialTime instanceof DateTimeImmutable) { $this->currentTime = $initialTime; } else { $this->currentTime = new DateTimeImmutable($initialTime, $timezone); } } public function now(): DateTimeImmutable { return $this->currentTime; } public function setCurrentTime(string|DateTimeImmutable $newTime, ?DateTimeZone $timezone = null): void { if ($newTime instanceof DateTimeImmutable) { $this->currentTime = $newTime; } else { $this->currentTime = new DateTimeImmutable($newTime, $timezone); } } public function advance(DateInterval $interval): void { $this->currentTime = $this->currentTime->add($interval); } public function rewind(DateInterval $interval): void { $this->currentTime = $this->currentTime->sub($interval); } public function addSecond(): void { $this->advance(new DateInterval("PT1S")); } public function addSeconds(int $seconds): void { $this->advance(new DateInterval("PT{$seconds}S")); } public function addMinutes(int $minutes): void { $this->advance(new DateInterval("PT{$minutes}M")); } public function addHours(int $hours): void { $this->advance(new DateInterval("PT{$hours}H")); } public function subSeconds(int $seconds): void { $this->rewind(new DateInterval("PT{$seconds}S")); } public function subMinutes(int $minutes): void { $this->rewind(new DateInterval("PT{$minutes}M")); } } ``` -------------------------------------------------------------------------------- /tests/Unit/Defaults/ListCompletionProviderTest.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Unit\Defaults; use PhpMcp\Server\Defaults\ListCompletionProvider; use PhpMcp\Server\Contracts\SessionInterface; use Mockery; beforeEach(function () { $this->session = Mockery::mock(SessionInterface::class); }); it('returns all values when current value is empty', function () { $values = ['apple', 'banana', 'cherry']; $provider = new ListCompletionProvider($values); $result = $provider->getCompletions('', $this->session); expect($result)->toBe($values); }); it('filters values based on current value prefix', function () { $values = ['apple', 'apricot', 'banana', 'cherry']; $provider = new ListCompletionProvider($values); $result = $provider->getCompletions('ap', $this->session); expect($result)->toBe(['apple', 'apricot']); }); it('returns empty array when no values match', function () { $values = ['apple', 'banana', 'cherry']; $provider = new ListCompletionProvider($values); $result = $provider->getCompletions('xyz', $this->session); expect($result)->toBe([]); }); it('works with single character prefix', function () { $values = ['apple', 'banana', 'cherry']; $provider = new ListCompletionProvider($values); $result = $provider->getCompletions('a', $this->session); expect($result)->toBe(['apple']); }); it('is case sensitive by default', function () { $values = ['Apple', 'apple', 'APPLE']; $provider = new ListCompletionProvider($values); $result = $provider->getCompletions('A', $this->session); expect($result)->toEqual(['Apple', 'APPLE']); }); it('handles empty values array', function () { $provider = new ListCompletionProvider([]); $result = $provider->getCompletions('test', $this->session); expect($result)->toBe([]); }); it('preserves array order', function () { $values = ['zebra', 'apple', 'banana']; $provider = new ListCompletionProvider($values); $result = $provider->getCompletions('', $this->session); expect($result)->toBe(['zebra', 'apple', 'banana']); }); ``` -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- ```json { "name": "php-mcp/server", "description": "PHP SDK for building Model Context Protocol (MCP) servers - Create MCP tools, resources, and prompts", "keywords": [ "mcp", "model context protocol", "server", "php", "php mcp", "php mcp sdk", "php mcp server", "php mcp tools", "php mcp resources", "php mcp prompts", "php model context protocol" ], "type": "library", "license": "MIT", "authors": [ { "name": "Kyrian Obikwelu", "email": "[email protected]" } ], "require": { "php": ">=8.1", "opis/json-schema": "^2.4", "php-mcp/schema": "^1.0", "phpdocumentor/reflection-docblock": "^5.6", "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "react/event-loop": "^1.5", "react/http": "^1.11", "react/promise": "^3.0", "react/stream": "^1.4", "symfony/finder": "^6.4 || ^7.2" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.75", "mockery/mockery": "^1.6", "pestphp/pest": "^2.36.0|^3.5.0", "react/async": "^4.0", "react/child-process": "^0.6.6", "symfony/var-dumper": "^6.4.11|^7.1.5" }, "suggest": { "ext-pcntl": "For signal handling support when using StdioServerTransport with StreamSelectLoop" }, "autoload": { "psr-4": { "PhpMcp\\Server\\": "src/" } }, "autoload-dev": { "psr-4": { "PhpMcp\\Server\\Tests\\": "tests/" } }, "scripts": { "test": "vendor/bin/pest", "test:coverage": "XDEBUG_MODE=coverage ./vendor/bin/pest --coverage", "lint": "vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php" }, "config": { "sort-packages": true, "allow-plugins": { "pestphp/pest-plugin": true } }, "minimum-stability": "dev", "prefer-stable": true } ``` -------------------------------------------------------------------------------- /src/Contracts/SessionInterface.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Contracts; use JsonSerializable; interface SessionInterface extends JsonSerializable { /** * Get the session ID. */ public function getId(): string; /** * Save the session. */ public function save(): void; /** * Get a specific attribute from the session. * Supports dot notation for nested access. */ public function get(string $key, mixed $default = null): mixed; /** * Set a specific attribute in the session. * Supports dot notation for nested access. */ public function set(string $key, mixed $value, bool $overwrite = true): void; /** * Check if an attribute exists in the session. * Supports dot notation for nested access. */ public function has(string $key): bool; /** * Remove an attribute from the session. * Supports dot notation for nested access. */ public function forget(string $key): void; /** * Remove all attributes from the session. */ public function clear(): void; /** * Get an attribute's value and then remove it from the session. * Supports dot notation for nested access. */ public function pull(string $key, mixed $default = null): mixed; /** * Get all attributes of the session. */ public function all(): array; /** * Set all attributes of the session, typically for hydration. * This will overwrite existing attributes. */ public function hydrate(array $attributes): void; /** * Add a message to the session's queue. */ public function queueMessage(string $message): void; /** * Retrieve and remove all messages from the queue. * @return array<string> */ public function dequeueMessages(): array; /** * Check if there are any messages in the queue. */ public function hasQueuedMessages(): bool; /** * Get the session handler instance. * * @return SessionHandlerInterface */ public function getHandler(): SessionHandlerInterface; } ``` -------------------------------------------------------------------------------- /src/Defaults/InMemoryEventStore.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Defaults; use PhpMcp\Server\Contracts\EventStoreInterface; /** * Simple in-memory implementation of the EventStore interface for resumability * This is primarily intended for examples and testing, not for production use * where a persistent storage solution would be more appropriate. */ class InMemoryEventStore implements EventStoreInterface { public const DEFAULT_MAX_EVENTS_PER_STREAM = 1000; /** * @var array<string, array{streamId: string, message: string}> * Example: [eventId1 => ['streamId' => 'abc', 'message' => '...']] */ private array $events = []; private function generateEventId(string $streamId): string { return $streamId . '_' . (int)(microtime(true) * 1000) . '_' . bin2hex(random_bytes(4)); } private function getStreamIdFromEventId(string $eventId): ?string { $parts = explode('_', $eventId); return $parts[0] ?? null; } public function storeEvent(string $streamId, string $message): string { $eventId = $this->generateEventId($streamId); $this->events[$eventId] = [ 'streamId' => $streamId, 'message' => $message, ]; return $eventId; } public function replayEventsAfter(string $lastEventId, callable $sendCallback): void { if (!isset($this->events[$lastEventId])) { return; } $streamId = $this->getStreamIdFromEventId($lastEventId); if ($streamId === null) { return; } $foundLastEvent = false; // Sort by eventId for deterministic ordering ksort($this->events); foreach ($this->events as $eventId => ['streamId' => $eventStreamId, 'message' => $message]) { if ($eventStreamId !== $streamId) { continue; } if ($eventId === $lastEventId) { $foundLastEvent = true; continue; } if ($foundLastEvent) { $sendCallback($eventId, $message); } } } } ``` -------------------------------------------------------------------------------- /src/Contracts/ServerTransportInterface.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Contracts; use Evenement\EventEmitterInterface; use PhpMcp\Server\Exception\TransportException; use PhpMcp\Schema\JsonRpc\Message; use React\Promise\PromiseInterface; /** * Interface for server-side MCP transports. * * Implementations handle listening for connections/data and sending raw messages. * MUST emit events for lifecycle and messages. * * --- Expected Emitted Events --- * 'ready': () - Optional: Fired when listening starts successfully. * 'client_connected': (string $sessionId) - New client connection * 'message': (Message $message, string $sessionId, array $context) - Complete message received from a client. * 'client_disconnected': (string $sessionId, ?string $reason) - Client connection closed. * 'error': (Throwable $error, ?string $sessionId) - Error occurred (general transport error if sessionId is null). * 'close': (?string $reason) - Transport listener stopped completely. */ interface ServerTransportInterface extends EventEmitterInterface { /** * Starts the transport listener (e.g., listens on STDIN, starts HTTP server). * Does NOT run the event loop itself. Prepares transport to emit events when loop runs. * * @throws TransportException on immediate setup failure (e.g., port binding). */ public function listen(): void; /** * Sends a message to a connected client session with optional context. * * @param Message $message Message to send. * @param string $sessionId Target session identifier. * @param array $context Optional context for the message. Eg. streamId for SSE. * @return PromiseInterface<void> Resolves on successful send/queue, rejects on specific send error. */ public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface; /** * Stops the transport listener gracefully and closes all active connections. * MUST eventually emit a 'close' event for the transport itself. * Individual client disconnects should emit 'client_disconnected' events. */ public function close(): void; } ``` -------------------------------------------------------------------------------- /examples/05-stdio-env-variables/server.php: -------------------------------------------------------------------------------- ```php #!/usr/bin/env php <?php declare(strict_types=1); chdir(__DIR__); require_once '../../vendor/autoload.php'; require_once './EnvToolHandler.php'; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\StdioServerTransport; use Psr\Log\AbstractLogger; /* |-------------------------------------------------------------------------- | MCP Stdio Environment Variable Example Server |-------------------------------------------------------------------------- | | This server demonstrates how to use environment variables to modify tool | behavior. The MCP client can set the APP_MODE environment variable to | control the server's behavior. | | Configure your MCP Client (eg. Cursor) for this server like this: | | { | "mcpServers": { | "my-php-env-server": { | "command": "php", | "args": ["/full/path/to/examples/05-stdio-env-variables/server.php"], | "env": { | "APP_MODE": "debug" // or "production", or leave it out | } | } | } | } | | The server will read the APP_MODE environment variable and use it to | modify the behavior of the tools. | | If the APP_MODE environment variable is not set, the server will use the | default behavior. | */ class StderrLogger extends AbstractLogger { public function log($level, \Stringable|string $message, array $context = []): void { fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); } } try { $logger = new StderrLogger(); $logger->info('Starting MCP Stdio Environment Variable Example Server...'); $server = Server::make() ->withServerInfo('Env Var Server', '1.0.0') ->withLogger($logger) ->build(); $server->discover(__DIR__, ['.']); $transport = new StdioServerTransport(); $server->listen($transport); $logger->info('Server listener stopped gracefully.'); exit(0); } catch (\Throwable $e) { fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); exit(1); } ``` -------------------------------------------------------------------------------- /examples/08-schema-showcase-streamable/server.php: -------------------------------------------------------------------------------- ```php #!/usr/bin/env php <?php /* |-------------------------------------------------------------------------- | MCP Schema Showcase Server (Attribute Discovery) |-------------------------------------------------------------------------- | | This server demonstrates various ways to use the Schema attribute to | validate tool inputs. It showcases string constraints, numeric validation, | object schemas, array handling, enums, and format validation. | | To Use: | 1. Ensure 'SchemaShowcaseElements.php' defines classes with MCP attributes. | 2. Configure your MCP Client (e.g., Cursor) for this server: | | { | "mcpServers": { | "php-schema-showcase": { | "command": "php", | "args": ["/full/path/to/examples/08-schema-showcase-stdio/server.php"] | } | } | } | | This example focuses specifically on demonstrating different Schema | attribute capabilities for robust input validation. | */ declare(strict_types=1); chdir(__DIR__); require_once '../../vendor/autoload.php'; require_once 'SchemaShowcaseElements.php'; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\StreamableHttpServerTransport; use Psr\Log\AbstractLogger; class StderrLogger extends AbstractLogger { public function log($level, \Stringable|string $message, array $context = []): void { fwrite(STDERR, sprintf( "[%s] %s %s\n", strtoupper($level), $message, empty($context) ? '' : json_encode($context) )); } } try { $logger = new StderrLogger(); $logger->info('Starting MCP Schema Showcase Server...'); $server = Server::make() ->withServerInfo('Schema Showcase', '1.0.0') ->withLogger($logger) ->build(); $server->discover(__DIR__, ['.']); $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); $server->listen($transport); $logger->info('Server listener stopped gracefully.'); exit(0); } catch (\Throwable $e) { fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); fwrite(STDERR, $e->getTraceAsString() . "\n"); exit(1); } ``` -------------------------------------------------------------------------------- /tests/Unit/Attributes/CompletionProviderTest.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Unit\Attributes; use PhpMcp\Server\Attributes\CompletionProvider; use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture; use PhpMcp\Server\Defaults\ListCompletionProvider; use PhpMcp\Server\Defaults\EnumCompletionProvider; use PhpMcp\Server\Tests\Fixtures\Enums\StatusEnum; it('can be constructed with provider class', function () { $attribute = new CompletionProvider(provider: CompletionProviderFixture::class); expect($attribute->provider)->toBe(CompletionProviderFixture::class); expect($attribute->values)->toBeNull(); expect($attribute->enum)->toBeNull(); }); it('can be constructed with provider instance', function () { $instance = new CompletionProviderFixture(); $attribute = new CompletionProvider(provider: $instance); expect($attribute->provider)->toBe($instance); expect($attribute->values)->toBeNull(); expect($attribute->enum)->toBeNull(); }); it('can be constructed with values array', function () { $values = ['draft', 'published', 'archived']; $attribute = new CompletionProvider(values: $values); expect($attribute->provider)->toBeNull(); expect($attribute->values)->toBe($values); expect($attribute->enum)->toBeNull(); }); it('can be constructed with enum class', function () { $attribute = new CompletionProvider(enum: StatusEnum::class); expect($attribute->provider)->toBeNull(); expect($attribute->values)->toBeNull(); expect($attribute->enum)->toBe(StatusEnum::class); }); it('throws exception when no parameters provided', function () { new CompletionProvider(); })->throws(\InvalidArgumentException::class, 'Only one of provider, values, or enum can be set'); it('throws exception when multiple parameters provided', function () { new CompletionProvider( provider: CompletionProviderFixture::class, values: ['test'] ); })->throws(\InvalidArgumentException::class, 'Only one of provider, values, or enum can be set'); it('throws exception when all parameters provided', function () { new CompletionProvider( provider: CompletionProviderFixture::class, values: ['test'], enum: StatusEnum::class ); })->throws(\InvalidArgumentException::class, 'Only one of provider, values, or enum can be set'); ``` -------------------------------------------------------------------------------- /tests/Unit/Defaults/EnumCompletionProviderTest.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Unit\Defaults; use PhpMcp\Server\Defaults\EnumCompletionProvider; use PhpMcp\Server\Contracts\SessionInterface; use Mockery; enum StringEnum: string { case DRAFT = 'draft'; case PUBLISHED = 'published'; case ARCHIVED = 'archived'; } enum IntEnum: int { case LOW = 1; case MEDIUM = 2; case HIGH = 3; } enum UnitEnum { case ALPHA; case BETA; case GAMMA; } beforeEach(function () { $this->session = Mockery::mock(SessionInterface::class); }); it('creates provider from string-backed enum', function () { $provider = new EnumCompletionProvider(StringEnum::class); $result = $provider->getCompletions('', $this->session); expect($result)->toBe(['draft', 'published', 'archived']); }); it('creates provider from int-backed enum using names', function () { $provider = new EnumCompletionProvider(IntEnum::class); $result = $provider->getCompletions('', $this->session); expect($result)->toBe(['LOW', 'MEDIUM', 'HIGH']); }); it('creates provider from unit enum using names', function () { $provider = new EnumCompletionProvider(UnitEnum::class); $result = $provider->getCompletions('', $this->session); expect($result)->toBe(['ALPHA', 'BETA', 'GAMMA']); }); it('filters string enum values by prefix', function () { $provider = new EnumCompletionProvider(StringEnum::class); $result = $provider->getCompletions('ar', $this->session); expect($result)->toEqual(['archived']); }); it('filters unit enum values by prefix', function () { $provider = new EnumCompletionProvider(UnitEnum::class); $result = $provider->getCompletions('A', $this->session); expect($result)->toBe(['ALPHA']); }); it('returns empty array when no values match prefix', function () { $provider = new EnumCompletionProvider(StringEnum::class); $result = $provider->getCompletions('xyz', $this->session); expect($result)->toBe([]); }); it('throws exception for non-enum class', function () { new EnumCompletionProvider(\stdClass::class); })->throws(\InvalidArgumentException::class, 'Class stdClass is not an enum'); it('throws exception for non-existent class', function () { new EnumCompletionProvider('NonExistentClass'); })->throws(\InvalidArgumentException::class, 'Class NonExistentClass is not an enum'); ``` -------------------------------------------------------------------------------- /examples/07-complex-tool-schema-http/McpEventScheduler.php: -------------------------------------------------------------------------------- ```php <?php namespace Mcp\ComplexSchemaHttpExample; use Mcp\ComplexSchemaHttpExample\Model\EventPriority; use Mcp\ComplexSchemaHttpExample\Model\EventType; use PhpMcp\Server\Attributes\McpTool; use Psr\Log\LoggerInterface; class McpEventScheduler { private LoggerInterface $logger; public function __construct(LoggerInterface $logger) { $this->logger = $logger; } /** * Schedules a new event. * The inputSchema for this tool will reflect all parameter types and defaults. * * @param string $title The title of the event. * @param string $date The date of the event (YYYY-MM-DD). * @param EventType $type The type of event. * @param string|null $time The time of the event (HH:MM), optional. * @param EventPriority $priority The priority of the event. Defaults to Normal. * @param string[]|null $attendees An optional list of attendee email addresses. * @param bool $sendInvites Send calendar invites to attendees? Defaults to true if attendees are provided. * @return array Confirmation of the scheduled event. */ #[McpTool(name: 'schedule_event')] public function scheduleEvent( string $title, string $date, EventType $type, ?string $time = null, // Optional, nullable EventPriority $priority = EventPriority::Normal, // Optional with enum default ?array $attendees = null, // Optional array of strings, nullable bool $sendInvites = true // Optional with default ): array { $this->logger->info("Tool 'schedule_event' called", compact('title', 'date', 'type', 'time', 'priority', 'attendees', 'sendInvites')); // Simulate scheduling logic $eventDetails = [ 'title' => $title, 'date' => $date, 'type' => $type->value, // Use enum value 'time' => $time ?? 'All day', 'priority' => $priority->name, // Use enum name 'attendees' => $attendees ?? [], 'invites_will_be_sent' => ($attendees && $sendInvites), ]; // In a real app, this would interact with a calendar service $this->logger->info('Event scheduled', ['details' => $eventDetails]); return [ 'success' => true, 'message' => "Event '{$title}' scheduled successfully for {$date}.", 'event_details' => $eventDetails, ]; } } ``` -------------------------------------------------------------------------------- /src/Defaults/ArrayCache.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Defaults; use DateInterval; use DateTime; use Psr\SimpleCache\CacheInterface; /** * Very basic PSR-16 array cache implementation (not for production). */ class ArrayCache implements CacheInterface { private array $store = []; private array $expiries = []; public function get(string $key, mixed $default = null): mixed { if (! $this->has($key)) { return $default; } return $this->store[$key]; } public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool { $this->store[$key] = $value; $this->expiries[$key] = $this->calculateExpiry($ttl); return true; } public function delete(string $key): bool { unset($this->store[$key], $this->expiries[$key]); return true; } public function clear(): bool { $this->store = []; $this->expiries = []; return true; } public function getMultiple(iterable $keys, mixed $default = null): iterable { $result = []; foreach ($keys as $key) { $result[$key] = $this->get($key, $default); } return $result; } public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool { $expiry = $this->calculateExpiry($ttl); foreach ($values as $key => $value) { $this->store[$key] = $value; $this->expiries[$key] = $expiry; } return true; } public function deleteMultiple(iterable $keys): bool { foreach ($keys as $key) { unset($this->store[$key], $this->expiries[$key]); } return true; } public function has(string $key): bool { if (! isset($this->store[$key])) { return false; } // Check expiry if (isset($this->expiries[$key]) && $this->expiries[$key] !== null && time() >= $this->expiries[$key]) { $this->delete($key); return false; } return true; } private function calculateExpiry(DateInterval|int|null $ttl): ?int { if ($ttl === null) { return null; // No expiry } if (is_int($ttl)) { return time() + $ttl; } if ($ttl instanceof DateInterval) { return (new DateTime())->add($ttl)->getTimestamp(); } // Invalid TTL type, treat as no expiry return null; } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/ServerScripts/HttpTestServer.php: -------------------------------------------------------------------------------- ```php #!/usr/bin/env php <?php declare(strict_types=1); require_once __DIR__ . '/../../../vendor/autoload.php'; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\HttpServerTransport; use PhpMcp\Server\Tests\Fixtures\General\ToolHandlerFixture; use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture; use PhpMcp\Server\Tests\Fixtures\General\PromptHandlerFixture; use PhpMcp\Server\Tests\Fixtures\General\RequestAttributeChecker; use PhpMcp\Server\Tests\Fixtures\Middlewares\HeaderMiddleware; use PhpMcp\Server\Tests\Fixtures\Middlewares\RequestAttributeMiddleware; use PhpMcp\Server\Tests\Fixtures\Middlewares\ShortCircuitMiddleware; use PhpMcp\Server\Tests\Fixtures\Middlewares\FirstMiddleware; use PhpMcp\Server\Tests\Fixtures\Middlewares\SecondMiddleware; use PhpMcp\Server\Tests\Fixtures\Middlewares\ThirdMiddleware; use PhpMcp\Server\Tests\Fixtures\Middlewares\ErrorMiddleware; use Psr\Log\AbstractLogger; use Psr\Log\NullLogger; class StdErrLogger extends AbstractLogger { public function log($level, \Stringable|string $message, array $context = []): void { fwrite(STDERR, sprintf("[%s] HTTP_SERVER_LOG: %s %s\n", strtoupper((string)$level), $message, empty($context) ? '' : json_encode($context))); } } $host = $argv[1] ?? '127.0.0.1'; $port = (int)($argv[2] ?? 8990); $mcpPathPrefix = $argv[3] ?? 'mcp_http_test'; try { $logger = new NullLogger(); $server = Server::make() ->withServerInfo('HttpIntegrationTestServer', '0.1.0') ->withLogger($logger) ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_http_tool') ->withTool([RequestAttributeChecker::class, 'checkAttribute'], 'check_request_attribute_tool') ->withResource([ResourceHandlerFixture::class, 'getStaticText'], "test://http/static", 'static_http_resource') ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_http_prompt') ->build(); $middlewares = [ new HeaderMiddleware(), new RequestAttributeMiddleware(), new ShortCircuitMiddleware(), new FirstMiddleware(), new SecondMiddleware(), new ThirdMiddleware(), new ErrorMiddleware() ]; $transport = new HttpServerTransport($host, $port, $mcpPathPrefix, null, $middlewares); $server->listen($transport); exit(0); } catch (\Throwable $e) { fwrite(STDERR, "[HTTP_SERVER_CRITICAL_ERROR]\nHost:{$host} Port:{$port} Prefix:{$mcpPathPrefix}\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); exit(1); } ``` -------------------------------------------------------------------------------- /examples/01-discovery-stdio-calculator/server.php: -------------------------------------------------------------------------------- ```php #!/usr/bin/env php <?php /* |-------------------------------------------------------------------------- | MCP Stdio Calculator Server (Attribute Discovery) |-------------------------------------------------------------------------- | | This server demonstrates using attribute-based discovery to find MCP | elements (Tools, Resources) in the 'McpElements.php' file within this | directory. It runs via the STDIO transport. | | To Use: | 1. Ensure 'McpElements.php' defines classes with MCP attributes. | 2. Configure your MCP Client (e.g., Cursor) for this server: | | { | "mcpServers": { | "php-stdio-calculator": { | "command": "php", | "args": ["/full/path/to/examples/01-discovery-stdio-calculator/server.php"] | } | } | } | | The ServerBuilder builds the server instance, then $server->discover() | scans the current directory (specified by basePath: __DIR__, scanDirs: ['.']) | to find and register elements before listening on STDIN/STDOUT. | | If you provided a `CacheInterface` implementation to the ServerBuilder, | the discovery process will be cached, so you can comment out the | discovery call after the first run to speed up subsequent runs. | */ declare(strict_types=1); chdir(__DIR__); require_once '../../vendor/autoload.php'; require_once 'McpElements.php'; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\StdioServerTransport; use Psr\Log\AbstractLogger; class StderrLogger extends AbstractLogger { public function log($level, \Stringable|string $message, array $context = []): void { fwrite(STDERR, sprintf( "[%s] %s %s\n", strtoupper($level), $message, empty($context) ? '' : json_encode($context) )); } } try { $logger = new StderrLogger(); $logger->info('Starting MCP Stdio Calculator Server...'); $server = Server::make() ->withServerInfo('Stdio Calculator', '1.1.0') ->withLogger($logger) ->build(); $server->discover(__DIR__, ['.']); $transport = new StdioServerTransport(); $server->listen($transport); $logger->info('Server listener stopped gracefully.'); exit(0); } catch (\Throwable $e) { fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); fwrite(STDERR, 'Error: '.$e->getMessage()."\n"); fwrite(STDERR, 'File: '.$e->getFile().':'.$e->getLine()."\n"); fwrite(STDERR, $e->getTraceAsString()."\n"); exit(1); } ``` -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- ```php <?php use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; use React\Promise\Promise; use React\Promise\PromiseInterface; use React\Socket\SocketServer; function getPrivateProperty(object $object, string $propertyName) { $reflector = new ReflectionClass($object); $property = $reflector->getProperty($propertyName); $property->setAccessible(true); return $property->getValue($object); } function delay($time, ?LoopInterface $loop = null) { if ($loop === null) { $loop = Loop::get(); } /** @var TimerInterface $timer */ $timer = null; return new Promise(function ($resolve) use ($loop, $time, &$timer) { $timer = $loop->addTimer($time, function () use ($resolve) { $resolve(null); }); }, function () use (&$timer, $loop) { $loop->cancelTimer($timer); $timer = null; throw new \RuntimeException('Timer cancelled'); }); } function timeout(PromiseInterface $promise, $time, ?LoopInterface $loop = null) { $canceller = null; if (\method_exists($promise, 'cancel')) { $canceller = function () use (&$promise) { $promise->cancel(); $promise = null; }; } if ($loop === null) { $loop = Loop::get(); } return new Promise(function ($resolve, $reject) use ($loop, $time, $promise) { $timer = null; $promise = $promise->then(function ($v) use (&$timer, $loop, $resolve) { if ($timer) { $loop->cancelTimer($timer); } $timer = false; $resolve($v); }, function ($v) use (&$timer, $loop, $reject) { if ($timer) { $loop->cancelTimer($timer); } $timer = false; $reject($v); }); if ($timer === false) { return; } // start timeout timer which will cancel the input promise $timer = $loop->addTimer($time, function () use ($time, &$promise, $reject) { $reject(new \RuntimeException('Timed out after ' . $time . ' seconds')); if (\method_exists($promise, 'cancel')) { $promise->cancel(); } $promise = null; }); }, $canceller); } function findFreePort() { $server = new SocketServer('127.0.0.1:0'); $address = $server->getAddress(); $port = $address ? parse_url($address, PHP_URL_PORT) : null; $server->close(); if (!$port) { throw new \RuntimeException("Could not find a free port for testing."); } return (int)$port; } ``` -------------------------------------------------------------------------------- /tests/Fixtures/Utils/DockBlockParserFixture.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Fixtures\Utils; /** * Test stub for DocBlock array type parsing */ class DockBlockParserFixture { /** * Method with simple array[] syntax * * @param string[] $strings Array of strings using [] syntax * @param int[] $integers Array of integers using [] syntax * @param bool[] $booleans Array of booleans using [] syntax * @param float[] $floats Array of floats using [] syntax * @param object[] $objects Array of objects using [] syntax * @param \DateTime[] $dateTimeInstances Array of DateTime objects */ public function simpleArraySyntax( array $strings, array $integers, array $booleans, array $floats, array $objects, array $dateTimeInstances ): void { } /** * Method with array<T> generic syntax * * @param array<string> $strings Array of strings using generic syntax * @param array<int> $integers Array of integers using generic syntax * @param array<bool> $booleans Array of booleans using generic syntax * @param array<float> $floats Array of floats using generic syntax * @param array<object> $objects Array of objects using generic syntax * @param array<\DateTime> $dateTimeInstances Array of DateTime objects using generic syntax */ public function genericArraySyntax( array $strings, array $integers, array $booleans, array $floats, array $objects, array $dateTimeInstances ): void { } /** * Method with nested array syntax * * @param array<array<string>> $nestedStringArrays Array of arrays of strings * @param array<array<int>> $nestedIntArrays Array of arrays of integers * @param string[][] $doubleStringArrays Array of arrays of strings using double [] * @param int[][] $doubleIntArrays Array of arrays of integers using double [] */ public function nestedArraySyntax( array $nestedStringArrays, array $nestedIntArrays, array $doubleStringArrays, array $doubleIntArrays ): void { } /** * Method with object-like array syntax * * @param array{name: string, age: int} $person Simple object array with name and age * @param array{id: int, title: string, tags: string[]} $article Article with array of tags * @param array{user: array{id: int, name: string}, items: array<int>} $order Order with nested user object and array of item IDs */ public function objectArraySyntax( array $person, array $article, array $order ): void { } } ``` -------------------------------------------------------------------------------- /src/Session/CacheSessionHandler.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Session; use PhpMcp\Server\Contracts\SessionHandlerInterface; use PhpMcp\Server\Defaults\SystemClock; use Psr\SimpleCache\CacheInterface; use Psr\Clock\ClockInterface; class CacheSessionHandler implements SessionHandlerInterface { private const SESSION_INDEX_KEY = 'mcp_session_index'; private array $sessionIndex = []; private ClockInterface $clock; public function __construct( public readonly CacheInterface $cache, public readonly int $ttl = 3600, ?ClockInterface $clock = null ) { $this->sessionIndex = $this->cache->get(self::SESSION_INDEX_KEY, []); $this->clock = $clock ?? new SystemClock(); } public function read(string $sessionId): string|false { $session = $this->cache->get($sessionId, false); if ($session === false) { if (isset($this->sessionIndex[$sessionId])) { unset($this->sessionIndex[$sessionId]); $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); } return false; } if (!isset($this->sessionIndex[$sessionId])) { $this->sessionIndex[$sessionId] = $this->clock->now()->getTimestamp(); $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); return $session; } if ($this->clock->now()->getTimestamp() - $this->sessionIndex[$sessionId] > $this->ttl) { $this->cache->delete($sessionId); return false; } return $session; } public function write(string $sessionId, string $data): bool { $this->sessionIndex[$sessionId] = $this->clock->now()->getTimestamp(); $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); return $this->cache->set($sessionId, $data); } public function destroy(string $sessionId): bool { unset($this->sessionIndex[$sessionId]); $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); return $this->cache->delete($sessionId); } public function gc(int $maxLifetime): array { $currentTime = $this->clock->now()->getTimestamp(); $deletedSessions = []; foreach ($this->sessionIndex as $sessionId => $timestamp) { if ($currentTime - $timestamp > $maxLifetime) { $this->cache->delete($sessionId); unset($this->sessionIndex[$sessionId]); $deletedSessions[] = $sessionId; } } $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); return $deletedSessions; } } ``` -------------------------------------------------------------------------------- /examples/06-custom-dependencies-stdio/McpTaskHandlers.php: -------------------------------------------------------------------------------- ```php <?php namespace Mcp\DependenciesStdioExample; use Mcp\DependenciesStdioExample\Services\StatsServiceInterface; use Mcp\DependenciesStdioExample\Services\TaskRepositoryInterface; use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpTool; use Psr\Log\LoggerInterface; class McpTaskHandlers { private TaskRepositoryInterface $taskRepo; private StatsServiceInterface $statsService; private LoggerInterface $logger; // Dependencies injected by the DI container public function __construct( TaskRepositoryInterface $taskRepo, StatsServiceInterface $statsService, LoggerInterface $logger ) { $this->taskRepo = $taskRepo; $this->statsService = $statsService; $this->logger = $logger; $this->logger->info('McpTaskHandlers instantiated with dependencies.'); } /** * Adds a new task for a given user. * * @param string $userId The ID of the user. * @param string $description The task description. * @return array The created task details. */ #[McpTool(name: 'add_task')] public function addTask(string $userId, string $description): array { $this->logger->info("Tool 'add_task' invoked", ['userId' => $userId]); return $this->taskRepo->addTask($userId, $description); } /** * Lists pending tasks for a specific user. * * @param string $userId The ID of the user. * @return array A list of tasks. */ #[McpTool(name: 'list_user_tasks')] public function listUserTasks(string $userId): array { $this->logger->info("Tool 'list_user_tasks' invoked", ['userId' => $userId]); return $this->taskRepo->getTasksForUser($userId); } /** * Marks a task as complete. * * @param int $taskId The ID of the task to complete. * @return array Status of the operation. */ #[McpTool(name: 'complete_task')] public function completeTask(int $taskId): array { $this->logger->info("Tool 'complete_task' invoked", ['taskId' => $taskId]); $success = $this->taskRepo->completeTask($taskId); return ['success' => $success, 'message' => $success ? "Task {$taskId} completed." : "Task {$taskId} not found."]; } /** * Provides current system statistics. * * @return array System statistics. */ #[McpResource(uri: 'stats://system/overview', name: 'system_stats', mimeType: 'application/json')] public function getSystemStatistics(): array { $this->logger->info("Resource 'stats://system/overview' invoked"); return $this->statsService->getSystemStats(); } } ``` -------------------------------------------------------------------------------- /src/Session/SubscriptionManager.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Session; use Psr\Log\LoggerInterface; class SubscriptionManager { /** @var array<string, array<string, true>> Key: URI, Value: array of session IDs */ private array $resourceSubscribers = []; /** @var array<string, array<string, true>> Key: Session ID, Value: array of URIs */ private array $sessionSubscriptions = []; public function __construct( private readonly LoggerInterface $logger ) { } /** * Subscribe a session to a resource */ public function subscribe(string $sessionId, string $uri): void { // Add to both mappings for efficient lookup $this->resourceSubscribers[$uri][$sessionId] = true; $this->sessionSubscriptions[$sessionId][$uri] = true; $this->logger->debug('Session subscribed to resource', [ 'sessionId' => $sessionId, 'uri' => $uri ]); } /** * Unsubscribe a session from a resource */ public function unsubscribe(string $sessionId, string $uri): void { unset($this->resourceSubscribers[$uri][$sessionId]); unset($this->sessionSubscriptions[$sessionId][$uri]); // Clean up empty arrays if (empty($this->resourceSubscribers[$uri])) { unset($this->resourceSubscribers[$uri]); } $this->logger->debug('Session unsubscribed from resource', [ 'sessionId' => $sessionId, 'uri' => $uri ]); } /** * Get all sessions subscribed to a resource */ public function getSubscribers(string $uri): array { return array_keys($this->resourceSubscribers[$uri] ?? []); } /** * Check if a session is subscribed to a resource */ public function isSubscribed(string $sessionId, string $uri): bool { return isset($this->sessionSubscriptions[$sessionId][$uri]); } /** * Clean up all subscriptions for a session */ public function cleanupSession(string $sessionId): void { if (!isset($this->sessionSubscriptions[$sessionId])) { return; } $uris = array_keys($this->sessionSubscriptions[$sessionId]); foreach ($uris as $uri) { unset($this->resourceSubscribers[$uri][$sessionId]); // Clean up empty arrays if (empty($this->resourceSubscribers[$uri])) { unset($this->resourceSubscribers[$uri]); } } unset($this->sessionSubscriptions[$sessionId]); $this->logger->debug('Cleaned up all subscriptions for session', [ 'sessionId' => $sessionId, 'count' => count($uris) ]); } } ``` -------------------------------------------------------------------------------- /examples/06-custom-dependencies-stdio/Services.php: -------------------------------------------------------------------------------- ```php <?php namespace Mcp\DependenciesStdioExample\Services; use Psr\Log\LoggerInterface; // --- Mock Services --- interface TaskRepositoryInterface { public function addTask(string $userId, string $description): array; public function getTasksForUser(string $userId): array; public function getAllTasks(): array; public function completeTask(int $taskId): bool; } class InMemoryTaskRepository implements TaskRepositoryInterface { private array $tasks = []; private int $nextTaskId = 1; private LoggerInterface $logger; public function __construct(LoggerInterface $logger) { $this->logger = $logger; // Add some initial tasks $this->addTask('user1', 'Buy groceries'); $this->addTask('user1', 'Write MCP example'); $this->addTask('user2', 'Review PR'); } public function addTask(string $userId, string $description): array { $task = [ 'id' => $this->nextTaskId++, 'userId' => $userId, 'description' => $description, 'completed' => false, 'createdAt' => date('c'), ]; $this->tasks[$task['id']] = $task; $this->logger->info('Task added', ['id' => $task['id'], 'user' => $userId]); return $task; } public function getTasksForUser(string $userId): array { return array_values(array_filter($this->tasks, fn ($task) => $task['userId'] === $userId && ! $task['completed'])); } public function getAllTasks(): array { return array_values($this->tasks); } public function completeTask(int $taskId): bool { if (isset($this->tasks[$taskId])) { $this->tasks[$taskId]['completed'] = true; $this->logger->info('Task completed', ['id' => $taskId]); return true; } return false; } } interface StatsServiceInterface { public function getSystemStats(): array; } class SystemStatsService implements StatsServiceInterface { private TaskRepositoryInterface $taskRepository; public function __construct(TaskRepositoryInterface $taskRepository) { $this->taskRepository = $taskRepository; } public function getSystemStats(): array { $allTasks = $this->taskRepository->getAllTasks(); $completed = count(array_filter($allTasks, fn ($task) => $task['completed'])); $pending = count($allTasks) - $completed; return [ 'total_tasks' => count($allTasks), 'completed_tasks' => $completed, 'pending_tasks' => $pending, 'server_uptime_seconds' => time() - $_SERVER['REQUEST_TIME_FLOAT'], // Approx uptime for CLI script ]; } } ``` -------------------------------------------------------------------------------- /examples/03-manual-registration-stdio/server.php: -------------------------------------------------------------------------------- ```php #!/usr/bin/env php <?php /* |-------------------------------------------------------------------------- | MCP Stdio Server (Manual Element Registration) |-------------------------------------------------------------------------- | | This server demonstrates how to manually register all MCP elements | (Tools, Resources, Prompts, ResourceTemplates) using the ServerBuilder's | fluent `withTool()`, `withResource()`, etc., methods. | It does NOT use attribute discovery. Handlers are in 'SimpleHandlers.php'. | It runs via the STDIO transport. | | To Use: | 1. Configure your MCP Client (e.g., Cursor) for this server: | | { | "mcpServers": { | "php-stdio-manual": { | "command": "php", | "args": ["/full/path/to/examples/03-manual-registration-stdio/server.php"] | } | } | } | | All elements are explicitly defined during the ServerBuilder chain. | The $server->discover() method is NOT called. | */ declare(strict_types=1); chdir(__DIR__); require_once '../../vendor/autoload.php'; require_once './SimpleHandlers.php'; use Mcp\ManualStdioExample\SimpleHandlers; use PhpMcp\Server\Defaults\BasicContainer; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\StdioServerTransport; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; class StderrLogger extends AbstractLogger { public function log($level, \Stringable|string $message, array $context = []): void { fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); } } try { $logger = new StderrLogger(); $logger->info('Starting MCP Manual Registration (Stdio) Server...'); $container = new BasicContainer(); $container->set(LoggerInterface::class, $logger); $server = Server::make() ->withServerInfo('Manual Reg Server', '1.0.0') ->withLogger($logger) ->withContainer($container) ->withTool([SimpleHandlers::class, 'echoText'], 'echo_text') ->withResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain') ->withPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') ->withResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json') ->build(); $transport = new StdioServerTransport(); $server->listen($transport); $logger->info('Server listener stopped gracefully.'); exit(0); } catch (\Throwable $e) { fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); exit(1); } ``` -------------------------------------------------------------------------------- /tests/Unit/ConfigurationTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit; use Mockery; use PhpMcp\Schema\Implementation; use PhpMcp\Schema\ServerCapabilities; use PhpMcp\Server\Configuration; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use React\EventLoop\LoopInterface; beforeEach(function () { $this->serverInfo = Implementation::make('TestServer', '1.1.0'); $this->logger = Mockery::mock(LoggerInterface::class); $this->loop = Mockery::mock(LoopInterface::class); $this->cache = Mockery::mock(CacheInterface::class); $this->container = Mockery::mock(ContainerInterface::class); $this->capabilities = ServerCapabilities::make(); }); afterEach(function () { Mockery::close(); }); it('constructs configuration object with all properties', function () { $paginationLimit = 100; $config = new Configuration( serverInfo: $this->serverInfo, capabilities: $this->capabilities, logger: $this->logger, loop: $this->loop, cache: $this->cache, container: $this->container, paginationLimit: $paginationLimit ); expect($config->serverInfo)->toBe($this->serverInfo); expect($config->capabilities)->toBe($this->capabilities); expect($config->logger)->toBe($this->logger); expect($config->loop)->toBe($this->loop); expect($config->cache)->toBe($this->cache); expect($config->container)->toBe($this->container); expect($config->paginationLimit)->toBe($paginationLimit); }); it('constructs configuration object with default pagination limit', function () { $config = new Configuration( serverInfo: $this->serverInfo, capabilities: $this->capabilities, logger: $this->logger, loop: $this->loop, cache: $this->cache, container: $this->container ); expect($config->paginationLimit)->toBe(50); // Default value }); it('constructs configuration object with null cache', function () { $config = new Configuration( serverInfo: $this->serverInfo, capabilities: $this->capabilities, logger: $this->logger, loop: $this->loop, cache: null, container: $this->container ); expect($config->cache)->toBeNull(); }); it('constructs configuration object with specific capabilities', function () { $customCaps = ServerCapabilities::make( resourcesSubscribe: true, logging: true, ); $config = new Configuration( serverInfo: $this->serverInfo, capabilities: $customCaps, logger: $this->logger, loop: $this->loop, cache: null, container: $this->container ); expect($config->capabilities)->toBe($customCaps); expect($config->capabilities->resourcesSubscribe)->toBeTrue(); expect($config->capabilities->logging)->toBeTrue(); }); ``` -------------------------------------------------------------------------------- /examples/04-combined-registration-http/server.php: -------------------------------------------------------------------------------- ```php #!/usr/bin/env php <?php /* |-------------------------------------------------------------------------- | MCP HTTP Server (Combined Manual & Discovered Elements) |-------------------------------------------------------------------------- | | This server demonstrates a combination of manual element registration | via the ServerBuilder and attribute-based discovery. | - Manually registered elements are defined in 'ManualHandlers.php'. | - Discoverable elements are in 'DiscoveredElements.php'. | | It runs via the HTTP transport. | | This example also shows precedence: if a manually registered element | has the same identifier (e.g., URI for a resource, or name for a tool) | as a discovered one, the manual registration takes priority. | | To Use: | 1. Run this script from your CLI: `php server.php` | The server will listen on http://127.0.0.1:8081 by default. | 2. Configure your MCP Client (e.g., Cursor): | | { | "mcpServers": { | "php-http-combined": { | "url": "http://127.0.0.1:8081/mcp_combined/sse" // Note the prefix | } | } | } | | Manual elements are registered during ServerBuilder->build(). | Then, $server->discover() scans for attributed elements. | */ declare(strict_types=1); chdir(__DIR__); require_once '../../vendor/autoload.php'; require_once './DiscoveredElements.php'; require_once './ManualHandlers.php'; use Mcp\CombinedHttpExample\Manual\ManualHandlers; use PhpMcp\Server\Defaults\BasicContainer; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\HttpServerTransport; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; class StderrLogger extends AbstractLogger { public function log($level, \Stringable|string $message, array $context = []): void { fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); } } try { $logger = new StderrLogger(); $logger->info('Starting MCP Combined Registration (HTTP) Server...'); $container = new BasicContainer(); $container->set(LoggerInterface::class, $logger); // ManualHandlers needs LoggerInterface $server = Server::make() ->withServerInfo('Combined HTTP Server', '1.0.0') ->withLogger($logger) ->withContainer($container) ->withTool([ManualHandlers::class, 'manualGreeter']) ->withResource( [ManualHandlers::class, 'getPriorityConfigManual'], 'config://priority', 'priority_config_manual', ) ->build(); // Now, run discovery. Discovered elements will be added. // If 'config://priority' was discovered, the manual one takes precedence. $server->discover(__DIR__, scanDirs: ['.']); $transport = new HttpServerTransport('127.0.0.1', 8081, 'mcp_combined'); $server->listen($transport); $logger->info('Server listener stopped gracefully.'); exit(0); } catch (\Throwable $e) { fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); exit(1); } ``` -------------------------------------------------------------------------------- /tests/Fixtures/General/ToolHandlerFixture.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Fixtures\General; use PhpMcp\Schema\Content\TextContent; use PhpMcp\Schema\Content\ImageContent; use PhpMcp\Schema\Content\AudioContent; use PhpMcp\Server\Context; use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; use Psr\Log\LoggerInterface; class ToolHandlerFixture { public function __construct() { } public function greet(string $name): string { return "Hello, {$name}!"; } public function sum(int $a, int $b): int { return $a + $b; } public function optionalParamsTool(string $required, ?string $optional = "default_val"): string { return "{$required} and {$optional}"; } public function noParamsTool(): array { return ['status' => 'ok', 'timestamp' => time()]; } public function processBackedEnum(BackedStringEnum $status): string { return "Status processed: " . $status->value; } public function returnString(): string { return "This is a string result."; } public function returnInteger(): int { return 12345; } public function returnFloat(): float { return 67.89; } public function returnBooleanTrue(): bool { return true; } public function returnBooleanFalse(): bool { return false; } public function returnNull(): ?string { return null; } public function returnArray(): array { return ['message' => 'Array result', 'data' => [1, 2, 3]]; } public function returnStdClass(): \stdClass { $obj = new \stdClass(); $obj->property = "value"; return $obj; } public function returnTextContent(): TextContent { return TextContent::make("Pre-formatted TextContent."); } public function returnImageContent(): ImageContent { return ImageContent::make("base64data==", "image/png"); } public function returnAudioContent(): AudioContent { return AudioContent::make("base64audio==", "audio/mp3"); } public function returnArrayOfContent(): array { return [ TextContent::make("Part 1"), ImageContent::make("imgdata", "image/jpeg") ]; } public function returnMixedArray(): array { return [ "A raw string", TextContent::make("A TextContent object"), 123, true, null, ['nested_key' => 'nested_value', 'sub_array' => [4, 5]], ImageContent::make("img_data_mixed", "image/gif"), (object)['obj_prop' => 'obj_val'] ]; } public function returnEmptyArray(): array { return []; } public function toolThatThrows(): void { throw new \InvalidArgumentException("Something went wrong in the tool."); } public function toolUnencodableResult() { return fopen('php://memory', 'r'); } public function toolReadsContext(Context $context): string { if (!$context->request) { return "No request instance present"; } return $context->request->getHeaderLine('X-Test-Header') ?: "No X-Test-Header"; } } ``` -------------------------------------------------------------------------------- /examples/07-complex-tool-schema-http/server.php: -------------------------------------------------------------------------------- ```php #!/usr/bin/env php <?php /* |-------------------------------------------------------------------------- | MCP HTTP Server with Complex Tool Schema (Event Scheduler) |-------------------------------------------------------------------------- | | This example demonstrates how to define an MCP Tool with a more complex | input schema, utilizing various PHP types, optional parameters, default | values, and backed Enums. The server automatically generates the | corresponding JSON Schema for the tool's input. | | Scenario: | An "Event Scheduler" tool that allows scheduling events with details like | title, date, time (optional), type (enum), priority (enum with default), | attendees (optional list), and invite preferences (boolean with default). | | Key Points: | - The `schedule_event` tool in `McpEventScheduler.php` showcases: | - Required string parameters (`title`, `date`). | - A required backed string enum parameter (`EventType $type`). | - Optional nullable string (`?string $time = null`). | - Optional backed integer enum with a default value (`EventPriority $priority = EventPriority::Normal`). | - Optional nullable array of strings (`?array $attendees = null`). | - Optional boolean with a default value (`bool $sendInvites = true`). | - PHP type hints and default values are used by `SchemaGenerator` (internal) | to create the `inputSchema` for the tool. | - This example uses attribute-based discovery and the HTTP transport. | | To Use: | 1. Run this script: `php server.php` (from this directory) | The server will listen on http://127.0.0.1:8082 by default. | 2. Configure your MCP Client (e.g., Cursor) for this server: | | { | "mcpServers": { | "php-http-complex-scheduler": { | "url": "http://127.0.0.1:8082/mcp_scheduler/sse" // Note the prefix | } | } | } | | Connect your client, list tools, and inspect the 'inputSchema' for the | 'schedule_event' tool. Prompt your LLM with question to test the tool. | */ declare(strict_types=1); chdir(__DIR__); require_once '../../vendor/autoload.php'; require_once './EventTypes.php'; // Include enums require_once './McpEventScheduler.php'; use PhpMcp\Server\Defaults\BasicContainer; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\HttpServerTransport; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; class StderrLogger extends AbstractLogger { public function log($level, \Stringable|string $message, array $context = []): void { fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); } } try { $logger = new StderrLogger(); $logger->info('Starting MCP Complex Schema HTTP Server...'); $container = new BasicContainer(); $container->set(LoggerInterface::class, $logger); $server = Server::make() ->withServerInfo('Event Scheduler Server', '1.0.0') ->withLogger($logger) ->withContainer($container) ->build(); $server->discover(__DIR__, ['.']); $transport = new HttpServerTransport('127.0.0.1', 8082, 'mcp_scheduler'); $server->listen($transport); $logger->info('Server listener stopped gracefully.'); exit(0); } catch (\Throwable $e) { fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); exit(1); } ``` -------------------------------------------------------------------------------- /tests/Fixtures/ServerScripts/StreamableHttpTestServer.php: -------------------------------------------------------------------------------- ```php #!/usr/bin/env php <?php declare(strict_types=1); require_once __DIR__ . '/../../../vendor/autoload.php'; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\StreamableHttpServerTransport; use PhpMcp\Server\Tests\Fixtures\General\ToolHandlerFixture; use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture; use PhpMcp\Server\Tests\Fixtures\General\PromptHandlerFixture; use PhpMcp\Server\Tests\Fixtures\General\RequestAttributeChecker; use PhpMcp\Server\Tests\Fixtures\Middlewares\HeaderMiddleware; use PhpMcp\Server\Tests\Fixtures\Middlewares\RequestAttributeMiddleware; use PhpMcp\Server\Tests\Fixtures\Middlewares\ShortCircuitMiddleware; use PhpMcp\Server\Tests\Fixtures\Middlewares\FirstMiddleware; use PhpMcp\Server\Tests\Fixtures\Middlewares\SecondMiddleware; use PhpMcp\Server\Tests\Fixtures\Middlewares\ThirdMiddleware; use PhpMcp\Server\Tests\Fixtures\Middlewares\ErrorMiddleware; use PhpMcp\Server\Defaults\InMemoryEventStore; use Psr\Log\AbstractLogger; use Psr\Log\NullLogger; class StdErrLogger extends AbstractLogger { public function log($level, \Stringable|string $message, array $context = []): void { fwrite(STDERR, sprintf("[%s] SERVER_LOG: %s %s\n", strtoupper((string)$level), $message, empty($context) ? '' : json_encode($context))); } } $host = $argv[1] ?? '127.0.0.1'; $port = (int)($argv[2] ?? 8992); $mcpPath = $argv[3] ?? 'mcp_streamable_test'; $enableJsonResponse = filter_var($argv[4] ?? 'true', FILTER_VALIDATE_BOOLEAN); $useEventStore = filter_var($argv[5] ?? 'false', FILTER_VALIDATE_BOOLEAN); $stateless = filter_var($argv[6] ?? 'false', FILTER_VALIDATE_BOOLEAN); try { $logger = new NullLogger(); $logger->info("Starting StreamableHttpTestServer on {$host}:{$port}/{$mcpPath}, JSON Mode: " . ($enableJsonResponse ? 'ON' : 'OFF') . ", Stateless: " . ($stateless ? 'ON' : 'OFF')); $eventStore = $useEventStore ? new InMemoryEventStore() : null; $server = Server::make() ->withServerInfo('StreamableHttpIntegrationServer', '0.1.0') ->withLogger($logger) ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_streamable_tool') ->withTool([ToolHandlerFixture::class, 'sum'], 'sum_streamable_tool') // For batch testing ->withTool([ToolHandlerFixture::class, 'toolReadsContext'], 'tool_reads_context') // for Context testing ->withTool([RequestAttributeChecker::class, 'checkAttribute'], 'check_request_attribute_tool') ->withResource([ResourceHandlerFixture::class, 'getStaticText'], "test://streamable/static", 'static_streamable_resource') ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_streamable_prompt') ->build(); $middlewares = [ new HeaderMiddleware(), new RequestAttributeMiddleware(), new ShortCircuitMiddleware(), new FirstMiddleware(), new SecondMiddleware(), new ThirdMiddleware(), new ErrorMiddleware() ]; $transport = new StreamableHttpServerTransport( host: $host, port: $port, mcpPath: $mcpPath, enableJsonResponse: $enableJsonResponse, stateless: $stateless, eventStore: $eventStore, middlewares: $middlewares ); $server->listen($transport); $logger->info("StreamableHttpTestServer listener stopped on {$host}:{$port}."); exit(0); } catch (\Throwable $e) { fwrite(STDERR, "[STREAMABLE_HTTP_SERVER_CRITICAL_ERROR]\nHost:{$host} Port:{$port} Prefix:{$mcpPath}\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); exit(1); } ``` -------------------------------------------------------------------------------- /src/Utils/HandlerResolver.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Utils; use InvalidArgumentException; use ReflectionMethod; use ReflectionException; /** * Utility class to validate and resolve MCP element handlers. */ class HandlerResolver { /** * Validates and resolves a handler to a ReflectionMethod or ReflectionFunction instance. * * A handler can be: * - A Closure: function() { ... } * - An array: [ClassName::class, 'methodName'] (instance method) * - An array: [ClassName::class, 'staticMethod'] (static method, if callable) * - A string: InvokableClassName::class (which will resolve to its '__invoke' method) * * @param \Closure|array|string $handler The handler to resolve. * @return \ReflectionMethod|\ReflectionFunction * * @throws InvalidArgumentException If the handler format is invalid, the class/method doesn't exist, * or the method is unsuitable (e.g., private, abstract). */ public static function resolve(\Closure|array|string $handler): \ReflectionMethod|\ReflectionFunction { // Handle Closures if ($handler instanceof \Closure) { return new \ReflectionFunction($handler); } $className = null; $methodName = null; if (is_array($handler)) { if (count($handler) !== 2 || !isset($handler[0]) || !isset($handler[1]) || !is_string($handler[0]) || !is_string($handler[1])) { throw new InvalidArgumentException('Invalid array handler format. Expected [ClassName::class, \'methodName\'].'); } [$className, $methodName] = $handler; if (!class_exists($className)) { throw new InvalidArgumentException("Handler class '{$className}' not found for array handler."); } if (!method_exists($className, $methodName)) { throw new InvalidArgumentException("Handler method '{$methodName}' not found in class '{$className}' for array handler."); } } elseif (is_string($handler) && class_exists($handler)) { $className = $handler; $methodName = '__invoke'; if (!method_exists($className, $methodName)) { throw new InvalidArgumentException("Invokable handler class '{$className}' must have a public '__invoke' method."); } } else { throw new InvalidArgumentException('Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); } try { $reflectionMethod = new ReflectionMethod($className, $methodName); // For discovered elements (non-manual), still reject static methods // For manual elements, we'll allow static methods since they're callable if (!$reflectionMethod->isPublic()) { throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' must be public."); } if ($reflectionMethod->isAbstract()) { throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be abstract."); } if ($reflectionMethod->isConstructor() || $reflectionMethod->isDestructor()) { throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be a constructor or destructor."); } return $reflectionMethod; } catch (ReflectionException $e) { // This typically occurs if class_exists passed but ReflectionMethod still fails (rare) throw new InvalidArgumentException("Reflection error for handler '{$className}::{$methodName}': {$e->getMessage()}", 0, $e); } } } ``` -------------------------------------------------------------------------------- /examples/06-custom-dependencies-stdio/server.php: -------------------------------------------------------------------------------- ```php #!/usr/bin/env php <?php /* |-------------------------------------------------------------------------- | MCP Stdio Server with Custom Dependencies (Task Manager) |-------------------------------------------------------------------------- | | This example demonstrates how to use a PSR-11 Dependency Injection (DI) | container (PhpMcp\Server\Defaults\BasicContainer in this case) to inject | custom services (like a TaskRepositoryInterface or StatsServiceInterface) | into your MCP element handler classes. | | Scenario: | A simple Task Management system where: | - Tools allow adding tasks, listing tasks for a user, and completing tasks. | - A Resource provides system statistics (total tasks, pending, etc.). | - Handlers in 'McpTaskHandlers.php' depend on service interfaces. | - Concrete service implementations are in 'Services.php'. | | Key Points: | - The `ServerBuilder` is configured with `->withContainer($container)`. | - The DI container is set up with bindings for service interfaces to | their concrete implementations (e.g., TaskRepositoryInterface -> InMemoryTaskRepository). | - The `McpTaskHandlers` class receives its dependencies (TaskRepositoryInterface, | StatsServiceInterface, LoggerInterface) via constructor injection, resolved by | the DI container when the Processor needs an instance of McpTaskHandlers. | - This example uses attribute-based discovery via `$server->discover()`. | - It runs using the STDIO transport. | | To Use: | 1. Run this script: `php server.php` (from this directory) | 2. Configure your MCP Client (e.g., Cursor) for this server: | | { | "mcpServers": { | "php-stdio-deps-taskmgr": { | "command": "php", | "args": ["/full/path/to/examples/06-custom-dependencies-stdio/server.php"] | } | } | } | | Interact with tools like 'add_task', 'list_user_tasks', 'complete_task' | and read the resource 'stats://system/overview'. | */ declare(strict_types=1); chdir(__DIR__); require_once '../../vendor/autoload.php'; require_once './Services.php'; require_once './McpTaskHandlers.php'; use Mcp\DependenciesStdioExample\Services; use PhpMcp\Server\Defaults\BasicContainer; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\StdioServerTransport; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; class StderrLogger extends AbstractLogger { public function log($level, \Stringable|string $message, array $context = []): void { fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); } } try { $logger = new StderrLogger(); $logger->info('Starting MCP Custom Dependencies (Stdio) Server...'); $container = new BasicContainer(); $container->set(LoggerInterface::class, $logger); $taskRepo = new Services\InMemoryTaskRepository($logger); $container->set(Services\TaskRepositoryInterface::class, $taskRepo); $statsService = new Services\SystemStatsService($taskRepo); $container->set(Services\StatsServiceInterface::class, $statsService); $server = Server::make() ->withServerInfo('Task Manager Server', '1.0.0') ->withLogger($logger) ->withContainer($container) ->build(); $server->discover(__DIR__, ['.']); $transport = new StdioServerTransport(); $server->listen($transport); $logger->info('Server listener stopped gracefully.'); exit(0); } catch (\Throwable $e) { fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); fwrite(STDERR, $e->getTraceAsString() . "\n"); exit(1); } ``` -------------------------------------------------------------------------------- /src/Session/SessionManager.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Session; use Evenement\EventEmitterInterface; use Evenement\EventEmitterTrait; use PhpMcp\Server\Contracts\SessionHandlerInterface; use PhpMcp\Server\Contracts\SessionInterface; use Psr\Log\LoggerInterface; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; class SessionManager implements EventEmitterInterface { use EventEmitterTrait; protected ?TimerInterface $gcTimer = null; public function __construct( protected SessionHandlerInterface $handler, protected LoggerInterface $logger, protected ?LoopInterface $loop = null, protected int $ttl = 3600, protected int|float $gcInterval = 300 ) { $this->loop ??= Loop::get(); } /** * Start the garbage collection timer */ public function startGcTimer(): void { if ($this->gcTimer !== null) { return; } $this->gcTimer = $this->loop->addPeriodicTimer($this->gcInterval, [$this, 'gc']); } public function gc(): array { $deletedSessions = $this->handler->gc($this->ttl); foreach ($deletedSessions as $sessionId) { $this->emit('session_deleted', [$sessionId]); } if (count($deletedSessions) > 0) { $this->logger->debug('Session garbage collection complete', [ 'purged_sessions' => count($deletedSessions), ]); } return $deletedSessions; } /** * Stop the garbage collection timer */ public function stopGcTimer(): void { if ($this->gcTimer !== null) { $this->loop->cancelTimer($this->gcTimer); $this->gcTimer = null; } } /** * Create a new session */ public function createSession(string $sessionId): SessionInterface { $session = new Session($this->handler, $sessionId); $session->hydrate([ 'initialized' => false, 'client_info' => null, 'protocol_version' => null, 'subscriptions' => [], // [uri => true] 'message_queue' => [], // string[] (raw JSON-RPC frames) 'log_level' => null, ]); $session->save(); $this->logger->info('Session created', ['sessionId' => $sessionId]); $this->emit('session_created', [$sessionId, $session]); return $session; } /** * Get an existing session */ public function getSession(string $sessionId): ?SessionInterface { return Session::retrieve($sessionId, $this->handler); } public function hasSession(string $sessionId): bool { return $this->getSession($sessionId) !== null; } /** * Delete a session completely */ public function deleteSession(string $sessionId): bool { $success = $this->handler->destroy($sessionId); if ($success) { $this->emit('session_deleted', [$sessionId]); $this->logger->info('Session deleted', ['sessionId' => $sessionId]); } else { $this->logger->warning('Failed to delete session', ['sessionId' => $sessionId]); } return $success; } public function queueMessage(string $sessionId, string $message): void { $session = $this->getSession($sessionId); if ($session === null) { return; } $session->queueMessage($message); $session->save(); } public function dequeueMessages(string $sessionId): array { $session = $this->getSession($sessionId); if ($session === null) { return []; } $messages = $session->dequeueMessages(); $session->save(); return $messages; } public function hasQueuedMessages(string $sessionId): bool { $session = $this->getSession($sessionId, true); if ($session === null) { return false; } return $session->hasQueuedMessages(); } } ``` -------------------------------------------------------------------------------- /src/Exception/McpServerException.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Exception; use Exception; use PhpMcp\Schema\Constants; use PhpMcp\Schema\JsonRpc\Error as JsonRpcError; use Throwable; /** * Base exception for all MCP Server library errors. */ class McpServerException extends Exception { // MCP reserved range: -32000 to -32099 (Server error) // Add specific server-side codes if needed later, e.g.: // public const CODE_RESOURCE_ACTION_FAILED = -32000; // public const CODE_TOOL_EXECUTION_FAILED = -32001; /** * Additional data associated with the error, suitable for JSON-RPC 'data' field. * * @var mixed|null */ protected mixed $data = null; /** * @param string $message Error message. * @param int $code Error code (use constants or appropriate HTTP status codes if applicable). * @param mixed|null $data Additional data. * @param ?Throwable $previous Previous exception. */ public function __construct( string $message = '', int $code = 0, mixed $data = null, ?Throwable $previous = null ) { parent::__construct($message, $code, $previous); $this->data = $data; } /** * Get additional error data. * * @return mixed|null */ public function getData(): mixed { return $this->data; } /** * Formats the exception into a JSON-RPC 2.0 error object structure. * Specific exceptions should override this or provide factories with correct codes. */ public function toJsonRpcError(string|int $id): JsonRpcError { $code = ($this->code >= -32768 && $this->code <= -32000) ? $this->code : Constants::INTERNAL_ERROR; return new JsonRpcError( jsonrpc: '2.0', id: $id, code: $code, message: $this->getMessage(), data: $this->getData() ); } public static function parseError(string $details, ?Throwable $previous = null): self { return new ProtocolException('Parse error: ' . $details, Constants::PARSE_ERROR, null, $previous); } public static function invalidRequest(?string $details = 'Invalid Request', ?Throwable $previous = null): self { return new ProtocolException($details, Constants::INVALID_REQUEST, null, $previous); } public static function methodNotFound(string $methodName, ?string $message = null, ?Throwable $previous = null): self { return new ProtocolException($message ?? "Method not found: {$methodName}", Constants::METHOD_NOT_FOUND, null, $previous); } public static function invalidParams(string $message = 'Invalid params', $data = null, ?Throwable $previous = null): self { // Pass data (e.g., validation errors) through return new ProtocolException($message, Constants::INVALID_PARAMS, $data, $previous); } public static function internalError(?string $details = 'Internal server error', ?Throwable $previous = null): self { $message = 'Internal error'; if ($details && is_string($details)) { $message .= ': ' . $details; } elseif ($previous && $details === null) { $message .= ' (See server logs)'; } return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); } public static function toolExecutionFailed(string $toolName, ?Throwable $previous = null): self { $message = "Execution failed for tool '{$toolName}'"; if ($previous) { $message .= ': ' . $previous->getMessage(); } return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); } public static function resourceReadFailed(string $uri, ?Throwable $previous = null): self { $message = "Failed to read resource '{$uri}'"; if ($previous) { $message .= ': ' . $previous->getMessage(); } return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); } public static function promptGenerationFailed(string $promptName, ?Throwable $previous = null): self { $message = "Failed to generate prompt '{$promptName}'"; if ($previous) { $message .= ': ' . $previous->getMessage(); } return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); } } ``` -------------------------------------------------------------------------------- /src/Elements/RegisteredTool.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Elements; use PhpMcp\Schema\Content\Content; use PhpMcp\Schema\Content\TextContent; use PhpMcp\Server\Context; use Psr\Container\ContainerInterface; use PhpMcp\Schema\Tool; use Throwable; class RegisteredTool extends RegisteredElement { public function __construct( public readonly Tool $schema, callable|array|string $handler, bool $isManual = false, ) { parent::__construct($handler, $isManual); } public static function make(Tool $schema, callable|array|string $handler, bool $isManual = false): self { return new self($schema, $handler, $isManual); } /** * Calls the underlying handler for this tool. * * @return Content[] The content items for CallToolResult. */ public function call(ContainerInterface $container, array $arguments, Context $context): array { $result = $this->handle($container, $arguments, $context); return $this->formatResult($result); } /** * Formats the result of a tool execution into an array of MCP Content items. * * - If the result is already a Content object, it's wrapped in an array. * - If the result is an array: * - If all elements are Content objects, the array is returned as is. * - If it's a mixed array (Content and non-Content items), non-Content items are * individually formatted (scalars to TextContent, others to JSON TextContent). * - If it's an array with no Content items, the entire array is JSON-encoded into a single TextContent. * - Scalars (string, int, float, bool) are wrapped in TextContent. * - null is represented as TextContent('(null)'). * - Other objects are JSON-encoded and wrapped in TextContent. * * @param mixed $toolExecutionResult The raw value returned by the tool's PHP method. * @return Content[] The content items for CallToolResult. * @throws JsonException if JSON encoding fails for non-Content array/object results. */ protected function formatResult(mixed $toolExecutionResult): array { if ($toolExecutionResult instanceof Content) { return [$toolExecutionResult]; } if (is_array($toolExecutionResult)) { if (empty($toolExecutionResult)) { return [TextContent::make('[]')]; } $allAreContent = true; $hasContent = false; foreach ($toolExecutionResult as $item) { if ($item instanceof Content) { $hasContent = true; } else { $allAreContent = false; } } if ($allAreContent && $hasContent) { return $toolExecutionResult; } if ($hasContent) { $result = []; foreach ($toolExecutionResult as $item) { if ($item instanceof Content) { $result[] = $item; } else { $result = array_merge($result, $this->formatResult($item)); } } return $result; } } if ($toolExecutionResult === null) { return [TextContent::make('(null)')]; } if (is_bool($toolExecutionResult)) { return [TextContent::make($toolExecutionResult ? 'true' : 'false')]; } if (is_scalar($toolExecutionResult)) { return [TextContent::make($toolExecutionResult)]; } $jsonResult = json_encode( $toolExecutionResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE ); return [TextContent::make($jsonResult)]; } public function toArray(): array { return [ 'schema' => $this->schema->toArray(), ...parent::toArray(), ]; } public static function fromArray(array $data): self|false { try { if (! isset($data['schema']) || ! isset($data['handler'])) { return false; } return new self( Tool::fromArray($data['schema']), $data['handler'], $data['isManual'] ?? false, ); } catch (Throwable $e) { return false; } } } ``` -------------------------------------------------------------------------------- /src/Utils/DocBlockParser.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Utils; use phpDocumentor\Reflection\DocBlock; use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\DocBlock\Tags\Return_; use phpDocumentor\Reflection\DocBlockFactory; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Throwable; /** * Parses DocBlocks using phpdocumentor/reflection-docblock. */ class DocBlockParser { private DocBlockFactory $docBlockFactory; private LoggerInterface $logger; public function __construct(?LoggerInterface $logger = null) { $this->docBlockFactory = DocBlockFactory::createInstance(); $this->logger = $logger ?? new NullLogger(); } /** * Safely parses a DocComment string into a DocBlock object. */ public function parseDocBlock(string|null|false $docComment): ?DocBlock { if ($docComment === false || $docComment === null || empty($docComment)) { return null; } try { return $this->docBlockFactory->create($docComment); } catch (Throwable $e) { // Log error or handle gracefully if invalid DocBlock syntax is encountered $this->logger->warning('Failed to parse DocBlock', [ 'error' => $e->getMessage(), 'exception_trace' => $e->getTraceAsString(), ]); return null; } } /** * Gets the summary line from a DocBlock. */ public function getSummary(?DocBlock $docBlock): ?string { if (! $docBlock) { return null; } $summary = trim($docBlock->getSummary()); return $summary ?: null; // Return null if empty after trimming } /** * Gets the description from a DocBlock (summary + description body). */ public function getDescription(?DocBlock $docBlock): ?string { if (! $docBlock) { return null; } $summary = trim($docBlock->getSummary()); $descriptionBody = trim((string) $docBlock->getDescription()); if ($summary && $descriptionBody) { return $summary . "\n\n" . $descriptionBody; } if ($summary) { return $summary; } if ($descriptionBody) { return $descriptionBody; } return null; } /** * Extracts @param tag information from a DocBlock, keyed by variable name (e.g., '$paramName'). * * @return array<string, Param> */ public function getParamTags(?DocBlock $docBlock): array { if (! $docBlock) { return []; } /** @var array<string, Param> $paramTags */ $paramTags = []; foreach ($docBlock->getTagsByName('param') as $tag) { if ($tag instanceof Param && $tag->getVariableName()) { $paramTags['$' . $tag->getVariableName()] = $tag; } } return $paramTags; } /** * Gets the @return tag information from a DocBlock. */ public function getReturnTag(?DocBlock $docBlock): ?Return_ { if (! $docBlock) { return null; } /** @var Return_|null $returnTag */ $returnTag = $docBlock->getTagsByName('return')[0] ?? null; return $returnTag; } /** * Gets the description string from a Param tag. */ public function getParamDescription(?Param $paramTag): ?string { return $paramTag ? (trim((string) $paramTag->getDescription()) ?: null) : null; } /** * Gets the type string from a Param tag. */ public function getParamTypeString(?Param $paramTag): ?string { if ($paramTag && $paramTag->getType()) { $typeFromTag = trim((string) $paramTag->getType()); if (! empty($typeFromTag)) { return ltrim($typeFromTag, '\\'); } } return null; } /** * Gets the description string from a Return_ tag. */ public function getReturnDescription(?Return_ $returnTag): ?string { return $returnTag ? (trim((string) $returnTag->getDescription()) ?: null) : null; } /** * Gets the type string from a Return_ tag. */ public function getReturnTypeString(?Return_ $returnTag): ?string { if ($returnTag && $returnTag->getType()) { $typeFromTag = trim((string) $returnTag->getType()); if (! empty($typeFromTag)) { return ltrim($typeFromTag, '\\'); } } return null; } } ```