diff --git a/.gitattributes b/.gitattributes index 27224aea..b7b2a18d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,8 @@ /tests export-ignore /.php-cs-fixer.dist.php export-ignore /Makefile export-ignore +/mkdocs.yml export-ignore +/requirements-docs.txt export-ignore /phpdoc.dist.xml /phpstan* export-ignore /phpunit.xml.dist export-ignore diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a1c3c832..20b6e081 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,15 +1,25 @@ -name: Deploy Documentation +name: Documentation + +# The site is built by `make docs`: Zensical renders the guides under docs/ +# (see mkdocs.yml) and phpDocumentor renders the API reference into /api/. +# Pull requests build only — the build is strict, so a broken internal link +# fails CI instead of shipping a dead link to the site. on: - release: - types: [published] + push: + branches: [main] + pull_request: workflow_dispatch: permissions: - contents: write + contents: read + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true jobs: - deploy: + build: runs-on: ubuntu-latest steps: - name: Checkout @@ -21,16 +31,33 @@ jobs: php-version: '8.4' coverage: "none" - - name: Install Composer + - name: Install Composer dependencies uses: "ramsey/composer-install@v4" - - name: Generate Documentation + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true + + - name: Build documentation run: make docs - - name: Deploy to gh-pages branch - uses: peaceiris/actions-gh-pages@v4 + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./.phpdoc/build - enable_jekyll: false - cname: php.sdk.modelcontextprotocol.io + path: ./site + + deploy: + needs: build + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index b0185431..e9c9e83f 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -244,5 +244,7 @@ jobs: - name: PHPStan run: vendor/bin/phpstan analyse + # Only the phpDocumentor half: this job is PHP-only, and the Zensical + # guides are built (strictly) by the Documentation workflow. - name: Documentation - run: make docs + run: make docs-api diff --git a/.gitignore b/.gitignore index c5d87ce7..ca3a13f1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ tests/Conformance/logs/*.log # phpDocumentor .phpdoc/build/ .phpdoc/cache/ + +# Documentation site (make docs) +/site/ +/.cache/ diff --git a/.phpdoc/template/base.html.twig b/.phpdoc/template/base.html.twig index 760f1652..f8feb3c8 100644 --- a/.phpdoc/template/base.html.twig +++ b/.phpdoc/template/base.html.twig @@ -2,7 +2,7 @@ {% set topMenu = { "menu": [ - { "name": "Guides", "url": "docs/index.html"}, + { "name": "Guides", "url": "/"}, { "name": "Specification", "url": "https://modelcontextprotocol.io/" } ], "social": [ diff --git a/.phpdoc/template/components/header-title.html.twig b/.phpdoc/template/components/header-title.html.twig index fe8d091f..ece437cc 100644 --- a/.phpdoc/template/components/header-title.html.twig +++ b/.phpdoc/template/components/header-title.html.twig @@ -1,5 +1,5 @@

