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..6c3b51ba 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,15 +1,47 @@ -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. +# +# NOTE: deployment uses the official GitHub Pages actions, so the repository's +# Pages source must be set to "GitHub Actions" (Settings → Pages) instead of +# the gh-pages branch this workflow published to before. on: - release: - types: [published] + push: + branches: [main] + # GitHub Actions does not support YAML anchors, so this list is repeated + # for pull_request below — keep the two in sync. + paths: + - docs/** + - mkdocs.yml + - requirements-docs.txt + - phpdoc.dist.xml + - src/** + - Makefile + - .github/workflows/docs.yml + pull_request: + paths: + - docs/** + - mkdocs.yml + - requirements-docs.txt + - phpdoc.dist.xml + - src/** + - Makefile + - .github/workflows/docs.yml 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 +53,34 @@ 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 + # setup-uv publishes no floating major tag; pin the exact release. + 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 9b76066b..1610e31d 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -188,5 +188,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 5ea477c0..56003a8f 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/Makefile b/Makefile index 667aa046..cf393126 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,9 @@ -.PHONY: deps-stable deps-low cs phpstan tests unit-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client docs +.PHONY: deps-stable deps-low cs phpstan tests unit-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client docs docs-guides docs-api docs-serve + +# 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 @@ -46,7 +51,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 e0885631..c5ecec02 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ $server = Server::builder() ->build(); ``` -[→ Server Documentation](docs/server-builder.md) +[→ Server Documentation](https://php.sdk.modelcontextprotocol.io/run/server-builder/) ## Client SDK @@ -272,24 +272,25 @@ $transport = new HttpTransport('http://localhost:8000'); $client->connect($transport); ``` -[→ Client Documentation](docs/client.md) +[→ Client Documentation](https://php.sdk.modelcontextprotocol.io/client/) ## Documentation +The full documentation is published at **[php.sdk.modelcontextprotocol.io](https://php.sdk.modelcontextprotocol.io/)**. + ### 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 +- **[Get started](https://php.sdk.modelcontextprotocol.io/get-started/)** — Install the SDK and build your first server +- **[Servers](https://php.sdk.modelcontextprotocol.io/servers/)** — Tools, resources, resource templates, prompts, and how to register them +- **[Inside your handler](https://php.sdk.modelcontextprotocol.io/handlers/)** — Sampling, logging, progress, and notifications from within a handler +- **[Running your server](https://php.sdk.modelcontextprotocol.io/run/)** — Server builder, STDIO and HTTP transports, framework integration, sessions, authorization +- **[Clients](https://php.sdk.modelcontextprotocol.io/client/)** — Client SDK for connecting to and communicating with MCP servers +- **[Advanced](https://php.sdk.modelcontextprotocol.io/advanced/)** — Events, protocol extensions (including MCP Apps), and custom message handlers +- **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** — Generated class reference ### Learning & Examples -- **[Examples](docs/examples.md)** — Comprehensive example walkthroughs for servers and clients +- **[Examples](https://php.sdk.modelcontextprotocol.io/examples/)** — Comprehensive example walkthroughs for servers and clients - **[ROADMAP.md](ROADMAP.md)** — Planned features and development roadmap ## External Resources 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..68a0ed04 --- /dev/null +++ b/docs/advanced/custom-handlers.md @@ -0,0 +1,97 @@ +# 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 +// 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` 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 96% rename from docs/extensions.md rename to docs/advanced/extensions.md index f1817026..72c0dd24 100644 --- a/docs/extensions.md +++ b/docs/advanced/extensions.md @@ -105,6 +105,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 494edf1f..00000000 --- a/docs/client.md +++ /dev/null @@ -1,757 +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) // Retry attempts for failed connections - ->build(); -``` - -### 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 (defaults to latest): - -```php -use Mcp\Schema\Enum\ProtocolVersion; - -$client = Client::builder() - ->setProtocolVersion(ProtocolVersion::V2025_11_25) - ->build(); -``` - -### 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 - -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) - -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(); -``` - -> [!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. - -## 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: 'end_turn', - ); - } 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..54aa0df5 --- /dev/null +++ b/docs/client/capabilities.md @@ -0,0 +1,157 @@ +# 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 +$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"; +} +``` diff --git a/docs/client/connecting.md b/docs/client/connecting.md new file mode 100644 index 00000000..22bc011d --- /dev/null +++ b/docs/client/connecting.md @@ -0,0 +1,181 @@ +# 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. + +## 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 + ->build(); +``` + +!!! note + The builder also exposes `setMaxRetries()`, but the value is currently stored and never acted on — no transport + retries a failed connection. Do not rely on it. + +### 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 (defaults to latest): + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +$client = Client::builder() + ->setProtocolVersion(ProtocolVersion::V2025_11_25) + ->build(); +``` + +### 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(); +``` + +## 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 + +!!! 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..0b3aff0b --- /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: 'end_turn', + ); + } 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..3bd172d1 --- /dev/null +++ b/docs/client/index.md @@ -0,0 +1,39 @@ +# 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. diff --git a/docs/client/server-requests.md b/docs/client/server-requests.md new file mode 100644 index 00000000..39637acf --- /dev/null +++ b/docs/client/server-requests.md @@ -0,0 +1,169 @@ +# Server-Initiated Communication + +The client can receive requests and notifications from the server when configured with appropriate handlers. + +## Logging Notifications + +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) + +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(); +``` + +!!! 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'); + ``` + +## 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. + +!!! 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` for a runnable example against the +elicitation demo server. diff --git a/docs/client/transports.md b/docs/client/transports.md new file mode 100644 index 00000000..edfc131e --- /dev/null +++ b/docs/client/transports.md @@ -0,0 +1,59 @@ +# 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 +``` diff --git a/docs/examples.md b/docs/examples.md index 14e97fde..f630d922 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -3,13 +3,6 @@ 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 @@ -32,13 +25,13 @@ 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 +npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php # Run with debugging enabled -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php +npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/server/discovery-calculator/server.php # Or configure the script path in your MCP client -# Path: php examples/discovery-calculator/server.php +# Path: php examples/server/discovery-calculator/server.php ``` ### HTTP Transport @@ -47,7 +40,7 @@ The Streamable HTTP transport will be chosen if running examples with a web serv ```bash # Start the server -php -S localhost:8000 examples/discovery-userprofile/server.php +php -S localhost:8000 examples/server/discovery-userprofile/server.php # Test with MCP Inspector npx @modelcontextprotocol/inspector http://localhost:8000 @@ -63,7 +56,7 @@ curl -X POST http://localhost:8000 \ ### Discovery Calculator -**File**: `examples/discovery-calculator/` +**File**: `examples/server/discovery-calculator/` **What it demonstrates:** - Attribute-based discovery using `#[McpTool]` and `#[McpResource]` @@ -87,14 +80,14 @@ public function getConfiguration(): array **Usage:** ```bash # Interactive testing -npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php +npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php -# Or configure in MCP client: php examples/discovery-calculator/server.php +# Or configure in MCP client: php examples/server/discovery-calculator/server.php ``` ### Explicit Registration -**File**: `examples/explicit-registration/` +**File**: `examples/server/explicit-registration/` **What it demonstrates:** - Manual registration of tools, resources, and prompts @@ -111,7 +104,7 @@ $server = Server::builder() ### Environment Variables -**File**: `examples/env-variables/` +**File**: `examples/server/env-variables/` **What it demonstrates:** - Environment variable integration @@ -125,7 +118,7 @@ $server = Server::builder() ### Custom Dependencies -**File**: `examples/custom-dependencies/` +**File**: `examples/server/custom-dependencies/` **What it demonstrates:** - Dependency injection with PSR-11 containers @@ -145,7 +138,7 @@ $server = Server::builder() ### Cached Discovery -**File**: `examples/cached-discovery/` +**File**: `examples/server/cached-discovery/` **What it demonstrates:** - Discovery caching for improved performance @@ -165,7 +158,7 @@ $server = Server::builder() ### Client Communication -**File**: `examples/client-communication/` +**File**: `examples/server/client-communication/` **What it demonstrates:** - Server initiated communication back to the client @@ -174,7 +167,7 @@ $server = Server::builder() ### Discovery User Profile -**File**: `examples/discovery-userprofile/` +**File**: `examples/server/discovery-userprofile/` **What it demonstrates:** - HTTP transport with StreamableHttpTransport @@ -202,7 +195,7 @@ public function generateBio(string $userId, string $tone = 'professional'): arra **Usage:** ```bash # Start the HTTP server -php -S localhost:8000 examples/discovery-userprofile/server.php +php -S localhost:8000 examples/server/discovery-userprofile/server.php # Test with MCP Inspector npx @modelcontextprotocol/inspector http://localhost:8000 @@ -212,7 +205,7 @@ npx @modelcontextprotocol/inspector http://localhost:8000 ### Combined Registration -**File**: `examples/combined-registration/` +**File**: `examples/server/combined-registration/` **What it demonstrates:** - Mixing attribute discovery with manual registration @@ -235,7 +228,7 @@ $server = Server::builder() ### Complex Tool Schema -**File**: `examples/complex-tool-schema/` +**File**: `examples/server/complex-tool-schema/` **What it demonstrates:** - Advanced JSON schema definitions @@ -258,7 +251,7 @@ public function scheduleEvent(array $eventData): array ### Schema Showcase -**File**: `examples/schema-showcase/` +**File**: `examples/server/schema-showcase/` **What it demonstrates:** - Comprehensive JSON schema features @@ -365,7 +358,7 @@ npx @modelcontextprotocol/inspector php examples/server/elicitation/server.php **File**: `examples/server/mcp-apps/` -A weather app demonstrating the [MCP Apps extension](extensions.md): a `ui://` +A weather app demonstrating the [MCP Apps extension](advanced/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`, @@ -431,8 +424,9 @@ $prompts = $client->listPrompts(); **Usage:** ```bash -# Start the server first -php -S localhost:8000 examples/server/http-discovery-calculator/server.php +# Start the server first — the example picks its transport from the SAPI, +# so running it under a web server makes it speak Streamable HTTP +php -S localhost:8000 examples/server/discovery-calculator/server.php # Then run the client php examples/client/http_discovery_calculator.php @@ -516,5 +510,5 @@ php -S 127.0.0.1:8000 examples/server/client-communication/server.php php examples/client/http_client_communication.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. +!!! 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. 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..86650e8f --- /dev/null +++ b/docs/get-started/inspector.md @@ -0,0 +1,88 @@ +# Try it with the Inspector + +The [MCP Inspector](https://github.com/modelcontextprotocol/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-06-18","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 72% rename from docs/server-client-communication.md rename to docs/handlers/client-communication.md index f54294bc..65d533b0 100644 --- a/docs/server-client-communication.md +++ b/docs/handlers/client-communication.md @@ -1,19 +1,14 @@ -# Client Communication +# Talking back to the client -MCP supports various ways a server can communicate back to a server on top of the main request-response flow. - -## Table of Contents - -- [ClientGateway](#client-gateway) -- [Sampling](#sampling) -- [Logging](#logging) -- [Notification](#notification) -- [Progress](#progress) +MCP supports various ways a server can communicate back to a client on top of the main +request-response flow. ## 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: @@ -41,10 +36,10 @@ $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 `SampleMessage` 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 `system_prompt`, `preferences` for model choice, `includeContext`, `temperature`, `stopSequences` and `metadata` +4. `options` which might include `systemPrompt`, `preferences` for model choice, `includeContext`, `temperature`, `stopSequences` and `metadata` [Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling#protocol-messages) @@ -70,7 +65,7 @@ $clientGateway->progress(4.2, 10, 'Downloading needed images.'); ## 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..a7ae135c --- /dev/null +++ b/docs/handlers/index.md @@ -0,0 +1,32 @@ +# 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 + return $result->content instanceof TextContent ? $result->content->text : ''; +} +``` + +* **[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. + +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/logging.md b/docs/handlers/logging.md new file mode 100644 index 00000000..f6db7fa5 --- /dev/null +++ b/docs/handlers/logging.md @@ -0,0 +1,31 @@ +# Logging + +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 + 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\Logger\ClientLogger; +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 91162290..ef5ed500 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,11 +1,100 @@ -# 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. -- [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)**. +* 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 0f8c9eb2..00000000 --- a/docs/mcp-elements.md +++ /dev/null @@ -1,803 +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, EmbeddedResource}; - -public function getFormattedCode(): TextContent -{ - return TextContent::code(' 'file://data.json', 'text' => 'File content'] - ); -} -``` - -#### 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.') - ]; -} -``` - -#### 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 - -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/authorization.md b/docs/run/authorization.md similarity index 92% rename from docs/authorization.md rename to docs/run/authorization.md index 184eb7e0..0a329277 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 @@ -99,7 +87,14 @@ $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 @@ -155,7 +150,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 +358,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 +401,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 +412,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/framework-integration.md b/docs/run/framework-integration.md new file mode 100644 index 00000000..95457a0c --- /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__, ['.']) + ->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 +``` 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 049ca2d6..42448fdd 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: @@ -159,7 +69,7 @@ The default stack can be inspected and recomposed via the public factory: $middleware = StreamableHttpTransport::defaultMiddleware(); ``` -### 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: @@ -197,13 +107,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: @@ -217,7 +127,7 @@ 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 @@ -231,7 +141,7 @@ use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]); ``` -### Request Body Size Limit +## 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 @@ -248,7 +158,7 @@ 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 +## 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 @@ -265,7 +175,7 @@ is a batch. Scalars, empty payloads, and non-object batch elements are returned 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 @@ -331,207 +241,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..2b69977f --- /dev/null +++ b/docs/run/index.md @@ -0,0 +1,35 @@ +# 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__, ['.']) + ->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. +* **[Authorization](authorization.md)** — validating OAuth 2 access tokens in front of + the HTTP transport. diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md new file mode 100644 index 00000000..17aa602f --- /dev/null +++ b/docs/run/server-builder.md @@ -0,0 +1,262 @@ +# 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__, ['.']) + ->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.'); +``` + +## 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. + +## 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 | +| `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/run/sessions.md b/docs/run/sessions.md new file mode 100644 index 00000000..929c0233 --- /dev/null +++ b/docs/run/sessions.md @@ -0,0 +1,122 @@ +# 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 +- `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..24d40302 --- /dev/null +++ b/docs/run/stdio.md @@ -0,0 +1,62 @@ +# 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`. + +!!! 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/server-builder.md b/docs/server-builder.md deleted file mode 100644 index 5016526a..00000000 --- a/docs/server-builder.md +++ /dev/null @@ -1,678 +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) -- [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.'); -``` - -## 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 | -| `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..e5fb4a61 --- /dev/null +++ b/docs/servers/prompts.md @@ -0,0 +1,130 @@ +# 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. 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\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..9a5c0b00 --- /dev/null +++ b/docs/servers/registration.md @@ -0,0 +1,242 @@ +# 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__, ['.']) // 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 + +### 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 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__, ['.']) // 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..ab6867c4 --- /dev/null +++ b/docs/servers/resource-templates.md @@ -0,0 +1,48 @@ +# 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. 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 — + 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..807a19cd --- /dev/null +++ b/docs/servers/resources.md @@ -0,0 +1,151 @@ +# 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. +// `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\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..c63a8ac3 --- /dev/null +++ b/docs/servers/tools.md @@ -0,0 +1,148 @@ +# 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. +- **`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, EmbeddedResource}; + +public function getFormattedCode(): TextContent +{ + return TextContent::code(' 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..db75dafb --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,8 @@ +# Toolchain for the documentation site under `docs/`, built by `make docs`. +# +# Zensical is the Material for MkDocs team's successor to MkDocs: it reads the +# same `mkdocs.yml` and renders the same Material theme, but resolves internal +# links against the page tree instead of copying markdown through verbatim. +# +# Pinned exactly: Zensical is pre-1.0, so bumps should be deliberate. +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).