* [Client][Docs][Schema][Server] Implement protocol version negotiation The `initialize` handler never looked at the version the client asked for: it always answered with the server's configured version (or the SDK default), so a client offering a revision the server supports could still be told to speak a different one. The spec requires echoing back a supported request and only counter-offering when the requested version cannot be honoured. Turns ProtocolVersion into the registry the other SDKs converged on (typescript-sdk's protocolEras.ts, python-sdk's mcp_types/version.py): `latestHandshake()`, `handshakeVersions()`, `modernVersions()` and ordered comparison through `isAtLeast()`. Comparison goes through declaration order rather than string collation, so an unrecognized peer string compares conservatively instead of accidentally sorting above a known revision. The one throw the enum can produce is the SDK's own LogicException, so a consumer catching `Mcp\Exception\ExceptionInterface` does not miss it. Adds 2026-07-28 as a known revision and splits the set into two eras: handshake (2025-11-25 and earlier, negotiated via `initialize`) and modern (2026-07-28+, per-request `_meta`). The lists are kept apart so a modern revision can never leak into an `initialize` exchange, which has no `initialize` at all. Serving the modern era is follow-up work; this only teaches the SDK that the revision exists and must not be mis-negotiated. Deliberately absent is any "newest revision in any era" accessor, which would hand back the one revision the handshake must never offer. Server: negotiates and stores the result on the session. A version pinned in Configuration still pins the handshake to exactly that revision. Client: validates the counter-offer and fails the handshake instead of continuing on a revision it cannot speak; records the negotiated version on the client state, readable through the new Client::getProtocolVersion(). The HTTP middleware now defaults to the handshake versions rather than every declared case, so it stops accepting a modern version the server cannot yet serve. When the header is absent it falls back to ProtocolVersion::DEFAULT_NEGOTIATED_VERSION (2025-03-26), the revision that introduced both Streamable HTTP and the header itself. The negotiation behaviour is one provideNegotiationTable() data provider whose rows are the documented table -- supported revision echoed back, unknown or malformed counter-offered, modern counter-offered -- so the docs and the suite cannot drift apart. The supported-revision rows iterate handshakeVersions() rather than a literal list, so declaring a new handshake revision extends the table automatically, and the "never answer with a modern revision" assertion runs on every row. Documented in server-builder.md (the enum registry, how the server answers a handshake, what pinning changes), transports.md (the header check) and client.md (the client's side), all linking the spec's versioning section. * [Client][Docs][Schema][Server] Warn when the configured protocol version cannot be negotiated * Log a warning instead of silently falling back when a modern revision is configured on the client or pinned on the server builder. * Document that setProtocolVersion() does not restrict the MCP-Protocol-Version header check, which validates against a fixed set. * Pin every revision's era in a test, so a new case declared past the modern boundary fails instead of dropping out of the handshake set. * Add CHANGELOG entry. * [Client][Docs][Schema][Server] Rename version fallback constant and trim comments
747 lines
27 KiB
Markdown
747 lines
27 KiB
Markdown
# Server Builder
|
|
|
|
The server `Builder` is a fluent builder class that simplifies the creation and configuration of an MCP server instance.
|
|
It provides methods for setting server information, configuring discovery, registering capabilities, and customizing
|
|
various aspects of the server behavior.
|
|
|
|
## Table of Contents
|
|
|
|
- [Basic Usage](#basic-usage)
|
|
- [Server Configuration](#server-configuration)
|
|
- [Protocol Version Negotiation](#protocol-version-negotiation)
|
|
- [Discovery Configuration](#discovery-configuration)
|
|
- [Session Management](#session-management)
|
|
- [Manual Capability Registration](#manual-capability-registration)
|
|
- [Service Dependencies](#service-dependencies)
|
|
- [Custom Message Handlers](#custom-message-handlers)
|
|
- [Complete Example](#complete-example)
|
|
- [Method Reference](#method-reference)
|
|
|
|
## Basic Usage
|
|
|
|
There are two ways to obtain a server builder instance:
|
|
|
|
### Method 1: Static Builder Method (Recommended)
|
|
|
|
```php
|
|
use Mcp\Server;
|
|
|
|
$server = Server::builder()
|
|
->setServerInfo('My MCP Server', '1.0.0')
|
|
->setDiscovery(__DIR__, ['.'])
|
|
->build();
|
|
```
|
|
|
|
### Method 2: Direct Instantiation
|
|
|
|
```php
|
|
use Mcp\Server\Builder;
|
|
|
|
$server = (new Builder())
|
|
->setServerInfo('My MCP Server', '1.0.0')
|
|
->setDiscovery(__DIR__, ['.'])
|
|
->build();
|
|
```
|
|
|
|
Both methods return a `Builder` instance that you can configure with fluent methods. The `build()` method returns the
|
|
final `Server` instance ready for use.
|
|
|
|
## Server Configuration
|
|
|
|
### Server Information
|
|
|
|
Set the server's identity with name, version, and optional description:
|
|
|
|
```php
|
|
use Mcp\Schema\Icon;
|
|
use Mcp\Server;
|
|
|
|
$server = Server::builder()
|
|
->setServerInfo(
|
|
name: 'Calculator Server',
|
|
version: '1.2.0',
|
|
description: 'Advanced mathematical calculations',
|
|
icons: [new Icon('https://example.com/icon.png', 'image/png', ['64x64'])],
|
|
websiteUrl: 'https://example.com'
|
|
');
|
|
```
|
|
|
|
**Parameters:**
|
|
- `$name` (string): The server name
|
|
- `$version` (string): Version string (semantic versioning recommended)
|
|
- `$description` (string|null): Optional description
|
|
- `$icons` (Icon[]|null): Optional array of server icons
|
|
- `$websiteUrl` (string|null): Optional server website URL
|
|
|
|
### Pagination Limit
|
|
|
|
Configure the maximum number of items returned in paginated responses:
|
|
|
|
```php
|
|
$server = Server::builder()
|
|
->setPaginationLimit(100); // Default: 50
|
|
```
|
|
|
|
### Instructions
|
|
|
|
Provide hints to help AI models understand how to use your server:
|
|
|
|
```php
|
|
$server = Server::builder()
|
|
->setInstructions('This calculator supports basic arithmetic operations. Use the calculate tool for math operations and check the config resource for current settings.');
|
|
```
|
|
|
|
### Protocol Version
|
|
|
|
By default the server negotiates the protocol revision with each client during the `initialize` handshake, and you do
|
|
not need to configure anything. See [Protocol Version Negotiation](#protocol-version-negotiation) below for how that
|
|
negotiation resolves, and for what `setProtocolVersion()` changes:
|
|
|
|
```php
|
|
use Mcp\Schema\Enum\ProtocolVersion;
|
|
|
|
$server = Server::builder()
|
|
->setProtocolVersion(ProtocolVersion::V2025_06_18);
|
|
```
|
|
|
|
## Protocol Version Negotiation
|
|
|
|
MCP revisions are identified by a date string such as `2025-11-25`. The client names the revision it wants to speak in
|
|
its `initialize` request, and the server answers with the revision the connection will actually use. Both sides
|
|
disconnect if they cannot agree. This follows the
|
|
[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation)
|
|
section of the specification.
|
|
|
|
The SDK's known revisions live in `Mcp\Schema\Enum\ProtocolVersion`, declared oldest to newest:
|
|
|
|
```php
|
|
use Mcp\Schema\Enum\ProtocolVersion;
|
|
|
|
ProtocolVersion::latestHandshake(); // newest revision reachable via `initialize`
|
|
ProtocolVersion::handshakeVersions(); // every revision the server will negotiate, oldest first
|
|
ProtocolVersion::V2025_11_25->isAtLeast(ProtocolVersion::V2025_06_18); // true
|
|
```
|
|
|
|
Comparisons go through declaration order rather than string collation. The identifiers happen to be ISO dates today,
|
|
but they are an enumerated set rather than an ordered scalar, so nothing should assume they sort chronologically.
|
|
|
|
### How the server answers
|
|
|
|
| Client requests | Server responds with |
|
|
| --- | --- |
|
|
| A revision the server supports | That same revision |
|
|
| An unknown or malformed revision | `ProtocolVersion::latestHandshake()` as a counter-offer |
|
|
| A modern revision such as `2026-07-28` | `ProtocolVersion::latestHandshake()` as a counter-offer |
|
|
|
|
A counter-offer is not an error: the client decides whether it can continue on the offered revision or must close the
|
|
connection. The negotiated revision is stored on the session under `protocol_version`.
|
|
|
|
The last row is not a rejection of an unknown revision — the SDK knows `2026-07-28`, it just cannot be reached through
|
|
this handshake. The modern era replaced `initialize` with per-request metadata, so answering with one of its revisions
|
|
would leave a connection neither side could use. Serving that era is separate work; today the server only knows not to
|
|
mis-negotiate it.
|
|
|
|
This table is mirrored by the `provideNegotiationTable()` data provider in
|
|
`tests/Unit/Server/Handler/Request/InitializeHandlerTest.php`, which drives its supported-revision rows off the enum so
|
|
a newly declared revision is covered automatically.
|
|
|
|
### Pinning a revision
|
|
|
|
`setProtocolVersion()` pins the handshake to exactly one revision instead of negotiating across the supported set. The
|
|
pin wins over the client's request, so a client asking for anything else receives the pinned revision as a
|
|
counter-offer and has to decide whether to continue. Leave it unset unless you have a reason to refuse other revisions.
|
|
|
|
> [!NOTE]
|
|
> On the Streamable HTTP transport, every request after the handshake also carries an `MCP-Protocol-Version` header,
|
|
> which is validated separately by `ProtocolVersionMiddleware`. The pin does not reach that check: the transport
|
|
> builds the middleware without access to the server configuration, so the header keeps being accepted for every
|
|
> revision in `ProtocolVersion::handshakeVersions()`. To narrow it too, construct the middleware yourself with the
|
|
> same revision — see [Protocol Version Validation](transports.md#protocol-version-validation).
|
|
|
|
## Discovery Configuration
|
|
|
|
**Required when using MCP attributes.** If you're using PHP attributes (`#[McpTool]`, `#[McpResource]`, `#[McpResourceTemplate]`, `#[McpPrompt]`) to define your MCP elements, you **MUST** configure discovery to tell the server where to look for these attributes.
|
|
|
|
```php
|
|
$server = Server::builder()
|
|
->setDiscovery(
|
|
basePath: __DIR__,
|
|
scanDirs: ['.', 'src', 'lib'], // Where to look for MCP attributes
|
|
excludeDirs: ['vendor', 'tests'], // Where NOT to look
|
|
cache: $cacheInstance, // Optional: cache discovered elements
|
|
namePatterns: ['*.php', '*.inc'], // Optional: list of filename patterns to match
|
|
);
|
|
```
|
|
|
|
**Parameters:**
|
|
- `$basePath` (string): Base directory for discovery (typically `__DIR__`)
|
|
- `$scanDirs` (array): Directories to recursively scan for `#[McpTool]`, `#[McpResource]`, etc. All subdirectories are included. (default: `['.', 'src']`)
|
|
- `$excludeDirs` (array): Directory names to exclude **within** the scanned directories during recursive scanning
|
|
- `$cache` (CacheInterface|null): Optional PSR-16 cache to store discovered elements for performance
|
|
- `$namePatterns` (array): Optional list of patterns (regexp, glob, or string) for file names (default: `['*.php']`)
|
|
|
|
**Basic Discovery (scans current directory and `src/`):**
|
|
```php
|
|
$server = Server::builder()
|
|
->setDiscovery(__DIR__) // Minimal setup
|
|
->build();
|
|
```
|
|
|
|
**Production Setup with Caching:**
|
|
```php
|
|
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
|
use Symfony\Component\Cache\Psr16Cache;
|
|
|
|
// Cache discovered elements to avoid filesystem scanning on every server start
|
|
$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery'));
|
|
|
|
$server = Server::builder()
|
|
->setDiscovery(
|
|
basePath: __DIR__,
|
|
scanDirs: ['src', 'lib'], // Scan these directories recursively
|
|
excludeDirs: ['vendor', 'tests', 'temp'], // Skip these directory names within scanned dirs
|
|
cache: $cache // Cache for performance
|
|
)
|
|
->build();
|
|
```
|
|
|
|
**How `excludeDirs` works:**
|
|
- If scanning `src/` and there's `src/vendor/`, it will be excluded
|
|
- If scanning `lib/` and there's `lib/tests/`, it will be excluded
|
|
- But if `vendor/` and `tests/` are at the same level as `src/`, they're not scanned anyway (not in `scanDirs`)
|
|
|
|
> **Performance**: Always use a cache in production. The first run scans and caches all discovered MCP elements, making
|
|
> subsequent server startups nearly instantaneous.
|
|
|
|
## Session Management
|
|
|
|
Configure session storage and lifecycle. By default, the SDK uses `InMemorySessionStore`:
|
|
|
|
```php
|
|
use Mcp\Server\Session\FileSessionStore;
|
|
use Mcp\Server\Session\InMemorySessionStore;
|
|
use Mcp\Server\Session\Psr16SessionStore;
|
|
use Symfony\Component\Cache\Psr16Cache;
|
|
use Symfony\Component\Cache\Adapter\RedisAdapter;
|
|
|
|
// Override with file-based storage
|
|
$server = Server::builder()
|
|
->setSession(new FileSessionStore(__DIR__ . '/sessions'))
|
|
->build();
|
|
|
|
// Override with in-memory storage and custom TTL
|
|
$server = Server::builder()
|
|
->setSession(new InMemorySessionStore(3600))
|
|
->build();
|
|
|
|
// Override with PSR-16 cache-based storage
|
|
// Requires psr/simple-cache and symfony/cache (or any other PSR-16 implementation)
|
|
// composer require psr/simple-cache symfony/cache
|
|
$redisAdapter = new RedisAdapter(
|
|
RedisAdapter::createConnection('redis://localhost:6379'),
|
|
'mcp_sessions'
|
|
);
|
|
|
|
$server = Server::builder()
|
|
->setSession(new Psr16SessionStore(
|
|
cache: new Psr16Cache($redisAdapter),
|
|
prefix: 'mcp-',
|
|
ttl: 3600
|
|
))
|
|
->build();
|
|
```
|
|
|
|
### Garbage Collection Configuration
|
|
|
|
The SDK periodically runs garbage collection to clean up expired sessions, similar to PHP's native
|
|
`session.gc_probability` and `session.gc_divisor` settings. The probability that GC runs on any given
|
|
request is `gcProbability / gcDivisor`.
|
|
|
|
```php
|
|
// Default: 1/100 (1% chance per request)
|
|
$server = Server::builder()
|
|
->setSession(new FileSessionStore(__DIR__ . '/sessions'))
|
|
->build();
|
|
|
|
// Higher frequency: 1/10 (10% chance per request)
|
|
$server = Server::builder()
|
|
->setSession(
|
|
new FileSessionStore(__DIR__ . '/sessions'),
|
|
gcProbability: 1,
|
|
gcDivisor: 10,
|
|
)
|
|
->build();
|
|
|
|
// Run GC on every request
|
|
$server = Server::builder()
|
|
->setSession(gcProbability: 1, gcDivisor: 1)
|
|
->build();
|
|
|
|
// Disable GC entirely (e.g. when using an external cleanup process)
|
|
$server = Server::builder()
|
|
->setSession(gcProbability: 0)
|
|
->build();
|
|
```
|
|
|
|
**Parameters:**
|
|
- `$gcProbability` (int): The numerator of the GC probability fraction (default: `1`). Set to `0` to disable GC.
|
|
- `$gcDivisor` (int): The denominator of the GC probability fraction (default: `100`). Must be >= 1.
|
|
|
|
> **Note**: When providing a custom `SessionManagerInterface` via the `$sessionManager` parameter,
|
|
> the `gcProbability` and `gcDivisor` settings are ignored — you control GC behavior in your own implementation.
|
|
|
|
**Available Session Stores:**
|
|
- `InMemorySessionStore`: Fast in-memory storage (default)
|
|
- `FileSessionStore`: Persistent file-based storage
|
|
- `Psr16StoreSession`: PSR-16 compliant cache-based storage
|
|
|
|
**Custom Session Stores:**
|
|
|
|
Implement `SessionStoreInterface` to create custom session storage:
|
|
|
|
```php
|
|
use Mcp\Server\Session\SessionStoreInterface;
|
|
use Symfony\Component\Uid\Uuid;
|
|
|
|
class RedisSessionStore implements SessionStoreInterface
|
|
{
|
|
public function __construct(private $redis, private int $ttl = 3600) {}
|
|
|
|
public function exists(Uuid $id): bool
|
|
{
|
|
return $this->redis->exists($id->toRfc4122());
|
|
}
|
|
|
|
public function read(Uuid $sessionId): string|false
|
|
{
|
|
$data = $this->redis->get($sessionId->toRfc4122());
|
|
return $data !== false ? $data : false;
|
|
}
|
|
|
|
public function write(Uuid $sessionId, string $data): bool
|
|
{
|
|
return $this->redis->setex($sessionId->toRfc4122(), $this->ttl, $data);
|
|
}
|
|
|
|
public function destroy(Uuid $sessionId): bool
|
|
{
|
|
return $this->redis->del($sessionId->toRfc4122()) > 0;
|
|
}
|
|
|
|
public function gc(): array
|
|
{
|
|
// Redis handles TTL automatically
|
|
return [];
|
|
}
|
|
}
|
|
```
|
|
|
|
## Manual Capability Registration
|
|
|
|
Register MCP elements programmatically without using attributes. The handler is the most important parameter and can be any PHP callable.
|
|
|
|
### Handler Types
|
|
|
|
**Handler** can be any PHP callable:
|
|
|
|
1. **Closure**: `function(int $a, int $b): int { return $a + $b; }`
|
|
2. **Class and method name pair**: `[ClassName::class, 'methodName']` - the class is instantiated lazily on first call, so it must be constructable through the container (or have a no-arg constructor)
|
|
3. **Class instance and method name**: `[$instance, 'methodName']` - the given, already-constructed object is invoked as-is. Use this for handlers the container cannot build, e.g. those with scalar constructor arguments or dependencies wired at runtime
|
|
4. **Invokable class name**: `InvokableClass::class` - class must be constructable through the container and have `__invoke` method
|
|
|
|
### Manual Tool Registration
|
|
|
|
```php
|
|
$server = Server::builder()
|
|
// Using closure
|
|
->addTool(
|
|
handler: function(int $a, int $b): int { return $a + $b; },
|
|
name: 'add_numbers',
|
|
description: 'Adds two numbers together'
|
|
)
|
|
|
|
// Using class method pair
|
|
->addTool(
|
|
handler: [Calculator::class, 'multiply'],
|
|
name: 'multiply_numbers'
|
|
// name and description are optional - derived from method name and docblock
|
|
)
|
|
|
|
// Using instance method
|
|
->addTool(
|
|
handler: [$calculatorInstance, 'divide']
|
|
)
|
|
|
|
// Using invokable class
|
|
->addTool(
|
|
handler: InvokableCalculator::class
|
|
);
|
|
```
|
|
|
|
#### Parameters
|
|
|
|
- `handler` (callable|string): The tool handler
|
|
- `name` (string|null): Optional tool name
|
|
- `title` (string|null): Optional human-readable title for display in UI
|
|
- `description` (string|null): Optional tool description
|
|
- `annotations` (ToolAnnotations|null): Optional annotations for the tool
|
|
- `inputSchema` (array|null): Optional input schema for the tool
|
|
- `icons` (Icon[]|null): Optional array of icons for the tool
|
|
- `meta` (array|null): Optional metadata for the tool
|
|
|
|
### Manual Resource Registration
|
|
|
|
Register static resources:
|
|
|
|
```php
|
|
$server = Server::builder()
|
|
->addResource(
|
|
handler: [Config::class, 'getSettings'],
|
|
uri: 'config://app/settings',
|
|
name: 'app_config',
|
|
description: 'Application configuration',
|
|
mimeType: 'application/json'
|
|
);
|
|
```
|
|
|
|
#### Parameters
|
|
|
|
- `handler` (callable|string): The resource handler
|
|
- `uri` (string): The resource URI
|
|
- `name` (string|null): Optional resource name
|
|
- `description` (string|null): Optional resource description
|
|
- `mimeType` (string|null): Optional MIME type of the resource
|
|
- `size` (int|null): Optional size of the resource in bytes
|
|
- `annotations` (Annotations|null): Optional annotations for the resource
|
|
- `icons` (Icon[]|null): Optional array of icons for the resource
|
|
- `meta` (array|null): Optional metadata for the resource
|
|
|
|
### Manual Resource Template Registration
|
|
|
|
Register dynamic resources with URI templates:
|
|
|
|
```php
|
|
$server = Server::builder()
|
|
->addResourceTemplate(
|
|
handler: [UserService::class, 'getUserProfile'],
|
|
uriTemplate: 'user://{userId}/profile',
|
|
name: 'user_profile',
|
|
description: 'User profile by ID',
|
|
mimeType: 'application/json'
|
|
);
|
|
```
|
|
|
|
#### Parameters
|
|
|
|
- `handler` (callable|string): The resource template handler
|
|
- `uriTemplate` (string): The resource URI template
|
|
- `name` (string|null): Optional resource template name
|
|
- `description` (string|null): Optional resource template description
|
|
- `mimeType` (string|null): Optional MIME type of the resource
|
|
- `annotations` (Annotations|null): Optional annotations for the resource template
|
|
|
|
### Manual Prompt Registration
|
|
|
|
Register prompt generators:
|
|
|
|
```php
|
|
$server = Server::builder()
|
|
->addPrompt(
|
|
handler: [PromptService::class, 'generatePrompt'],
|
|
name: 'custom_prompt',
|
|
description: 'A custom prompt generator'
|
|
);
|
|
```
|
|
|
|
#### Parameters
|
|
|
|
- `handler` (callable|string): The prompt handler
|
|
- `name` (string|null): Optional prompt name
|
|
- `title` (string|null): Optional human-readable title for display in UI
|
|
- `description` (string|null): Optional prompt description
|
|
- `icons` (Icon[]|null): Optional array of icons for the prompt
|
|
|
|
**Note:** `name` and `description` are optional for all manual registrations. If not provided, they will be derived from
|
|
the handler's method name and docblock.
|
|
|
|
For more details on MCP elements, handlers, and attribute-based discovery, see [MCP Elements](mcp-elements.md).
|
|
|
|
### Explicit element registration
|
|
|
|
When an element's name, schema, or description is only known at runtime, pair an `Mcp\Schema\*` value object with one of
|
|
the four handler interfaces below and register it through `Builder::add()`.
|
|
|
|
| Element kind | Handler interface |
|
|
|-------------------|-------------------------------------------------------|
|
|
| Tool | `Mcp\Server\Handler\ToolHandlerInterface` |
|
|
| Resource | `Mcp\Server\Handler\ResourceHandlerInterface` |
|
|
| Resource template | `Mcp\Server\Handler\ResourceTemplateHandlerInterface` |
|
|
| Prompt | `Mcp\Server\Handler\PromptHandlerInterface` |
|
|
|
|
Each handler interface declares a single execution method. Tool and prompt handlers receive an arguments map and a
|
|
`ClientGateway`. Resource handlers receive the requested URI; resource template handlers additionally receive the parsed
|
|
template variables.
|
|
|
|
```php
|
|
use Mcp\Schema\Tool;
|
|
use Mcp\Server;
|
|
use Mcp\Server\ClientGateway;
|
|
use Mcp\Server\Handler\ToolHandlerInterface;
|
|
|
|
final class WeatherHandler implements ToolHandlerInterface
|
|
{
|
|
public function execute(array $arguments, ClientGateway $gateway): mixed
|
|
{
|
|
return ['temperature' => 21, 'unit' => 'C'];
|
|
}
|
|
}
|
|
|
|
$tool = new Tool(
|
|
name: 'get_weather',
|
|
title: null,
|
|
inputSchema: [
|
|
'type' => 'object',
|
|
'properties' => ['city' => ['type' => 'string']],
|
|
'required' => ['city'],
|
|
],
|
|
description: 'Returns the current weather for a city.',
|
|
annotations: null,
|
|
);
|
|
|
|
$server = Server::builder()
|
|
->add($tool, new WeatherHandler())
|
|
->build();
|
|
```
|
|
|
|
`Builder::add()` validates the pairing at registration time. Pairing a `Tool` definition with, for example, a
|
|
`PromptHandlerInterface` raises `Mcp\Exception\InvalidArgumentException`. The schema value object validates its own
|
|
inputs (name pattern, schema shape, etc.), so passing an incomplete definition fails before `add()` returns.
|
|
|
|
Use `add()` when the metadata cannot be inferred from a handler class via reflection. For statically-known elements,
|
|
prefer `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from the handler's signature and
|
|
docblock.
|
|
|
|
## Service Dependencies
|
|
|
|
### Container
|
|
|
|
The container is used to resolve handlers and their dependencies when handlers inject dependencies in their constructors.
|
|
The SDK includes a basic container with simple auto-wiring capabilities.
|
|
|
|
```php
|
|
use Mcp\Capability\Registry\Container;
|
|
|
|
// Use the default basic container
|
|
$container = new Container();
|
|
$container->set(DatabaseService::class, new DatabaseService($pdo));
|
|
$container->set(\PDO::class, $pdo);
|
|
|
|
$server = Server::builder()
|
|
->setContainer($container)
|
|
->build();
|
|
```
|
|
|
|
**Basic Container Features:**
|
|
- Supports constructor auto-wiring for classes with parameterless constructors
|
|
- Resolves dependencies where all parameters are type-hinted classes/interfaces known to the container
|
|
- Supports parameters with default values
|
|
- Does NOT support scalar/built-in type injection without defaults
|
|
- Detects circular dependencies
|
|
|
|
You can also use any PSR-11 compatible container (Symfony DI, PHP-DI, Laravel Container, etc.).
|
|
|
|
### Logger
|
|
|
|
Provide a PSR-3 logger instance for internal server logging (request/response processing, errors, session management, transport events):
|
|
|
|
```php
|
|
use Monolog\Logger;
|
|
use Monolog\Handler\StreamHandler;
|
|
|
|
$logger = new Logger('mcp-server');
|
|
$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO));
|
|
|
|
$server = Server::builder()
|
|
->setLogger($logger);
|
|
```
|
|
|
|
### Event Dispatcher
|
|
|
|
Configure event dispatching:
|
|
|
|
```php
|
|
$server = Server::builder()
|
|
->setEventDispatcher($eventDispatcher);
|
|
```
|
|
|
|
## Custom Message Handlers
|
|
|
|
**Low-level escape hatch.** Custom message handlers run before the SDK's built-in handlers and give you total control over
|
|
individual JSON-RPC messages. They do not receive the builder's registry, container, or discovery output unless you pass
|
|
those dependencies in yourself.
|
|
|
|
> **Warning**: Custom message handlers bypass discovery, manual capability registration, and container lookups (unless
|
|
> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler
|
|
> loads and executes them manually. Reach for this API only when you need that level of control and are comfortable
|
|
> taking on the additional plumbing.
|
|
|
|
### Request Handlers
|
|
|
|
Handle JSON-RPC requests (messages with an `id` that expect a response). Request handlers **must** return either a
|
|
`Response` or an `Error` object.
|
|
|
|
Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these
|
|
methods as many times as needed; each call prepends the handlers so they execute before the defaults:
|
|
|
|
```php
|
|
$server = Server::builder()
|
|
->addRequestHandler(new CustomListToolsHandler())
|
|
->addRequestHandlers([
|
|
new CustomCallToolHandler(),
|
|
new CustomGetPromptHandler(),
|
|
])
|
|
->build();
|
|
```
|
|
|
|
Request handlers implement `RequestHandlerInterface`:
|
|
|
|
```php
|
|
use Mcp\Schema\JsonRpc\Error;
|
|
use Mcp\Schema\JsonRpc\Request;
|
|
use Mcp\Schema\JsonRpc\Response;
|
|
use Mcp\Server\Handler\Request\RequestHandlerInterface;
|
|
use Mcp\Server\Session\SessionInterface;
|
|
|
|
interface RequestHandlerInterface
|
|
{
|
|
public function supports(Request $request): bool;
|
|
|
|
public function handle(Request $request, SessionInterface $session): Response|Error;
|
|
}
|
|
```
|
|
|
|
- `supports()` decides if the handler should process the incoming request
|
|
- `handle()` **must** return a `Response` (on success) or an `Error` (on failure)
|
|
|
|
### Notification Handlers
|
|
|
|
Handle JSON-RPC notifications (messages without an `id` that don't expect a response). Notification handlers **do not**
|
|
return anything - they perform side effects only.
|
|
|
|
Attach notification handlers with `addNotificationHandler()` (single) or `addNotificationHandlers()` (multiple):
|
|
|
|
```php
|
|
$server = Server::builder()
|
|
->addNotificationHandler(new LoggingNotificationHandler())
|
|
->addNotificationHandlers([
|
|
new InitializedNotificationHandler(),
|
|
new ProgressNotificationHandler(),
|
|
])
|
|
->build();
|
|
```
|
|
|
|
Notification handlers implement `NotificationHandlerInterface`:
|
|
|
|
```php
|
|
use Mcp\Schema\JsonRpc\Notification;
|
|
use Mcp\Server\Handler\Notification\NotificationHandlerInterface;
|
|
use Mcp\Server\Session\SessionInterface;
|
|
|
|
interface NotificationHandlerInterface
|
|
{
|
|
public function supports(Notification $notification): bool;
|
|
|
|
public function handle(Notification $notification, SessionInterface $session): void;
|
|
}
|
|
```
|
|
|
|
- `supports()` decides if the handler should process the incoming notification
|
|
- `handle()` performs side effects but **does not** return a value (notifications have no response)
|
|
|
|
### Key Differences
|
|
|
|
| Handler Type | Interface | Returns | Use Case |
|
|
|-------------|-----------|---------|----------|
|
|
| Request Handler | `RequestHandlerInterface` | `Response\|Error` | Handle requests that need responses (e.g., `tools/list`, `tools/call`) |
|
|
| Notification Handler | `NotificationHandlerInterface` | `void` | Handle fire-and-forget notifications (e.g., `notifications/initialized`, `notifications/progress`) |
|
|
|
|
### Example
|
|
|
|
Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement
|
|
custom `tools/list` and `tools/call` request handlers independently of the registry.
|
|
|
|
## Complete Example
|
|
|
|
Here's a comprehensive example showing all major configuration options:
|
|
|
|
```php
|
|
use Mcp\Server;
|
|
use Mcp\Server\Session\FileSessionStore;
|
|
use Mcp\Capability\Registry\Container;
|
|
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
|
use Symfony\Component\Cache\Psr16Cache;
|
|
use Monolog\Logger;
|
|
use Monolog\Handler\StreamHandler;
|
|
|
|
// Setup dependencies
|
|
$logger = new Logger('mcp-server');
|
|
$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO));
|
|
|
|
$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery'));
|
|
$sessionStore = new FileSessionStore(__DIR__ . '/sessions');
|
|
|
|
// Setup container with dependencies
|
|
$container = new Container();
|
|
$container->set(\PDO::class, new \PDO('sqlite::memory:'));
|
|
$container->set(DatabaseService::class, new DatabaseService($container->get(\PDO::class)));
|
|
|
|
// Build server
|
|
$server = Server::builder()
|
|
// Server identity
|
|
->setServerInfo('Advanced Calculator', '2.1.0')
|
|
|
|
// Performance and behavior
|
|
->setPaginationLimit(100)
|
|
->setInstructions('Use calculate tool for math operations. Check config resource for current settings.')
|
|
|
|
// Discovery with caching
|
|
->setDiscovery(__DIR__, ['src'], ['vendor', 'tests'], $cache)
|
|
|
|
// Session management
|
|
->setSession($sessionStore)
|
|
|
|
// Services
|
|
->setLogger($logger)
|
|
->setContainer($container)
|
|
|
|
// Manual capability registration
|
|
->addTool([Calculator::class, 'advancedCalculation'], 'advanced_calc')
|
|
->addResource([Config::class, 'getSettings'], 'config://app/settings', 'app_settings')
|
|
|
|
// Build the server
|
|
->build();
|
|
```
|
|
|
|
## Method Reference
|
|
|
|
| Method | Parameters | Description |
|
|
|--------|------------|-------------|
|
|
| `setServerInfo()` | name, version, description? | Set server identity |
|
|
| `setPaginationLimit()` | limit | Set max items per page |
|
|
| `setInstructions()` | instructions | Set usage instructions |
|
|
| `setProtocolVersion()` | protocolVersion | Pin the handshake to one protocol revision |
|
|
| `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery |
|
|
| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management |
|
|
| `setLogger()` | logger | Set PSR-3 logger |
|
|
| `setContainer()` | container | Set PSR-11 container |
|
|
| `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher |
|
|
| `addRequestHandler()` | handler | Prepend a single custom request handler |
|
|
| `addRequestHandlers()` | handlers | Prepend multiple custom request handlers |
|
|
| `addNotificationHandler()` | handler | Prepend a single custom notification handler |
|
|
| `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers |
|
|
| `addTool()` | handler, name?, title?, description?, annotations?, inputSchema?, ... | Register tool |
|
|
| `addResource()` | handler, uri, name?, title?, description?, mimeType?, size?, annotations?, icons?, meta? | Register resource |
|
|
| `addResourceTemplate()` | handler, uriTemplate, name?, title?, description?, mimeType?, annotations?, meta? | Register resource template |
|
|
| `addPrompt()` | handler, name?, title?, description?, icons?, meta? | Register prompt |
|
|
| `add()` | definition, handler | Register an element from a schema VO + handler pair |
|
|
| `build()` | - | Create the server instance |
|