- + diff --git a/CHANGELOG.md b/CHANGELOG.md index 06280c6f..6e3cb3ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Answer a request over a response stream under the 2026-07-28 lifecycle: `StatelessProtocol` runs handlers in a fiber, so `$gateway->progress()` and `$gateway->log()` work there as they do in the handshake era. The stream opens only if the handler actually emits something *and* the client's `Accept` admits `text/event-stream`, and the choice is made after the handler's first suspension, so a request that turns out to need `-32021` or `-32602` is still answered with the status the spec fixes for it. * Honour `io.modelcontextprotocol/logLevel` (SEP-2575), which replaced the `logging/setLevel` RPC: a request naming no level receives no `notifications/message` at all, one naming a level receives the messages at or above it. Adds `LoggingLevel::severity()` and `LoggingLevel::isAtLeast()`. * [BC Break] Answer a not-found subject with `-32602` (Invalid params) instead of `-32002`, which the 2026-07-28 revision reserves and forbids emitting (SEP-2164). `resources/read` picks the code from the revision serving the request — `-32602` with the uri in `error.data` from `2026-07-28` on, `-32002` below. `prompts/get` for an unknown prompt, `completion/complete` for an unknown reference and `tools/call` for an unknown tool switch to `-32602` in *every* revision: `-32002` was never the code for those. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`. -* Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestStateKey()`. +* Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestState()`. * Validate the standard MCP request headers under the 2026-07-28 lifecycle (SEP-2243): `Mcp\Server\Stateless\StandardHeaderValidator`, set with `Builder::setHeaderValidator()`, checks that `Mcp-Method` and `Mcp-Name` agree with the body they travel with and that a `Mcp-Param-*` mirrors the argument its tool marked `x-mcp-header`, answering `-32020` when they disagree. Intermediaries route on these headers, so a value contradicting the body has to be refused rather than ignored. * [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. * Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response. diff --git a/Makefile b/Makefile index 93fbf1f2..f1d78ef9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs +.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs docs-guides docs-api docs-serve # The 2026-07-28 scenarios ship on the `alpha` dist-tag; `latest` (0.1.x) has # none of them. Pinned to the same version CI runs (see @@ -6,6 +6,11 @@ CONFORMANCE_VERSION ?= 0.2.0-alpha.11 CONFORMANCE = npx --yes @modelcontextprotocol/conformance@$(CONFORMANCE_VERSION) +# The documentation toolchain is Python (Zensical, see requirements-docs.txt), +# run through uv so no virtualenv has to be managed by hand: +# https://docs.astral.sh/uv/getting-started/installation/ +DOCS_RUN = uv run --no-project --with-requirements requirements-docs.txt -- + deps-stable: composer update --prefer-stable @@ -73,7 +78,19 @@ ci-stable: deps-stable cs phpstan tests ci-lowest: deps-low cs phpstan tests -docs: - vendor/bin/phpdoc - @grep -q 'No errors have been found' .phpdoc/build/reports/errors.html || \ - (echo "Documentation errors found. See build/docs/reports/errors.html" && exit 1) +# The published site is the guides (Zensical) with the phpDocumentor API +# reference mounted at /api/. `zensical build` wipes site/, so it runs first. +docs: docs-guides docs-api + rm -rf site/api + cp -a .phpdoc/build/api site/api + +docs-guides: + $(DOCS_RUN) zensical build --strict + +docs-api: + vendor/bin/phpdoc --no-interaction + @grep -q 'No errors have been found' .phpdoc/build/api/reports/errors.html || \ + (echo "Documentation errors found. See .phpdoc/build/api/reports/errors.html" && exit 1) + +docs-serve: + $(DOCS_RUN) zensical serve diff --git a/README.md b/README.md index 235d3f51..194230a2 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ -The official PHP SDK for Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP servers -and clients in PHP. +The official PHP SDK for the Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP +servers and clients in PHP — tools, resources, prompts, STDIO and HTTP transports, sessions, authorization, and both +protocol eras (the `initialize` handshake and the stateless `2026-07-28` revision). This project represents a collaboration between [the PHP Foundation](https://thephp.foundation/) and the [Symfony project](https://symfony.com/). It adopts development practices and standards from the Symfony project, including [Coding Standards](https://symfony.com/doc/current/contributing/code/standards.html) and the @@ -24,47 +25,27 @@ development practices and standards from the Symfony project, including [Coding Until the first major release, this SDK is considered [experimental](https://symfony.com/doc/current/contributing/code/experimental.html), please see the [roadmap](./ROADMAP.md) for planned next steps and features. -## Table of Contents - -- [Installation](#installation) -- [Overview](#overview) -- [Server SDK](#server-sdk) -- [Client SDK](#client-sdk) -- [Documentation](#documentation) -- [External Resources](#external-resources) -- [PHP Libraries Using the MCP SDK](#php-libraries-using-the-mcp-sdk) -- [Contributing](#contributing) -- [Credits](#credits) -- [License](#license) - ## Installation ```bash composer require mcp/sdk ``` -## Overview - -The MCP PHP SDK provides both **server** and **client** implementations for the Model Context Protocol, enabling you to: - -- **Build MCP Servers**: Expose your PHP application's functionality (tools, resources, prompts) to AI agents -- **Build MCP Clients**: Connect to and interact with MCP servers from your PHP applications - -## Server SDK - -Build MCP servers to expose your PHP application's capabilities to AI agents like Claude, Codex, and others. +## Build a server -### Quick Start +A server is a plain PHP class plus three lines of wiring: ```php +use Mcp\Capability\Attribute\McpResource; +use Mcp\Capability\Attribute\McpTool; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; -use Mcp\Capability\Attribute\McpTool; -use Mcp\Capability\Attribute\McpResource; -// Define capabilities using PHP attributes -class CalculatorCapabilities +class Calculator { + /** + * Adds two numbers. + */ #[McpTool] public function add(int $a, int $b): int { @@ -72,237 +53,56 @@ class CalculatorCapabilities } #[McpResource(uri: 'config://calculator/settings')] - public function getSettings(): array + public function settings(): array { return ['precision' => 2]; } } -// Build and run the server -$server = Server::builder() - ->setServerInfo('Calculator Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) // Auto-discover attributes - ->build(); - -$transport = new StdioTransport(); -$server->run($transport); -``` - -### Server Capabilities - -- **Tools**: Executable functions that AI agents can call -- **Resources**: Data sources that can be read (files, configs, databases) -- **Resource Templates**: Dynamic resources with URI parameters -- **Prompts**: Pre-defined templates for AI interactions -- **Server-Initiated Communication**: Elicitations, sampling, logging, progress notifications - -### Registration Methods - -There are multiple ways to register your MCP capabilities—choose the approach that best fits your application's architecture: - -**1. Attribute-Based Discovery** — Define capabilities using PHP attributes for automatic discovery: -```php -#[McpTool] -public function generateReport(): string { /* ... */ } - -#[McpResource(uri: 'config://app/settings')] -public function getConfig(): array { /* ... */ } -``` - -**2. Manual Registration** — Register capabilities programmatically without attributes: -```php -$server = Server::builder() - ->addTool([Calculator::class, 'add'], 'add_numbers') - ->addResource([Config::class, 'get'], 'config://app') - ->build(); -``` - -**3. Hybrid Approach** — Combine both methods for maximum flexibility: -```php -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) - ->addTool([ExternalService::class, 'process'], 'external') - ->build(); -``` - -### Transports - -Choose the transport that matches your deployment environment: - -**1. STDIO Transport** — For command-line integration and local processes: -```php -$transport = new StdioTransport(); -$server->run($transport); -``` - -**2. HTTP Transport** — For web-based servers and distributed systems: -```php -$transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); -$response = $server->run($transport); -``` - -### Session Management - -Configure session storage to maintain state between requests. Choose the backend that fits your infrastructure: - -**In-Memory** (default, suitable for STDIO): -```php -$server = Server::builder() - ->setSession(ttl: 7200) // 2 hours - ->build(); -``` - -**File-Based** (suitable for single-server HTTP deployments): -```php -$server = Server::builder() - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) - ->build(); -``` - -**PSR-16 Cache** (for example with Redis for scaled deployments): -```php -$server = Server::builder() - ->setSession(new Psr16SessionStore( - cache: new Psr16Cache($redisAdapter), - prefix: 'mcp-', - ttl: 3600 - )) - ->build(); +exit(Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build() + ->run(new StdioTransport())); ``` -[→ Server Documentation](docs/server-builder.md) +The walkthrough in [First server](docs/get-started/first-server.md) explains each piece, and +[Try it with the Inspector](docs/get-started/inspector.md) shows it running. -## Client SDK - -Connect to MCP servers from your PHP applications to access their tools, resources, and prompts. - -### Quick Start +## Build a client ```php use Mcp\Client; use Mcp\Client\Transport\StdioTransport; -// Build the client $client = Client::builder() ->setClientInfo('My Application', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(120) ->build(); -// Connect to a server -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], -); - -$client->connect($transport); +$client->connect(new StdioTransport(command: 'php', args: ['/path/to/server.php'])); -// Discover and use capabilities $tools = $client->listTools(); $result = $client->callTool('add', ['a' => 5, 'b' => 3]); -$resources = $client->listResources(); -$content = $client->readResource('config://calculator/settings'); - $client->disconnect(); ``` -### Client Capabilities - -- **Tool Calling**: List and execute tools from any MCP server -- **Resource Access**: Read static and dynamic resources -- **Prompt Management**: List and retrieve prompt templates -- **Completion Support**: Request argument completion suggestions -- **Sampling & Elicitation**: Respond to server-initiated LLM sampling and user-input requests - -### Advanced Features - -- **Progress Tracking**: Real-time progress during long operations -```php -$result = $client->callTool( - name: 'process_data', - arguments: ['dataset' => 'large_file.csv'], - onProgress: function (float $progress, ?float $total, ?string $message) { - echo "Progress: {$progress}/{$total} - {$message}\n"; - } -); -``` - -- **Sampling Support**: Handle server LLM sampling requests -```php -$samplingHandler = new SamplingRequestHandler($myCallback); -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addRequestHandler($samplingHandler) - ->build(); -``` - -- **Elicitation Support**: Respond to server requests for user input -```php -$elicitationHandler = new ElicitationRequestHandler($myCallback); -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler($elicitationHandler) - ->build(); -``` - -- **Roots Support**: Expose `file://` workspace folders to the server -```php -$rootsHandler = new ListRootsRequestHandler($myCallback); -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) - ->addRequestHandler($rootsHandler) - ->build(); -``` - -- **Logging Notifications**: Receive server log messages -```php -$loggingHandler = new LoggingNotificationHandler($myCallback); -$client = Client::builder() - ->addNotificationHandler($loggingHandler) - ->build(); -``` - -### Transports - -Connect to MCP servers using the transport that matches your setup: - -**1. STDIO Transport** — Connect to local server processes: -```php -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], -); - -$client->connect($transport); -``` - -**2. HTTP Transport** — Connect to remote or web-based servers: -```php -$transport = new HttpTransport('http://localhost:8000'); - -$client->connect($transport); -``` - -[→ Client Documentation](docs/client.md) +See [Connecting to a server](docs/client/connecting.md) for transports, timeouts, and the +handlers that answer server-initiated requests. ## Documentation -### Core Concepts - -- **[Server Builder](docs/server-builder.md)** — Complete ServerBuilder reference and configuration -- **[Client](docs/client.md)** — Client SDK for connecting to and communicating with MCP servers -- **[Transports](docs/transports.md)** — STDIO and HTTP transport setup and usage -- **[MCP Elements](docs/mcp-elements.md)** — Creating tools, resources, prompts, and templates -- **[Server-Client Communication](docs/server-client-communication.md)** — Sampling, logging, progress, and notifications -- **[Protocol Extensions](docs/extensions.md)** — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources) -- **[Authorization](docs/authorization.md)** — OAuth and authorization setup for HTTP transport -- **[Events](docs/events.md)** — Hooking into server lifecycle with events +The full documentation is published at **[php.sdk.modelcontextprotocol.io](https://php.sdk.modelcontextprotocol.io/)**. -### Learning & Examples - -- **[Examples](docs/examples.md)** — Comprehensive example walkthroughs for servers and clients -- **[ROADMAP.md](ROADMAP.md)** — Planned features and development roadmap +- **[Get started](docs/get-started/index.md)** — Install the SDK and build your first server +- **[Servers](docs/servers/index.md)** — Tools, resources, resource templates, prompts, and how to register them +- **[Inside your handler](docs/handlers/index.md)** — Talking back to the client, logging, and asking for input +- **[Running your server](docs/run/index.md)** — Server builder, STDIO and HTTP transports, framework integration, sessions, authorization +- **[Clients](docs/client/index.md)** — Client SDK for connecting to and communicating with MCP servers +- **[Protocol versions](docs/protocol-versions.md)** — The two protocol eras, and what revision `2026-07-28` changed +- **[Advanced](docs/advanced/index.md)** — Events, protocol extensions (including MCP Apps), and custom message handlers +- **[Examples](docs/examples.md)** — Runnable server and client examples +- **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** — Generated class reference ## External Resources @@ -324,9 +124,9 @@ Building something on top of the SDK? Open a pull request to add it to this list ## Contributing -We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. - -See the [Contributing Guide](CONTRIBUTING.md) to get started before you [report issues](https://github.com/modelcontextprotocol/php-sdk/issues) and [send pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). +We are passionate about supporting contributors of all levels of experience and would love to see you get involved in +the project. Start by [reporting issues](https://github.com/modelcontextprotocol/php-sdk/issues) or +[sending pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). ## Credits diff --git a/adr/0001-oauth-authorization-server-out-of-scope.md b/adr/0001-oauth-authorization-server-out-of-scope.md index fa5b091f..ebb5947c 100644 --- a/adr/0001-oauth-authorization-server-out-of-scope.md +++ b/adr/0001-oauth-authorization-server-out-of-scope.md @@ -93,5 +93,5 @@ If you need an authorization server (token issuance, client registration, login, validator seams. The MCP server validates the tokens it issues; it does not issue them itself. -See [`../docs/authorization.md`](../docs/authorization.md) for the supported Resource Server +See [`../docs/run/authorization.md`](../docs/run/authorization.md) for the supported Resource Server and delegation setup. diff --git a/docs/.overrides/.icons/mcp.svg b/docs/.overrides/.icons/mcp.svg new file mode 100644 index 00000000..67d800b0 --- /dev/null +++ b/docs/.overrides/.icons/mcp.svg @@ -0,0 +1 @@ + diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 00000000..c7735adb --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +php.sdk.modelcontextprotocol.io diff --git a/docs/advanced/custom-handlers.md b/docs/advanced/custom-handlers.md new file mode 100644 index 00000000..a7fd28f9 --- /dev/null +++ b/docs/advanced/custom-handlers.md @@ -0,0 +1,98 @@ +# 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; handlers are collected in call order, and the whole custom list runs before the +built-in 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 +// Handlers are your own classes implementing NotificationHandlerInterface; +// the SDK ships only its internal ones. +$server = Server::builder() + ->addNotificationHandler(new AuditNotificationHandler($auditLog)) + ->addNotificationHandlers([ + new MetricsNotificationHandler($metrics), + new CancellationNotificationHandler(), + ]) + ->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/server/custom-method-handlers/server.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/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. diff --git a/docs/events.md b/docs/advanced/events.md similarity index 91% rename from docs/events.md rename to docs/advanced/events.md index ebd70ed2..590ad6ea 100644 --- a/docs/events.md +++ b/docs/advanced/events.md @@ -2,21 +2,12 @@ The MCP SDK provides a PSR-14 compatible event system that allows you to hook into the server's lifecycle. Events enable request/response modification, and other user-defined behaviors. -## Table of Contents - -- [Setup](#setup) -- [Protocol Events](#protocol-events) - - [RequestEvent](#requestevent) - - [ResponseEvent](#responseevent) - - [ErrorEvent](#errorevent) - - [NotificationEvent](#notificationevent) -- [List Change Events](#list-change-events) - ## Setup Configure an event dispatcher when building your server: ```php +use Mcp\Event\RequestEvent; use Mcp\Server; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -67,7 +58,7 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob **Properties**: - `getError(): Error` - The error being sent - `setError(Error $error): void` - Modify the error before sending -- `getRequest(): Request` - The original request (null for parse errors) +- `getRequest(): Request` - The original request. Messages that fail to parse are rejected before this event, so a listener never sees them. - `getThrowable(): ?\Throwable` - The exception that caused the error (if any) - `getSession(): SessionInterface` - The current session diff --git a/docs/extensions.md b/docs/advanced/extensions.md similarity index 90% rename from docs/extensions.md rename to docs/advanced/extensions.md index aff06aba..041f9862 100644 --- a/docs/extensions.md +++ b/docs/advanced/extensions.md @@ -1,7 +1,9 @@ # Protocol Extensions -MCP protocol extensions advertise additional, optional capabilities during the initialize handshake. -A server opts in via `Builder::enableExtension()`: +MCP protocol extensions advertise additional, optional capabilities alongside the regular ones — +during the `initialize` handshake, or on revision `2026-07-28` (which has no handshake) inside the +capabilities that travel with every request. A server opts in via `Builder::enableExtension()` and +the SDK places the advertisement correctly for whichever era the client speaks: ```php use Mcp\Schema\Extension\Apps\McpApps; @@ -19,7 +21,8 @@ be enabled in a single call. Enabling the same extension twice throws a Clients (hosts) advertise the extensions they support the same way, via `Client\Builder::enableExtension()`; the payload lands under -`capabilities.extensions` in the initialize request. +`capabilities.extensions` in the initialize request — or, on a modern revision, +under the client capabilities carried in each request's `_meta` envelope. > Note: extensions enabled via `enableExtension()` are merged into the > `extensions` capability even when you supply your own `ServerCapabilities` / @@ -152,6 +155,6 @@ handshake: See the [`ext-apps` repository][ext-apps] for the full protocol, official TypeScript SDK (`@modelcontextprotocol/ext-apps`), and view-side examples. A working minimal view is included in -[`examples/server/mcp-apps/weather-app.html`](../examples/server/mcp-apps/weather-app.html). +[`examples/server/mcp-apps/weather-app.html`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/mcp-apps/weather-app.html). [ext-apps]: https://github.com/modelcontextprotocol/ext-apps diff --git a/docs/advanced/index.md b/docs/advanced/index.md new file mode 100644 index 00000000..2a79a3ce --- /dev/null +++ b/docs/advanced/index.md @@ -0,0 +1,10 @@ +# Advanced + +Everything here is optional. A working server needs none of it. + +* **[Events](events.md)** — PSR-14 events dispatched around every request, response, + error, and notification. Useful for metrics, audit logs, and debugging. +* **[Protocol extensions](extensions.md)** — opt-in extensions announced during + capability negotiation, including MCP Apps (HTML UI resources). +* **[Custom message handlers](custom-handlers.md)** — taking over a JSON-RPC method the + SDK does not implement, or overriding one it does. diff --git a/docs/client.md b/docs/client.md deleted file mode 100644 index f17b8555..00000000 --- a/docs/client.md +++ /dev/null @@ -1,879 +0,0 @@ -# Client - -The MCP Client SDK provides a synchronous, framework-agnostic API for communicating with MCP servers from PHP applications. -It handles connection management, request/response correlation, server-initiated requests (sampling), and real-time notifications. - -## Table of Contents - -- [Overview](#overview) -- [Client Builder](#client-builder) -- [Transports](#transports) -- [Connecting to Servers](#connecting-to-servers) -- [Server Information](#server-information) -- [Working with Tools](#working-with-tools) -- [Working with Resources](#working-with-resources) -- [Working with Prompts](#working-with-prompts) -- [Server-Initiated Communication](#server-initiated-communication) -- [Error Handling](#error-handling) -- [Complete Example](#complete-example) - -## Overview - -The client follows a builder pattern for configuration and provides a synchronous API for all operations: - -```php -use Mcp\Client; -use Mcp\Client\Transport\StdioTransport; - -// Build and configure the client -$client = Client::builder() - ->setClientInfo('My Client', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(120) - ->build(); - -// Create a transport -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], -); - -// Connect and use the server -$client->connect($transport); -$tools = $client->listTools(); -$client->disconnect(); -``` - -## Client Builder - -The `Client\Builder` provides fluent configuration of client instances. - -### Basic Configuration - -```php -use Mcp\Client; - -$client = Client::builder() - ->setClientInfo('My Application', '1.0.0', 'Description of my client') - ->setInitTimeout(30) // Seconds to wait for initialization - ->setRequestTimeout(120) // Seconds to wait for request responses - ->setMaxRetries(3) // Retries for failed connections - ->build(); -``` - -### Connection Retries - -`setMaxRetries()` controls how often `connect()` retries a failed connection. It -counts retries rather than attempts, so the default of `3` means one initial -attempt plus up to three retries — four in total — before the `ConnectionException` -of the last attempt is rethrown: - -```php -$client = Client::builder() - ->setMaxRetries(0) // Fail on the first failed attempt - ->build(); -``` - -Between two attempts the transport is closed, so a retry never reuses a -half-established connection: a `StdioTransport` spawns a fresh server process and -an `HttpTransport` discards the session ID of the failed attempt. Each retry is -preceded by a short, linearly growing delay (100ms, 200ms, 300ms, …). - -Only the connection handshake is retried. Individual requests such as -`callTool()` are always sent once — retrying them is unsafe as tool calls are not -necessarily idempotent. - -### Client Information - -Set the client's identity reported to servers during initialization: - -```php -$client = Client::builder() - ->setClientInfo( - name: 'AI Assistant Client', - version: '2.1.0', - description: 'Client for automated AI workflows' - ) - ->build(); -``` - -### Protocol Version - -Specify the MCP protocol version to offer during the handshake (defaults to the latest): - -```php -use Mcp\Schema\Enum\ProtocolVersion; - -$client = Client::builder() - ->setProtocolVersion(ProtocolVersion::V2025_11_25) - ->build(); -``` - -This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as -described in the specification's -[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation) -section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK -cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed -on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated. - -Modern revisions such as `2026-07-28` replaced `initialize` with per-request metadata, so they cannot be offered here. -Configuring one still opens the handshake with `ProtocolVersion::latestHandshake()`, and the client logs a warning -saying so. - -See [Protocol Version Negotiation](server-builder.md#protocol-version-negotiation) for the server side of the exchange. - -### Capabilities - -Declare client capabilities to enable server features: - -```php -use Mcp\Schema\ClientCapabilities; - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities( - sampling: true, // Enable LLM sampling requests from server - roots: true, // Enable filesystem root listing - )) - ->build(); -``` - -### Notification Handlers - -Register handlers for server-initiated notifications: - -```php -use Mcp\Client\Handler\Notification\LoggingNotificationHandler; -use Mcp\Schema\Notification\LoggingMessageNotification; - -$loggingHandler = new LoggingNotificationHandler( - static function (LoggingMessageNotification $notification) { - echo "[{$notification->level->value}] {$notification->data}\n"; - } -); - -$client = Client::builder() - ->addNotificationHandler($loggingHandler) - ->build(); -``` - -### Request Handlers - -Register handlers for server-initiated requests (e.g., sampling): - -```php -use Mcp\Client\Handler\Request\SamplingRequestHandler; -use Mcp\Client\Handler\Request\SamplingCallbackInterface; -use Mcp\Schema\Request\CreateSamplingMessageRequest; -use Mcp\Schema\Result\CreateSamplingMessageResult; - -$samplingCallback = new class implements SamplingCallbackInterface { - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - // Perform LLM sampling and return result - } -}; - -$client = Client::builder() - ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) - ->build(); -``` - -### Logger - -Configure PSR-3 logging for debugging: - -```php -use Monolog\Logger; -use Monolog\Handler\StreamHandler; - -$logger = new Logger('mcp-client'); -$logger->pushHandler(new StreamHandler('client.log', Logger::DEBUG)); - -$client = Client::builder() - ->setLogger($logger) - ->build(); -``` - -## Transports - -Transports handle the communication layer between client and server. - -### STDIO Transport - -Spawns a server process and communicates via standard input/output: - -```php -use Mcp\Client\Transport\StdioTransport; - -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], - cwd: '/working/directory', // Optional working directory - env: ['KEY' => 'value'], // Optional environment variables -); -``` - -**Parameters:** -- `command` (string): The command to execute -- `args` (array): Command arguments -- `cwd` (string|null): Working directory for the process -- `env` (array|null): Environment variables -- `logger` (LoggerInterface|null): Optional PSR-3 logger - -### HTTP Transport - -Communicates with remote MCP servers over HTTP: - -```php -use Mcp\Client\Transport\HttpTransport; - -$transport = new HttpTransport( - endpoint: 'http://localhost:8000', - headers: ['Authorization' => 'Bearer token'], -); -``` - -**Parameters:** -- `endpoint` (string): The MCP server URL -- `headers` (array): Additional HTTP headers -- `httpClient` (ClientInterface|null): PSR-18 HTTP client (auto-discovered) -- `requestFactory` (RequestFactoryInterface|null): PSR-17 request factory (auto-discovered) -- `streamFactory` (StreamFactoryInterface|null): PSR-17 stream factory (auto-discovered) -- `logger` (LoggerInterface|null): Optional PSR-3 logger - -**PSR-18 Auto-Discovery:** - -The transport automatically discovers PSR-18 HTTP clients from: -- `php-http/guzzle7-adapter` -- `php-http/curl-client` -- `symfony/http-client` -- And other PSR-18 compatible implementations - -```bash -# Install any PSR-18 client - discovery works automatically -composer require php-http/guzzle7-adapter -``` - - -## Connecting to Servers - -### Establishing Connection - -```php -$client->connect($transport); -``` - -The `connect()` method performs the MCP initialization handshake: -1. Opens the transport connection -2. Sends InitializeRequest with client capabilities -3. Waits for InitializeResult from server -4. Sends InitializedNotification - -> [!IMPORTANT] -> Always wrap connection in try/catch to handle `ConnectionException` for failed connections. - -### Checking Connection State - -```php -if ($client->isConnected()) { - // Client is connected and initialized -} -``` - -### Disconnecting - -```php -$client->disconnect(); -``` - -Always disconnect when finished to clean up resources: - -```php -try { - $client->connect($transport); - // ... use the client ... -} finally { - $client->disconnect(); -} -``` - -## Server Information - -After successful connection, retrieve server metadata: - -```php -// Get server implementation info -$serverInfo = $client->getServerInfo(); -echo "Server: {$serverInfo->name} v{$serverInfo->version}\n"; - -// Get server instructions -$instructions = $client->getInstructions(); -if ($instructions) { - echo "Instructions: {$instructions}\n"; -} -``` - -## Working with Tools - -### Listing Tools - -```php -$toolsResult = $client->listTools(); - -foreach ($toolsResult->tools as $tool) { - echo "- {$tool->name}: {$tool->description}\n"; -} - -// Handle pagination -if ($toolsResult->nextCursor) { - $moreTools = $client->listTools($toolsResult->nextCursor); -} -``` - -### Calling Tools - -```php -$result = $client->callTool( - name: 'calculate', - arguments: ['a' => 5, 'b' => 3, 'operation' => 'add'], -); - -// Access results -foreach ($result->content as $content) { - if ($content instanceof TextContent) { - echo $content->text; - } -} -``` - -### Progress Notifications - -Hook into tool execution progress (if server supports it): - -```php -$result = $client->callTool( - name: 'long_running_task', - arguments: ['data' => 'large_dataset'], - onProgress: static function (float $progress, ?float $total, ?string $message) { - $percent = $total > 0 ? round(($progress / $total) * 100) : 0; - echo "Progress: {$percent}% - {$message}\n"; - } -); -``` - -> [!NOTE] -> Progress notifications are only received if the server sends them. The callback will not be invoked if the server doesn't support or send progress updates. - -## Working with Resources - -### Listing Resources - -```php -$resourcesResult = $client->listResources(); - -foreach ($resourcesResult->resources as $resource) { - echo "- {$resource->uri}: {$resource->name}\n"; -} -``` - -### Listing Resource Templates - -```php -$templatesResult = $client->listResourceTemplates(); - -foreach ($templatesResult->resourceTemplates as $template) { - echo "- {$template->uriTemplate}: {$template->name}\n"; -} -``` - -### Reading Resources - -```php -$resourceResult = $client->readResource('config://app/settings'); - -foreach ($resourceResult->contents as $content) { - if ($content instanceof TextResourceContents) { - echo "Text: {$content->text}\n"; - } elseif ($content instanceof BlobResourceContents) { - echo "Binary data (base64): {$content->blob}\n"; - } -} -``` - -Resources also support progress notifications: - -```php -$result = $client->readResource( - uri: 'file://large-file.bin', - onProgress: static function (float $progress, ?float $total, ?string $message) { - echo "Reading: {$progress}/{$total} bytes\n"; - } -); -``` - -## Working with Prompts - -### Listing Prompts - -```php -$promptsResult = $client->listPrompts(); - -foreach ($promptsResult->prompts as $prompt) { - echo "- {$prompt->name}: {$prompt->description}\n"; -} -``` - -### Getting Prompts - -```php -$promptResult = $client->getPrompt( - name: 'code_review', - arguments: ['language' => 'php', 'code' => '...'], -); - -foreach ($promptResult->messages as $message) { - echo "{$message->role->value}: {$message->content->text}\n"; -} -``` - -Prompts also support progress notifications: - -```php -$result = $client->getPrompt( - name: 'generate_report', - arguments: ['topic' => 'quarterly_analysis'], - onProgress: static function (float $progress, ?float $total, ?string $message) { - echo "Generating: {$message}\n"; - } -); -``` - -### Requesting Completions - -Request auto-completion suggestions for prompt or resource arguments: - -```php -use Mcp\Schema\PromptReference; - -$completionResult = $client->complete( - ref: new PromptReference('code_review'), - argument: ['name' => 'language', 'value' => 'ph'], -); - -foreach ($completionResult->values as $value) { - echo "Suggestion: {$value}\n"; -} -``` - -## Server-Initiated Communication - -The client can receive requests and notifications from the server when configured with appropriate handlers. - -### Logging Notifications - -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. - -Receive structured log messages from the server: - -```php -use Mcp\Client\Handler\Notification\LoggingNotificationHandler; -use Mcp\Schema\Notification\LoggingMessageNotification; -use Mcp\Schema\Enum\LoggingLevel; - -$loggingHandler = new LoggingNotificationHandler( - static function (LoggingMessageNotification $notification) { - // Route to your application's logging system - $level = $notification->level; - $message = $notification->data; - - match ($level) { - LoggingLevel::Debug => logger()->debug($message), - LoggingLevel::Info => logger()->info($message), - LoggingLevel::Warning => logger()->warning($message), - LoggingLevel::Error => logger()->error($message), - default => logger()->info($message), - }; - } -); - -$client = Client::builder() - ->addNotificationHandler($loggingHandler) - ->build(); - -// Set minimum log level (optional) -$client->setLoggingLevel(LoggingLevel::Info); -``` - -### Sampling (LLM Requests) - -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. - -Handle server requests for LLM completions: - -```php -use Mcp\Client\Handler\Request\SamplingRequestHandler; -use Mcp\Client\Handler\Request\SamplingCallbackInterface; -use Mcp\Exception\SamplingException; -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Request\CreateSamplingMessageRequest; -use Mcp\Schema\Result\CreateSamplingMessageResult; -use Mcp\Schema\Content\TextContent; -use Mcp\Schema\Enum\Role; - -class LlmSamplingCallback implements SamplingCallbackInterface -{ - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - try { - // Call your LLM provider - $response = $this->llmClient->complete( - messages: $request->messages, - maxTokens: $request->maxTokens, - temperature: $request->temperature ?? 0.7, - ); - - return new CreateSamplingMessageResult( - role: Role::Assistant, - content: new TextContent($response->text), - model: $response->model, - stopReason: $response->stopReason, - ); - } catch (\Throwable $e) { - // Throw SamplingException to surface error to server - throw new SamplingException( - "LLM sampling failed: {$e->getMessage()}", - (int) $e->getCode(), - $e - ); - } - } -} - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addRequestHandler(new SamplingRequestHandler(new LlmSamplingCallback)) - ->build(); -``` - -#### Sampling with Tools - -Clients that support tool-enabled sampling should advertise that capability and forward the request's `tools` and -`toolChoice` fields to their LLM provider. A provider response that requests tools can be returned as one or more -`ToolUseContent` blocks: - -```php -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Content\ToolUseContent; -use Mcp\Schema\Enum\Role; -use Mcp\Schema\Result\CreateSamplingMessageResult; - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities( - sampling: true, - samplingContext: true, - samplingTools: true, - )) - ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) - ->build(); - -// Inside the sampling callback, after invoking the LLM provider: -return new CreateSamplingMessageResult( - role: Role::Assistant, - content: array_map( - static fn ($call) => new ToolUseContent($call->id, $call->name, $call->input), - $providerResponse->toolCalls, - ), - model: $providerResponse->model, - stopReason: 'toolUse', -); -``` - -The server executes the requested tools and sends their results in a later sampling request as `ToolResultContent` -blocks in a user message. The client should pass those blocks back to the LLM provider to continue the sampling loop. - -> [!IMPORTANT] -> **Error Handling in Sampling Callbacks:** -> -> When implementing sampling callbacks, error handling is critical: -> -> - **Throw `SamplingException`** to forward specific error messages to the server -> - **Any other exception** will be logged but return a generic error to the server -> -> This distinction allows you to control what error information the server receives: -> -> ```php -> // Good: Server receives "Rate limit exceeded" message -> throw new SamplingException('Rate limit exceeded. Retry after 60 seconds.'); -> -> // Bad: Server receives generic "Error while sampling LLM" message -> throw new \RuntimeException('Rate limit exceeded'); -> ``` - -### Elicitation (User Input Requests) - -Handle server requests to elicit additional information from the user during tool -execution. The server sends an `elicitation/create` request describing the fields it -needs; your callback presents them to the user and returns an `ElicitResult` with one of -three actions — accept (with the collected content), decline, or cancel: - -```php -use Mcp\Client\Handler\Request\ElicitationRequestHandler; -use Mcp\Client\Handler\Request\ElicitationCallbackInterface; -use Mcp\Exception\ElicitationException; -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Enum\ElicitAction; -use Mcp\Schema\Request\ElicitRequest; -use Mcp\Schema\Result\ElicitResult; - -class ConsoleElicitationCallback implements ElicitationCallbackInterface -{ - public function __invoke(ElicitRequest $request): ElicitResult - { - echo $request->message.\PHP_EOL; - - // Present $request->requestedSchema->properties to the user and collect input. - $content = []; - foreach ($request->requestedSchema->properties as $name => $definition) { - $answer = readline($definition->title.': '); - - if (false === $answer) { - // No input available — let the server know the user cancelled. - return new ElicitResult(ElicitAction::Cancel); - } - - $content[$name] = $answer; - } - - return new ElicitResult(ElicitAction::Accept, $content); - } -} - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler(new ElicitationRequestHandler(new ConsoleElicitationCallback)) - ->build(); -``` - -Return `new ElicitResult(ElicitAction::Decline)` when the user refuses to provide the -information, and `new ElicitResult(ElicitAction::Cancel)` when they dismiss the request. -Only the `Accept` action carries content. - -> [!IMPORTANT] -> **Error Handling in Elicitation Callbacks:** -> -> - **Throw `ElicitationException`** to forward a specific error message to the server -> - **Any other exception** is logged but returns a generic error to the server -> -> ```php -> // Good: Server receives "No interactive console available" message -> throw new ElicitationException('No interactive console available'); -> -> // Bad: Server receives generic "Error while processing elicitation" message -> throw new \RuntimeException('No interactive console available'); -> ``` - -See `examples/client/stdio_elicitation.php` for a runnable example against the -elicitation demo server. - -### Roots - -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Roots keep working until then; new integrations should pass directories or files through tool arguments, resource URIs or server configuration instead. - -Roots let the client expose a list of `file://` "workspace folders" that the server -is allowed to operate on. Advertise the `roots` capability and register a handler -that answers server `roots/list` requests: - -```php -use Mcp\Client\Handler\Request\ListRootsRequestHandler; -use Mcp\Client\Handler\Request\RootsCallbackInterface; -use Mcp\Schema\ClientCapabilities; -use Mcp\Schema\Request\ListRootsRequest; -use Mcp\Schema\Result\ListRootsResult; -use Mcp\Schema\Root; - -class WorkspaceRootsCallback implements RootsCallbackInterface -{ - public function __invoke(ListRootsRequest $request): ListRootsResult - { - return new ListRootsResult([ - new Root('file:///home/user/projects/app', 'Application'), - new Root('file:///home/user/projects/library', 'Library'), - ]); - } -} - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) - ->addRequestHandler(new ListRootsRequestHandler(new WorkspaceRootsCallback)) - ->build(); -``` - -When the client's roots change, notify the server so it can request the updated -list via `roots/list`. This requires advertising the `roots.listChanged` -capability (`rootsListChanged: true` above); otherwise `sendRootsListChanged()` -throws a `RuntimeException`. On a client that is not connected it throws a -`ConnectionException`: - -```php -$client->sendRootsListChanged(); -``` - -See `examples/client/stdio_roots.php` for a runnable example: it calls the -`inspect_workspace_roots` tool of the client-communication demo server, which -answers by issuing the `roots/list` request back to the client. - -## Error Handling - -The client throws exceptions for various error conditions: - -### ConnectionException - -Thrown when connection or initialization fails: - -```php -use Mcp\Exception\ConnectionException; - -try { - $client->connect($transport); -} catch (ConnectionException $e) { - echo "Failed to connect: {$e->getMessage()}\n"; -} -``` - -### RequestException - -Thrown when a request returns an error response: - -```php -use Mcp\Exception\RequestException; - -try { - $result = $client->callTool('unknown_tool', []); -} catch (RequestException $e) { - echo "Request failed: {$e->getMessage()}\n"; - echo "Error code: {$e->getCode()}\n"; -} -``` - -## Complete Example - -Here's a comprehensive example demonstrating client usage: - -```php -level->value}] {$notification->data}\n"; - } -); - -// Configure sampling callback -$samplingCallback = new class implements SamplingCallbackInterface { - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - echo "[SAMPLING] Processing request (max {$request->maxTokens} tokens)\n"; - - try { - // Integration with your LLM provider - $response = "This is a mock LLM response for: " . - json_encode($request->messages); - - return new CreateSamplingMessageResult( - role: Role::Assistant, - content: new TextContent($response), - model: 'mock-llm', - stopReason: 'endTurn', - ); - } catch (\Throwable $e) { - throw new SamplingException( - "Sampling failed: {$e->getMessage()}", - 0, - $e - ); - } - } -}; - -// Build client -$client = Client::builder() - ->setClientInfo('Example Client', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(120) - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addNotificationHandler($loggingHandler) - ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) - ->build(); - -// Create transport -$transport = new StdioTransport( - command: 'php', - args: [__DIR__ . '/server.php'], -); - -// Connect and use server -try { - echo "Connecting to server...\n"; - $client->connect($transport); - - // Get server info - $serverInfo = $client->getServerInfo(); - echo "Connected to: {$serverInfo->name} v{$serverInfo->version}\n\n"; - - // List capabilities - echo "Available tools:\n"; - $tools = $client->listTools(); - foreach ($tools->tools as $tool) { - echo " - {$tool->name}\n"; - } - - echo "\nAvailable resources:\n"; - $resources = $client->listResources(); - foreach ($resources->resources as $resource) { - echo " - {$resource->uri}\n"; - } - - // Set logging level - $client->setLoggingLevel(LoggingLevel::Debug); - - // Call tool with progress - echo "\nCalling tool with progress...\n"; - $result = $client->callTool( - name: 'process_data', - arguments: ['dataset' => 'large_file.csv'], - onProgress: static function (float $progress, ?float $total, ?string $message) { - $percent = $total > 0 ? round(($progress / $total) * 100) : 0; - echo " Progress: {$percent}% - {$message}\n"; - } - ); - - echo "\nResult:\n"; - foreach ($result->content as $content) { - if ($content instanceof TextContent) { - echo $content->text . "\n"; - } - } - -} catch (\Throwable $e) { - echo "Error: {$e->getMessage()}\n"; - echo $e->getTraceAsString() . "\n"; -} finally { - $client->disconnect(); - echo "\nDisconnected.\n"; -} -``` diff --git a/docs/client/capabilities.md b/docs/client/capabilities.md new file mode 100644 index 00000000..5ba082e7 --- /dev/null +++ b/docs/client/capabilities.md @@ -0,0 +1,161 @@ +# Tools, resources & prompts + +Once connected, everything the server exposes is reachable through the client: list what +is there, then call it. Each list method returns the server's own descriptions and +schemas, so a generic client can build its UI from them. + +## Working with Tools + +### Listing Tools + +```php +$toolsResult = $client->listTools(); + +foreach ($toolsResult->tools as $tool) { + echo "- {$tool->name}: {$tool->description}\n"; +} + +// Handle pagination +if ($toolsResult->nextCursor) { + $moreTools = $client->listTools($toolsResult->nextCursor); +} +``` + +### Calling Tools + +```php +use Mcp\Schema\Content\TextContent; + +$result = $client->callTool( + name: 'calculate', + arguments: ['a' => 5, 'b' => 3, 'operation' => 'add'], +); + +// Access results +foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text; + } +} +``` + +### Progress Notifications + +Hook into tool execution progress (if server supports it): + +```php +$result = $client->callTool( + name: 'long_running_task', + arguments: ['data' => 'large_dataset'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : 0; + echo "Progress: {$percent}% - {$message}\n"; + } +); +``` + +!!! note + Progress notifications are only received if the server sends them. The callback will not be invoked if the server doesn't support or send progress updates. + +## Working with Resources + +### Listing Resources + +```php +$resourcesResult = $client->listResources(); + +foreach ($resourcesResult->resources as $resource) { + echo "- {$resource->uri}: {$resource->name}\n"; +} +``` + +### Listing Resource Templates + +```php +$templatesResult = $client->listResourceTemplates(); + +foreach ($templatesResult->resourceTemplates as $template) { + echo "- {$template->uriTemplate}: {$template->name}\n"; +} +``` + +### Reading Resources + +```php +use Mcp\Schema\Content\{TextResourceContents, BlobResourceContents}; + +$resourceResult = $client->readResource('config://app/settings'); + +foreach ($resourceResult->contents as $content) { + if ($content instanceof TextResourceContents) { + echo "Text: {$content->text}\n"; + } elseif ($content instanceof BlobResourceContents) { + echo "Binary data (base64): {$content->blob}\n"; + } +} +``` + +Resources also support progress notifications: + +```php +$result = $client->readResource( + uri: 'file://large-file.bin', + onProgress: static function (float $progress, ?float $total, ?string $message) { + echo "Reading: {$progress}/{$total} bytes\n"; + } +); +``` + +## Working with Prompts + +### Listing Prompts + +```php +$promptsResult = $client->listPrompts(); + +foreach ($promptsResult->prompts as $prompt) { + echo "- {$prompt->name}: {$prompt->description}\n"; +} +``` + +### Getting Prompts + +```php +$promptResult = $client->getPrompt( + name: 'code_review', + arguments: ['language' => 'php', 'code' => '...'], +); + +foreach ($promptResult->messages as $message) { + echo "{$message->role->value}: {$message->content->text}\n"; +} +``` + +Prompts also support progress notifications: + +```php +$result = $client->getPrompt( + name: 'generate_report', + arguments: ['topic' => 'quarterly_analysis'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + echo "Generating: {$message}\n"; + } +); +``` + +### Requesting Completions + +Request auto-completion suggestions for prompt or resource arguments: + +```php +use Mcp\Schema\PromptReference; + +$completionResult = $client->complete( + ref: new PromptReference('code_review'), + argument: ['name' => 'language', 'value' => 'ph'], +); + +foreach ($completionResult->values as $value) { + echo "Suggestion: {$value}\n"; +} +``` diff --git a/docs/client/connecting.md b/docs/client/connecting.md new file mode 100644 index 00000000..b060916c --- /dev/null +++ b/docs/client/connecting.md @@ -0,0 +1,222 @@ +# Connecting to a server + +A client is configured once through its builder, then connected to a +[transport](transports.md). Connecting performs the MCP initialization handshake, after +which the server's capabilities are known and its elements can be used. On protocol +revision `2026-07-28` there is no handshake to perform — see +[Clients on this revision](../protocol-versions.md). + +## Client Builder + +The `Client\Builder` provides fluent configuration of client instances. + +### Basic Configuration + +```php +use Mcp\Client; + +$client = Client::builder() + ->setClientInfo('My Application', '1.0.0', 'Description of my client') + ->setInitTimeout(30) // Seconds to wait for initialization + ->setRequestTimeout(120) // Seconds to wait for request responses + ->setMaxRetries(3) // Retries for failed connections + ->build(); +``` + +### Connection Retries + +`setMaxRetries()` controls how often `connect()` retries a failed connection. It +counts retries rather than attempts, so the default of `3` means one initial +attempt plus up to three retries — four in total — before the `ConnectionException` +of the last attempt is rethrown: + +```php +$client = Client::builder() + ->setMaxRetries(0) // Fail on the first failed attempt + ->build(); +``` + +Between two attempts the transport is closed, so a retry never reuses a +half-established connection: a `StdioTransport` spawns a fresh server process and +an `HttpTransport` discards the session ID of the failed attempt. Each retry is +preceded by a short, linearly growing delay (100ms, 200ms, 300ms, …). + +Only the connection handshake is retried. Individual requests such as +`callTool()` are always sent once — retrying them is unsafe as tool calls are not +necessarily idempotent. + +### Client Information + +Set the client's identity reported to servers during initialization: + +```php +$client = Client::builder() + ->setClientInfo( + name: 'AI Assistant Client', + version: '2.1.0', + description: 'Client for automated AI workflows' + ) + ->build(); +``` + +### Protocol Version + +Specify the MCP protocol version to offer during the handshake (defaults to `V2025_11_25`, the +latest handshake revision — the modern `2026-07-28` revision must be chosen explicitly): + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +$client = Client::builder() + ->setProtocolVersion(ProtocolVersion::V2025_11_25) + ->build(); +``` + +This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as +described in the specification's +[protocol version negotiation](https://modelcontextprotocol.io/specification/latest/basic/versioning#protocol-version-negotiation) +section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK +cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed +on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated. + +Setting a modern revision such as `2026-07-28` selects the other lifecycle rather than making an offer: there is no +`initialize` to negotiate with, so `connect()` sends none and every request carries its own revision instead. Nothing +else about the client API changes. See [Clients on this revision](../protocol-versions.md) for what happens underneath. + +See [Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for the server side of the +exchange. + +### Capabilities + +Declare client capabilities to enable server features: + +```php +use Mcp\Schema\ClientCapabilities; + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities( + sampling: true, // Enable LLM sampling requests from server + roots: true, // Enable filesystem root listing + )) + ->build(); +``` + +### Notification Handlers + +Register handlers for server-initiated notifications: + +```php +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Schema\Notification\LoggingMessageNotification; + +$loggingHandler = new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) { + echo "[{$notification->level->value}] {$notification->data}\n"; + } +); + +$client = Client::builder() + ->addNotificationHandler($loggingHandler) + ->build(); +``` + +### Request Handlers + +Register handlers for server-initiated requests (e.g., sampling). The same handlers answer a +[multi round-trip](../handlers/input-required.md) `input_required` result on a modern revision, where the server +returns its ask instead of sending a request: + +```php +use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Client\Handler\Request\SamplingCallbackInterface; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Result\CreateSamplingMessageResult; + +$samplingCallback = new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + // Perform LLM sampling and return result + } +}; + +$client = Client::builder() + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); +``` + +### Logger + +Configure PSR-3 logging for debugging: + +```php +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +$logger = new Logger('mcp-client'); +$logger->pushHandler(new StreamHandler('client.log', Logger::DEBUG)); + +$client = Client::builder() + ->setLogger($logger) + ->build(); +``` + +## Connecting to Servers + +### Establishing Connection + +```php +$client->connect($transport); +``` + +The `connect()` method performs the MCP initialization handshake: +1. Opens the transport connection +2. Sends InitializeRequest with client capabilities +3. Waits for InitializeResult from server +4. Sends InitializedNotification + +On a modern revision it opens the transport and asks `server/discover` for the server's identity instead; a server +that does not answer that optional method still yields a usable connection. + +!!! warning + Always wrap connection in try/catch to handle `ConnectionException` for failed connections. + +### Checking Connection State + +```php +if ($client->isConnected()) { + // Client is connected and initialized +} +``` + +### Disconnecting + +```php +$client->disconnect(); +``` + +Always disconnect when finished to clean up resources: + +```php +try { + $client->connect($transport); + // ... use the client ... +} finally { + $client->disconnect(); +} +``` + +## Server Information + +After successful connection, retrieve server metadata: + +```php +// Get server implementation info +$serverInfo = $client->getServerInfo(); +echo "Server: {$serverInfo->name} v{$serverInfo->version}\n"; + +// Get server instructions +$instructions = $client->getInstructions(); +if ($instructions) { + echo "Instructions: {$instructions}\n"; +} +``` diff --git a/docs/client/errors.md b/docs/client/errors.md new file mode 100644 index 00000000..94e27f99 --- /dev/null +++ b/docs/client/errors.md @@ -0,0 +1,155 @@ +# Error Handling + +The client throws exceptions for various error conditions: + +## ConnectionException + +Thrown when connection or initialization fails: + +```php +use Mcp\Exception\ConnectionException; + +try { + $client->connect($transport); +} catch (ConnectionException $e) { + echo "Failed to connect: {$e->getMessage()}\n"; +} +``` + +## RequestException + +Thrown when a request returns an error response: + +```php +use Mcp\Exception\RequestException; + +try { + $result = $client->callTool('unknown_tool', []); +} catch (RequestException $e) { + echo "Request failed: {$e->getMessage()}\n"; + echo "Error code: {$e->getCode()}\n"; +} +``` + +## Complete Example + +Here's a comprehensive example demonstrating client usage: + +```php-file +level->value}] {$notification->data}\n"; + } +); + +// Configure sampling callback +$samplingCallback = new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + echo "[SAMPLING] Processing request (max {$request->maxTokens} tokens)\n"; + + try { + // Integration with your LLM provider + $response = "This is a mock LLM response for: " . + json_encode($request->messages); + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($response), + model: 'mock-llm', + stopReason: 'endTurn', + ); + } catch (\Throwable $e) { + throw new SamplingException( + "Sampling failed: {$e->getMessage()}", + 0, + $e + ); + } + } +}; + +// Build client +$client = Client::builder() + ->setClientInfo('Example Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addNotificationHandler($loggingHandler) + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); + +// Create transport +$transport = new StdioTransport( + command: 'php', + args: [__DIR__ . '/server.php'], +); + +// Connect and use server +try { + echo "Connecting to server...\n"; + $client->connect($transport); + + // Get server info + $serverInfo = $client->getServerInfo(); + echo "Connected to: {$serverInfo->name} v{$serverInfo->version}\n\n"; + + // List capabilities + echo "Available tools:\n"; + $tools = $client->listTools(); + foreach ($tools->tools as $tool) { + echo " - {$tool->name}\n"; + } + + echo "\nAvailable resources:\n"; + $resources = $client->listResources(); + foreach ($resources->resources as $resource) { + echo " - {$resource->uri}\n"; + } + + // Set logging level + $client->setLoggingLevel(LoggingLevel::Debug); + + // Call tool with progress + echo "\nCalling tool with progress...\n"; + $result = $client->callTool( + name: 'process_data', + arguments: ['dataset' => 'large_file.csv'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : 0; + echo " Progress: {$percent}% - {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text . "\n"; + } + } + +} catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString() . "\n"; +} finally { + $client->disconnect(); + echo "\nDisconnected.\n"; +} +``` diff --git a/docs/client/index.md b/docs/client/index.md new file mode 100644 index 00000000..f2a9848f --- /dev/null +++ b/docs/client/index.md @@ -0,0 +1,41 @@ +# Clients + +The client side is for applications that *use* MCP servers: you connect to a server, +discover what it offers, and call it. The API is synchronous — every method returns a +result or throws. + +```php +use Mcp\Client; +use Mcp\Client\Transport\StdioTransport; + +// Build and configure the client +$client = Client::builder() + ->setClientInfo('My Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->build(); + +// Create a transport +$transport = new StdioTransport( + command: 'php', + args: ['/path/to/server.php'], +); + +// Connect and use the server +$client->connect($transport); +$tools = $client->listTools(); +$client->disconnect(); +``` + +* **[Connecting to a server](connecting.md)** — the builder, the connection lifecycle, + and what the server told you about itself during initialization. +* **[Transports](transports.md)** — launching a local server process (STDIO) or talking + to a remote one (HTTP). +* **[Tools, resources & prompts](capabilities.md)** — listing and calling everything a + server exposes, including progress callbacks and completions. +* **[Server-initiated requests](server-requests.md)** — the other direction: log + messages, sampling requests, and elicitations the server sends *you*. +* **[Error handling](errors.md)** — which exception means what, plus a complete + end-to-end example. +* **[Clients on this revision](../protocol-versions.md)** — the one builder line that speaks + protocol revision `2026-07-28`, and what it changes underneath. diff --git a/docs/client/server-requests.md b/docs/client/server-requests.md new file mode 100644 index 00000000..d25cd5c9 --- /dev/null +++ b/docs/client/server-requests.md @@ -0,0 +1,265 @@ +# Server-Initiated Communication + +The client can receive requests and notifications from the server when configured with appropriate handlers. + +## Elicitation (User Input Requests) + +Handle server requests to elicit additional information from the user during tool +execution. The server sends an `elicitation/create` request describing the fields it +needs; your callback presents them to the user and returns an `ElicitResult` with one of +three actions — accept (with the collected content), decline, or cancel: + +```php +use Mcp\Client\Handler\Request\ElicitationRequestHandler; +use Mcp\Client\Handler\Request\ElicitationCallbackInterface; +use Mcp\Exception\ElicitationException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ElicitationMode; +use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Result\ElicitResult; + +class ConsoleElicitationCallback implements ElicitationCallbackInterface +{ + public function __invoke(ElicitRequest $request): ElicitResult + { + echo $request->message.\PHP_EOL; + + // In url mode there is no schema to fill in — the user completes the + // interaction in the browser instead. + if (ElicitationMode::Url === $request->mode) { + echo 'Continue in your browser: '.$request->url.\PHP_EOL; + + return new ElicitResult(ElicitAction::Accept); + } + + // Form mode: present $request->requestedSchema->properties and collect input. + $content = []; + foreach ($request->requestedSchema->properties as $name => $definition) { + $answer = readline($definition->title.': '); + + if (false === $answer) { + // No input available — let the server know the user cancelled. + return new ElicitResult(ElicitAction::Cancel); + } + + $content[$name] = $answer; + } + + return new ElicitResult(ElicitAction::Accept, $content); + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler(new ElicitationRequestHandler(new ConsoleElicitationCallback)) + ->build(); +``` + +Return `new ElicitResult(ElicitAction::Decline)` when the user refuses to provide the +information, and `new ElicitResult(ElicitAction::Cancel)` when they dismiss the request. +Only the `Accept` action carries content. + +!!! warning + **Error Handling in Elicitation Callbacks:** + + - **Throw `ElicitationException`** to forward a specific error message to the server + - **Any other exception** is logged but returns a generic error to the server + + ```php + // Good: Server receives "No interactive console available" message + throw new ElicitationException('No interactive console available'); + + // Bad: Server receives generic "Error while processing elicitation" message + throw new \RuntimeException('No interactive console available'); + ``` + +See [`examples/client/stdio_elicitation.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_elicitation.php) for a runnable example against the +elicitation demo server. + +## Sampling (LLM Requests) + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. + +Handle server requests for LLM completions: + +```php +use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Client\Handler\Request\SamplingCallbackInterface; +use Mcp\Exception\SamplingException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Result\CreateSamplingMessageResult; +use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\Role; + +class LlmSamplingCallback implements SamplingCallbackInterface +{ + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + try { + // Call your LLM provider + $response = $this->llmClient->complete( + messages: $request->messages, + maxTokens: $request->maxTokens, + temperature: $request->temperature ?? 0.7, + ); + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($response->text), + model: $response->model, + stopReason: $response->stopReason, + ); + } catch (\Throwable $e) { + // Throw SamplingException to surface error to server + throw new SamplingException( + "LLM sampling failed: {$e->getMessage()}", + (int) $e->getCode(), + $e + ); + } + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addRequestHandler(new SamplingRequestHandler(new LlmSamplingCallback)) + ->build(); +``` + +### Sampling with Tools + +Clients that support tool-enabled sampling should advertise that capability and forward the request's `tools` and +`toolChoice` fields to their LLM provider. A provider response that requests tools can be returned as one or more +`ToolUseContent` blocks: + +```php +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Content\ToolUseContent; +use Mcp\Schema\Enum\Role; +use Mcp\Schema\Result\CreateSamplingMessageResult; + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities( + sampling: true, + samplingContext: true, + samplingTools: true, + )) + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); + +// Inside the sampling callback, after invoking the LLM provider: +return new CreateSamplingMessageResult( + role: Role::Assistant, + content: array_map( + static fn ($call) => new ToolUseContent($call->id, $call->name, $call->input), + $providerResponse->toolCalls, + ), + model: $providerResponse->model, + stopReason: 'toolUse', +); +``` + +The server executes the requested tools and sends their results in a later sampling request as `ToolResultContent` +blocks in a user message. The client should pass those blocks back to the LLM provider to continue the sampling loop. + +!!! warning + **Error Handling in Sampling Callbacks:** + + When implementing sampling callbacks, error handling is critical: + + - **Throw `SamplingException`** to forward specific error messages to the server + - **Any other exception** will be logged but return a generic error to the server + + This distinction allows you to control what error information the server receives: + + ```php + // Good: Server receives "Rate limit exceeded" message + throw new SamplingException('Rate limit exceeded. Retry after 60 seconds.'); + + // Bad: Server receives generic "Error while sampling LLM" message + throw new \RuntimeException('Rate limit exceeded'); + ``` + +## Logging Notifications + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. + +Receive structured log messages from the server: + +```php +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Schema\Notification\LoggingMessageNotification; +use Mcp\Schema\Enum\LoggingLevel; + +$loggingHandler = new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) { + // Route to your application's logging system + $level = $notification->level; + $message = $notification->data; + + match ($level) { + LoggingLevel::Debug => logger()->debug($message), + LoggingLevel::Info => logger()->info($message), + LoggingLevel::Warning => logger()->warning($message), + LoggingLevel::Error => logger()->error($message), + default => logger()->info($message), + }; + } +); + +$client = Client::builder() + ->addNotificationHandler($loggingHandler) + ->build(); + +// Set minimum log level (optional) +$client->setLoggingLevel(LoggingLevel::Info); +``` + +## Roots + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Roots keep working until then; new integrations should pass directories or files through tool arguments, resource URIs or server configuration instead. + +Roots let the client expose a list of `file://` "workspace folders" that the server +is allowed to operate on. Advertise the `roots` capability and register a handler +that answers server `roots/list` requests: + +```php +use Mcp\Client\Handler\Request\ListRootsRequestHandler; +use Mcp\Client\Handler\Request\RootsCallbackInterface; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Request\ListRootsRequest; +use Mcp\Schema\Result\ListRootsResult; +use Mcp\Schema\Root; + +class WorkspaceRootsCallback implements RootsCallbackInterface +{ + public function __invoke(ListRootsRequest $request): ListRootsResult + { + return new ListRootsResult([ + new Root('file:///home/user/projects/app', 'Application'), + new Root('file:///home/user/projects/library', 'Library'), + ]); + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->addRequestHandler(new ListRootsRequestHandler(new WorkspaceRootsCallback)) + ->build(); +``` + +When the client's roots change, notify the server so it can request the updated +list via `roots/list`. This requires advertising the `roots.listChanged` +capability (`rootsListChanged: true` above); otherwise `sendRootsListChanged()` +throws a `RuntimeException`. On a client that is not connected it throws a +`ConnectionException`: + +```php +$client->sendRootsListChanged(); +``` + +See [`examples/client/stdio_roots.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_roots.php) for a runnable example: it calls the +`inspect_workspace_roots` tool of the client-communication demo server, which +answers by issuing the `roots/list` request back to the client. diff --git a/docs/client/transports.md b/docs/client/transports.md new file mode 100644 index 00000000..dd271661 --- /dev/null +++ b/docs/client/transports.md @@ -0,0 +1,61 @@ +# Transports + +Transports handle the communication layer between client and server. + +## STDIO Transport + +Spawns a server process and communicates via standard input/output: + +```php +use Mcp\Client\Transport\StdioTransport; + +$transport = new StdioTransport( + command: 'php', + args: ['/path/to/server.php'], + cwd: '/working/directory', // Optional working directory + env: ['KEY' => 'value'], // Optional environment variables +); +``` + +**Parameters:** +- `command` (string): The command to execute +- `args` (array): Command arguments +- `cwd` (string|null): Working directory for the process +- `env` (array|null): Environment variables +- `logger` (LoggerInterface|null): Optional PSR-3 logger +- `maxBufferSize` (int): Maximum buffered bytes per message before the transport gives up + +## HTTP Transport + +Communicates with remote MCP servers over HTTP: + +```php +use Mcp\Client\Transport\HttpTransport; + +$transport = new HttpTransport( + endpoint: 'http://localhost:8000', + headers: ['Authorization' => 'Bearer token'], +); +``` + +**Parameters:** +- `endpoint` (string): The MCP server URL +- `headers` (array): Additional HTTP headers +- `httpClient` (ClientInterface|null): PSR-18 HTTP client (auto-discovered) +- `requestFactory` (RequestFactoryInterface|null): PSR-17 request factory (auto-discovered) +- `streamFactory` (StreamFactoryInterface|null): PSR-17 stream factory (auto-discovered) +- `logger` (LoggerInterface|null): Optional PSR-3 logger +- `maxSseBufferBytes` (int): Maximum buffered bytes for a streamed SSE response + +**PSR-18 Auto-Discovery:** + +The transport automatically discovers PSR-18 HTTP clients from: +- `php-http/guzzle7-adapter` +- `php-http/curl-client` +- `symfony/http-client` +- And other PSR-18 compatible implementations + +```bash +# Install any PSR-18 client - discovery works automatically +composer require php-http/guzzle7-adapter +``` diff --git a/docs/examples.md b/docs/examples.md index 14e97fde..4eae5ba4 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,520 +1,97 @@ # Examples -The MCP PHP SDK includes comprehensive examples demonstrating different patterns and use cases. Each example showcases -specific features and can be run independently to understand how the SDK works. - -## Table of Contents - -- [Getting Started](#getting-started) -- [Running Examples](#running-examples) -- [Server Examples](#server-examples) -- [Client Examples](#client-examples) - -## Getting Started - -All examples are located in the `examples/` directory and use the SDK dependencies from the root project. Most examples -can be run directly without additional setup. - -### Prerequisites - -```bash -# Install dependencies (in project root) -composer install -``` - -## Running Examples - -The bootstrapping of the example will choose the used transport based on the SAPI you use. - -### STDIO Transport - -The STDIO transport will use standard input/output for communication: - -```bash -# Interactive testing with MCP Inspector -npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php - -# Run with debugging enabled -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php - -# Or configure the script path in your MCP client -# Path: php examples/discovery-calculator/server.php -``` - -### HTTP Transport - -The Streamable HTTP transport will be chosen if running examples with a web servers: +Every example lives in [`examples/`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples) +and runs against the dependencies installed in the repository root. Each server example is a +`server.php` whose bootstrap picks the transport from the SAPI it runs under: ```bash -# Start the server -php -S localhost:8000 examples/discovery-userprofile/server.php - -# Test with MCP Inspector -npx @modelcontextprotocol/inspector http://localhost:8000 - -# Test with curl -curl -X POST http://localhost:8000 \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' -``` - -## Server Examples - -### Discovery Calculator +# STDIO transport +php examples/server/discovery-calculator/server.php + +# Streamable HTTP transport +php -S 127.0.0.1:8000 examples/server/discovery-userprofile/server.php + +# Interactive testing with the MCP Inspector +npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php +``` + +## Server examples + +| Example | What it shows | Docs | +| --- | --- | --- | +| [`discovery-calculator`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/discovery-calculator) | Attribute discovery of tools, resources and prompts; `ResourceLink` content | [Tools](servers/tools.md) | +| [`discovery-userprofile`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/discovery-userprofile) | Resource templates with completion providers; `FileSessionStore` | [Resource templates](servers/resource-templates.md), [Completions](servers/completions.md) | +| [`explicit-registration`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/explicit-registration) | Manual `addTool()`/`addResource()`/`addResourceTemplate()`/`addPrompt()` without discovery | [Registering elements](servers/registration.md) | +| [`combined-registration`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/combined-registration) | Discovery and manual registration combined, and which wins on conflict | [Registering elements](servers/registration.md) | +| [`cached-discovery`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/cached-discovery) | Caching the discovery scan in a PSR-16 cache | [Server builder](run/server-builder.md#discovery-configuration) | +| [`custom-dependencies`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/custom-dependencies) | Handlers pulling services from a PSR-11 container | [Server builder](run/server-builder.md#service-dependencies) | +| [`complex-tool-schema`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/complex-tool-schema) | Rich input schemas from typed parameters, PHP enums and defaults | [Schema generation](servers/schemas.md) | +| [`schema-showcase`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/schema-showcase) | `#[Schema]` constraint attributes: formats, patterns, ranges | [Schema generation](servers/schemas.md) | +| [`env-variables`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/env-variables) | Configuring a server through environment variables | [Server builder](run/server-builder.md) | +| [`client-communication`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-communication) | Sampling, roots, progress and log messages from inside a handler | [Talking back to the client](handlers/client-communication.md) | +| [`client-logging`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-logging) | Structured log notifications through the `ClientLogger` | [Logging](handlers/logging.md) | +| [`elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) | Asking the user for input mid-call with `InputRequiredResult` and typed elicitation schemas | [Asking for input](handlers/input-required.md) | +| [`custom-method-handlers`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/custom-method-handlers) | Registering handlers for custom JSON-RPC methods | [Custom message handlers](advanced/custom-handlers.md) | +| [`mcp-apps`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/mcp-apps) | The MCP Apps extension: a tool that ships an interactive HTML view | [Protocol extensions](advanced/extensions.md) | +| [`stateless-lifecycle`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/stateless-lifecycle) | Revision `2026-07-28`: cache policy, request state, notification bus | [Serving both eras](run/protocol-eras.md), [Caching](run/caching.md), [Subscriptions](run/subscriptions.md) | +| [`oauth-keycloak`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/oauth-keycloak) | OAuth authorization against a Keycloak instance (own README) | [Authorization](run/authorization.md) | +| [`oauth-microsoft`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/oauth-microsoft) | OAuth authorization against Microsoft Entra ID (own README) | [Authorization](run/authorization.md) | + +## Client examples + +| Example | What it shows | Docs | +| --- | --- | --- | +| [`stdio_discovery_calculator.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_discovery_calculator.php) | Connecting over STDIO, listing and calling tools, reading resources | [Connecting to a server](client/connecting.md) | +| [`http_discovery_calculator.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/http_discovery_calculator.php) | The same conversation over the Streamable HTTP transport | [Transports](client/transports.md) | +| [`stdio_client_communication.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_client_communication.php) | Answering server-initiated sampling, log and progress messages | [Server-initiated requests](client/server-requests.md) | +| [`http_client_communication.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/http_client_communication.php) | The same handlers over HTTP — see the note on PHP's built-in server below | [Server-initiated requests](client/server-requests.md) | +| [`stdio_elicitation.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_elicitation.php) | Answering elicitation requests from an interactive prompt | [Server-initiated requests](client/server-requests.md) | +| [`stdio_roots.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_roots.php) | Exposing workspace roots and signalling `roots/list_changed` | [Server-initiated requests](client/server-requests.md) | +| [`stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) | A client pinned to revision `2026-07-28` — see [Modern-era client](#modern-era-client) | [Connecting to a server](client/connecting.md) | + +Client examples run directly: -**File**: `examples/discovery-calculator/` - -**What it demonstrates:** -- Attribute-based discovery using `#[McpTool]` and `#[McpResource]` -- Basic arithmetic operations -- Configuration management through resources -- State management between tool calls - -**Key Features:** -```php -#[McpTool(name: 'calculate')] -public function calculate(float $a, float $b, string $operation): float|string - -#[McpResource( - uri: 'config://calculator/settings', - name: 'calculator_config', - mimeType: 'application/json' -)] -public function getConfiguration(): array -``` - -**Usage:** ```bash -# Interactive testing -npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php - -# Or configure in MCP client: php examples/discovery-calculator/server.php -``` - -### Explicit Registration - -**File**: `examples/explicit-registration/` - -**What it demonstrates:** -- Manual registration of tools, resources, and prompts -- Alternative to attribute-based discovery -- Simple handler functions - -**Key Features:** -```php -$server = Server::builder() - ->addTool([SimpleHandlers::class, 'echoText'], 'echo_text') - ->addResource([SimpleHandlers::class, 'getAppVersion'], 'app://version') - ->addPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') -``` - -### Environment Variables - -**File**: `examples/env-variables/` - -**What it demonstrates:** -- Environment variable integration -- Server configuration from environment -- Environment-based tool behavior - -**Key Features:** -- Reading environment variables within tools -- Conditional behavior based on environment -- Environment validation and defaults - -### Custom Dependencies - -**File**: `examples/custom-dependencies/` - -**What it demonstrates:** -- Dependency injection with PSR-11 containers -- Service layer architecture -- Repository pattern implementation -- Complex business logic integration - -**Key Features:** -```php -$container->set(TaskRepositoryInterface::class, $taskRepo); -$container->set(StatsServiceInterface::class, $statsService); - -$server = Server::builder() - ->setContainer($container) - ->setDiscovery(__DIR__, ['.']) -``` - -### Cached Discovery - -**File**: `examples/cached-discovery/` - -**What it demonstrates:** -- Discovery caching for improved performance -- PSR-16 cache integration -- Cache invalidation strategies - -**Key Features:** -```php -use Symfony\Component\Cache\Adapter\FilesystemAdapter; -use Symfony\Component\Cache\Psr16Cache; - -$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); - -$server = Server::builder() - ->setDiscovery(__DIR__, ['.'], [], $cache) -``` - -### Client Communication - -**File**: `examples/client-communication/` - -**What it demonstrates:** -- Server initiated communication back to the client -- Logging, sampling, progress and notifications -- Using `ClientGateway` in tool method via method argument injection of `RequestContext` - -### Discovery User Profile - -**File**: `examples/discovery-userprofile/` - -**What it demonstrates:** -- HTTP transport with StreamableHttpTransport -- Resource templates with URI parameters -- Completion providers for parameter hints -- User profile management system -- Session persistence with FileSessionStore - -**Key Features:** -```php -#[McpResourceTemplate( - uriTemplate: 'user://{userId}/profile', - name: 'user_profile', - mimeType: 'application/json' -)] -public function getUserProfile( - #[CompletionProvider(values: ['101', '102', '103'])] - string $userId -): array - -#[McpPrompt(name: 'generate_bio_prompt')] -public function generateBio(string $userId, string $tone = 'professional'): array -``` - -**Usage:** -```bash -# Start the HTTP server -php -S localhost:8000 examples/discovery-userprofile/server.php - -# Test with MCP Inspector -npx @modelcontextprotocol/inspector http://localhost:8000 - -# Or configure in MCP client: http://localhost:8000 -``` - -### Combined Registration - -**File**: `examples/combined-registration/` - -**What it demonstrates:** -- Mixing attribute discovery with manual registration -- HTTP server with both discovered and manual capabilities -- All three handler styles: discovered, `[Class::class, 'method']`, and a pre-built `[$instance, 'method']` -- Pre-built instance handlers for classes the container cannot auto-wire (e.g. constructor scalars) - -**Key Features:** -```php -// Built here so its constructor dependencies are injected before registration; -// the SDK invokes this very instance instead of constructing one itself. -$preconfiguredGreeter = new PreconfiguredGreeter('Willkommen', logger()); - -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) // Automatic discovery - ->addTool([ManualHandlers::class, 'manualGreeter']) // Manual class-string handler - ->addTool([$preconfiguredGreeter, 'greet'], 'instance_greeter') // Pre-built instance handler - ->addResource([ManualHandlers::class, 'getPriorityConfigManual'], 'config://priority') -``` - -### Complex Tool Schema - -**File**: `examples/complex-tool-schema/` - -**What it demonstrates:** -- Advanced JSON schema definitions -- Complex data structures and validation -- Event scheduling and management -- Enum types and nested objects - -**Key Features:** -```php -#[Schema(definition: [ - 'type' => 'object', - 'properties' => [ - 'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], - 'eventType' => ['type' => 'string', 'enum' => ['meeting', 'deadline', 'reminder']], - 'priority' => ['type' => 'string', 'enum' => ['low', 'medium', 'high', 'urgent']] - ] -])] -public function scheduleEvent(array $eventData): array -``` - -### Schema Showcase - -**File**: `examples/schema-showcase/` - -**What it demonstrates:** -- Comprehensive JSON schema features -- Parameter-level schema validation -- String constraints (minLength, maxLength, pattern) -- Numeric constraints (minimum, maximum, multipleOf) -- Array and object validation - -**Key Features:** -```php -#[McpTool] -public function formatText( - #[Schema( - type: 'string', - minLength: 5, - maxLength: 100, - pattern: '^[a-zA-Z0-9\s\.,!?\-]+$' - )] - string $text, - - #[Schema(enum: ['uppercase', 'lowercase', 'title', 'sentence'])] - string $format = 'sentence' -): array -``` - -### Elicitation - -**File**: `examples/server/elicitation/` - -**What it demonstrates:** -- Server-to-client elicitation requests -- Interactive user input during tool execution -- Multi-field form schemas with validation -- Boolean confirmation dialogs -- Enum fields with human-readable labels -- Handling accept/decline/cancel responses -- Session persistence requirement for server-initiated requests - -**Key Features:** -```php -// Check client support before eliciting -if (!$context->getClientGateway()->supportsElicitation()) { - return ['status' => 'error', 'message' => 'Client does not support elicitation']; -} - -// Build schema with multiple field types -$schema = new ElicitationSchema( - properties: [ - 'party_size' => new NumberSchemaDefinition( - title: 'Party Size', - integerOnly: true, - minimum: 1, - maximum: 20 - ), - 'date' => new StringSchemaDefinition( - title: 'Reservation Date', - format: 'date' - ), - 'dietary' => new EnumSchemaDefinition( - title: 'Dietary Restrictions', - enum: ['none', 'vegetarian', 'vegan'], - enumNames: ['None', 'Vegetarian', 'Vegan'] - ), - ], - required: ['party_size', 'date'] -); - -// Send elicitation request -$result = $client->elicit( - message: 'Please provide your reservation details', - requestedSchema: $schema -); - -// Handle response -if ($result->isAccepted()) { - $data = $result->content; // User-provided data -} elseif ($result->isDeclined() || $result->isCancelled()) { - // User declined or cancelled -} -``` - -**Important Notes:** -- Elicitation requires a session store (e.g., `FileSessionStore`) -- Check client capabilities with `supportsElicitation()` before sending requests -- Schema supports primitive types: string, number/integer, boolean, enum -- String fields support format validation: date, date-time, email, uri -- Users can accept (providing data), decline, or cancel requests - -**Usage:** -```bash -# Interactive testing with MCP client that supports elicitation -npx @modelcontextprotocol/inspector php examples/server/elicitation/server.php - -# Test with Goose (confirmed working by reviewer) -# Or configure in Claude Desktop or other MCP clients -``` - -**Example Tools:** -1. **book_restaurant** - Multi-field reservation form with number, date, and enum fields -2. **confirm_action** - Simple boolean confirmation dialog -3. **collect_feedback** - Rating and comments form with optional fields - -### MCP Apps - -**File**: `examples/server/mcp-apps/` - -A weather app demonstrating the [MCP Apps extension](extensions.md): a `ui://` -HTML resource is opened by an MCP App-aware client (e.g. Goose) and bridged to -the `get_weather` tool. The bundled `weather-app.html` performs the -`ui/initialize` handshake, reports its size via `ui/notifications/size-changed`, -and calls back into the server. See the -[ext-apps repo](https://github.com/modelcontextprotocol/ext-apps) for the -TypeScript SDK and richer view-side patterns. - -## Client Examples - -### STDIO Discovery Calculator (Client) - -**File**: `examples/client/stdio_discovery_calculator.php` - -**What it demonstrates:** -- Basic MCP client usage with STDIO transport -- Connecting to a local MCP server process -- Listing and calling tools -- Reading resources - -**Key Features:** -```php -$client = Client::builder() - ->setClientInfo('STDIO Example Client', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(60) - ->build(); - -$transport = new StdioTransport( - command: 'php', - args: [__DIR__.'/../server/discovery-calculator/server.php'], -); - -$client->connect($transport); -$tools = $client->listTools(); -$result = $client->callTool('calculate', ['a' => 5, 'b' => 3, 'operation' => 'add']); -$resourceContent = $client->readResource('config://calculator/settings'); -``` - -**Usage:** -```bash -# Run the client (automatically starts the server) php examples/client/stdio_discovery_calculator.php ``` -### HTTP Discovery Calculator (Client) +> **Note**: PHP's built-in development server handles one request at a time, so the sampling +> round-trip in `http_client_communication.php` will not complete under a plain `php -S`. +> Start it with worker processes instead: `PHP_CLI_SERVER_WORKERS=2 php -S 127.0.0.1:8000 …`. -**File**: `examples/client/http_discovery_calculator.php` +## The 2026-07-28 lifecycle -**What it demonstrates:** -- MCP client with HTTP transport -- Connecting to remote MCP servers -- Listing tools, resources, and prompts +`stateless-lifecycle/server.php` answers both eras on the same URL. On revision `2026-07-28` +every request carries its own protocol version and client capabilities, so a call is a single +POST with no handshake before it: -**Key Features:** -```php -$transport = new HttpTransport('http://localhost:8000'); -$client->connect($transport); - -$tools = $client->listTools(); -$resources = $client->listResources(); -$prompts = $client->listPrompts(); -``` - -**Usage:** ```bash -# Start the server first -php -S localhost:8000 examples/server/http-discovery-calculator/server.php - -# Then run the client -php examples/client/http_discovery_calculator.php +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php ``` -### STDIO Client Communication - -**File**: `examples/client/stdio_client_communication.php` - -**What it demonstrates:** -- Server-to-client communication (logging, progress, sampling) -- Handling logging notifications from server -- Implementing sampling callbacks for LLM requests -- Progress tracking during tool execution - -**Key Features:** -```php -use Mcp\Client\Handler\Notification\LoggingNotificationHandler; -use Mcp\Client\Handler\Request\SamplingRequestHandler; -use Mcp\Client\Handler\Request\SamplingCallbackInterface; -use Mcp\Schema\ClientCapabilities; - -$loggingHandler = new LoggingNotificationHandler( - static function (LoggingMessageNotification $n) { - echo "[LOG {$n->level->value}] {$n->data}\n"; - } -); - -$samplingHandler = new SamplingRequestHandler(new class implements SamplingCallbackInterface { - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - // Perform LLM sampling and return result - } -}); - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addNotificationHandler($loggingHandler) - ->addRequestHandler($samplingHandler) - ->build(); - -// Call tool with progress tracking -$result = $client->callTool( - name: 'run_dataset_quality_checks', - arguments: ['dataset' => 'customer_orders_2024'], - onProgress: static function (float $progress, ?float $total, ?string $message) { - $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; - echo "[PROGRESS {$percent}%] {$message}\n"; - } -); -``` - -**Usage:** ```bash -# Run the client (automatically starts the communication server) -php examples/client/stdio_client_communication.php +curl -sS http://127.0.0.1:8000/ \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: server/discover' \ + -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientCapabilities":{}}}}' ``` -### HTTP Client Communication - -**File**: `examples/client/http_client_communication.php` +The example's header comments document the equivalent handshake-era conversation against the +same endpoint. [Serving both eras](run/protocol-eras.md) explains how the routing works. -**What it demonstrates:** -- Server-to-client communication over HTTP -- Receiving logging and progress notifications via SSE streaming -- Implementing sampling for HTTP-based servers -- Progress tracking with long-running operations +## Modern-era client -**Key Features:** -- Same client-side code as STDIO version -- Uses HttpTransport instead of StdioTransport -- Demonstrates SSE-based real-time notifications -- Shows HTTP session management +`stateless_lifecycle_client.php` drives the server above from PHP: it pins +`ProtocolVersion::V2026_07_28`, skips the handshake, discovers the server through +`server/discover` and calls a tool — one process, no session: -**Usage:** ```bash -# Start the server -php -S 127.0.0.1:8000 examples/server/client-communication/server.php - -# Run the client -php examples/client/http_client_communication.php +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php & +php examples/client/stateless_lifecycle_client.php ``` -> [!NOTE] -> For sampling with HTTP transport, the server must support concurrent request processing (e.g., using Symfony CLI, PHP-FPM, or a production web server). PHP's built-in development server cannot handle the concurrent requests required for sampling. +See [Clients on the modern revision](client/connecting.md) for the API it uses. diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 00000000..a280d7fd --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/get-started/first-server.md b/docs/get-started/first-server.md new file mode 100644 index 00000000..1fbb8a31 --- /dev/null +++ b/docs/get-started/first-server.md @@ -0,0 +1,84 @@ +# First server + +A server is a plain PHP class plus three lines of wiring. Create `server.php` next to +your `vendor/` directory: + +```php-file title="server.php" +#!/usr/bin/env php + 2]; + } +} + +exit(Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build() + ->run(new StdioTransport())); +``` + +Discovery needs `symfony/finder`: + +```bash +composer require symfony/finder +``` + +## What each piece does + +`#[McpTool]` marks a method as an action the *model* can call. Its name defaults to the +method name, its description comes from the docblock (the summary, plus the longer +description if you write one), and its input schema is +generated from the parameter types — `int $a, int $b` becomes a JSON Schema with two +required integers. See [Tools](../servers/tools.md). + +`#[McpResource]` marks a method as read-only data the *application* can read, addressed +by URI. See [Resources](../servers/resources.md). + +`setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor'])` scans those directories for +attributed classes. Scanning is lazy: it happens on the first request that needs the +registry, not when `build()` returns — call `setLazyLoading(false)` if you would rather +pay for it up front. Excluding `vendor` matters because the scan is recursive and would +otherwise read and autoload every file your dependencies ship. If you would rather +register elements explicitly — or mix both — see +[Registering elements](../servers/registration.md). + +`run(new StdioTransport())` speaks JSON-RPC over stdin/stdout and returns an exit code. +That is the transport local MCP hosts launch as a subprocess; for a web-facing server +use the [HTTP transport](../run/http.md) instead. + +!!! warning "Never write to STDOUT" + With the STDIO transport, `STDOUT` carries the protocol. `echo`, `print_r()`, or a + stray `var_dump()` in a handler corrupts the stream. Write to `STDERR`, or use the + [logger](../handlers/logging.md). + +## Run it + +```bash +php server.php +``` + +Nothing happens — the server is waiting for JSON-RPC on stdin, which is exactly right. +Stop it with `Ctrl+C`, and let a real client drive it instead: +[Try it with the Inspector](inspector.md). diff --git a/docs/get-started/index.md b/docs/get-started/index.md new file mode 100644 index 00000000..eada9d31 --- /dev/null +++ b/docs/get-started/index.md @@ -0,0 +1,27 @@ +# Get started + +New to MCP, or new to this SDK? Start here. These pages take you from nothing to a +server a real MCP host can talk to: [install the SDK](installation.md), build your +[first server](first-server.md), and [open it in the Inspector](inspector.md). + +## Run the code + +Every code block on these pages is a complete, working file — copy it into +`server.php` next to your `vendor/` directory and run it. + +It is worth actually typing (or pasting) and running them: what the SDK does for you +only really shows up in your own editor, where the type hints you write turn into the +schema a model sees. + +## Where to go next + +Once you have a server running, the rest of these docs are a reference, not a course. +Every page stands on its own, so jump straight to what you need: + +* What a server exposes (tools, resources, prompts) is **[Servers](../servers/index.md)**. +* Getting it in front of clients (STDIO, HTTP, an existing framework app) is + **[Running your server](../run/index.md)**. +* What is available inside the functions you register is + **[Inside your handler](../handlers/index.md)**. +* Building the other side, an application that *uses* MCP servers, is + **[Clients](../client/index.md)**. diff --git a/docs/get-started/inspector.md b/docs/get-started/inspector.md new file mode 100644 index 00000000..27e146a8 --- /dev/null +++ b/docs/get-started/inspector.md @@ -0,0 +1,88 @@ +# Try it with the Inspector + +The [MCP Inspector](https://modelcontextprotocol.io/docs/latest/tools/inspector) is an interactive +UI for poking at a server: it lists what the server exposes and lets you call it by +hand. It is the fastest way to see whether your server does what you think it does. + +It is a Node.js application, so this needs `npx` on your `PATH`. + +## STDIO + +Point the Inspector at the command that starts your server — it launches the process +itself: + +```bash +npx @modelcontextprotocol/inspector php server.php +``` + +Open the URL it prints. Under **Tools**, call `add` with `a=1` and `b=2`; you get `3` +back. The form the Inspector built for you — a required integer field for each argument +— came from the type hints on the method. So will the schema every other MCP host sees. + +Under **Resources**, read `config://calculator/settings` to get the array back as JSON. + +## HTTP + +A server behind a web server speaks the [HTTP transport](../run/http.md) instead, so the +last lines of `server.php` change — `StdioTransport` reads stdin and would just block +under `php -S`: + +```php title="server.php (HTTP variant)" +use Http\Discovery\Psr17Factory; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StreamableHttpTransport; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; + +$request = (new Psr17Factory())->createServerRequestFromGlobals(); + +$response = Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->build() + ->run(new StreamableHttpTransport($request)); + +(new SapiEmitter())->emit($response); +``` + +That needs a PSR-17 implementation and an emitter +(`composer require nyholm/psr7 laminas/laminas-httphandlerrunner`). Start it, then give +the Inspector its URL: + +```bash +php -S localhost:8000 server.php +npx @modelcontextprotocol/inspector http://localhost:8000 +``` + +`curl` works too, if you would rather see the wire format: + +```bash +curl -X POST http://localhost:8000 \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' +``` + +## Connect a real host + +Hosts that launch local servers take the same command the Inspector did. For Claude +Desktop, that is an entry in its configuration file: + +```json +{ + "mcpServers": { + "calculator": { + "command": "php", + "args": ["/absolute/path/to/server.php"] + } + } +} +``` + +Use an absolute path: the host does not run the command from your project directory. + +## Next + +* Add more of what a server can expose: **[Servers](../servers/index.md)**. +* Put it on the web instead of stdin/stdout: **[HTTP transport](../run/http.md)**. +* Drive a server from PHP instead of a UI: **[Clients](../client/index.md)**. diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md new file mode 100644 index 00000000..4edca464 --- /dev/null +++ b/docs/get-started/installation.md @@ -0,0 +1,44 @@ +# Installation + +The SDK ships as a single Composer package: + +```bash +composer require mcp/sdk +``` + +It requires **PHP 8.1+** and the `fileinfo` extension. Most of what it pulls in is PSR +interface packages (`psr/container`, `psr/log`, `psr/http-message`, …); the rest is +`opis/json-schema` for schema validation, `symfony/uid` for session identifiers, +`phpdocumentor/reflection-docblock` for reading descriptions out of your docblocks, and +`php-http/discovery` for finding PSR-17/PSR-18 implementations. + +That is enough for a complete [STDIO server](../run/stdio.md) and for the +[STDIO client](../client/transports.md). + +## Optional packages + +Which extras you need depends on what you build: + +| You want to… | Also install | +| --- | --- | +| discover `#[McpTool]` & friends from a directory | `symfony/finder` | +| serve over [HTTP](../run/http.md) | any PSR-17 implementation, e.g. `nyholm/psr7` | +| emit the response from a standalone HTTP entry point | `laminas/laminas-httphandlerrunner` | +| [connect a client over HTTP](../client/transports.md) | any PSR-18 client, e.g. `symfony/http-client` | +| [validate JWT access tokens](../run/authorization.md) | `firebase/php-jwt` | +| store sessions in a PSR-16 cache | `psr/simple-cache` implementation, e.g. `symfony/cache` | + +PSR-17 and PSR-18 implementations are found through +[`php-http/discovery`](https://docs.php-http.org/en/latest/discovery.html), so +installing the package is all that is needed — no wiring: + +```bash +composer require nyholm/psr7 +``` + +If discovery picks the wrong one, or you want to be explicit, pass the factories to the +transport yourself; see [HTTP transport](../run/http.md). + +## Next + +Write your [first server](first-server.md). diff --git a/docs/server-client-communication.md b/docs/handlers/client-communication.md similarity index 68% rename from docs/server-client-communication.md rename to docs/handlers/client-communication.md index 084f51d2..578d31ed 100644 --- a/docs/server-client-communication.md +++ b/docs/handlers/client-communication.md @@ -1,26 +1,22 @@ -# Client Communication +# Talking back to the client -MCP supports various ways a server can communicate back to a client on top of the main request-response flow. +MCP supports various ways a server can communicate back to a client on top of the main +request-response flow. -> **Protocol revision `2026-07-28`.** This page describes the handshake era, where a server sends its own -> JSON-RPC requests to the client. The modern lifecycle removed that: sampling, elicitation and roots are -> carried back inside the *result* instead, and `ClientGateway::sample()`, `elicit()` and `listRoots()` -> raise a `LogicException` there. Logging and progress still work as described below — they simply travel -> on the request's own response stream, and the client opts into each. See -> [The 2026-07-28 Lifecycle](stateless-lifecycle.md). - -## Table of Contents - -- [ClientGateway](#client-gateway) -- [Sampling](#sampling) -- [Logging](#logging) -- [Notification](#notification) -- [Progress](#progress) +> **Protocol revision `2026-07-28`.** This page describes the handshake era, where a server +> sends its own JSON-RPC requests to the client. The modern lifecycle removed that: sampling, +> elicitation and roots are carried back inside the *result* instead, and +> `ClientGateway::sample()`, `elicit()` and `listRoots()` raise a `LogicException` there. +> Logging and progress still work as described below — they simply travel on the request's own +> response stream, and the client opts into each. See +> [Asking for input](input-required.md). ## ClientGateway Every communication back to client is handled using the `Mcp\Server\ClientGateway` and its dedicated methods per -operation. To use the `ClientGateway` in your code, you need to use method argument injection for `RequestContext`. +operation. Reach it through method argument injection for `RequestContext`. (A `ClientGateway`-typed parameter is +injected too, but unlike `RequestContext` it is not excluded from the generated input schema, so it would show up as +an argument of your tool.) Every reference of a MCP element, that translates to an actual method call, can just add an type-hinted argument for the `RequestContext` and the SDK will take care to include the gateway in the arguments of the method call: @@ -37,8 +33,10 @@ class MyService $context->getClientGateway()->log(...); ``` -The same object also carries the protocol revision negotiated for the current request, which is useful when a feature is -only available from a certain revision on: +## Request metadata + +`RequestContext` also carries what the current request said about itself, which is useful when a feature is only +available from a certain revision on: ```php use Mcp\Schema\Enum\ProtocolVersion; @@ -48,6 +46,22 @@ if ($context->getProtocolVersion()->isAtLeast(ProtocolVersion::V2026_07_28)) { } ``` +Two more accessors read what the client declared, and both work in either +[protocol era](../protocol-versions.md) — negotiated once during the handshake, or declared per request from +`2026-07-28` on: + +```php +$context->getClientCapabilities(); // what this client declared, or null in the handshake era +$context->getTraceContext(); // traceparent / tracestate / baggage, verbatim +``` + +`ClientGateway`'s capability probes — `supportsElicitation()`, `supportsSampling()`, `supportsRoots()` and the +sub-capability variants — read the same declaration. + +W3C trace context is passed through exactly as it arrived, and echoed onto every notification the request causes, +so a span stays joined across the response stream. Reading it adds no OpenTelemetry dependency — the values are +strings. + ## Sampling > **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. @@ -61,7 +75,7 @@ $result = $clientGateway->sample('Roses are red, violets are', 350, 90, ['temper The `sample` method accepts four arguments: -1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `SamplingMessage` instances. +1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `Mcp\Schema\Content\SamplingMessage` instances. 2. `maxTokens`, which defaults to `1000` 3. `timeout` in seconds, which defaults to `120` 4. `options` which might include `systemPrompt`, `preferences` for model choice, `includeContext`, `temperature`, @@ -119,9 +133,12 @@ notification a server can update a client while an operation is ongoing: $clientGateway->progress(4.2, 10, 'Downloading needed images.'); ``` +Progress is opt-in by the client in both eras: it sends a `progressToken` with the request, and without one this +call sends nothing. + ## Notification -Lastly, the server can push all kind of notifications, that implement the `Mcp\Schema\JsonRpc\Notification` interface +Lastly, the server can push all kind of notifications, that extend the abstract `Mcp\Schema\JsonRpc\Notification` class to the client to: ```php diff --git a/docs/handlers/index.md b/docs/handlers/index.md new file mode 100644 index 00000000..bb788141 --- /dev/null +++ b/docs/handlers/index.md @@ -0,0 +1,42 @@ +# Inside your handler + +The methods you register are ordinary PHP methods, but they are not cut off from the +protocol. Type-hint a `Mcp\Server\RequestContext` argument anywhere in the signature and +the SDK passes it in — that object is the way back to the client mid-request. + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Schema\Content\TextContent; +use Mcp\Server\RequestContext; + +#[McpTool] +public function summarize(string $text, RequestContext $context): string +{ + $context->getClientLogger()->info(\sprintf('Summarizing %d characters', \strlen($text))); + + $result = $context->getClientGateway()->sample("Summarize:\n\n".$text, 500); + + // `content` is TextContent|ImageContent|AudioContent (or ToolUseContent + // blocks when the client samples with tools) + return $result->content instanceof TextContent ? $result->content->text : ''; +} +``` + +Two caveats on this example: clients drop log messages below `warning` unless they raise +the level first (see [Logging](logging.md)), and `sample()` belongs to the features +[deprecated by revision `2026-07-28`](../protocol-versions.md#deprecations) — on that +revision it throws, and [Asking for input](input-required.md) is the way to write it +instead. + +* **[Talking back to the client](client-communication.md)** — the `ClientGateway`: + asking the client's model for a completion (sampling), reporting progress on a long + call, and sending notifications. +* **[Logging](logging.md)** — structured PSR-3 log messages that surface in the client, + not in your server's log file. +* **[Asking for input](input-required.md)** — returning an `InputRequiredResult` when a + handler needs elicitation, sampling or roots. Written that way, one handler serves both + [protocol eras](../protocol-versions.md). + +Handlers that need application services (a database connection, an API client) get them +from the container instead; see +[Service dependencies](../run/server-builder.md#service-dependencies). diff --git a/docs/handlers/input-required.md b/docs/handlers/input-required.md new file mode 100644 index 00000000..9cb3dd5e --- /dev/null +++ b/docs/handlers/input-required.md @@ -0,0 +1,111 @@ +# Asking for input + +Some handlers cannot finish in one go: they need the user to confirm something, fill in a +form, name a directory, or have the client's model draft a paragraph. The way to write that +is to **return** the ask — an `InputRequiredResult` naming what you need — and read the +answer off `RequestContext` when the call comes back. + +Write it that way once and it serves both [protocol eras](../protocol-versions.md). +Revision `2026-07-28` has no server-initiated requests at all, so the client retries the +original call carrying the answers; the specification calls that a multi round-trip request +(MRTR). On a handshake-era connection the SDK fulfils the same ask over that connection's own +channel instead. Your handler does not fork on which — see +[What a handler forks on](#what-a-handler-forks-on). + +```php +use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\InputRequiredResult; + +static function (RequestContext $context): CallToolResult|InputRequiredResult { + $answer = $context->getInputContext()?->elicitResult('who'); + + if (null === $answer) { + return new InputRequiredResult( + ['who' => new ElicitRequest('Your name?', $schema)], + requestState: $context->mintRequestState(['asked' => 'who']), + ); + } + + return new CallToolResult([new TextContent("Hello, {$answer->content['name']}!")]); +} +``` + +`tools/call`, `prompts/get` and `resources/read` may answer this way; nothing else may. + +## Reading the answers + +`InputContext` hands them back typed — `elicitResult()`, `samplingResult()`, +`rootsResult()` — and returns `null` for an answer that is absent *or* malformed. Both mean +the same thing to a handler: ask again. `response()` is still there for the raw array. + +## `requestState` + +Whatever the server needs to remember between rounds. It travels through the client, so it +is attacker-controlled on return; `mintRequestState()` seals it with an HMAC and a TTL, and +a state that fails verification never reaches a handler. Configure the key with +`Builder::setRequestState()`: + +```php +->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) +``` + +The **same key must reach every process that might serve the retry**. A per-process random +value works only for a single-process deployment. Nothing secret belongs in the payload — +it is signed, not encrypted. + +## Capabilities + +A server must not ask for input the client cannot provide. The SDK checks each ask against +the request's declared capabilities and answers `-32021` — with the missing set in +`data.requiredCapabilities` — rather than sending an ask that could never be answered. +Url-mode elicitation needs its own `elicitation.url` declaration; a bare `elicitation` means +form mode only. + +## What not to call + +`ClientGateway::sample()`, `elicit()`, `elicitUrl()` and `listRoots()` belong to the +handshake era. Calling one under this revision raises a `LogicException` naming +`InputRequiredResult` as the replacement. + +## What a handler forks on + +Nothing. Tools, resources, prompts, structured output, progress and errors do not care +which era called, and neither does the one thing that looks like it should: **asking the +user something**. + +Write it the 2026-07-28 way — return an `InputRequiredResult` naming what you need, read the +answer off `RequestContext::getInputContext()` when the call comes back. On a handshake-era +connection the SDK's input-required shim fulfils the same ask over that connection's own +channel: each embedded request goes out as the real `elicitation/create` / +`sampling/createMessage` / `roots/list`, and the handler is re-entered with the answers under +the keys it asked for. It is on by default; +[`examples/server/elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) +and +[`examples/server/client-communication`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-communication) +are written this way and name no era anywhere. + +Two things to know about it. + +**Re-entry is re-execution.** The handler runs again from the top each round, so it has to +re-derive where it is from what came back rather than from anything it kept. That is already +true of the modern era — the client retries the whole call there — so a portable handler is +written that way regardless. It is only new if you were relying on `ClientGateway::elicit()` +suspending mid-body and keeping your locals; that keeps working untouched, since nothing here +runs unless a handler *returns* an ask. + +**Each round holds the request open.** The shim waits for the client's answer inside the +originating request, which on a process-per-request runtime means it holds a worker for as +long as the user takes. That is the same cost `ClientGateway::elicit()` already pays on that +leg, but the shim makes it reachable from handlers that never mention it — so size +`setInputRequiredLimits()` against your pool. + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + // Re-entries per request, and seconds to wait for one answer. + ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) + ->build(); +``` + +`withoutInputRequiredShim()` turns it off, so such a handler fails on a handshake-era +connection instead of being fulfilled behind your back. diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md new file mode 100644 index 00000000..1a10e50f --- /dev/null +++ b/docs/handlers/logging.md @@ -0,0 +1,39 @@ +# Logging + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. + +The SDK provides support to send log messages to clients. All standard PSR-3 log levels are supported. +Level **warning** is the default level, so anything below it is dropped until the client raises the level with +`logging/setLevel`. + +!!! note + Under the [2026-07-28 lifecycle](../run/protocol-eras.md#what-a-modern-request-carries) there is no + `logging/setLevel`: the client names its level in each request's + `_meta["io.modelcontextprotocol/logLevel"]`, and a request naming none receives no log + notifications at all. + +!!! note + Only the message is forwarded to the client. A PSR-3 `$context` array is accepted for interface compatibility + but is **not** sent — interpolate anything you need into the message itself. + +## Usage + +The SDK automatically injects a `RequestContext` instance into handlers. This can be used to create a `ClientLogger`. + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Server\RequestContext; + +#[McpTool] +public function processData(string $input, RequestContext $context): array { + $logger = $context->getClientLogger(); + + $logger->info(\sprintf('Processing started for "%s"', $input)); + $logger->warning('Deprecated API used'); + + // ... processing logic ... + + $logger->info('Processing completed'); + return ['result' => 'processed']; +} +``` diff --git a/docs/index.md b/docs/index.md index a1f6f663..cc9c375a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,12 +1,102 @@ -# MCP PHP SDK Guides - -- [MCP Elements](mcp-elements.md) — Core capabilities (Tools, Resources, Resource Templates, and Prompts) with registration methods. -- [Server Builder](server-builder.md) — Fluent builder class for creating and configuring MCP server instances. -- [Client](client.md) — Client SDK for connecting to and communicating with MCP servers. -- [The 2026-07-28 Lifecycle](stateless-lifecycle.md) — The stateless protocol revision: per-request metadata, `server/discover`, multi round-trip requests, caching and subscriptions. -- [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them. -- [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications. -- [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources). -- [Authorization](authorization.md) — OAuth and authorization setup for the HTTP transport. -- [Events](events.md) — Hooking into the server lifecycle with PSR-14 events. -- [Examples](examples.md) — Example projects demonstrating attribute-based discovery, dependency injection, HTTP transport, and more. +# MCP PHP SDK + +The **Model Context Protocol (MCP)** lets applications provide context to LLMs in a +standardized way, separating the concern of *providing* context from the LLM +interaction itself. + +This is the official PHP SDK for it, a collaboration between +[the PHP Foundation](https://thephp.foundation/) and the +[Symfony project](https://symfony.com/). With it you can: + +* **Build MCP servers** that expose tools, resources, and prompts to any MCP host. +* **Build MCP clients** that connect to any MCP server. +* Speak both standard transports: STDIO and Streamable HTTP. + +!!! warning "Experimental until 1.0" + This SDK is [experimental](https://symfony.com/doc/current/contributing/code/experimental.html) + until the first major release; see the + [roadmap](https://github.com/modelcontextprotocol/php-sdk/blob/main/ROADMAP.md) + for what is planned next. + +## Requirements + +PHP 8.1+. + +## Installation + +```bash +composer require mcp/sdk +``` + +See [Installation](get-started/installation.md) for the optional PSR packages an HTTP +server or client needs. + +## Example + +Create a file `server.php`: + +```php-file title="server.php" + 2]; + } +} + +Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build() + ->run(new StdioTransport()); +``` + +That's a complete MCP server. It exposes one **tool**, `add`, and one **resource**, +`config://calculator/settings`. + +Attribute discovery needs `symfony/finder` (`composer require symfony/finder`). Without +it the server still starts, but discovers nothing and only logs a warning. + +Look at what you did *not* write: no JSON Schema — `int $a, int $b` *is* the schema — +no request parsing, no serialization, no protocol handling. You wrote a PHP class with +type hints and a docblock; the SDK does the rest. + +[First server](get-started/first-server.md) walks through running it, and +[Try it with the Inspector](get-started/inspector.md) opens it in a UI you can click +around in. + +## Where to go next + +* **[Get started](get-started/index.md)** takes you from `composer require` to a server + a real MCP host can talk to. +* What a server exposes — tools, resources, prompts — is **[Servers](servers/index.md)**. +* Getting it in front of clients (STDIO, HTTP, your existing Symfony or Laravel app) is + **[Running your server](run/index.md)**. +* What is available *inside* the functions you register is + **[Inside your handler](handlers/index.md)**. +* Building the other side, an application that *uses* MCP servers, is + **[Clients](client/index.md)**. +* The two protocol eras, and what revision `2026-07-28` changed, are + **[Protocol versions](protocol-versions.md)**. +* Complete, runnable projects are in **[Examples](examples.md)**. +* Hunting for an exact signature? The **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** + is generated from the source. diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md deleted file mode 100644 index 34809062..00000000 --- a/docs/mcp-elements.md +++ /dev/null @@ -1,892 +0,0 @@ -# MCP Elements - -MCP elements are the core capabilities of your server: Tools, Resources, Resource Templates, and Prompts. These elements -define what your server can do and how clients can interact with it. The PHP MCP SDK provides both attribute-based -discovery and manual registration methods. - -## Table of Contents - -- [Overview](#overview) -- [Tools](#tools) -- [Resources](#resources) -- [Resource Templates](#resource-templates) -- [Prompts](#prompts) -- [Logging](#logging) -- [Completion Providers](#completion-providers) -- [Schema Generation and Validation](#schema-generation-and-validation) -- [Discovery vs Manual Registration](#discovery-vs-manual-registration) - -## Overview - -MCP defines four types of capabilities: - -- **Tools**: Functions that can be called by clients to perform actions -- **Resources**: Data sources that clients can read (static URIs) -- **Resource Templates**: URI templates for dynamic resources with variables -- **Prompts**: Template generators for AI prompts - -### Registration Methods - -Each capability can be registered using two methods: - -1. **Attribute-Based Discovery**: Use PHP attributes (`#[McpTool]`, `#[McpResource]`, etc.) on methods or classes. The - server automatically discovers and registers them. - -2. **Manual Registration**: Explicitly register capabilities using `ServerBuilder` methods (`addTool()`, `addResource()`, etc.). - -**Priority**: Manual registrations **always override** discovered elements with the same identifier: -- **Tools**: Same `name` -- **Resources**: Same `uri` -- **Resource Templates**: Same `uriTemplate` -- **Prompts**: Same `name` - -For manual registration details, see [Server Builder Manual Registration](server-builder.md#manual-capability-registration). - -For runtime, config-driven elements whose shape is not known at compile time, see -[Explicit element registration](server-builder.md#explicit-element-registration) in the Server Builder docs. - -## Tools - -Tools are callable functions that perform actions and return results. - -```php -use Mcp\Capability\Attribute\McpTool; - -class Calculator -{ - /** - * Performs arithmetic operations with validation. - */ - #[McpTool(name: 'calculate')] - public function performCalculation(float $a, float $b, string $operation): float - { - return match($operation) { - 'add' => $a + $b, - 'subtract' => $a - $b, - 'multiply' => $a * $b, - 'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Division by zero'), - default => throw new \InvalidArgumentException('Invalid operation') - }; - } -} -``` - -### Parameters - -- **`name`** (optional): Tool identifier. Defaults to method name if not provided. -- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Tool description. Defaults to docblock summary if not provided, otherwise uses method name. -- **`annotations`** (optional): `ToolAnnotations` object for additional metadata. -- **`icons`** (optional): Array of `Icon` objects for visual representation. -- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. - -**Priority for name/description**: Attribute parameters → DocBlock content → Method name - -For tool parameter validation and JSON schema generation, see [Schema Generation and Validation](#schema-generation-and-validation). - -### Tool Return Values - -Tools can return any data type and the SDK will automatically wrap them in appropriate MCP content types. - -#### Automatic Content Wrapping - -```php -// Primitive types → TextContent -public function getString(): string { return "Hello"; } // TextContent -public function getNumber(): int { return 42; } // TextContent -public function getBool(): bool { return true; } // TextContent -public function getArray(): array { return ['key' => 'value']; } // TextContent (JSON) - -// Special cases -public function getNull(): ?string { return null; } // TextContent("(null)") -public function returnVoid(): void { /* no return */ } // Empty content -``` - -#### Explicit Content Types - -For fine control over output formatting: - -```php -use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, ResourceLink, EmbeddedResource}; - -public function getFormattedCode(): TextContent -{ - return TextContent::code(' 'file://data.json', 'text' => 'File content'] - ); -} - -public function getResourceLink(): ResourceLink -{ - // Reference a resource by URI without embedding its contents, e.g. when - // a tool result would otherwise need to inline many or large resources. - return new ResourceLink( - uri: 'file://data.json', - name: 'data.json', - mimeType: 'application/json' - ); -} -``` - -#### Multiple Content Items - -Return an array of content items: - -```php -public function getMultipleContent(): array -{ - return [ - new TextContent('Here is the analysis:'), - TextContent::code($code, 'php'), - new TextContent('And here is the summary.') - ]; -} -``` - -#### Structured Output - -Besides the human-readable `content`, a tool result can carry a machine-readable `structuredContent` value. Declare its -shape with `outputSchema`, a JSON Schema of type `object`: - -```php -#[McpTool( - name: 'get_weather', - outputSchema: [ - 'type' => 'object', - 'properties' => [ - 'temperature' => ['type' => 'number'], - 'conditions' => ['type' => 'string'], - ], - 'required' => ['temperature', 'conditions'], - ] -)] -public function getWeather(string $city): array -{ - // Sent as `structuredContent`, and JSON-encoded into `content` for clients that ignore it - return ['temperature' => 22.5, 'conditions' => 'sunny']; -} -``` - -The same schema can be passed to manual registration: - -```php -$builder->addTool([WeatherHandler::class, 'getWeather'], outputSchema: [/* ... */]); -``` - -The SDK fills `structuredContent` whenever the return value qualifies — `outputSchema` is what tells clients to expect it -and lets them validate it. What qualifies depends on the protocol revision the call is served under: - -| Return value | `structuredContent` | -|---|---| -| Associative array (`['temperature' => 22.5]`) | The array | -| Object (`stdClass`, DTO, `JsonSerializable`) that serializes to a JSON object | Its JSON representation | -| List (`[1, 2, 3]`, `[['id' => 1], ['id' => 2]]`), or an object serializing to one | Omitted before `2026-07-28`, kept from it on | -| Array holding `Content` instances | Omitted (already carried in `content`) | -| Scalars, `null`, `Content` instances | Omitted | - -Up to revision `2025-11-25`, `structuredContent` had to be a JSON object, so a PHP list — which serializes to a JSON -array — was not emittable and strict clients rejected the whole tool call over one. [SEP-2106][sep-2106], part of -revision `2026-07-28`, widened `outputSchema` to any JSON Schema 2020-12 and `structuredContent` to any JSON value -conforming to it. The SDK picks the rule from the revision negotiated for the call, so a tool serving both eras needs the -object shape to produce structured output everywhere. Wrap the list in a key for that: - -```php -// Structured content only from 2026-07-28 on: a bare list is not a JSON object -public function listUsersFlat(): array -{ - return [['id' => 1], ['id' => 2]]; -} - -#[McpTool(outputSchema: [ - 'type' => 'object', - 'properties' => [ - 'items' => ['type' => 'array', 'items' => ['type' => 'object']], - ], - 'required' => ['items'] -])] -public function listUsers(): array -{ - return ['items' => [['id' => 1], ['id' => 2]]]; -} -``` - -Either way the data reaches the client: a return value with no structured representation is still JSON-encoded into -`content` as a `TextContent`. When a tool declares an `outputSchema` but returns something that cannot be sent as -`structuredContent`, the SDK logs a warning — the value is not silently dropped. - -A tool that wants to branch on the revision itself can read it from the injected `RequestContext`, see -[Client Communication](server-client-communication.md#client-gateway). - -[sep-2106]: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content - -#### Error Handling - -Tool handlers can throw any exception, but the type determines how it's handled: - -- **`ToolCallException`**: Converted to JSON-RPC response with `CallToolResult` where `isError: true`, allowing the LLM to see the error message and self-correct -- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message - -```php -use Mcp\Exception\ToolCallException; - -#[McpTool] -public function divideNumbers(float $a, float $b): float -{ - if ($b === 0.0) { - throw new ToolCallException('Division by zero is not allowed'); - } - - return $a / $b; -} - -#[McpTool] -public function processFile(string $filename): string -{ - if (!file_exists($filename)) { - throw new ToolCallException("File not found: {$filename}"); - } - - return file_get_contents($filename); -} -``` - -**Recommendation**: Use `ToolCallException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. - - -## Resources - -Resources provide access to static data that clients can read. - -```php -use Mcp\Capability\Attribute\McpResource; - -class ConfigProvider -{ - /** - * Provides the current application configuration. - */ - #[McpResource(uri: 'config://app/settings', name: 'app_settings')] - public function getSettings(): array - { - return [ - 'version' => '1.0.0', - 'debug' => false, - 'features' => ['auth', 'logging'] - ]; - } -} -``` - -### Parameters - -- **`uri`** (required): Unique resource identifier. Must comply with [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). -- **`name`** (optional): Short resource identifier. Defaults to method name if not provided. -- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Resource description. Defaults to docblock summary if not provided. -- **`mimeType`** (optional): MIME type of the resource content. -- **`size`** (optional): Size in bytes if known. -- **`annotations`** (optional): Additional metadata. -- **`icons`** (optional): Array of `Icon` objects for visual representation. -- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. - -**Standard Protocol URI Schemes**: `https://` (web resources), `file://` (filesystem), `git://` (version control). -**Custom schemes**: `config://`, `data://`, `db://`, `api://` or any RFC 3986 compliant scheme. - -### Resource Return Values - -Resource handlers can return various data types that are automatically formatted into appropriate MCP resource content types. - -#### Supported Return Types - -```php -// String content - converted to text resource -public function getTextFile(): string -{ - return "File content here"; -} - -// Array content - converted to JSON -public function getConfig(): array -{ - return ['debug' => true, 'version' => '1.0']; -} - -// Stream resource - read and converted to blob -public function getImageStream(): resource -{ - return fopen('image.png', 'r'); -} - -// SplFileInfo - file content with MIME type detection -public function getFileInfo(): \SplFileInfo -{ - return new \SplFileInfo('document.pdf'); -} -``` - -**Explicit resource content types** - -```php -use Mcp\Schema\Content\{TextResourceContents, BlobResourceContents}; - -public function getExplicitText(): TextResourceContents -{ - return new TextResourceContents( - uri: 'config://app/settings', - mimeType: 'application/json', - text: json_encode(['setting' => 'value']) - ); -} - -public function getExplicitBlob(): BlobResourceContents -{ - return new BlobResourceContents( - uri: 'file://image.png', - mimeType: 'image/png', - blob: base64_encode(file_get_contents('image.png')) - ); -} -``` - -**Special Array Formats** - -```php -// Array with 'text' key - used as text content -public function getTextArray(): array -{ - return ['text' => 'Content here', 'mimeType' => 'text/plain']; -} - -// Array with 'blob' key - used as blob content -public function getBlobArray(): array -{ - return ['blob' => base64_encode($data), 'mimeType' => 'image/png']; -} - -// Multiple resource contents -public function getMultipleResources(): array -{ - return [ - new TextResourceContents('file://readme.txt', 'text/plain', 'README content'), - new TextResourceContents('file://config.json', 'application/json', '{"key": "value"}') - ]; -} -``` - -#### Error Handling - -Resource handlers can throw any exception, but the type determines how it's handled: - -- **`ResourceReadException`**: Converted to JSON-RPC error response with the actual exception message -- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message - -```php -use Mcp\Exception\ResourceReadException; - -#[McpResource(uri: 'file://{path}')] -public function getFile(string $path): string -{ - if (!file_exists($path)) { - throw new ResourceReadException("File not found: {$path}"); - } - - if (!is_readable($path)) { - throw new ResourceReadException("File not readable: {$path}"); - } - - return file_get_contents($path); -} -``` - -**Recommendation**: Use `ResourceReadException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. - -## Resource Templates - -Resource templates are **dynamic resources** that use parameterized URIs with variables. They follow all the same rules -as static resources (URI schemas, return values, MIME types, etc.) but accept variables using [RFC 6570 URI template syntax](https://datatracker.ietf.org/doc/html/rfc6570). - -```php -use Mcp\Capability\Attribute\McpResourceTemplate; - -class UserProvider -{ - /** - * Retrieves user profile information by ID. - */ - #[McpResourceTemplate( - uriTemplate: 'user://{userId}/profile/{section}', - name: 'user_profile', - description: 'User profile data by section', - mimeType: 'application/json' - )] - public function getUserProfile(string $userId, string $section): array - { - return $this->users[$userId][$section] ?? throw new \InvalidArgumentException("Profile section not found"); - } -} -``` - -### Parameters - -- **`uriTemplate`** (required): URI template with `{variables}` using RFC 6570 syntax. Must comply with RFC 3986. -- **`name`** (optional): Short resource template identifier. Defaults to method name if not provided. -- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Template description. Defaults to docblock summary if not provided. -- **`mimeType`** (optional): MIME type of the resource content. -- **`annotations`** (optional): Additional metadata. - -### Variable Rules - -1. **Variable names must match exactly** between URI template and method parameters -2. **Parameter order matters** - variables are passed in the order they appear in the URI template -3. **All variables are required** - no optional parameters supported -4. **Type hints work normally** - parameters can be typed (string, int, etc.) - -**Example mapping**: `user://123/profile/settings` → `getUserProfile("123", "settings")` - -## Prompts - -Prompts generate templates for AI interactions. - -```php -use Mcp\Capability\Attribute\McpPrompt; - -class PromptGenerator -{ - /** - * Generates a code review request prompt. - */ - #[McpPrompt(name: 'code_review')] - public function reviewCode(string $language, string $code, string $focus = 'general'): array - { - return [ - ['role' => 'system', 'content' => 'You are an expert code reviewer.'], - ['role' => 'user', 'content' => "Review this {$language} code focusing on {$focus}:\n\n```{$language}\n{$code}\n```"] - ]; - } -} -``` - -### Parameters - -- **`name`** (optional): Prompt identifier. Defaults to method name if not provided. -- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. -- **`description`** (optional): Prompt description. Defaults to docblock summary if not provided. -- **`icons`** (optional): Array of `Icon` objects for visual representation. -- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. - -### Prompt Return Values - -Prompt handlers must return an array of message structures that are automatically formatted into MCP prompt messages. - -#### Supported Return Formats - -```php -// Array of message objects with role and content -public function basicPrompt(): array -{ - return [ - ['role' => 'assistant', 'content' => 'You are a helpful assistant'], - ['role' => 'user', 'content' => 'Hello, how are you?'] - ]; -} - -// Single message (automatically wrapped in array) -public function singleMessage(): array -{ - return [ - ['role' => 'user', 'content' => 'Write a poem about PHP'] - ]; -} - -// Associative array with user/assistant keys -public function userAssistantFormat(): array -{ - return [ - 'user' => 'Explain how arrays work in PHP', - 'assistant' => 'Arrays in PHP are ordered maps...' - ]; -} - -// Mixed content types in messages -use Mcp\Schema\Content\{TextContent, ImageContent}; - -public function mixedContent(): array -{ - return [ - [ - 'role' => 'user', - 'content' => [ - new TextContent('Analyze this image:'), - new ImageContent(data: $imageData, mimeType: 'image/png') - ] - ] - ]; -} - -// Using explicit PromptMessage objects -use Mcp\Schema\PromptMessage; -use Mcp\Schema\Enum\Role; - -public function explicitMessages(): array -{ - return [ - new PromptMessage(Role::Assistant, [new TextContent('System instructions')]), - new PromptMessage(Role::User, [new TextContent('User question')]) - ]; -} -``` - -The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. - -#### Valid Message Roles - -- **`user`**: User input or questions -- **`assistant`**: Assistant responses/system - -#### Error Handling - -Prompt handlers can throw any exception, but the type determines how it's handled: -- **`PromptGetException`**: Converted to JSON-RPC error response with the actual exception message -- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message - -```php -use Mcp\Exception\PromptGetException; - -#[McpPrompt] -public function generatePrompt(string $topic, string $style): array -{ - $validStyles = ['casual', 'formal', 'technical']; - - if (!in_array($style, $validStyles)) { - throw new PromptGetException( - "Invalid style '{$style}'. Must be one of: " . implode(', ', $validStyles) - ); - } - - return [ - ['role' => 'user', 'content' => "Write about {$topic} in a {$style} style"] - ]; -} -``` - -**Recommendation**: Use `PromptGetException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. - -## Logging - -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. - -The SDK provides support to send structured log messages to clients. All standard PSR-3 log levels are supported. -Level **warning** as the default level. - -### Usage - -The SDK automatically injects a `RequestContext` instance into handlers. This can be used to create a `ClientLogger`. - -```php -use Mcp\Capability\Logger\ClientLogger; -use Mcp\Server\RequestContext; - -#[McpTool] -public function processData(string $input, RequestContext $context): array { - $logger = $context->getClientLogger(); - - $logger->info('Processing started', ['input' => $input]); - $logger->warning('Deprecated API used'); - - // ... processing logic ... - - $logger->info('Processing completed'); - return ['result' => 'processed']; -} -``` - -## Completion Providers - -Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have dynamic parameters that benefit from completion hints. - -### Completion Provider Types - -#### 1. Value Lists - -Provide a static list of possible values: - -```php -use Mcp\Capability\Attribute\CompletionProvider; - -#[McpPrompt] -public function generateContent( - #[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide'])] - string $contentType, - - #[CompletionProvider(values: ['beginner', 'intermediate', 'advanced'])] - string $difficulty -): array -{ - return [ - ['role' => 'user', 'content' => "Create a {$difficulty} level {$contentType}"] - ]; -} -``` - -#### 2. Enum Classes - -Use enum values for completion: - -```php -enum Priority: string -{ - case LOW = 'low'; - case MEDIUM = 'medium'; - case HIGH = 'high'; -} - -enum Status // Unit enum -{ - case DRAFT; - case PUBLISHED; - case ARCHIVED; -} - -#[McpResourceTemplate(uriTemplate: 'tasks/{taskId}')] -public function getTask( - string $taskId, - - #[CompletionProvider(enum: Priority::class)] // Uses backing values - string $priority, - - #[CompletionProvider(enum: Status::class)] // Uses case names - string $status -): array -{ - // Implementation -} -``` - -#### 3. Custom Provider Classes - -For dynamic completion logic: - -```php -use Mcp\Capability\Prompt\Completion\ProviderInterface; - -class UserIdCompletionProvider implements ProviderInterface -{ - public function __construct(private DatabaseService $db) {} - - public function getCompletions(string $currentValue): array - { - // Return dynamic completions based on current input - return $this->db->searchUserIds($currentValue); - } -} - -#[McpResourceTemplate(uriTemplate: 'user://{userId}/profile')] -public function getUserProfile( - #[CompletionProvider(provider: UserIdCompletionProvider::class)] - string $userId -): array -{ - // Implementation -} -``` - -**Provider Resolution:** -- **Class strings** (`Provider::class`) → Resolved from PSR-11 container -- **Instances** (`new Provider()`) → Used directly -- **Values** (`['a', 'b']`) → Wrapped in `ListCompletionProvider` -- **Enums** (`MyEnum::class`) → Wrapped in `EnumCompletionProvider` - -> **Important** -> -> Completion providers only offer **suggestions** to users. Users can still input any value, so **always validate -> parameters** in your handlers. Providers don't enforce validation - they're purely for UX improvement. - -## Schema Generation and Validation - -The SDK automatically generates JSON schemas for **tool parameters** using a sophisticated priority system. Schema -generation applies to both attribute-discovered and manually registered tools. - -### Schema Generation Priority - -The server follows this order of precedence: - -1. **`#[Schema]` attribute with `definition`** - Complete schema override (highest priority) -2. **Parameter-level `#[Schema]` attribute** - Parameter-specific enhancements -3. **Method-level `#[Schema]` attribute** - Method-wide configuration -4. **PHP type hints + docblocks** - Automatic inference (lowest priority) - -### Automatic Schema from PHP Types - -```php -#[McpTool] -public function processUser( - string $email, // Required string - int $age, // Required integer - ?string $name = null, // Optional string - bool $active = true // Boolean with default -): array -{ - // Schema auto-generated from method signature -} -``` - -### Parameter-Level Schema Enhancement - -Add validation rules to specific parameters: - -```php -use Mcp\Capability\Attribute\Schema; - -#[McpTool] -public function validateUser( - #[Schema(format: 'email')] - string $email, - - #[Schema(minimum: 18, maximum: 120)] - int $age, - - #[Schema( - pattern: '^[A-Z][a-z]+$', - description: 'Capitalized first name' - )] - string $firstName -): bool -{ - // PHP types provide base validation - // Schema attributes add constraints -} -``` - -### Method-Level Schema - -Add validation for complex object structures: - -```php -#[McpTool] -#[Schema( - properties: [ - 'userData' => [ - 'type' => 'object', - 'properties' => [ - 'name' => ['type' => 'string', 'minLength' => 2], - 'email' => ['type' => 'string', 'format' => 'email'], - 'age' => ['type' => 'integer', 'minimum' => 18] - ], - 'required' => ['name', 'email'] - ] - ], - required: ['userData'] -)] -public function createUser(array $userData): array -{ - // Method-level schema adds object structure validation - // PHP array type provides base type -} -``` - -### Complete Schema Override - -**Use sparingly** - bypasses all automatic inference: - -```php -#[McpTool] -#[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 makeApiRequest(string $endpoint, string $method, array $headers): array -{ - // Complete definition override - PHP types ignored -} -``` - -**Warning:** Only use complete schema override if you're well-versed with JSON Schema specification and have complex -validation requirements that cannot be achieved through the priority system. - -## Discovery vs Manual Registration - -### Attribute-Based Discovery - -**Advantages:** -- Declarative and readable -- Automatic parameter inference -- DocBlock integration -- Type-safe by default -- Caching support - -**Example:** -```php -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) // Automatic discovery - ->build(); -``` - -### Manual Registration - -**Advantages:** -- Fine-grained control -- Runtime configuration -- Conditional registration -- External handler support - -**Example:** -```php -$server = Server::builder() - ->addTool([Calculator::class, 'add'], 'add_numbers') - ->addResource([Config::class, 'get'], 'config://app') - ->addPrompt([Prompts::class, 'email'], 'write_email') - ->build(); -``` - -For detailed information on manual registration, see [Server Builder](server-builder.md#manual-capability-registration). - -### Hybrid Approach - -Combine both methods for maximum flexibility: - -```php -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) // Discover most capabilities - ->addTool([ExternalService::class, 'process'], 'external') // Add specific ones - ->build(); -``` - -Manual registrations always take precedence over discovered elements with the same identifier. diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md new file mode 100644 index 00000000..09081bab --- /dev/null +++ b/docs/protocol-versions.md @@ -0,0 +1,178 @@ +# Protocol versions + +MCP has two eras. Everything up to `2025-11-25` opens with an `initialize` handshake and +keeps the negotiated revision on a session. Protocol revision `2026-07-28` removed both: +everything a server needs to answer a request travels *in* that request, so any process can +answer any request and none of them need to share state. + +The SDK serves both, and a server built the ordinary way answers either. This page is the +map; the mechanics live with the task they belong to. + +## The two eras + +| | Handshake era (`2025-11-25` and earlier) | Modern era (`2026-07-28`) | +| --- | --- | --- | +| Opening | `initialize` / `notifications/initialized` | none | +| Version | negotiated once, kept on the session | declared on every request | +| Capabilities | exchanged once | declared on every request | +| Discovery | `initialize` result | `server/discover` | +| Sessions | `Mcp-Session-Id` | removed | +| Server → client requests | sent as JSON-RPC requests | returned in the result (MRTR) | +| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` | +| Dispatcher | `Protocol` | `StatelessProtocol` | +| HTTP entry | `StreamableHttpTransport` — the same one, for both | + +`ProtocolVersion::isModern()` tells the two apart, and +`Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` is where the boundary sits. + +## Negotiating in the handshake era + +Revisions up to `2025-11-25` agree on one during `initialize`. The client names the revision it wants to speak, 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/latest/basic/versioning#protocol-version-negotiation) +section of the specification. The modern era negotiates nothing — each request names its own revision. + +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::modernVersions(); // every revision served without a handshake +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. A client speaking it never gets here: it sends the envelope instead +of an `initialize` request, and the transport routes it to the modern dispatcher before any negotiation is attempted. +See [Serving both eras](run/protocol-eras.md). + + +### Pinning a revision + +`Builder::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. + +It pins the handshake era only. `setModernVersions()` narrows what the modern leg answers for, and +`withoutModernEra()` removes that leg altogether — see +[Serving one era only](run/protocol-eras.md#serving-one-era-only). + +!!! note + On the Streamable HTTP transport, every handshake-era 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](run/http.md#protocol-version-validation). + +## What changes, and where it is written down + +Tools, resources, prompts and their handlers are unaffected — the same registrations serve +either lifecycle. What changes: + +* **[Asking for input](handlers/input-required.md)** — a handler that needs elicitation, + sampling or roots *returns* the ask instead of calling out. Write handlers this way and + they serve both eras. +* **[Serving both eras](run/protocol-eras.md)** — what a modern request carries, how one + endpoint classifies and routes each request, and how to serve one era only. +* **[Caching](run/caching.md)** — the `ttlMs` / `cacheScope` hints a cacheable result must + carry. +* **[Subscriptions](run/subscriptions.md)** — `subscriptions/listen` and the notification bus + behind it. +* **[Sessions](run/sessions.md)** — handshake-era only; the modern era has none. +* Progress and logging become per-request opt-ins — see + [Talking back to the client](handlers/client-communication.md#progress) and + [Logging](handlers/logging.md). + +## Speaking it from a client + +One line selects the lifecycle; nothing else about the [client API](client/index.md) changes. + +```php +$client = Client::builder() + ->setClientInfo('my-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($myElicitationHandler) + ->build(); + +$client->connect(new HttpTransport('https://example.com/mcp')); + +$client->callTool('greet', []); +``` + +What that changes underneath: + +- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the + server's identity, and a server that does not answer it still yields a usable connection — + the method is optional. If discovery *does* report `supportedVersions` and the configured + revision is not among them, the client moves to a modern revision the server lists, or + refuses the connection outright rather than talking past it. +- **An envelope on every request**, carrying the revision, the declared capabilities and the + client identity. The capabilities are what let a server decide, per request, whether it may + ask for input. +- **Headers on every POST** — `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` where the + method addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into + `Mcp-Param-*`, which requires the client to have listed the tool first; `tools/list` is what + populates that knowledge. A tool whose annotations are malformed is dropped from the listing + and refused if called, since the client cannot produce the headers it demands. +- **[Multi round-trip calls](handlers/input-required.md) are answered by the client.** A result + of `resultType: "input_required"` is resolved through the same + [request handlers](client/server-requests.md) that served server-initiated requests in the + handshake era, and the call is re-sent with `inputResponses` and the server's `requestState` + echoed back byte for byte, under a new JSON-RPC id. The caller sees one call and one result. + +Headers are an HTTP concern, so a transport opts into them by implementing +`HeaderAwareTransportInterface`; `HttpTransport` does, `StdioTransport` has nothing to carry +them on. Everything else — the envelope, the skipped handshake, the round-trip loop — applies +to both. + +See +[`examples/client/stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) +for a runnable version, described in [Examples](examples.md#modern-era-client). + +## What was removed + +Answered with `404` and `-32601` by a modern server: + +- `initialize`, `notifications/initialized` +- `ping` +- `logging/setLevel` — replaced by `_meta["io.modelcontextprotocol/logLevel"]` +- `resources/subscribe`, `resources/unsubscribe` — replaced by the `resourceSubscriptions` + filter of `subscriptions/listen` +- `notifications/roots/list_changed` + +Also gone: `Mcp-Session-Id`, the HTTP `GET` stream, and SSE resumability (`Last-Event-ID`). +A broken response stream loses the request; the client re-issues it with a new id. + +Error code `-32002` (resource not found) is retired in favour of `-32602`, and must not be +emitted by a server of this revision. The SDK picks the code from the revision serving the +request, so a handshake-era client still gets `-32002`. + +## Deprecations + +Roots, sampling and logging are all **deprecated** as of this revision +([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest +removal `2027-07-28`. They remain functional until then; new servers should pass directories +through tool arguments or resource URIs instead of roots, integrate with an LLM provider +directly instead of sampling, and log to `stderr` or OpenTelemetry instead of +`notifications/message`. diff --git a/docs/authorization.md b/docs/run/authorization.md similarity index 91% rename from docs/authorization.md rename to docs/run/authorization.md index 184eb7e0..0985656a 100644 --- a/docs/authorization.md +++ b/docs/run/authorization.md @@ -3,18 +3,6 @@ The PHP MCP SDK provides OAuth 2.1 authorization support for HTTP transports, implementing the [MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). -## Table of Contents - -- [Scope: what this SDK does and does not do](#scope-what-this-sdk-does-and-does-not-do) -- [Overview](#overview) -- [Quick Start](#quick-start) -- [Components](#components) -- [JWT Token Validation](#jwt-token-validation) -- [Protected Resource Metadata](#protected-resource-metadata) -- [Custom Token Validators](#custom-token-validators) -- [Scope-Based Access Control](#scope-based-access-control) -- [Examples](#examples) - ## Scope: what this SDK does and does not do The MCP server is an OAuth 2.1 **Resource Server**. It validates the tokens it receives and may @@ -30,7 +18,7 @@ and it does not issue tokens.** To issue tokens, front the MCP server with an external IdP (Keycloak, Auth0, Microsoft Entra ID, Okta) or run `league/oauth2-server` in your own application, and let the MCP server validate those tokens as a Resource Server. See -[adr/0001-oauth-authorization-server-out-of-scope.md](../adr/0001-oauth-authorization-server-out-of-scope.md). +[adr/0001-oauth-authorization-server-out-of-scope.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/adr/0001-oauth-authorization-server-out-of-scope.md). ## Overview @@ -62,6 +50,7 @@ Authorization in MCP is implemented at the transport level using PSR-15 middlewa ```php use Mcp\Server; use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware; +use Mcp\Server\Transport\Http\Middleware\OAuthRequestMetaMiddleware; use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware; use Mcp\Server\Transport\Http\OAuth\JwksProvider; use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; @@ -99,13 +88,20 @@ $metadataMiddleware = new ProtectedResourceMetadataMiddleware( // 5. Create transport with middleware $transport = new StreamableHttpTransport( $request, - middlewares: [$metadataMiddleware, $authMiddleware], + middleware: [ + ...StreamableHttpTransport::defaultMiddleware(), + $metadataMiddleware, + $authMiddleware, + // Bridges the OAuth attributes onto the JSON-RPC request meta, which is + // what makes them reachable from a handler (see Scope-Based Access Control). + new OAuthRequestMetaMiddleware(), + ], ); // 6. Run server $server = Server::builder() ->setServerInfo('Protected MCP Server', '1.0.0') - ->setDiscovery(__DIR__) + ->setDiscovery(__DIR__, excludeDirs: ['vendor']) ->build(); $response = $server->run($transport); @@ -155,7 +151,7 @@ $validator = new JwtTokenValidator( audience: 'mcp-server', // Expected audience (string or array) jwksProvider: $jwksProvider, // JwksProviderInterface jwksUri: null, // Explicit JWKS URI (auto-discovered) - algorithms: ['RS256', 'RS384'], // Allowed algorithms + algorithms: ['RS256', 'RS384', 'RS512'], // Allowed algorithms (this is the default) scopeClaim: 'scope', // Claim name for scopes ); ``` @@ -363,7 +359,10 @@ AuthorizationResult::badRequest('invalid_request', 'Malformed header'); #[McpTool(name: 'admin_action')] public function adminAction(RequestContext $context): array { - $scopes = $context->getRequest()?->getAttribute('oauth.scopes') ?? []; + // The OAuth attributes arrive on the request meta, under the `oauth` key. + // This requires OAuthRequestMetaMiddleware in the transport's middleware stack. + $meta = $context->getRequest()->getMeta() ?? []; + $scopes = $meta['oauth']['oauth.scopes'] ?? []; if (!in_array('mcp:admin', $scopes, true)) { throw new \RuntimeException('Admin scope required'); @@ -403,7 +402,7 @@ docker-compose up -d # Test credentials: demo / demo123 ``` -See [oauth-keycloak/README.md](../examples/server/oauth-keycloak/README.md) +See [oauth-keycloak/README.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/oauth-keycloak/README.md) ### Microsoft Entra ID Example @@ -414,7 +413,7 @@ cp env.example .env docker-compose up -d ``` -See [oauth-microsoft/README.md](../examples/server/oauth-microsoft/README.md) +See [oauth-microsoft/README.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/oauth-microsoft/README.md) ## Security Considerations diff --git a/docs/run/caching.md b/docs/run/caching.md new file mode 100644 index 00000000..777b66c7 --- /dev/null +++ b/docs/run/caching.md @@ -0,0 +1,28 @@ +# Caching + +> Caching hints belong to protocol revision `2026-07-28`; see [Protocol versions](../protocol-versions.md). +> A handshake-era client is served by the same server and simply never sees them. + +`server/discover`, the four list methods and `resources/read` **must** carry `ttlMs` and +`cacheScope`. The default is `ttlMs: 0, cacheScope: "private"` — conformant, and a flat +refusal to let anything be cached. Say what you actually mean: + +```php +use Mcp\Schema\Enum\CacheScope; +use Mcp\Server\Wire\CachePolicy; + +->setCachePolicy( + CachePolicy::default(30_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), +) +``` + +`public` lets a shared proxy serve one caller's answer to another, so use it only for +results that do not vary by caller. Only the operator can make that call, which is why the +conservative default stands until you change it. + +A `ReadResourceResult` may set its own `ttlMs`/`cacheScope`, which win over the policy. + +Results produced by an [MRTR retry](../handlers/input-required.md) are never given hints: their inputs +are not part of any cache key. diff --git a/docs/run/framework-integration.md b/docs/run/framework-integration.md new file mode 100644 index 00000000..f461773f --- /dev/null +++ b/docs/run/framework-integration.md @@ -0,0 +1,202 @@ +# Framework integration + +The HTTP transport is a PSR-7 request handler, not a web server. This page +covers how it fits into an application you already have. + +## Architecture + +The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that +your application can handle however it needs to: + +``` +Your Web App → PSR-7 Request → StreamableHttpTransport → PSR-7 Response → Your Web App +``` + +This design allows integration with any PHP framework or application that supports PSR-7. + +## Basic Usage (Standalone) + +Here's a simplified example using PSR-17 discovery and Laminas emitter: + +```php +use Http\Discovery\Psr17Factory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; +use Mcp\Server\Session\FileSessionStore; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; + +$psr17Factory = new Psr17Factory(); +$request = $psr17Factory->createServerRequestFromGlobals(); + +$server = Server::builder() + ->setServerInfo('HTTP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions + ->build(); + +$transport = new StreamableHttpTransport($request); + +$response = $server->run($transport); + +(new SapiEmitter())->emit($response); +``` + +## Framework Integration + +### Symfony Integration + +First install the required PSR libraries: + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` + +Then create a controller that uses Symfony's PSR-7 bridge: + +> **Note**: This example assumes your MCP `Server` instance is configured in Symfony's service container. + +```php +// In a Symfony controller +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +class McpController +{ + #[Route('/mcp', name: 'mcp_endpoint')] + public function handle(Request $request, Server $server): Response + { + // Convert Symfony request to PSR-7 (PSR-17 factories auto-discovered) + $psrHttpFactory = new PsrHttpFactory(); + $httpFoundationFactory = new HttpFoundationFactory(); + $psrRequest = $psrHttpFactory->createRequest($request); + + // Process with MCP (factories auto-discovered) + $transport = new StreamableHttpTransport($psrRequest); + $psrResponse = $server->run($transport); + + // Convert PSR-7 response back to Symfony + return $httpFoundationFactory->createResponse($psrResponse); + } +} +``` + +### Laravel Integration + +First install the required PSR libraries: + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` + +Then create a controller that type-hints `ServerRequestInterface`: + +> **Note**: This example assumes your MCP `Server` instance is constructed and bound in a Laravel service provider for dependency injection. + +```php +// In a Laravel controller +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +class McpController +{ + public function handle(ServerRequestInterface $request, Server $server): ResponseInterface + { + // Create the MCP HTTP transport + $transport = new StreamableHttpTransport($request); + + // Process MCP request and return PSR-7 response + // Laravel automatically handles PSR-7 responses + return $server->run($transport); + } +} + +// Route registration +Route::any('/mcp', [McpController::class, 'handle']); +``` + +### Slim Framework Integration + +Slim Framework works natively with PSR-7. + +Create a route handler using Slim's built-in factories and container: + +```php +use Slim\Factory\AppFactory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +$app = AppFactory::create(); + +$app->any('/mcp', function ($request, $response) { + $server = Server::builder() + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build(); + + $transport = new StreamableHttpTransport($request); + + return $server->run($transport); +}); +``` + +## HTTP Method Handling + +The transport handles all HTTP methods automatically: + +- **POST**: Send MCP requests +- **GET**: Not implemented (returns 405) +- **DELETE**: End session +- **OPTIONS**: CORS preflight + +You should route **all methods** to your MCP endpoint, not just POST. + +## Session Management + +HTTP transport requires persistent sessions since PHP doesn't maintain state between requests. Unlike STDIO transport +where in-memory sessions work fine, HTTP transport needs a persistent session store: + +```php +use Mcp\Server\Session\FileSessionStore; + +// ✅ Good for HTTP +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// ❌ Not recommended for HTTP (sessions lost between requests) +$server = Server::builder() + ->setSession(new InMemorySessionStore()) + ->build(); +``` + +## Recommended Route + +It's recommended to mount the MCP endpoint at `/mcp`, but this is not enforced: + +```php +// Recommended +Route::any('/mcp', [McpController::class, 'handle']); + +// Also valid +Route::any('/', [McpController::class, 'handle']); +Route::any('/api/mcp', [McpController::class, 'handle']); +``` + +## Testing HTTP Transport + +Use the MCP Inspector to test HTTP servers: + +```bash +# Start your PHP server +php -S localhost:8000 server.php + +# Connect with MCP Inspector +npx @modelcontextprotocol/inspector http://localhost:8000 +``` diff --git a/docs/transports.md b/docs/run/http.md similarity index 50% rename from docs/transports.md rename to docs/run/http.md index ffa1496e..a57a8dbe 100644 --- a/docs/transports.md +++ b/docs/run/http.md @@ -1,94 +1,4 @@ -# Transports - -Transports handle the communication layer between MCP servers and clients. The PHP MCP SDK provides two main transport -implementations: STDIO for command-line integration and HTTP for web-based communication. - -## Table of Contents - -- [Transport Overview](#transport-overview) -- [STDIO Transport](#stdio-transport) -- [HTTP Transport](#http-transport) -- [Choosing a Transport](#choosing-a-transport) - -## Transport Overview - -All transports implement the `TransportInterface` and follow the same basic pattern: - -```php -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->build(); - -$transport = new SomeTransport(); - -$result = $server->run($transport); // Blocks for STDIO, returns a response for HTTP -``` - -## STDIO Transport - -The STDIO transport communicates via standard input/output streams, ideal for command-line tools and MCP client integrations. - -```php -$transport = new StdioTransport( - input: STDIN, // Input stream (default: STDIN) - output: STDOUT, // Output stream (default: STDOUT) - logger: $logger // Optional PSR-3 logger -); -``` - -### Parameters - -- **`input`** (optional): Input stream resource. Defaults to `STDIN`. -- **`output`** (optional): Output stream resource. Defaults to `STDOUT`. -- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. - -> [!IMPORTANT] -> When using STDIO transport, **never** write to `STDOUT` in your handlers as it's reserved for JSON-RPC communication. -> Use `STDERR` for debugging instead. - -### Example Server Script - -```php -#!/usr/bin/env php -setServerInfo('STDIO Calculator', '1.0.0') - ->addTool(function(int $a, int $b): int { return $a + $b; }, 'add_numbers') - ->addTool(InvokableCalculator::class) - ->build(); - -$transport = new StdioTransport(); - -$status = $server->run($transport); - -exit($status); // 0 on clean shutdown, non-zero if STDIN errored -``` - -### Client Configuration - -For MCP clients like Claude Desktop: - -```json -{ - "mcpServers": { - "my-php-server": { - "command": "php", - "args": ["/absolute/path/to/server.php"] - } - } -} -``` - -## HTTP Transport +# HTTP Transport The HTTP transport was designed to sit between any PHP project, regardless of the HTTP implementation or how they receive and process requests and send responses. It provides a flexible architecture that can integrate with any PSR-7 compatible application. @@ -105,7 +15,7 @@ $transport = new StreamableHttpTransport( ); ``` -### Parameters +## Parameters - **`request`** (required): `ServerRequestInterface` - The incoming PSR-7 HTTP request - **`responseFactory`** (optional): `ResponseFactoryInterface` - PSR-17 factory for creating HTTP responses. Auto-discovered if not provided. @@ -114,7 +24,7 @@ $transport = new StreamableHttpTransport( - **`middleware`** (optional): `iterable|null` - PSR-15 middleware chain. `null` (omitted) installs the [default stack](#default-middleware). `[]` disables all defaults — useful when the surrounding application already handles CORS, host validation, etc. - **`maxBodyBytes`** (optional): `int` - Upper bound on the POST request body read, in bytes. Defaults to 4 MiB (`StreamableHttpTransport::DEFAULT_MAX_BODY_BYTES`). See [Request Body Size Limit](#request-body-size-limit). -### PSR-17 Auto-Discovery +## PSR-17 Auto-Discovery The transport automatically discovers PSR-17 factory implementations from these popular packages: @@ -138,7 +48,7 @@ $psr17Factory = new Psr17Factory(); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); ``` -### Default Middleware +## Default Middleware When the `middleware` argument is omitted (or set to `null`), the transport installs a secure default stack: @@ -163,9 +73,9 @@ both eras. `ProtocolVersionMiddleware` is not in that stack: the `MCP-Protocol-V to the handshake era, so the transport applies it only to requests it classified as handshake-era traffic, and the modern leg answers for its own revisions. It is available as `StreamableHttpTransport::handshakeMiddleware()` and is applied whether or not you replace the edge stack. -See [Serving both eras](stateless-lifecycle.md#serving-both-eras). +See [Serving both eras](protocol-eras.md). -### CORS Configuration +## CORS Configuration CORS is handled by `CorsMiddleware`. To enable cross-origin browser requests, configure it explicitly and pass it in place of (or alongside) the defaults: @@ -173,7 +83,6 @@ in place of (or alongside) the defaults: ```php use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; -use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; use Mcp\Server\Transport\StreamableHttpTransport; // Reflect a specific origin @@ -182,7 +91,6 @@ $transport = new StreamableHttpTransport( middleware: [ new CorsMiddleware(allowedOrigins: ['https://myapp.com']), new DnsRebindingProtectionMiddleware(), - new ProtocolVersionMiddleware(), ], ); @@ -192,7 +100,6 @@ $transport = new StreamableHttpTransport( middleware: [ new CorsMiddleware(allowedOrigins: ['*']), new DnsRebindingProtectionMiddleware(), - new ProtocolVersionMiddleware(), ], ); ``` @@ -203,13 +110,13 @@ so shared caches/CDNs do not serve a response generated for one origin to a requ Headers already present on a response (e.g. set by inner middleware) are preserved — `CorsMiddleware` only adds defaults when they are absent. -> [!IMPORTANT] -> `Access-Control-Allow-Origin: *` is incompatible with credentialed browser requests (those carrying -> `Authorization`, cookies, or client certificates). If your MCP server runs OAuth/Bearer auth and serves -> a browser client, configure `allowedOrigins` with the explicit origin(s) you trust rather than `['*']`. -> The middleware reflects the matching origin verbatim, which is the form browsers accept with credentials. +!!! warning + `Access-Control-Allow-Origin: *` is incompatible with credentialed browser requests (those carrying + `Authorization`, cookies, or client certificates). If your MCP server runs OAuth/Bearer auth and serves + a browser client, configure `allowedOrigins` with the explicit origin(s) you trust rather than `['*']`. + The middleware reflects the matching origin verbatim, which is the form browsers accept with credentials. -### DNS Rebinding Protection +## DNS Rebinding Protection `DnsRebindingProtectionMiddleware` validates the `Origin` header against an allowlist (falling back to `Host` when `Origin` is absent). The default allowlist is localhost-only: @@ -223,12 +130,16 @@ new DnsRebindingProtectionMiddleware(allowedHosts: ['myapp.local', 'mcp.internal If the server is fronted by a reverse proxy that already validates `Host`, drop this middleware from the chain or supply a permissive allowlist. -### Protocol Version Validation +## Protocol Version Validation `ProtocolVersionMiddleware` rejects requests whose `MCP-Protocol-Version` header is not in the SDK's supported set with `400 Bad Request`. Requests without the header pass through, since the `initialize` round-trip and some legacy clients do not send it. +It is applied by the transport itself, to handshake-era traffic only — do not add it to a custom `middleware` +list, where it would run before the era is classified and reject every modern-era request. Construct it +yourself only to narrow the supported set on a server that also pins its handshake: + ```php use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; @@ -243,12 +154,16 @@ The default set is `ProtocolVersion::handshakeVersions()` — every revision the header itself, so a header-less request cannot be newer than that. This header check is separate from, and happens after, the handshake itself. See -[Protocol Version Negotiation](server-builder.md#protocol-version-negotiation) for how the revision is agreed in the +[Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for how the revision is agreed in the first place. Being separate also means it is unaffected by `setProtocolVersion()`: the middleware validates against the set it was constructed with, not against the revision a given session negotiated, so a server that pins the handshake has to pass that revision here as well. -### Request Body Size Limit +Modern revisions never reach it. A `2026-07-28` request declares its revision in `params._meta` rather than in a +header, and is routed away from this check entirely — see +[Serving both eras](protocol-eras.md). + +## Request Body Size Limit `StreamableHttpTransport` caps the POST body it reads to guard against memory exhaustion from an oversized or unbounded (chunked) payload. The default cap is 4 MiB. A body over the cap is rejected with `413` and never reaches @@ -265,24 +180,19 @@ When the request stream advertises a size, the transport rejects it up-front. Ot unknown size) the body is read incrementally and aborted as soon as it crosses the cap, so an unbounded stream cannot exhaust memory. A value below `1` throws `InvalidArgumentException`. -### JSON-RPC Batch Size Limit - -A JSON-RPC batch (top-level array) is capped at 100 messages by default. Oversized batches are rejected before any -message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap lives on -`MessageFactory`: - -```php -use Mcp\JsonRpc\MessageFactory; +## JSON-RPC Batch Size Limit -$factory = MessageFactory::make(maxBatchSize: 50); -``` +A JSON-RPC batch (top-level array) is capped at 100 messages. Oversized batches are rejected before any +message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap +lives on `Mcp\JsonRpc\MessageFactory` and is not currently configurable through the builder — a server built +with `Server::builder()` always uses the default of 100. Single-message vs batch is determined from the decoded JSON type — a JSON object is a single message, a JSON array is a batch. Scalars, empty payloads, and non-object batch elements are returned as `InvalidInputMessageException` entries (the existing per-message error contract), not parse errors or crashes. A `maxBatchSize` below `1` throws `InvalidArgumentException`. -### Custom PSR-15 Middleware +## Custom PSR-15 Middleware `StreamableHttpTransport` accepts any PSR-15 middleware chain. To extend the defaults, spread them and append your own middleware — the defaults stay outermost so CORS headers are applied to every response, including @@ -348,207 +258,3 @@ $transport = new StreamableHttpTransport( middleware: [new AuthMiddleware($responseFactory)], ); ``` - -### Architecture - -The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that -your application can handle however it needs to: - -``` -Your Web App → PSR-7 Request → StreamableHttpTransport → PSR-7 Response → Your Web App -``` - -This design allows integration with any PHP framework or application that supports PSR-7. - -### Basic Usage (Standalone) - -Here's a simplified example using PSR-17 discovery and Laminas emitter: - -```php -use Http\Discovery\Psr17Factory; -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; -use Mcp\Server\Session\FileSessionStore; -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; - -$psr17Factory = new Psr17Factory(); -$request = $psr17Factory->createServerRequestFromGlobals(); - -$server = Server::builder() - ->setServerInfo('HTTP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions - ->build(); - -$transport = new StreamableHttpTransport($request); - -$response = $server->run($transport); - -(new SapiEmitter())->emit($response); -``` - -### Framework Integration - -#### Symfony Integration - -First install the required PSR libraries: - -```bash -composer require symfony/psr-http-message-bridge nyholm/psr7 -``` - -Then create a controller that uses Symfony's PSR-7 bridge: - -> **Note**: This example assumes your MCP `Server` instance is configured in Symfony's service container. - -```php -// In a Symfony controller -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Attribute\Route; -use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; -use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; - -class McpController -{ - #[Route('/mcp', name: 'mcp_endpoint')] - public function handle(Request $request, Server $server): Response - { - // Convert Symfony request to PSR-7 (PSR-17 factories auto-discovered) - $psrHttpFactory = new PsrHttpFactory(); - $httpFoundationFactory = new HttpFoundationFactory(); - $psrRequest = $psrHttpFactory->createRequest($request); - - // Process with MCP (factories auto-discovered) - $transport = new StreamableHttpTransport($psrRequest); - $psrResponse = $server->run($transport); - - // Convert PSR-7 response back to Symfony - return $httpFoundationFactory->createResponse($psrResponse); - } -} -``` - -#### Laravel Integration - -First install the required PSR libraries: - -```bash -composer require symfony/psr-http-message-bridge nyholm/psr7 -``` - -Then create a controller that type-hints `ServerRequestInterface`: - -> **Note**: This example assumes your MCP `Server` instance is constructed and bound in a Laravel service provider for dependency injection. - -```php -// In a Laravel controller -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\ResponseInterface; -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; - -class McpController -{ - public function handle(ServerRequestInterface $request, Server $server): ResponseInterface - { - // Create the MCP HTTP transport - $transport = new StreamableHttpTransport($request); - - // Process MCP request and return PSR-7 response - // Laravel automatically handles PSR-7 responses - return $server->run($transport); - } -} - -// Route registration -Route::any('/mcp', [McpController::class, 'handle']); -``` - -#### Slim Framework Integration - -Slim Framework works natively with PSR-7. - -Create a route handler using Slim's built-in factories and container: - -```php -use Slim\Factory\AppFactory; -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; - -$app = AppFactory::create(); - -$app->any('/mcp', function ($request, $response) { - $server = Server::builder() - ->setServerInfo('My MCP Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->build(); - - $transport = new StreamableHttpTransport($request); - - return $server->run($transport); -}); -``` - -### HTTP Method Handling - -The transport handles all HTTP methods automatically: - -- **POST**: Send MCP requests -- **GET**: Not implemented (returns 405) -- **DELETE**: End session -- **OPTIONS**: CORS preflight - -You should route **all methods** to your MCP endpoint, not just POST. - -### Session Management - -HTTP transport requires persistent sessions since PHP doesn't maintain state between requests. Unlike STDIO transport -where in-memory sessions work fine, HTTP transport needs a persistent session store: - -```php -use Mcp\Server\Session\FileSessionStore; - -// ✅ Good for HTTP -$server = Server::builder() - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) - ->build(); - -// ❌ Not recommended for HTTP (sessions lost between requests) -$server = Server::builder() - ->setSession(new InMemorySessionStore()) - ->build(); -``` - -### Recommended Route - -It's recommended to mount the MCP endpoint at `/mcp`, but this is not enforced: - -```php -// Recommended -Route::any('/mcp', [McpController::class, 'handle']); - -// Also valid -Route::any('/', [McpController::class, 'handle']); -Route::any('/api/mcp', [McpController::class, 'handle']); -``` - -### Testing HTTP Transport - -Use the MCP Inspector to test HTTP servers: - -```bash -# Start your PHP server -php -S localhost:8000 server.php - -# Connect with MCP Inspector -npx @modelcontextprotocol/inspector http://localhost:8000 -``` - -## Choosing a Transport - -The choice between STDIO and HTTP transport depends on the client you want to integrate with. -If you are integrating with a client that is running **locally** (like Claude Desktop), use STDIO. -If you are building a server in a distributed environment and need to integrate with a **remote** client, use Streamable HTTP. diff --git a/docs/run/index.md b/docs/run/index.md new file mode 100644 index 00000000..208f22a7 --- /dev/null +++ b/docs/run/index.md @@ -0,0 +1,41 @@ +# Running your server + +`Server::builder()` configures a server; `run()` puts it on a transport and starts +answering. Every transport implements `TransportInterface` and is used the same way: + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build(); + +$transport = new SomeTransport(); + +$result = $server->run($transport); // Blocks for STDIO, returns a response for HTTP +``` + +## Choosing a transport + +The choice depends on the client you want to integrate with: + +* The client runs **locally** and launches your server as a subprocess (Claude Desktop, + most editors) → **[STDIO](stdio.md)**. +* The client is **remote**, or your server lives in a web application → + **[HTTP](http.md)**, the Streamable HTTP transport. + +The rest of this section: + +* **[Server builder](server-builder.md)** — every configuration knob: server info, + discovery, dependency injection, logging, pagination. +* **[Framework integration](framework-integration.md)** — mounting the HTTP transport in + a Symfony, Laravel, or Slim application, or running it standalone. +* **[Sessions](sessions.md)** — where per-client state lives, which matters as soon as + you serve HTTP from more than one process. +* **[Serving both eras](protocol-eras.md)** — the same endpoint also answers protocol + revision `2026-07-28`, which has no handshake and no sessions; what a modern request + carries, and how each one is routed. +* **[Caching](caching.md)** — the `ttlMs` / `cacheScope` hints a modern-era result carries. +* **[Subscriptions](subscriptions.md)** — `subscriptions/listen` and the notification bus + behind it. +* **[Authorization](authorization.md)** — validating OAuth 2 access tokens in front of + the HTTP transport. diff --git a/docs/run/protocol-eras.md b/docs/run/protocol-eras.md new file mode 100644 index 00000000..9c6cb28f --- /dev/null +++ b/docs/run/protocol-eras.md @@ -0,0 +1,136 @@ +# Serving both eras + +`Server::builder()->build()` produces a server carrying a dispatcher for each +[protocol era](../protocol-versions.md), and `StreamableHttpTransport` decides per request +which of them answers. There is nothing to configure: + +```php +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->addTool(static fn (string $city): string => "17°C in {$city}", name: 'get_weather', description: '…') + ->build(); + +(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request))); +``` + +That one endpoint answers `initialize` and `server/discover` alike. Modern-era requests +accept `POST` only; a `GET` or `DELETE` is a handshake-era session operation and is routed as +one. + +A full example lives in +[`examples/server/stateless-lifecycle/server.php`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/stateless-lifecycle), +described in [Examples](../examples.md#the-2026-07-28-lifecycle). + +## What a modern request carries + +There is no handshake to remember anything, so every request carries what the server needs to +answer it. Two members in `params._meta` are **required**, and the HTTP layer mirrors some of +them into headers so an intermediary can route without parsing the body: + +| `_meta` key | Required | Header | +| --- | --- | --- | +| `io.modelcontextprotocol/protocolVersion` | yes | `MCP-Protocol-Version` | +| `io.modelcontextprotocol/clientCapabilities` | yes | — | +| `io.modelcontextprotocol/clientInfo` | no | — | +| `io.modelcontextprotocol/logLevel` | no | — | +| `progressToken` | no | — | +| `traceparent`, `tracestate`, `baggage` | no | — | + +Plus `Mcp-Method` on every request, and `Mcp-Name` on `tools/call`, `prompts/get` and +`resources/read`. A header that disagrees with the body is refused with `-32020`; a missing +required `_meta` member with `-32602`; an unsupported version with `-32022`, carrying the +supported set for the client to retry from. + +What a handler can read off all this is +[Talking back to the client](../handlers/client-communication.md#request-metadata). + +### Mirroring a tool argument into a header + +A tool parameter annotated with `x-mcp-header` is mirrored into `Mcp-Param-{Name}` by the +client, and the server checks that the two agree: + +```php +->addTool( + static fn (string $region, string $query): string => …, + name: 'execute_sql', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'query' => ['type' => 'string'], + ], + 'required' => ['region', 'query'], + ], +) +``` + +The annotation must name a valid HTTP field, be unique case-insensitively, and sit on a +`string`, `integer` or `boolean` property reachable through `properties` keys alone. `Tool` +refuses a definition that breaks any of those rather than letting it fail later as a header +mismatch. See [Schema generation](../servers/schemas.md) for where a hand-written +`inputSchema` fits. + +## How a request is routed + +Every request is classified once, before anything else looks at it. The decision is +**body-primary**: + +| Evidence | Routed to | +| --- | --- | +| `params._meta` names a modern revision | modern era | +| `params._meta` names a handshake revision | handshake era | +| no such member | handshake era — `initialize` included | +| a notification with no member, under a modern header | modern era | +| `GET` / `DELETE` | handshake era | + +The `MCP-Protocol-Version` header never decides. It is cross-checked against the body, and a +request whose header contradicts its `_meta` is refused with `-32020` before either leg sees +it — the check has to happen at the edge, because a body claiming a handshake revision routes +to a leg that has no such check of its own. A modern header on a request carrying no envelope +is refused with `-32602` naming the member it wants. + +An unrecognised revision goes to whichever leg can answer it best: claimed in the envelope, +the modern leg answers, naming the modern revisions it serves; named only in a header, the +handshake leg answers, naming the handshake ones. + +Both legs come from **one** builder configuration — one registry, one set of handler +instances, one session manager. A tool registered once is reachable from both, and a change +made through one is visible to the other. + +## Middleware + +The [default middleware stack](http.md#default-middleware) runs at the edge, before the +request's era is known, because what it enforces is true of both. `ProtocolVersionMiddleware` +is not in that stack: the `MCP-Protocol-Version` header rule belongs to the handshake era, so +the transport applies it only to requests it classified as handshake-era traffic, and the +modern leg answers for its own revisions. It is available as +`StreamableHttpTransport::handshakeMiddleware()` and is applied whether or not you replace the +edge stack. + +## Serving one era only + +To serve the handshake era alone, say so: + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->withoutModernEra() + ->build(); +``` + +That server refuses a modern claim with `-32022`, naming the handshake revisions it does +serve. `setModernVersions()` narrows the modern leg instead of removing it. + +For the opposite — an endpoint that serves the modern era and nothing else — build the +dispatcher on its own and mount it on `StatelessHttpTransport`: + +```php +$protocol = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->buildStateless([ProtocolVersion::V2026_07_28]); + +(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request)); +``` diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md new file mode 100644 index 00000000..52d27e7c --- /dev/null +++ b/docs/run/server-builder.md @@ -0,0 +1,336 @@ +# 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. + +## 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__, ['.'], excludeDirs: ['vendor']) + ->build(); +``` + +### Method 2: Direct Instantiation + +```php +use Mcp\Server\Builder; + +$server = (new Builder()) + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->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 +- `$title` (string|null): Optional human-readable display title, distinct from `$name` + +### 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. `setProtocolVersion()` pins that handshake to exactly one revision instead: + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +$server = Server::builder() + ->setProtocolVersion(ProtocolVersion::V2025_06_18); +``` + +See [Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for how negotiation resolves and +what pinning changes. It only pins the handshake era; to narrow or remove what the modern leg answers for, use +`setModernVersions()` / `withoutModernEra()` — see +[Serving both eras](protocol-eras.md#serving-one-era-only). + +## Modern-Era Options + +`build()` returns a server that answers both [protocol eras](../protocol-versions.md), and none of the following is +required to serve either one. Each knob is covered in depth elsewhere: +[Asking for input](../handlers/input-required.md) for the first two, +[Caching](caching.md), [Subscriptions](subscriptions.md), and +[Serving both eras](protocol-eras.md) for the last. + +```php +use Mcp\Schema\Enum\CacheScope; +use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Server\Subscription\Psr16NotificationBus; +use Mcp\Server\Wire\CachePolicy; + +$server = Server::builder() + // Signs the state a multi round-trip request carries through the client. + // The same key must reach every process that might serve the retry. + ->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) + + // Bounds the input-required shim, which fulfils an `InputRequiredResult` + // over a handshake-era connection. `withoutInputRequiredShim()` turns it off. + ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) + + // Caching hints stamped on cacheable results. Defaults to `ttlMs: 0, private`. + ->setCachePolicy(CachePolicy::default(30_000)->withMethod('tools/list', 3_600_000, CacheScope::Public)) + + // Delivery for `subscriptions/listen`, and how long such a stream is held. + ->setNotificationBus(new Psr16NotificationBus($cache)) + ->setSubscriptionLifetime(0) + + // Narrow the modern leg, or drop it entirely. + ->setModernVersions([ProtocolVersion::V2026_07_28]) + ->build(); +``` + +`buildStateless()` returns the modern dispatcher alone, for an endpoint that serves no handshake-era traffic at all. +Its requests are checked against the SEP-2243 standard headers — that `Mcp-Method`, `Mcp-Name` and `Mcp-Param-*` agree +with the body they travel with. `setHeaderValidator(false)` turns that check off, which is needed only when the +dispatcher is served by a transport that has no header layer to check. + +## 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__, excludeDirs: ['vendor']) // Scans '.' and 'src' + ->build(); +``` + +!!! warning + Always exclude `vendor/` when the scanned directories contain it. The recursive scan + tries to load every class it finds, and a single file that cannot be loaded makes the + scan abort — the server then reports an **empty** element list instead of an error. + +**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. + +## 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); +``` + +## 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 | +| `setModernVersions()` | versions | Narrow the revisions the modern (2026-07-28) leg answers for | +| `withoutModernEra()` | - | Serve the handshake era only | +| `setRequestState()` | key, ttl? | Signing key and lifetime for multi round-trip request state | +| `setInputRequiredLimits()` | maxRounds, roundTimeout | Bound the input-required shim on handshake-era connections | +| `withoutInputRequiredShim()` | - | Do not fulfil an `InputRequiredResult` over a handshake-era connection | +| `setCachePolicy()` | policy | Set the `ttlMs`/`cacheScope` hints on cacheable results | +| `setNotificationBus()` | bus | Delivery for `subscriptions/listen` streams | +| `setSubscriptionLifetime()` | seconds | How long a subscription stream is held open (`0` = unbounded) | +| `setHeaderValidator()` | enabled | Toggle the SEP-2243 standard-header check on `buildStateless()` | +| `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 | +| `buildStateless()` | supportedVersions? | Create the modern dispatcher alone, for `StatelessHttpTransport` | diff --git a/docs/run/sessions.md b/docs/run/sessions.md new file mode 100644 index 00000000..abdac607 --- /dev/null +++ b/docs/run/sessions.md @@ -0,0 +1,127 @@ +# Session Management + +> Sessions belong to the handshake era. Protocol revision `2026-07-28` removed them, along with +> `Mcp-Session-Id`, because every request carries what is needed to answer it — see +> [The 2026-07-28 lifecycle](../protocol-versions.md). One server serves both, so the configuration below still +> applies to the handshake-era clients it answers. + +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 +- `Psr16SessionStore`: 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 []; + } +} +``` diff --git a/docs/run/stdio.md b/docs/run/stdio.md new file mode 100644 index 00000000..2f6ccca7 --- /dev/null +++ b/docs/run/stdio.md @@ -0,0 +1,64 @@ +# STDIO Transport + +The STDIO transport communicates via standard input/output streams, ideal for command-line tools and MCP client integrations. + +```php +$transport = new StdioTransport( + input: STDIN, // Input stream (default: STDIN) + output: STDOUT, // Output stream (default: STDOUT) + logger: $logger // Optional PSR-3 logger +); +``` + +## Parameters + +- **`input`** (optional): Input stream resource. Defaults to `STDIN`. +- **`output`** (optional): Output stream resource. Defaults to `STDOUT`. +- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. +- **`runnerControl`** (optional): `RunnerControlInterface` - controls the read loop; the default runs until the input stream closes. +- **`maxLineBytes`** (optional): Maximum accepted line length in bytes. Oversized lines are rejected as invalid messages. + +!!! warning + When using STDIO transport, **never** write to `STDOUT` in your handlers as it's reserved for JSON-RPC communication. + Use `STDERR` for debugging instead. + +## Example Server Script + +```php-file +#!/usr/bin/env php +setServerInfo('STDIO Calculator', '1.0.0') + ->addTool(function(int $a, int $b): int { return $a + $b; }, 'add_numbers') + ->addTool(InvokableCalculator::class) + ->build(); + +$transport = new StdioTransport(); + +$status = $server->run($transport); + +exit($status); // listen() returns 0 when the input stream closes +``` + +## Client Configuration + +For MCP clients like Claude Desktop: + +```json +{ + "mcpServers": { + "my-php-server": { + "command": "php", + "args": ["/absolute/path/to/server.php"] + } + } +} +``` diff --git a/docs/run/subscriptions.md b/docs/run/subscriptions.md new file mode 100644 index 00000000..4c3e989f --- /dev/null +++ b/docs/run/subscriptions.md @@ -0,0 +1,40 @@ +# Subscriptions + +> `subscriptions/listen` belongs to protocol revision `2026-07-28`; see +> [Protocol versions](../protocol-versions.md). Handshake-era clients keep using the HTTP `GET` stream and +> `resources/subscribe`, which the same server still answers. + +`subscriptions/listen` replaces the HTTP `GET` stream and `resources/subscribe`. The client +opens a long-lived POST whose response stream carries the notification types it asked for; +the server acknowledges first with `notifications/subscriptions/acknowledged`, reporting the +subset it agreed to honour. + +## The notification bus + +Delivery needs a bus, because the process that publishes and the process holding the stream +open are often not the same one: + +```php +use Mcp\Server\Subscription\InMemoryNotificationBus; +use Mcp\Server\Subscription\Psr16NotificationBus; + +// stdio, or a persistent runtime where the whole server is one process +->setNotificationBus(new InMemoryNotificationBus()) + +// PHP-FPM: the publisher and the stream are different workers +->setNotificationBus(new Psr16NotificationBus($cache)) +``` + +Registry changes (`registerTool()`, `unregisterPrompt()`, …) are published automatically. +Anything else — `notifications/resources/updated` above all — is published by the +application: + +```php +$bus->publish(new ResourceUpdatedNotification('file:///project/config.json')); +``` + +## How long a stream lives + +`Builder::setSubscriptionLifetime()` bounds how long a stream is held before the server +closes it gracefully. The real ceiling is the runtime's: under PHP-FPM a stream cannot +outlive `max_execution_time`. Pass `0` for "until the client or the runtime ends it". diff --git a/docs/server-builder.md b/docs/server-builder.md deleted file mode 100644 index 6e1cee7f..00000000 --- a/docs/server-builder.md +++ /dev/null @@ -1,747 +0,0 @@ -# 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 | diff --git a/docs/servers/completions.md b/docs/servers/completions.md new file mode 100644 index 00000000..c4226205 --- /dev/null +++ b/docs/servers/completions.md @@ -0,0 +1,98 @@ +# Completion Providers + +Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have dynamic parameters that benefit from completion hints. + +## Completion Provider Types + +### 1. Value Lists + +Provide a static list of possible values: + +```php +use Mcp\Capability\Attribute\CompletionProvider; + +#[McpPrompt] +public function generateContent( + #[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide'])] + string $contentType, + + #[CompletionProvider(values: ['beginner', 'intermediate', 'advanced'])] + string $difficulty +): array +{ + return [ + ['role' => 'user', 'content' => "Create a {$difficulty} level {$contentType}"] + ]; +} +``` + +### 2. Enum Classes + +Use enum values for completion: + +```php +enum Priority: string +{ + case LOW = 'low'; + case MEDIUM = 'medium'; + case HIGH = 'high'; +} + +enum Status // Unit enum +{ + case DRAFT; + case PUBLISHED; + case ARCHIVED; +} + +#[McpResourceTemplate(uriTemplate: 'tasks://{priority}/{status}')] +public function getTask( + #[CompletionProvider(enum: Priority::class)] // Uses backing values + string $priority, + + #[CompletionProvider(enum: Status::class)] // Uses case names + string $status +): array +{ + // Implementation +} +``` + +### 3. Custom Provider Classes + +For dynamic completion logic: + +```php +use Mcp\Capability\Completion\ProviderInterface; + +class UserIdCompletionProvider implements ProviderInterface +{ + public function __construct(private DatabaseService $db) {} + + public function getCompletions(string $currentValue): array + { + // Return dynamic completions based on current input + return $this->db->searchUserIds($currentValue); + } +} + +#[McpResourceTemplate(uriTemplate: 'user://{userId}/profile')] +public function getUserProfile( + #[CompletionProvider(provider: UserIdCompletionProvider::class)] + string $userId +): array +{ + // Implementation +} +``` + +**Provider Resolution:** +- **Class strings** (`Provider::class`) → Resolved from PSR-11 container +- **Instances** (`new Provider()`) → Used directly +- **Values** (`['a', 'b']`) → Wrapped in `ListCompletionProvider` +- **Enums** (`MyEnum::class`) → Wrapped in `EnumCompletionProvider` + +> **Important** +> +> Completion providers only offer **suggestions** to users. Users can still input any value, so **always validate +> parameters** in your handlers. Providers don't enforce validation - they're purely for UX improvement. diff --git a/docs/servers/index.md b/docs/servers/index.md new file mode 100644 index 00000000..616acacf --- /dev/null +++ b/docs/servers/index.md @@ -0,0 +1,30 @@ +# Servers + +An MCP server exposes four kinds of elements to a connected client. They differ by who +decides to use them: + +* A **[tool](tools.md)** is an action the *model* picks and calls. This is the page most + people want first. +* A **[resource](resources.md)** is read-only data the *application* chooses to read, + addressed by a fixed URI. **[Resource templates](resource-templates.md)** are the same + thing with variables in the URI, for data that is generated per request. +* A **[prompt](prompts.md)** is a message template a *person* invokes by name, from a + menu or a slash command. + +Around those, the rest of what a server declares: + +* **[Completions](completions.md)** is server-side autocomplete for prompt and + resource-template arguments. +* **[Schema generation](schemas.md)** explains how your PHP types and docblocks become + the JSON Schema a model sees, and how to override it where the types are not enough. +* **[Registering elements](registration.md)** covers the three ways an element reaches + the registry: attribute discovery, explicit registration, or both at once. + +Every page here stands on its own; jump straight to the one you need. If you have not +built a server yet, start with **[First server](../get-started/first-server.md)** +instead. + +What happens *inside* the functions you register — logging, progress, asking the client +for an LLM completion — is the next section, +**[Inside your handler](../handlers/index.md)**. Getting the server in front of a client +is **[Running your server](../run/index.md)**. diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md new file mode 100644 index 00000000..33da3c7c --- /dev/null +++ b/docs/servers/prompts.md @@ -0,0 +1,126 @@ +# Prompts + +Prompts generate templates for AI interactions. + +```php +use Mcp\Capability\Attribute\McpPrompt; + +class PromptGenerator +{ + /** + * Generates a code review request prompt. + */ + #[McpPrompt(name: 'code_review')] + public function reviewCode(string $language, string $code, string $focus = 'general'): array + { + return [ + ['role' => 'assistant', 'content' => 'You are an expert code reviewer.'], + ['role' => 'user', 'content' => "Review this {$language} code focusing on {$focus}:\n\n```{$language}\n{$code}\n```"] + ]; + } +} +``` + +## Parameters + +- **`name`** (optional): Prompt identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Prompt description. Falls back to the docblock (summary plus long description). +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +## Prompt Return Values + +Prompt handlers must return an array of message structures that are automatically formatted into MCP prompt messages. + +### Supported Return Formats + +```php +// Array of message objects with role and content +public function basicPrompt(): array +{ + return [ + ['role' => 'assistant', 'content' => 'You are a helpful assistant'], + ['role' => 'user', 'content' => 'Hello, how are you?'] + ]; +} + +// Single message (automatically wrapped in array) +public function singleMessage(): array +{ + return [ + ['role' => 'user', 'content' => 'Write a poem about PHP'] + ]; +} + +// Associative array with user/assistant keys +public function userAssistantFormat(): array +{ + return [ + 'user' => 'Explain how arrays work in PHP', + 'assistant' => 'Arrays in PHP are ordered maps...' + ]; +} + +// Non-text content — each message carries exactly one content block, +// so an image goes into its own message +use Mcp\Schema\Content\{TextContent, ImageContent}; + +public function mixedContent(): array +{ + return [ + ['role' => 'user', 'content' => new TextContent('Analyze this image:')], + ['role' => 'user', 'content' => new ImageContent(data: $imageData, mimeType: 'image/png')] + ]; +} + +// Using explicit PromptMessage objects +use Mcp\Schema\Content\PromptMessage; +use Mcp\Schema\Enum\Role; + +public function explicitMessages(): array +{ + return [ + new PromptMessage(Role::Assistant, new TextContent('System instructions')), + new PromptMessage(Role::User, new TextContent('User question')) + ]; +} +``` + +The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. + +### Valid Message Roles + +- **`user`**: User input or questions +- **`assistant`**: Assistant responses, including system-style instructions + +Those two are the only valid roles — MCP has no `system` role, and any other value +makes the prompt handler throw. + +### Error Handling + +Prompt handlers can throw any exception, but the type determines how it's handled: +- **`PromptGetException`**: Converted to JSON-RPC error response with the actual exception message +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message + +```php +use Mcp\Exception\PromptGetException; + +#[McpPrompt] +public function generatePrompt(string $topic, string $style): array +{ + $validStyles = ['casual', 'formal', 'technical']; + + if (!in_array($style, $validStyles)) { + throw new PromptGetException( + "Invalid style '{$style}'. Must be one of: " . implode(', ', $validStyles) + ); + } + + return [ + ['role' => 'user', 'content' => "Write about {$topic} in a {$style} style"] + ]; +} +``` + +**Recommendation**: Use `PromptGetException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. diff --git a/docs/servers/registration.md b/docs/servers/registration.md new file mode 100644 index 00000000..e2dc563c --- /dev/null +++ b/docs/servers/registration.md @@ -0,0 +1,247 @@ +# Registering elements + +Every tool, resource, resource template, and prompt has to reach the server's +registry somehow. There are three ways to get it there, and they mix freely. + +## Attribute-Based Discovery + +**Advantages:** +- Declarative and readable +- Automatic parameter inference +- DocBlock integration +- Type-safe by default +- Caching support + +**Example:** +```php +$server = Server::builder() + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) // Automatic discovery + ->build(); +``` + +## Manual Registration + +Register MCP elements programmatically without using attributes. The handler is the most important parameter and can be +any PHP callable. + +**Advantages:** +- Fine-grained control +- Runtime configuration +- Conditional registration +- External handler support + +**Example:** +```php +$server = Server::builder() + ->addTool([Calculator::class, 'add'], 'add_numbers') + ->addResource([Config::class, 'get'], 'config://app') + ->addPrompt([Prompts::class, 'email'], 'write_email') + ->build(); +``` + + +### 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 +- `outputSchema` (array|null): Optional JSON schema describing the tool's `structuredContent` + +### 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 +- `title` (string|null): Optional human-readable title for display in UI +- `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 +- `title` (string|null): Optional human-readable title for display in UI +- `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 +- `meta` (array|null): Optional metadata 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 +- `meta` (array|null): Optional metadata for the prompt + +**Note:** `name` and `description` are optional when the handler is a method or an invokable class — they are then +derived from the method name and its docblock. A **closure** handler has neither, so it gets a generated name +(`closure_tool_`) and no description; name your closures explicitly. + +For more details on the elements themselves, see [Tools](tools.md), [Resources](resources.md), [Resource templates](resource-templates.md), and [Prompts](prompts.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 objects validate some of +their own input as well — `Tool` requires an object-typed input schema, `ResourceDefinition` and `ResourceTemplate` +check the name pattern and URI — but an invalid tool or prompt *name* is not rejected, it is only logged as a warning +when the element is registered. + +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. + +## Hybrid Approach + +Combine both methods for maximum flexibility: + +```php +$server = Server::builder() + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) // Discover most capabilities + ->addTool([ExternalService::class, 'process'], 'external') // Add specific ones + ->build(); +``` + +Manual registrations always take precedence over discovered elements with the same identifier — same `name` for tools +and prompts, same `uri` for resources, same `uriTemplate` for resource templates. + +For runtime, config-driven elements whose shape is not known at compile time, see +[Explicit element registration](#explicit-element-registration). diff --git a/docs/servers/resource-templates.md b/docs/servers/resource-templates.md new file mode 100644 index 00000000..2cff71e4 --- /dev/null +++ b/docs/servers/resource-templates.md @@ -0,0 +1,49 @@ +# Resource Templates + +Resource templates are **dynamic resources** that use parameterized URIs with variables. They follow all the same rules +as static resources (URI schemas, return values, MIME types, etc.) but accept `{variable}` placeholders in the URI. + +Only simple [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) variable expansion +is supported — `{var}`, one path segment each. Operators such as `{+var}`, `{#var}`, +`{/path}`, `{?query}` and explode (`{list*}`) are not parsed, and a variable's value +cannot contain `/`. + +```php +use Mcp\Capability\Attribute\McpResourceTemplate; + +class UserProvider +{ + /** + * Retrieves user profile information by ID. + */ + #[McpResourceTemplate( + uriTemplate: 'user://{userId}/profile/{section}', + name: 'user_profile', + description: 'User profile data by section', + mimeType: 'application/json' + )] + public function getUserProfile(string $userId, string $section): array + { + return $this->users[$userId][$section] ?? throw new \InvalidArgumentException("Profile section not found"); + } +} +``` + +## Parameters + +- **`uriTemplate`** (required): URI with `{variables}`. Must start with a scheme (`file://`, `user://`, …) and contain at least one variable. +- **`name`** (optional): Short resource template identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Template description. Falls back to the docblock (summary plus long description). +- **`mimeType`** (optional): MIME type of the resource content. +- **`annotations`** (optional): Additional metadata. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +## Variable Rules + +1. **Variable names must match exactly** between URI template and method parameters — + they are bound by name, so the parameter order is free +2. **All variables are required** - no optional parameters supported +3. **Type hints work normally** - parameters can be typed (string, int, etc.) + +**Example mapping**: `user://123/profile/settings` → `getUserProfile("123", "settings")` diff --git a/docs/servers/resources.md b/docs/servers/resources.md new file mode 100644 index 00000000..10736c48 --- /dev/null +++ b/docs/servers/resources.md @@ -0,0 +1,152 @@ +# Resources + +Resources provide access to static data that clients can read. + +```php +use Mcp\Capability\Attribute\McpResource; + +class ConfigProvider +{ + /** + * Provides the current application configuration. + */ + #[McpResource(uri: 'config://app/settings', name: 'app_settings')] + public function getSettings(): array + { + return [ + 'version' => '1.0.0', + 'debug' => false, + 'features' => ['auth', 'logging'] + ]; + } +} +``` + +## Parameters + +- **`uri`** (required): Unique resource identifier. Must comply with [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). +- **`name`** (optional): Short resource identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Resource description. Falls back to the docblock (summary plus long description). +- **`mimeType`** (optional): MIME type of the resource content. +- **`size`** (optional): Size in bytes if known. +- **`annotations`** (optional): Additional metadata. +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +**Standard Protocol URI Schemes**: `https://` (web resources), `file://` (filesystem), `git://` (version control). +**Custom schemes**: `config://`, `data://`, `db://`, `api://` or any RFC 3986 compliant scheme. + +## Resource Return Values + +Resource handlers can return various data types that are automatically formatted into appropriate MCP resource content types. + +### Supported Return Types + +```php +// String content - converted to text resource +public function getTextFile(): string +{ + return "File content here"; +} + +// Array content - converted to JSON +public function getConfig(): array +{ + return ['debug' => true, 'version' => '1.0']; +} + +// Stream resource - read and converted to blob. +// `resource` is not a PHP type declaration, so the return type is left off. +/** @return resource */ +public function getImageStream() +{ + return fopen('image.png', 'r'); +} + +// SplFileInfo - file content with MIME type detection +public function getFileInfo(): \SplFileInfo +{ + return new \SplFileInfo('document.pdf'); +} +``` + +**Explicit resource content types** + +```php +use Mcp\Schema\Content\{TextResourceContents, BlobResourceContents}; + +public function getExplicitText(): TextResourceContents +{ + return new TextResourceContents( + uri: 'config://app/settings', + mimeType: 'application/json', + text: json_encode(['setting' => 'value']) + ); +} + +public function getExplicitBlob(): BlobResourceContents +{ + return new BlobResourceContents( + uri: 'file://image.png', + mimeType: 'image/png', + blob: base64_encode(file_get_contents('image.png')) + ); +} +``` + +**Special Array Formats** + +```php +// Array with 'text' key - used as text content +public function getTextArray(): array +{ + return ['text' => 'Content here', 'mimeType' => 'text/plain']; +} + +// Array with 'blob' key - used as blob content +public function getBlobArray(): array +{ + return ['blob' => base64_encode($data), 'mimeType' => 'image/png']; +} + +// Multiple resource contents +public function getMultipleResources(): array +{ + return [ + new TextResourceContents('file://readme.txt', 'text/plain', 'README content'), + new TextResourceContents('file://config.json', 'application/json', '{"key": "value"}') + ]; +} +``` + +### Error Handling + +Resource handlers can throw any exception, but the type determines how it's handled: + +- **`ResourceReadException`**: Converted to JSON-RPC error response with the actual exception message +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message + +```php +use Mcp\Capability\Attribute\McpResourceTemplate; +use Mcp\Exception\ResourceReadException; + +// A URI with variables is a resource *template*; `#[McpResource]` registers a +// fixed URI and would never receive `$path`. Note a variable matches a single +// segment, so `$path` here cannot contain `/`. +#[McpResourceTemplate(uriTemplate: 'file://{path}')] +public function getFile(string $path): string +{ + if (!file_exists($path)) { + throw new ResourceReadException("File not found: {$path}"); + } + + if (!is_readable($path)) { + throw new ResourceReadException("File not readable: {$path}"); + } + + return file_get_contents($path); +} +``` + +**Recommendation**: Use `ResourceReadException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. diff --git a/docs/servers/schemas.md b/docs/servers/schemas.md new file mode 100644 index 00000000..d6d11d7f --- /dev/null +++ b/docs/servers/schemas.md @@ -0,0 +1,111 @@ +# Schema Generation and Validation + +The SDK automatically generates JSON schemas for **tool parameters** using a sophisticated priority system. Schema +generation applies to both attribute-discovered and manually registered tools. + +## Schema Generation Priority + +The server follows this order of precedence: + +1. **`#[Schema]` attribute with `definition`** - Complete schema override (highest priority) +2. **Parameter-level `#[Schema]` attribute** - Parameter-specific enhancements +3. **Method-level `#[Schema]` attribute** - Method-wide configuration +4. **PHP type hints + docblocks** - Automatic inference (lowest priority) + +## Automatic Schema from PHP Types + +```php +#[McpTool] +public function processUser( + string $email, // Required string + int $age, // Required integer + ?string $name = null, // Optional string + bool $active = true // Boolean with default +): array +{ + // Schema auto-generated from method signature +} +``` + +## Parameter-Level Schema Enhancement + +Add validation rules to specific parameters: + +```php +use Mcp\Capability\Attribute\Schema; + +#[McpTool] +public function validateUser( + #[Schema(format: 'email')] + string $email, + + #[Schema(minimum: 18, maximum: 120)] + int $age, + + #[Schema( + pattern: '^[A-Z][a-z]+$', + description: 'Capitalized first name' + )] + string $firstName +): bool +{ + // PHP types provide base validation + // Schema attributes add constraints +} +``` + +## Method-Level Schema + +Add validation for complex object structures: + +```php +#[McpTool] +#[Schema( + properties: [ + 'userData' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'minLength' => 2], + 'email' => ['type' => 'string', 'format' => 'email'], + 'age' => ['type' => 'integer', 'minimum' => 18] + ], + 'required' => ['name', 'email'] + ] + ], + required: ['userData'] +)] +public function createUser(array $userData): array +{ + // Method-level schema adds object structure validation + // PHP array type provides base type +} +``` + +## Complete Schema Override + +**Use sparingly** - bypasses all automatic inference: + +```php +#[McpTool] +#[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 makeApiRequest(string $endpoint, string $method, array $headers): array +{ + // Complete definition override - PHP types ignored +} +``` + +**Warning:** Only use complete schema override if you're well-versed with JSON Schema specification and have complex +validation requirements that cannot be achieved through the priority system. diff --git a/docs/servers/tools.md b/docs/servers/tools.md new file mode 100644 index 00000000..a919b401 --- /dev/null +++ b/docs/servers/tools.md @@ -0,0 +1,236 @@ +# Tools + +Tools are callable functions that perform actions and return results. + +```php +use Mcp\Capability\Attribute\McpTool; + +class Calculator +{ + /** + * Performs arithmetic operations with validation. + */ + #[McpTool(name: 'calculate')] + public function performCalculation(float $a, float $b, string $operation): float + { + return match($operation) { + 'add' => $a + $b, + 'subtract' => $a - $b, + 'multiply' => $a * $b, + 'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Division by zero'), + default => throw new \InvalidArgumentException('Invalid operation') + }; + } +} +``` + +## Parameters + +- **`name`** (optional): Tool identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Tool description. Falls back to the docblock (summary plus long description); stays unset if there is no docblock. +- **`annotations`** (optional): `ToolAnnotations` object for additional metadata. +- **`outputSchema`** (optional): JSON schema describing `structuredContent`; see [Structured Output](#structured-output). +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +**Priority**: `name` is the attribute parameter, else the method name. `description` is the attribute parameter, else the docblock — the method name is never used as a description. + +For tool parameter validation and JSON schema generation, see [Schema generation](schemas.md). + +## Tool Return Values + +Tools can return any data type and the SDK will automatically wrap them in appropriate MCP content types. + +### Automatic Content Wrapping + +```php +// Primitive types → TextContent +public function getString(): string { return "Hello"; } // TextContent +public function getNumber(): int { return 42; } // TextContent +public function getBool(): bool { return true; } // TextContent +public function getArray(): array { return ['key' => 'value']; } // TextContent (JSON) + +// Special cases +public function getNull(): ?string { return null; } // TextContent("(null)") +public function returnVoid(): void { /* no return */ } // TextContent("(null)") +``` + +### Explicit Content Types + +For fine control over output formatting: + +```php +use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, ResourceLink, EmbeddedResource}; + +public function getFormattedCode(): TextContent +{ + return TextContent::code(' 'object', + 'properties' => [ + 'temperature' => ['type' => 'number'], + 'conditions' => ['type' => 'string'], + ], + 'required' => ['temperature', 'conditions'], + ] +)] +public function getWeather(string $city): array +{ + // Sent as `structuredContent`, and JSON-encoded into `content` for clients that ignore it + return ['temperature' => 22.5, 'conditions' => 'sunny']; +} +``` + +The same schema can be passed to manual registration: + +```php +$builder->addTool([WeatherHandler::class, 'getWeather'], outputSchema: [/* ... */]); +``` + +The SDK fills `structuredContent` whenever the return value qualifies — `outputSchema` is what tells clients to expect it +and lets them validate it. What qualifies depends on the protocol revision the call is served under: + +| Return value | `structuredContent` | +|---|---| +| Associative array (`['temperature' => 22.5]`) | The array | +| Object (`stdClass`, DTO, `JsonSerializable`) that serializes to a JSON object | Its JSON representation | +| List (`[1, 2, 3]`, `[['id' => 1], ['id' => 2]]`), or an object serializing to one | Omitted before `2026-07-28`, kept from it on | +| Array holding `Content` instances | Omitted (already carried in `content`) | +| Scalars, `null`, `Content` instances | Omitted | + +Up to revision `2025-11-25`, `structuredContent` had to be a JSON object, so a PHP list — which serializes to a JSON +array — was not emittable and strict clients rejected the whole tool call over one. [SEP-2106][sep-2106], part of +revision `2026-07-28`, widened `outputSchema` to any JSON Schema 2020-12 and `structuredContent` to any JSON value +conforming to it. The SDK picks the rule from the revision negotiated for the call, so a tool serving both eras needs the +object shape to produce structured output everywhere. Wrap the list in a key for that: + +```php +// Structured content only from 2026-07-28 on: a bare list is not a JSON object +public function listUsersFlat(): array +{ + return [['id' => 1], ['id' => 2]]; +} + +#[McpTool(outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'items' => ['type' => 'array', 'items' => ['type' => 'object']], + ], + 'required' => ['items'] +])] +public function listUsers(): array +{ + return ['items' => [['id' => 1], ['id' => 2]]]; +} +``` + +Either way the data reaches the client: a return value with no structured representation is still JSON-encoded into +`content` as a `TextContent`. When a tool declares an `outputSchema` but returns something that cannot be sent as +`structuredContent`, the SDK logs a warning — the value is not silently dropped. + +A tool that wants to branch on the revision itself can read it from the injected `RequestContext`, see +[Talking back to the client](../handlers/client-communication.md#clientgateway). + +[sep-2106]: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content + +### Error Handling + +Tool handlers can throw any exception, but the type determines how it's handled: + +- **`ToolCallException`**: Converted to JSON-RPC response with `CallToolResult` where `isError: true`, allowing the LLM to see the error message and self-correct +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message + +```php +use Mcp\Exception\ToolCallException; + +#[McpTool] +public function divideNumbers(float $a, float $b): float +{ + if ($b === 0.0) { + throw new ToolCallException('Division by zero is not allowed'); + } + + return $a / $b; +} + +#[McpTool] +public function processFile(string $filename): string +{ + if (!file_exists($filename)) { + throw new ToolCallException("File not found: {$filename}"); + } + + return file_get_contents($filename); +} +``` + +**Recommendation**: Use `ToolCallException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. diff --git a/docs/stateless-lifecycle.md b/docs/stateless-lifecycle.md deleted file mode 100644 index 45e3cb2d..00000000 --- a/docs/stateless-lifecycle.md +++ /dev/null @@ -1,391 +0,0 @@ -# The 2026-07-28 lifecycle - -Protocol revision `2026-07-28` removed the `initialize` handshake and protocol-level sessions. Everything a -server needs to answer a request now travels *in* that request, which means any process can answer any -request and none of them need to share state. - -This guide covers what changes for a server author. Tools, resources, prompts and their handlers are -unaffected — the same registrations serve either lifecycle. - -- [The two eras](#the-two-eras) -- [Building a stateless server](#building-a-stateless-server) -- [Per-request metadata](#per-request-metadata) -- [Multi round-trip requests](#multi-round-trip-requests) -- [Progress and logging](#progress-and-logging) -- [Caching](#caching) -- [Subscriptions](#subscriptions) -- [Serving both eras](#serving-both-eras) -- [What was removed](#what-was-removed) - -## The two eras - -| | Handshake era (`2025-11-25` and earlier) | Modern era (`2026-07-28`) | -| --- | --- | --- | -| Opening | `initialize` / `notifications/initialized` | none | -| Version | negotiated once, kept on the session | declared on every request | -| Capabilities | exchanged once | declared on every request | -| Discovery | `initialize` result | `server/discover` | -| Sessions | `Mcp-Session-Id` | removed | -| Server → client requests | sent as JSON-RPC requests | returned in the result (MRTR) | -| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` | -| Dispatcher | `Protocol` | `StatelessProtocol` | -| HTTP entry | `StreamableHttpTransport` — the same one, for both | - -`ProtocolVersion::isModern()` tells the two apart, and `Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` -is where the boundary sits. - -## Building a stateless server - -There is nothing to build differently. `Builder::build()` produces a `Server` carrying a dispatcher for -each era, and `StreamableHttpTransport` decides per request which of them answers: - -```php -use Mcp\Server; -use Mcp\Server\Transport\StreamableHttpTransport; - -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->addTool(static fn (string $city): string => "17°C in {$city}", name: 'get_weather', description: '…') - ->build(); - -(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request))); -``` - -That one endpoint answers `initialize` and `server/discover` alike. See -[Serving both eras](#serving-both-eras) for how the decision is made and how to opt out of it. - -Modern-era requests accept `POST` only; a `GET` or `DELETE` is a handshake-era session operation and is -routed as one. - -A full example lives in [`examples/server/stateless-lifecycle/server.php`](../examples/server/stateless-lifecycle/server.php). - -## Per-request metadata - -Every request **must** carry two members in `params._meta`, and the HTTP layer mirrors some of them into -headers so an intermediary can route without parsing the body: - -| `_meta` key | Required | Header | -| --- | --- | --- | -| `io.modelcontextprotocol/protocolVersion` | yes | `MCP-Protocol-Version` | -| `io.modelcontextprotocol/clientCapabilities` | yes | — | -| `io.modelcontextprotocol/clientInfo` | no | — | -| `io.modelcontextprotocol/logLevel` | no | — | -| `progressToken` | no | — | -| `traceparent`, `tracestate`, `baggage` | no | — | - -Plus `Mcp-Method` on every request, and `Mcp-Name` on `tools/call`, `prompts/get` and `resources/read`. -A header that disagrees with the body is refused with `-32020`; a missing required `_meta` member with -`-32602`; an unsupported version with `-32022`, carrying the supported set for the client to retry from. - -Handlers read the metadata through `RequestContext`: - -```php -$context->getProtocolVersion(); // the revision serving this request -$context->getClientCapabilities(); // what this client declared, or null in the handshake era -$context->getTraceContext(); // traceparent / tracestate / baggage, verbatim -``` - -`ClientGateway`'s capability probes — `supportsElicitation()`, `supportsSampling()`, `supportsRoots()` and -the sub-capability variants — read the same declaration, so they work in both eras. - -### Mirroring a tool argument into a header - -A tool parameter annotated with `x-mcp-header` is mirrored into `Mcp-Param-{Name}` by the client, and the -server checks that the two agree: - -```php -->addTool( - static fn (string $region, string $query): string => …, - name: 'execute_sql', - inputSchema: [ - 'type' => 'object', - 'properties' => [ - 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], - 'query' => ['type' => 'string'], - ], - 'required' => ['region', 'query'], - ], -) -``` - -The annotation must name a valid HTTP field, be unique case-insensitively, and sit on a `string`, `integer` -or `boolean` property reachable through `properties` keys alone. `Tool` refuses a definition that breaks -any of those rather than letting it fail later as a header mismatch. - -## Multi round-trip requests - -There are no server-initiated requests in this revision. A server that needs sampling, elicitation or roots -**returns** the ask, and the client retries the original call carrying the answers. - -This is the shape to write handlers in even if you also serve handshake-era clients — the SDK fulfils the -same ask over their connection instead. See [What a handler forks on](#what-a-handler-forks-on). - -```php -use Mcp\Schema\Result\CallToolResult; -use Mcp\Schema\Result\InputRequiredResult; - -static function (RequestContext $context): CallToolResult|InputRequiredResult { - $answer = $context->getInputContext()?->elicitResult('who'); - - if (null === $answer) { - return new InputRequiredResult( - ['who' => new ElicitRequest('Your name?', $schema)], - requestState: $context->mintRequestState(['asked' => 'who']), - ); - } - - return new CallToolResult([new TextContent("Hello, {$answer->content['name']}!")]); -} -``` - -`tools/call`, `prompts/get` and `resources/read` may answer this way; nothing else may. - -**Reading the answers.** `InputContext` hands them back typed — `elicitResult()`, `samplingResult()`, -`rootsResult()` — and returns `null` for an answer that is absent *or* malformed. Both mean the same thing -to a handler: ask again. `response()` is still there for the raw array. - -**`requestState`.** Whatever the server needs to remember between rounds. It travels through the client, so -it is attacker-controlled on return; `mintRequestState()` seals it with an HMAC and a TTL, and a state that -fails verification never reaches a handler. Configure the key with `Builder::setRequestState()`: - -```php -->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) -``` - -The **same key must reach every process that might serve the retry**. A per-process random value works only -for a single-process deployment. Nothing secret belongs in the payload — it is signed, not encrypted. - -**Capabilities.** A server must not ask for input the client cannot provide. The SDK checks each ask against -the request's declared capabilities and answers `-32021` — with the missing set in -`data.requiredCapabilities` — rather than sending an ask that could never be answered. Url-mode elicitation -needs its own `elicitation.url` declaration; a bare `elicitation` means form mode only. - -**What not to call.** `ClientGateway::sample()`, `elicit()`, `elicitUrl()` and `listRoots()` belong to the -handshake era. Calling one under this revision raises a `LogicException` naming `InputRequiredResult` as the -replacement. - -## Progress and logging - -Both travel on the request's own response stream, and both are opt-in by the client: - -- **Progress** — the client sends `_meta.progressToken`; without one, `$gateway->progress()` sends nothing. -- **Logging** — the client sends `_meta["io.modelcontextprotocol/logLevel"]`; without it the server **must - not** emit `notifications/message` at all, and does not. - -```php -static function (RequestContext $context): string { - $client = $context->getClientGateway(); - $client->log(LoggingLevel::Info, 'Reindexing shard 1 of 3'); - $client->progress(1, 3, 'Shard 1 of 3'); - - return 'done'; -} -``` - -The server answers with a single JSON object when the handler emits nothing, and opens an SSE stream when it -does — so an error that has to carry a specific status still gets one, and a handler that talks gets a -stream. Trace context from the request is echoed onto every notification it causes. - -## Caching - -`server/discover`, the four list methods and `resources/read` **must** carry `ttlMs` and `cacheScope`. The -default is `ttlMs: 0, cacheScope: "private"` — conformant, and a flat refusal to let anything be cached. -Say what you actually mean: - -```php -use Mcp\Schema\Enum\CacheScope; -use Mcp\Server\Wire\CachePolicy; - -->setCachePolicy( - CachePolicy::default(30_000) - ->withMethod('tools/list', 3_600_000, CacheScope::Public) - ->withMethod('server/discover', 3_600_000, CacheScope::Public), -) -``` - -`public` lets a shared proxy serve one caller's answer to another, so use it only for results that do not -vary by caller. A `ReadResourceResult` may set its own `ttlMs`/`cacheScope`, which win over the policy. -Results produced by an MRTR retry are never given hints: their inputs are not part of any cache key. - -## Subscriptions - -`subscriptions/listen` replaces the HTTP `GET` stream and `resources/subscribe`. The client opens a -long-lived POST whose response stream carries the notification types it asked for; the server acknowledges -first with `notifications/subscriptions/acknowledged`, reporting the subset it agreed to honour. - -Delivery needs a bus, because the process that publishes and the process holding the stream open are often -not the same one: - -```php -use Mcp\Server\Subscription\InMemoryNotificationBus; -use Mcp\Server\Subscription\Psr16NotificationBus; - -// stdio, or a persistent runtime where the whole server is one process -->setNotificationBus(new InMemoryNotificationBus()) - -// PHP-FPM: the publisher and the stream are different workers -->setNotificationBus(new Psr16NotificationBus($cache)) -``` - -Registry changes (`registerTool()`, `unregisterPrompt()`, …) are published automatically. Anything else — -`notifications/resources/updated` above all — is published by the application: - -```php -$bus->publish(new ResourceUpdatedNotification('file:///project/config.json')); -``` - -`Builder::setSubscriptionLifetime()` bounds how long a stream is held before the server closes it -gracefully. The real ceiling is the runtime's: under PHP-FPM a stream cannot outlive `max_execution_time`. -Pass `0` for "until the client or the runtime ends it". - -## Serving both eras - -One endpoint serves both, and the client picks nothing. Every request is classified once, before anything -else looks at it, and routed to the lifecycle it belongs to. The decision is **body-primary**: - -| Evidence | Routed to | -| --- | --- | -| `params._meta` names a modern revision | modern era | -| `params._meta` names a handshake revision | handshake era | -| no such member | handshake era — `initialize` included | -| a notification with no member, under a modern header | modern era | -| `GET` / `DELETE` | handshake era | - -The `MCP-Protocol-Version` header never decides. It is cross-checked against the body, and a request whose -header contradicts its `_meta` is refused with `-32020` before either leg sees it — the check has to happen -at the edge, because a body claiming a handshake revision routes to a leg that has no such check of its -own. A modern header on a request carrying no envelope is refused with `-32602` naming the member it wants. - -An unrecognised revision goes to whichever leg can answer it best: claimed in the envelope, the modern leg -answers, naming the modern revisions it serves; named only in a header, the handshake leg answers, naming -the handshake ones. - -Both legs come from **one** builder configuration — one registry, one set of handler instances, one session -manager. A tool registered once is reachable from both, and a change made through one is visible to the -other. - -To serve the handshake era alone, say so: - -```php -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->withoutModernEra() - ->build(); -``` - -That server refuses a modern claim with `-32022`, naming the handshake revisions it does serve. -`setModernVersions()` narrows the modern leg instead of removing it. - -For the opposite — an endpoint that serves the modern era and nothing else — build the dispatcher on its -own and mount it on `StatelessHttpTransport`: - -```php -$protocol = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->buildStateless([ProtocolVersion::V2026_07_28]); - -(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request)); -``` - -### What a handler forks on - -Nothing. Tools, resources, prompts, structured output, progress and errors do not care which era called, -and neither does the one thing that looks like it should: **asking the user something**. - -Write it the 2026-07-28 way — return an `InputRequiredResult` naming what you need, read the answer off -`RequestContext::getInputContext()` when the call comes back. On a handshake-era connection the SDK's -input-required shim fulfils the same ask over that connection's own channel: each embedded request goes -out as the real `elicitation/create` / `sampling/createMessage` / `roots/list`, and the handler is -re-entered with the answers under the keys it asked for. It is on by default; -[`examples/server/elicitation`](../examples/server/elicitation) and -[`examples/server/client-communication`](../examples/server/client-communication) are written this way and -name no era anywhere. - -Two things to know about it. - -**Re-entry is re-execution.** The handler runs again from the top each round, so it has to re-derive where -it is from what came back rather than from anything it kept. That is already true of the modern era — the -client retries the whole call there — so a portable handler is written that way regardless. It is only new -if you were relying on `ClientGateway::elicit()` suspending mid-body and keeping your locals; that keeps -working untouched, since nothing here runs unless a handler *returns* an ask. - -**Each round holds the request open.** The shim waits for the client's answer inside the originating -request, which on a process-per-request runtime means it holds a worker for as long as the user takes. -That is the same cost `ClientGateway::elicit()` already pays on that leg, but the shim makes it reachable -from handlers that never mention it — so size `setInputRequiredLimits()` against your pool. - -```php -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - // Re-entries per request, and seconds to wait for one answer. - ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) - ->build(); -``` - -`withoutInputRequiredShim()` turns it off, so such a handler fails on a handshake-era connection instead of -being fulfilled behind your back. - -## Writing a client for this revision - -One line selects the lifecycle; nothing else about the API changes. - -```php -$client = Client::builder() - ->setClientInfo('my-client', '1.0.0') - ->setProtocolVersion(ProtocolVersion::V2026_07_28) - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler($myElicitationHandler) - ->build(); - -$client->connect(new HttpTransport('https://example.com/mcp')); - -$client->callTool('greet', []); -``` - -What that changes underneath: - -- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the server's - identity, and a server that does not answer it still yields a usable connection — the method is - optional. If discovery *does* report `supportedVersions` and the configured revision is not among - them, the client moves to a modern revision the server lists, or refuses the connection outright - rather than talking past it. -- **An envelope on every request**, carrying the revision, the declared capabilities and the client - identity. The capabilities are what let a server decide, per request, whether it may ask for input. -- **Headers on every POST** — `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` where the method - addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into `Mcp-Param-*`, which - requires the client to have listed the tool first; `tools/list` is what populates that knowledge. - A tool whose annotations are malformed is dropped from the listing and refused if called, since the - client cannot produce the headers it demands. -- **Multi round-trip calls are answered by the client.** A result of `resultType: "input_required"` is - resolved through the same request handlers that served server-initiated requests in the handshake era, - and the call is re-sent with `inputResponses` and the server's `requestState` echoed back byte for - byte, under a new JSON-RPC id. The caller sees one call and one result. - -Headers are an HTTP concern, so a transport opts into them by implementing `HeaderAwareTransportInterface`; -`HttpTransport` does, `StdioTransport` has nothing to carry them on. Everything else — the envelope, the -skipped handshake, the round-trip loop — applies to both. - -See `examples/client/stateless_lifecycle_client.php` for a runnable version. - -## What was removed - -Answered with `404` and `-32601` by a modern server: - -- `initialize`, `notifications/initialized` -- `ping` -- `logging/setLevel` — replaced by `_meta["io.modelcontextprotocol/logLevel"]` -- `resources/subscribe`, `resources/unsubscribe` — replaced by the `resourceSubscriptions` filter of - `subscriptions/listen` -- `notifications/roots/list_changed` - -Also gone: `Mcp-Session-Id`, the HTTP `GET` stream, and SSE resumability (`Last-Event-ID`). A broken -response stream loses the request; the client re-issues it with a new id. - -Error code `-32002` (resource not found) is retired in favour of `-32602`, and must not be emitted by a -server of this revision. The SDK picks the code from the revision serving the request, so a handshake-era -client still gets `-32002`. - -Roots, sampling and logging are all **deprecated** as of this revision. They remain functional for at least -twelve months; new servers should pass directories through tool arguments or resource URIs instead of roots, -integrate with an LLM provider directly instead of sampling, and log to `stderr` or OpenTelemetry instead of -`notifications/message`. diff --git a/examples/client/README.md b/examples/client/README.md index c2121719..0a6dd8ff 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -35,6 +35,25 @@ php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php php examples/client/stateless_lifecycle_client.php ``` +## Server-initiated requests + +The remaining examples answer requests the server sends back during a call: + +```bash +# Sampling, log and progress messages (also available over HTTP) +php examples/client/stdio_client_communication.php + +# Elicitation: answering the server's questions from an interactive prompt +php examples/client/stdio_elicitation.php + +# Roots: exposing workspace folders and signalling roots/list_changed +php examples/client/stdio_roots.php +``` + +> **Note**: `http_client_communication.php` needs a server that can answer a second request +> mid-call; PHP's built-in web server only does that with worker processes, e.g. +> `PHP_CLI_SERVER_WORKERS=2 php -S 127.0.0.1:8000 …`. + ## Requirements All examples require the server examples to be available. The STDIO examples spawn the server process, while the HTTP examples connect to a running HTTP server. diff --git a/examples/client/http_discovery_calculator.php b/examples/client/http_discovery_calculator.php index ffcfa67d..06de8797 100644 --- a/examples/client/http_discovery_calculator.php +++ b/examples/client/http_discovery_calculator.php @@ -18,7 +18,7 @@ * Usage: php examples/client/http_discovery_calculator.php * * Before running, start an HTTP MCP server: - * php -S localhost:8080 examples/server/http-discovery-calculator/server.php + * php -S localhost:8000 examples/server/discovery-calculator/server.php */ require_once __DIR__.'/../../vendor/autoload.php'; diff --git a/examples/server/README.md b/examples/server/README.md index 779d8a43..8d60b4da 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -24,9 +24,9 @@ npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/ser ## The 2026-07-28 lifecycle -`stateless-lifecycle/server.php` speaks protocol revision `2026-07-28`, which removed the `initialize` -handshake and protocol-level sessions. It is HTTP-only and cannot be driven by the Inspector, which -opens with `initialize`: +`stateless-lifecycle/server.php` demonstrates protocol revision `2026-07-28`, which removed the +`initialize` handshake and protocol-level sessions. Like every example it answers both eras — the +Inspector's `initialize` still works — but the interesting part is the modern one: ```bash php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..491441b4 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,169 @@ +site_name: MCP PHP SDK +site_description: The official PHP SDK for the Model Context Protocol +site_url: https://php.sdk.modelcontextprotocol.io/ +repo_name: modelcontextprotocol/php-sdk +repo_url: https://github.com/modelcontextprotocol/php-sdk +edit_uri: edit/main/docs/ + +# The class-level API reference is still generated by phpDocumentor and copied +# into `site/api/` by `make docs`; Zensical owns everything under `docs/`. +# A relative nav entry would have to resolve to a page in this build, so the +# reference is linked by its absolute URL. +nav: + - MCP PHP SDK: index.md + - Get started: + - get-started/index.md + - Installation: get-started/installation.md + - First server: get-started/first-server.md + - Try it with the Inspector: get-started/inspector.md + - Servers: + - servers/index.md + - Tools: servers/tools.md + - Resources: servers/resources.md + - Resource templates: servers/resource-templates.md + - Prompts: servers/prompts.md + - Completions: servers/completions.md + - Schema generation: servers/schemas.md + - Registering elements: servers/registration.md + - Inside your handler: + - handlers/index.md + - Talking back to the client: handlers/client-communication.md + - Asking for input: handlers/input-required.md + - Logging: handlers/logging.md + - Running your server: + - run/index.md + - Server builder: run/server-builder.md + - STDIO transport: run/stdio.md + - HTTP transport: run/http.md + - Framework integration: run/framework-integration.md + - Sessions: run/sessions.md + - Serving both eras: run/protocol-eras.md + - Caching: run/caching.md + - Subscriptions: run/subscriptions.md + - Authorization: run/authorization.md + - Clients: + - client/index.md + - Connecting to a server: client/connecting.md + - Transports: client/transports.md + - "Tools, resources & prompts": client/capabilities.md + - Server-initiated requests: client/server-requests.md + - Error handling: client/errors.md + - Protocol versions: protocol-versions.md + - Advanced: + - advanced/index.md + - Events: advanced/events.md + - Protocol extensions: advanced/extensions.md + - Custom message handlers: advanced/custom-handlers.md + - Examples: examples.md + - API Reference: https://php.sdk.modelcontextprotocol.io/api/ + +# Look and feel is kept in sync with the Python SDK's documentation +# (https://py.sdk.modelcontextprotocol.io/) so the language SDKs read as one +# set of docs: same MCP mark, same black/slate palette, same navigation. +theme: + name: material + # Zensical's own look, and its default; `classic` renders the older + # Material for MkDocs styling instead. + variant: modern + custom_dir: docs/.overrides + font: + text: Inter + code: JetBrains Mono + icon: + logo: mcp + favicon: favicon.svg + palette: + - media: "(prefers-color-scheme)" + scheme: default + primary: black + accent: black + toggle: + icon: material/lightbulb + name: "Switch to light mode" + - media: "(prefers-color-scheme: light)" + scheme: default + primary: black + accent: black + toggle: + icon: material/lightbulb-outline + name: "Switch to dark mode" + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + accent: white + toggle: + icon: material/lightbulb-auto-outline + name: "Switch to system preference" + features: + - search.suggest + - search.highlight + - content.tabs.link + - content.code.annotate + - content.code.copy + - content.code.select + - navigation.footer + - navigation.indexes + - navigation.instant + - navigation.instant.prefetch + - navigation.instant.progress + - navigation.path + - navigation.prune + - navigation.sections + - navigation.top + - navigation.tracking + - toc.follow + + +# Zensical natively re-implements `search`; it does not run arbitrary MkDocs +# plugins or hooks. +plugins: + - search + +markdown_extensions: + - tables + - abbr + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - sane_lists # this means you can start a list from any number + - pymdownx.betterem + - pymdownx.caret + - pymdownx.details + - pymdownx.inlinehilite + - pymdownx.mark + - pymdownx.tilde + - pymdownx.highlight: + pygments_lang_class: true + # Pygments' PHP lexer only starts highlighting after a ` MCP PHP SDK + - .phpdoc/build + .phpdoc/build/api - latest src - api vendor/**/* tests/**/* @@ -31,14 +34,8 @@ implements - - - docs - - / - - + diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 00000000..ffc1be80 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,3 @@ +# Toolchain for the documentation site under `docs/`, built by `make docs`. +# +zensical==0.0.50 diff --git a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php index 0b0cf7f2..1d0d2a19 100644 --- a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php +++ b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php @@ -27,7 +27,7 @@ * - inside the MCP transport, wrapped by {@see \Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware}; * - as a bare PSR-7 handler in a hand-rolled front controller; * - as a framework callable controller (Symfony/Laravel), by converting the framework - * request to PSR-7 and the returned PSR-7 response back — see docs/authorization.md. + * request to PSR-7 and the returned PSR-7 response back — see docs/run/authorization.md. * * It performs no path or method matching: routing is the caller's responsibility (the * middleware's guard, or the framework router).