diff --git a/.gitattributes b/.gitattributes index 27224ae..b7b2a18 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/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml index f12f379..9b255b3 100644 --- a/.github/workflows/conformance-weekly.yaml +++ b/.github/workflows/conformance-weekly.yaml @@ -1,13 +1,8 @@ name: conformance-weekly -# Runs the MCP conformance suite weekly against the latest -# @modelcontextprotocol/conformance release. The on:pull_request pipeline -# pins to whatever version is available at PR time; this schedule catches -# upstream releases that add scenarios between PRs. -# -# It also scores each run and publishes the client/server pass-rate as -# shields.io endpoint JSON to the orphan `badges` branch (consumed by the -# README); that branch is created on the first run. +# Tracks the newest published @modelcontextprotocol/conformance; the +# on:pull_request pipeline pins an exact version instead. Also publishes +# score badges to the orphan `badges` branch, consumed by the README. on: schedule: - cron: '0 6 * * 1' # Mondays 06:00 UTC @@ -19,8 +14,20 @@ permissions: jobs: server: - name: conformance / server (latest) + name: conformance / server (${{ matrix.spec-version }}, ${{ matrix.dist-tag }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - spec-version: '2025-11-25' + dist-tag: latest + path: '/' + baseline: conformance-baseline-2025-11-25.yml + - spec-version: '2026-07-28' + dist-tag: alpha + path: '/' + baseline: conformance-baseline-2026-07-28.yml steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -35,10 +42,16 @@ jobs: sleep 5 - name: Run conformance tests working-directory: ./tests/Conformance - run: npx --yes @modelcontextprotocol/conformance@latest server --url http://localhost:8000/ --expected-failures conformance-baseline.yml --output-dir results + run: >- + npx --yes @modelcontextprotocol/conformance@${{ matrix.dist-tag }} server + --url http://localhost:8000${{ matrix.path }} + --suite all + --spec-version ${{ matrix.spec-version }} + --expected-failures ${{ matrix.baseline }} + --output-dir results - name: Generate score badge if: always() - run: php tests/Conformance/score.php server + run: php tests/Conformance/score.php server ${{ matrix.spec-version }} - name: Show docker logs on failure if: failure() run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml logs @@ -46,7 +59,7 @@ jobs: if: failure() uses: actions/upload-artifact@v7 with: - name: conformance-server-results + name: conformance-server-results-${{ matrix.spec-version }} path: | tests/Conformance/logs tests/Conformance/results @@ -54,12 +67,22 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: server-badge - path: tests/Conformance/server-conformance.json + name: badge-server-${{ matrix.spec-version }} + path: tests/Conformance/server-conformance-${{ matrix.spec-version }}.json client: - name: conformance / client (latest) + name: conformance / client (${{ matrix.spec-version }}, ${{ matrix.dist-tag }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - spec-version: '2025-11-25' + dist-tag: latest + baseline: conformance-baseline-2025-11-25.yml + - spec-version: '2026-07-28' + dist-tag: alpha + baseline: conformance-baseline-2026-07-28.yml steps: - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 @@ -73,15 +96,21 @@ jobs: - run: mkdir -p tests/Conformance/logs - name: Run conformance tests working-directory: ./tests/Conformance - run: npx --yes @modelcontextprotocol/conformance@latest client --command "php ${{ github.workspace }}/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml --output-dir results + run: >- + npx --yes @modelcontextprotocol/conformance@${{ matrix.dist-tag }} client + --command "php ${{ github.workspace }}/tests/Conformance/client.php" + --suite all + --spec-version ${{ matrix.spec-version }} + --expected-failures ${{ matrix.baseline }} + --output-dir results - name: Generate score badge if: always() - run: php tests/Conformance/score.php client + run: php tests/Conformance/score.php client ${{ matrix.spec-version }} - name: Upload conformance results if: failure() uses: actions/upload-artifact@v7 with: - name: conformance-client-results + name: conformance-client-results-${{ matrix.spec-version }} path: | tests/Conformance/logs tests/Conformance/results @@ -89,17 +118,18 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: client-badge - path: tests/Conformance/client-conformance.json + name: badge-client-${{ matrix.spec-version }} + path: tests/Conformance/client-conformance-${{ matrix.spec-version }}.json notify: name: Open issue on failure runs-on: ubuntu-latest needs: [server, client] - if: failure() && github.event_name == 'schedule' + if: failure() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} steps: - name: File or comment tracking issue @@ -111,31 +141,27 @@ jobs: gh issue create \ --title '[conformance] Weekly conformance run failed' \ --label conformance-weekly \ - --body "Weekly conformance against \`@modelcontextprotocol/conformance@latest\` failed. + --body "Conformance against the newest published \`@modelcontextprotocol/conformance\` failed. - Run: $RUN_URL - - Triggered: $(date -u +%FT%TZ) + - Trigger: $EVENT_NAME + - Date: $(date -u +%FT%TZ) - Upstream likely published a release whose scenarios the SDK does not satisfy. Either fix the SDK, update the conformance fixtures, or add the new failure to \`tests/Conformance/conformance-baseline.yml\`." + Upstream likely published a release whose scenarios the SDK does not satisfy. Either fix the SDK, update the conformance fixtures, or add the new failure to the matching \`tests/Conformance/conformance-baseline-.yml\`." fi publish: name: Publish conformance badges runs-on: ubuntu-latest needs: [server, client] - # Publish even when the suite regressed (the badge should reflect reality); - # skip on forks, which cannot push the `badges` branch. if: ${{ !cancelled() && github.repository == 'modelcontextprotocol/php-sdk' }} steps: - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: - name: server-badge - path: badges-in - - uses: actions/download-artifact@v8 - with: - name: client-badge + pattern: badge-* path: badges-in + merge-multiple: true - name: Publish to badges branch run: | git config user.name "github-actions[bot]" @@ -150,7 +176,8 @@ jobs: git -C badges-wt rm -rf --quiet . >/dev/null 2>&1 || true fi - cp badges-in/server-conformance.json badges-in/client-conformance.json badges-wt/ + rm -f badges-wt/server-conformance.json badges-wt/client-conformance.json + cp badges-in/*.json badges-wt/ cd badges-wt git add -A diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a1c3c83..7a399f1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,15 +1,25 @@ -name: Deploy Documentation +name: Documentation + +# The site is built by `make docs`: Zensical renders the guides under docs/ +# (see mkdocs.yml) and phpDocumentor renders the API reference into /api/. +# Pull requests build only — the build is strict, so a broken internal link +# fails CI instead of shipping a dead link to the site. on: - release: - types: [published] + push: + branches: [main] + pull_request: workflow_dispatch: permissions: - contents: write + contents: read + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true jobs: - deploy: + build: runs-on: ubuntu-latest steps: - name: Checkout @@ -21,16 +31,33 @@ jobs: php-version: '8.4' coverage: "none" - - name: Install Composer + - name: Install Composer dependencies uses: "ramsey/composer-install@v4" - - name: Generate Documentation + - name: Install uv + uses: astral-sh/setup-uv@v10.1.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 9b76066..e9c9e83 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -5,6 +5,10 @@ permissions: contents: read pull-requests: write +env: + # Pinned exact version; conformance-weekly.yaml tracks upstream releases. + CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.11" + jobs: unit: runs-on: ubuntu-latest @@ -49,6 +53,28 @@ jobs: - name: Tests run: vendor/bin/phpunit --testsuite=unit + integration: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['8.1', '8.2', '8.3', '8.4', '8.5'] + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: "none" + + - name: Install Composer + uses: "ramsey/composer-install@v4" + + - name: Tests + run: vendor/bin/phpunit --testsuite=integration + inspector: runs-on: ubuntu-latest steps: @@ -73,8 +99,19 @@ jobs: run: vendor/bin/phpunit --testsuite=inspector conformance-server: - name: conformance / server + name: conformance / server (${{ matrix.spec-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - spec-version: '2025-11-25' + path: '/' + baseline: conformance-baseline-2025-11-25.yml + - spec-version: '2026-07-28' + path: '/' + baseline: conformance-baseline-2026-07-28.yml + steps: - name: Checkout uses: actions/checkout@v7 @@ -96,7 +133,12 @@ jobs: - name: Run conformance tests working-directory: ./tests/Conformance - run: npx @modelcontextprotocol/conformance server --url http://localhost:8000/ --expected-failures conformance-baseline.yml + run: >- + npx --yes ${{ env.CONFORMANCE_PKG }} server + --url http://localhost:8000${{ matrix.path }} + --suite all + --spec-version ${{ matrix.spec-version }} + --expected-failures ${{ matrix.baseline }} - name: Show logs on failure if: failure() @@ -124,8 +166,17 @@ jobs: run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml down conformance-client: - name: conformance / client + name: conformance / client (${{ matrix.spec-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - spec-version: '2025-11-25' + baseline: conformance-baseline-2025-11-25.yml + - spec-version: '2026-07-28' + baseline: conformance-baseline-2026-07-28.yml + steps: - name: Checkout uses: actions/checkout@v7 @@ -149,7 +200,12 @@ jobs: - name: Run client conformance tests working-directory: ./tests/Conformance - run: npx @modelcontextprotocol/conformance client --command "php ${{ github.workspace }}/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml + run: >- + npx --yes ${{ env.CONFORMANCE_PKG }} client + --command "php ${{ github.workspace }}/tests/Conformance/client.php" + --suite all + --spec-version ${{ matrix.spec-version }} + --expected-failures ${{ matrix.baseline }} - name: Show logs on failure if: failure() @@ -188,5 +244,7 @@ jobs: - name: PHPStan run: vendor/bin/phpstan analyse + # Only the phpDocumentor half: this job is PHP-only, and the Zensical + # guides are built (strictly) by the Documentation workflow. - name: Documentation - run: make docs + run: make docs-api diff --git a/.gitignore b/.gitignore index 5ea477c..ca3a13f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,10 +8,14 @@ examples/**/cache examples/**/sessions tests/Conformance/client-conformance.json tests/Conformance/server-conformance.json -tests/Conformance/results +tests/Conformance/results* tests/Conformance/sessions 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 760f165..f8feb3c 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 fe8d091..ece437c 100644 --- a/.phpdoc/template/components/header-title.html.twig +++ b/.phpdoc/template/components/header-title.html.twig @@ -1,5 +1,5 @@

- + diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bf9a9..e77f062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.9.0 +----- + +* [BC Break] Remove the `providerClass` argument of `#[CompletionProvider]`. Use `provider:`, which takes the same class-string and is now the first positional argument. +* Add `HttpTransport::getSessionId()` to read the server-minted `Mcp-Session-Id`: a request-scoped caller can persist it and pass it back through the constructor's `$headers` on a later transport. Always `null` on `2026-07-28`, which removed protocol-level sessions. +* Fix OIDC discovery rejecting issuers with a trailing slash (e.g. Authentik, Auth0). + +0.8.0 +----- + +* Support for MCP spec version 2026-07-28 incl. stateless HTTP transport + * Negotiate the protocol revision during `initialize`: the server counter-offers a revision it supports and the client fails the handshake instead of continuing on an unagreed one. Adds `Client::getProtocolVersion()`. + * Serve both protocol eras from one endpoint: `StreamableHttpTransport` classifies each request (`InboundClassifier`) and routes it to the matching dispatcher, so one URL answers modern and handshake-era clients alike. `Builder::withoutModernEra()` opts out, `Builder::setModernVersions()` narrows the modern leg. + * Speak the lifecycle from the client: `Client` opens with `server/discover`, stamps `_meta` with version, capabilities and client info, and sends the `Mcp-Method`/`Mcp-Name`/`Mcp-Param-*` headers (via `Client\Stateless\ToolCatalog`). `Schema\Wire\McpHeader` holds the shared header names. + * Add multi round-trip requests (SEP-2322): a handler returning `InputRequiredResult` yields `resultType: "input_required"` with an opaque, signed `requestState` (`RequestStateCodec`, key via `Builder::setRequestState()`); the client retries with `inputResponses`, read through `RequestContext::getInputContext()` and its typed `elicitResult()`/`samplingResult()`/`rootsResult()`. On the client, `InputRequestResolver` answers such results automatically from the host's elicitation, sampling and roots handlers. + * Serve `ClientGateway::elicit()`/`elicitUrl()` on every revision: where the client cannot be asked mid-request, `Server\Stateless\ElicitationReplay` turns the ask into `input_required` and resumes once re-sent — the handler is entered once per ask. `InputRequiredShim` does the reverse for handshake-era clients. `sample()`/`listRoots()` raise a `LogicException` on `2026-07-28`, which removed them. + * Run stateless handlers in a fiber so `$gateway->progress()`/`log()` stream over SSE when the handler emits something and the client accepts `text/event-stream`; honour `io.modelcontextprotocol/logLevel` (SEP-2575). Adds `LoggingLevel::severity()`/`isAtLeast()`. + * Deliver notifications on `subscriptions/listen` (SEP-2575) via `NotificationBusInterface` — `InMemoryNotificationBus` for persistent runtimes, `Psr16NotificationBus` for PHP-FPM — set with `Builder::setNotificationBus()`; `Builder::setSubscriptionLifetime()` replaces the hard-coded 30s ceiling. + * Validate the standard request headers (SEP-2243) with `StandardHeaderValidator` (`Builder::setHeaderValidator()`), answering `-32020` when they contradict the body. + * Add `Wire\CachePolicy` (`Builder::setCachePolicy()`) for SEP-2549 caching hints; defaults to `ttlMs: 0, cacheScope: private`. A `ReadResourceResult` may override with its own values. + * Carry W3C trace context (SEP-414): `traceparent`/`tracestate`/`baggage` from `_meta` are exposed via `RequestContext::getTraceContext()` and echoed onto the request's notifications. + * Close the schema gaps for `2025-06-18`/`2025-11-25` and add the `2026-07-28` surface (SEP-2106): url-mode elicitation (`ClientGateway::elicitUrl()`/`supportsElicitationUrl()`), `Implementation::title`, and `outputSchema`/`structuredContent` accepting any JSON value. + * Deprecate Roots, Sampling and Logging (SEP-2577, earliest removal `2027-07-28`); they keep working but trigger a deprecation notice. + * [BC Break] Answer a not-found subject with `-32602` instead of `-32002` (SEP-2164): `resources/read` picks the code by revision (`-32602` from `2026-07-28` on), `prompts/get`, `completion/complete` and `tools/call` switch on every revision. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`. + * [BC Break] A list-shaped tool result is only sent as `structuredContent` on `2026-07-28`+; older revisions keep the JSON-encoded value in `content`. +* [BC Break] Add the extensions framework (SEP-2133) MCP Apps sits on: `ExtensionInterface::getId()` returns an `ExtensionIdentifier` value object, and the interface gains `getMessages()`/`getRequestHandlers()` (extend `AbstractExtension` to skip both). `MessageFactory::make()` takes an `$additional` message list; `RequestHandlerInterface`'s result template is covariant. `ServerExtensionInterface` is replaced by the side-agnostic `Schema\Extension\ExtensionInterface`. +* Add client-side extension negotiation: `ClientGateway::supportsExtension()`, `Client\Builder::enableExtension()`, `ClientCapabilities::withExtensions()`. +* Add sampling-with-tools: sampling requests can carry tools and tool-choice preferences, messages support tool-use/tool-result blocks, and clients advertise `sampling.context`/`sampling.tools` (`ClientGateway::supportsSamplingTools()`/`supportsSamplingContext()`). Requests violating the tool-flow rules are rejected with a JSON-RPC error. +* [BC Break] `SamplingMessage::$content` and `CreateSamplingMessageResult::$content` may be a list of content blocks — use `getContentBlocks()`. `CreateSamplingMessageResult` rejects roles other than `assistant` and empty content. +* Add client-side Roots support (`RootsCallbackInterface`, `Client::sendRootsListChanged()`) and server-side `ClientGateway::listRoots()`/`supportsRoots()`/`supportsSampling()`. +* Add `Schema\Content\ResourceLink` to reference a resource by URI in tool results and prompt messages. +* [BC Break] `Schema\JsonRpc\Error` accepts `null` as `$id`; an unreadable id now omits the member instead of sending `"id": ""`. `MessageFactory` decodes a missing or null id as an id-less error. +* Preserve the request `id` on an invalid-but-parseable message (`-32600`) via `InvalidInputMessageException::getRequestId()`. +* [BC Break] Drop the SDK-only name pattern on `ResourceDefinition`/`ResourceTemplate` `$name`; the spec allows any string. +* Log expected tool failures (`ToolCallException`) at debug level instead of error. +* Add `annotations` to `ImageContent`. +* Fix empty tool/resource schemas serializing as `[]` instead of `{}`. +* Fix `PromptResultFormatter` dropping `annotations`, `_meta` and `mimeType` for plain-array content. + 0.7.0 ----- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c02ee47 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing + +Thanks for considering a contribution to the PHP SDK for the Model Context Protocol. This is a +collaboration between [the PHP Foundation](https://thephp.foundation/) and the +[Symfony project](https://symfony.com/), and it follows Symfony's conventions throughout. + +## Ways to contribute + +- **Report a bug** or **propose a feature** by [opening an issue](https://github.com/modelcontextprotocol/php-sdk/issues). + Check existing issues first — many spec-driven changes are already tracked under the relevant + `2026-07-28`-style release label. +- **Send a pull request** for a fix, a new capability, or a docs improvement. +- **Improve the guides** under `docs/` — see [Documentation](#documentation) below for how they're built. +- **Help close a conformance gap** — `make conformance-tests` runs the official MCP conformance + suite against this SDK; a failing scenario there is a concrete, well-scoped contribution. + +## Development setup + +Requires PHP 8.1+. + +```bash +composer install +``` + +## Before opening a pull request + +Run the full CI suite locally: + +```bash +make ci +``` + +This runs, in order: `make cs` (PHP CS Fixer, auto-fixes style), `make phpstan` (static analysis), +and `make tests` (unit + inspector tests). All three must pass. If your change touches +protocol-observable behavior, also run: + +```bash +make conformance-tests # requires Docker +``` + +## Coding standards + +This project follows [Symfony's coding standards](https://symfony.com/doc/current/contributing/code/standards.html) +and [backward compatibility promise](https://symfony.com/doc/current/contributing/code/bc.html). In short: + +See [CLAUDE.md](CLAUDE.md) for a fuller tour of the codebase's architecture and layout. + +## Tests + +New capabilities need unit tests (`tests/Unit/`) covering the core logic, and — for anything +reachable over the wire — inspector tests (`tests/Inspector/`) for end-to-end coverage. If you're +adding a documented pattern, consider adding or updating an example under `examples/`. + +## Documentation + +The guides under `docs/` are built with [Zensical](https://zensical.org/) in `--strict` mode, +which fails the build on a broken internal link: + +```bash +make docs-guides +``` + +Links between guide pages must be relative paths that resolve within `docs/` (e.g. +`protocol-versions.md`, `../CLAUDE.md` will *not* resolve — Zensical only follows the `docs/` tree). +For anything at the repo root (`CLAUDE.md`, `ROADMAP.md`, `CHANGELOG.md`), link to it by its GitHub +URL instead, matching the pattern already used across `docs/`. The class-level API reference is +generated separately by phpDocumentor (`make docs-api`) and isn't hand-written. + +## Versioning + +The SDK follows [Semantic Versioning](https://semver.org/) — see +[SDK tier target](docs/sdk-tier.md#versioning) for what that means pre- and post-1.0, and how it +lines up with Symfony's backward compatibility promise. + +## Licensing + +New contributions are licensed under Apache License, Version 2.0. Existing code predating this +policy remains under the MIT License — see [LICENSE](LICENSE) for the details. By opening a pull +request, you agree your contribution is provided under those terms. + +## Getting help + +If something is unclear or you want early feedback on an approach before writing code, open an +issue or a draft pull request — that's the right place to ask, rather than guessing at scope. diff --git a/Makefile b/Makefile index 667aa04..f1d78ef 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,15 @@ -.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 integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs docs-guides docs-api docs-serve + +# The 2026-07-28 scenarios ship on the `alpha` dist-tag; `latest` (0.1.x) has +# none of them. Pinned to the same version CI runs (see +# .github/workflows/pipeline.yaml), so a local pass means a green pipeline. +CONFORMANCE_VERSION ?= 0.2.0-alpha.11 +CONFORMANCE = npx --yes @modelcontextprotocol/conformance@$(CONFORMANCE_VERSION) + +# The documentation toolchain is Python (Zensical, see requirements-docs.txt), +# run through uv so no virtualenv has to be managed by hand: +# https://docs.astral.sh/uv/getting-started/installation/ +DOCS_RUN = uv run --no-project --with-requirements requirements-docs.txt -- deps-stable: composer update --prefer-stable @@ -18,6 +29,9 @@ tests: unit-tests: vendor/bin/phpunit --testsuite=unit +integration-tests: + vendor/bin/phpunit --testsuite=integration + inspector-tests: vendor/bin/phpunit --testsuite=inspector @@ -28,14 +42,32 @@ conformance-server: @echo "Waiting for server to start..." @sleep 5 rm -rf tests/Conformance/results - cd tests/Conformance && npx @modelcontextprotocol/conformance server --url http://localhost:8000/ --output-dir results || true - php tests/Conformance/score.php server + cd tests/Conformance && $(CONFORMANCE) server --url http://localhost:8000/ --suite all --spec-version 2025-11-25 --expected-failures conformance-baseline-2025-11-25.yml --output-dir results || true + php tests/Conformance/score.php server 2025-11-25 docker compose -f tests/Conformance/Fixtures/docker-compose.yml down conformance-client: rm -rf tests/Conformance/results - cd tests/Conformance && npx @modelcontextprotocol/conformance client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml --output-dir results || true - php tests/Conformance/score.php client + cd tests/Conformance && $(CONFORMANCE) client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2025-11-25 --expected-failures conformance-baseline-2025-11-25.yml --output-dir results || true + php tests/Conformance/score.php client 2025-11-25 + +# --- 2026-07-28 (SEP-2575 stateless lifecycle) ------------------------------ + +conformance-draft: conformance-draft-server conformance-draft-client + +conformance-draft-server: + docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d + @echo "Waiting for server to start..." + @sleep 5 + rm -rf tests/Conformance/results-2026-07-28 + cd tests/Conformance && $(CONFORMANCE) server --url http://localhost:8000/ --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true + php tests/Conformance/score.php server 2026-07-28 results-2026-07-28 + docker compose -f tests/Conformance/Fixtures/docker-compose.yml down + +conformance-draft-client: + rm -rf tests/Conformance/results-2026-07-28 + cd tests/Conformance && $(CONFORMANCE) client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true + php tests/Conformance/score.php client 2026-07-28 results-2026-07-28 coverage: XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage @@ -46,7 +78,19 @@ ci-stable: deps-stable cs phpstan tests ci-lowest: deps-low cs phpstan tests -docs: - vendor/bin/phpdoc - @grep -q 'No errors have been found' .phpdoc/build/reports/errors.html || \ - (echo "Documentation errors found. See build/docs/reports/errors.html" && exit 1) +# The published site is the guides (Zensical) with the phpDocumentor API +# reference mounted at /api/. `zensical build` wipes site/, so it runs first. +docs: docs-guides docs-api + rm -rf site/api + cp -a .phpdoc/build/api site/api + +docs-guides: + $(DOCS_RUN) zensical build --strict + +docs-api: + vendor/bin/phpdoc --no-interaction + @grep -q 'No errors have been found' .phpdoc/build/api/reports/errors.html || \ + (echo "Documentation errors found. See .phpdoc/build/api/reports/errors.html" && exit 1) + +docs-serve: + $(DOCS_RUN) zensical serve diff --git a/README.md b/README.md index e088563..231c4fe 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,13 @@ # MCP PHP SDK -
- [![Latest Version](https://img.shields.io/packagist/v/mcp/sdk.svg)](https://packagist.org/packages/mcp/sdk) [![CI](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/pipeline.yaml/badge.svg)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/pipeline.yaml) [![PHP Version](https://img.shields.io/packagist/php-v/mcp/sdk.svg)](https://packagist.org/packages/mcp/sdk) [![License](https://img.shields.io/packagist/l/mcp/sdk.svg)](LICENSE) -[![Server Conformance](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/server-conformance.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) -[![Client Conformance](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/client-conformance.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) -
- -The official PHP SDK for Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP servers -and clients in PHP. +The official PHP SDK for the Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP +servers and clients in PHP — tools, resources, prompts, STDIO and HTTP transports, sessions, authorization, and both +protocol eras (the `initialize` handshake and the stateless `2026-07-28` revision). This project represents a collaboration between [the PHP Foundation](https://thephp.foundation/) and the [Symfony project](https://symfony.com/). It adopts development practices and standards from the Symfony project, including [Coding Standards](https://symfony.com/doc/current/contributing/code/standards.html) and the @@ -21,18 +16,16 @@ development practices and standards from the Symfony project, including [Coding Until the first major release, this SDK is considered [experimental](https://symfony.com/doc/current/contributing/code/experimental.html), please see the [roadmap](./ROADMAP.md) for planned next steps and features. -## Table of Contents +## Protocol Conformance -- [Installation](#installation) -- [Overview](#overview) -- [Server SDK](#server-sdk) -- [Client SDK](#client-sdk) -- [Documentation](#documentation) -- [External Resources](#external-resources) -- [PHP Libraries Using the MCP SDK](#php-libraries-using-the-mcp-sdk) -- [Contributing](#contributing) -- [Credits](#credits) -- [License](#license) +Measured weekly against the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance), +as passing checks out of total. The `2026-07-28` scores run against the framework's `alpha` releases, so they move as +upstream publishes new scenarios. + +| Revision | Server | Client | +|:--------:|:------:|:------:| +| `2026-07-28` | [![Server conformance 2026-07-28](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/server-conformance-2026-07-28.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) | [![Client conformance 2026-07-28](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/client-conformance-2026-07-28.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) | +| `2025-11-25` | [![Server conformance 2025-11-25](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/server-conformance-2025-11-25.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) | [![Client conformance 2025-11-25](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/modelcontextprotocol/php-sdk/badges/client-conformance-2025-11-25.json)](https://github.com/modelcontextprotocol/php-sdk/actions/workflows/conformance-weekly.yaml) | ## Installation @@ -40,28 +33,21 @@ planned next steps and features. composer require mcp/sdk ``` -## Overview +## Build a server -The MCP PHP SDK provides both **server** and **client** implementations for the Model Context Protocol, enabling you to: - -- **Build MCP Servers**: Expose your PHP application's functionality (tools, resources, prompts) to AI agents -- **Build MCP Clients**: Connect to and interact with MCP servers from your PHP applications - -## Server SDK - -Build MCP servers to expose your PHP application's capabilities to AI agents like Claude, Codex, and others. - -### Quick Start +A server is a plain PHP class plus three lines of wiring: ```php +use Mcp\Capability\Attribute\McpResource; +use Mcp\Capability\Attribute\McpTool; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; -use Mcp\Capability\Attribute\McpTool; -use Mcp\Capability\Attribute\McpResource; -// Define capabilities using PHP attributes -class CalculatorCapabilities +class Calculator { + /** + * Adds two numbers. + */ #[McpTool] public function add(int $a, int $b): int { @@ -69,228 +55,56 @@ class CalculatorCapabilities } #[McpResource(uri: 'config://calculator/settings')] - public function getSettings(): array + public function settings(): array { return ['precision' => 2]; } } -// Build and run the server -$server = Server::builder() - ->setServerInfo('Calculator Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) // Auto-discover attributes - ->build(); - -$transport = new StdioTransport(); -$server->run($transport); +exit(Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build() + ->run(new StdioTransport())); ``` -### Server Capabilities +The walkthrough in [First server](docs/get-started/first-server.md) explains each piece, and +[Try it with the Inspector](docs/get-started/inspector.md) shows it running. -- **Tools**: Executable functions that AI agents can call -- **Resources**: Data sources that can be read (files, configs, databases) -- **Resource Templates**: Dynamic resources with URI parameters -- **Prompts**: Pre-defined templates for AI interactions -- **Server-Initiated Communication**: Elicitations, sampling, logging, progress notifications - -### Registration Methods - -There are multiple ways to register your MCP capabilities—choose the approach that best fits your application's architecture: - -**1. Attribute-Based Discovery** — Define capabilities using PHP attributes for automatic discovery: -```php -#[McpTool] -public function generateReport(): string { /* ... */ } - -#[McpResource(uri: 'config://app/settings')] -public function getConfig(): array { /* ... */ } -``` - -**2. Manual Registration** — Register capabilities programmatically without attributes: -```php -$server = Server::builder() - ->addTool([Calculator::class, 'add'], 'add_numbers') - ->addResource([Config::class, 'get'], 'config://app') - ->build(); -``` - -**3. Hybrid Approach** — Combine both methods for maximum flexibility: -```php -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) - ->addTool([ExternalService::class, 'process'], 'external') - ->build(); -``` - -### Transports - -Choose the transport that matches your deployment environment: - -**1. STDIO Transport** — For command-line integration and local processes: -```php -$transport = new StdioTransport(); -$server->run($transport); -``` - -**2. HTTP Transport** — For web-based servers and distributed systems: -```php -$transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); -$response = $server->run($transport); -``` - -### Session Management - -Configure session storage to maintain state between requests. Choose the backend that fits your infrastructure: - -**In-Memory** (default, suitable for STDIO): -```php -$server = Server::builder() - ->setSession(ttl: 7200) // 2 hours - ->build(); -``` - -**File-Based** (suitable for single-server HTTP deployments): -```php -$server = Server::builder() - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) - ->build(); -``` - -**PSR-16 Cache** (for example with Redis for scaled deployments): -```php -$server = Server::builder() - ->setSession(new Psr16SessionStore( - cache: new Psr16Cache($redisAdapter), - prefix: 'mcp-', - ttl: 3600 - )) - ->build(); -``` - -[→ Server Documentation](docs/server-builder.md) - -## Client SDK - -Connect to MCP servers from your PHP applications to access their tools, resources, and prompts. - -### Quick Start +## Build a client ```php use Mcp\Client; use Mcp\Client\Transport\StdioTransport; -// Build the client $client = Client::builder() ->setClientInfo('My Application', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(120) ->build(); -// Connect to a server -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], -); +$client->connect(new StdioTransport(command: 'php', args: ['/path/to/server.php'])); -$client->connect($transport); - -// Discover and use capabilities $tools = $client->listTools(); $result = $client->callTool('add', ['a' => 5, 'b' => 3]); -$resources = $client->listResources(); -$content = $client->readResource('config://calculator/settings'); - $client->disconnect(); ``` -### Client Capabilities - -- **Tool Calling**: List and execute tools from any MCP server -- **Resource Access**: Read static and dynamic resources -- **Prompt Management**: List and retrieve prompt templates -- **Completion Support**: Request argument completion suggestions -- **Sampling & Elicitation**: Respond to server-initiated LLM sampling and user-input requests - -### Advanced Features - -- **Progress Tracking**: Real-time progress during long operations -```php -$result = $client->callTool( - name: 'process_data', - arguments: ['dataset' => 'large_file.csv'], - onProgress: function (float $progress, ?float $total, ?string $message) { - echo "Progress: {$progress}/{$total} - {$message}\n"; - } -); -``` - -- **Sampling Support**: Handle server LLM sampling requests -```php -$samplingHandler = new SamplingRequestHandler($myCallback); -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addRequestHandler($samplingHandler) - ->build(); -``` - -- **Elicitation Support**: Respond to server requests for user input -```php -$elicitationHandler = new ElicitationRequestHandler($myCallback); -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(elicitation: true)) - ->addRequestHandler($elicitationHandler) - ->build(); -``` - -- **Logging Notifications**: Receive server log messages -```php -$loggingHandler = new LoggingNotificationHandler($myCallback); -$client = Client::builder() - ->addNotificationHandler($loggingHandler) - ->build(); -``` - -### Transports - -Connect to MCP servers using the transport that matches your setup: - -**1. STDIO Transport** — Connect to local server processes: -```php -$transport = new StdioTransport( - command: 'php', - args: ['/path/to/server.php'], -); - -$client->connect($transport); -``` - -**2. HTTP Transport** — Connect to remote or web-based servers: -```php -$transport = new HttpTransport('http://localhost:8000'); - -$client->connect($transport); -``` - -[→ Client Documentation](docs/client.md) +See [Connecting to a server](docs/client/connecting.md) for transports, timeouts, and the +handlers that answer server-initiated requests. ## Documentation -### Core Concepts +The full documentation is published at **[php.sdk.modelcontextprotocol.io](https://php.sdk.modelcontextprotocol.io/)**. -- **[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 - -### Learning & Examples - -- **[Examples](docs/examples.md)** — Comprehensive example walkthroughs for servers and clients -- **[ROADMAP.md](ROADMAP.md)** — Planned features and development roadmap +- **[Get started](docs/get-started/index.md)** — Install the SDK and build your first server +- **[Servers](docs/servers/index.md)** — Tools, resources, resource templates, prompts, and how to register them +- **[Inside your handler](docs/handlers/index.md)** — Talking back to the client, logging, and asking for input +- **[Running your server](docs/run/index.md)** — Server builder, STDIO and HTTP transports, framework integration, sessions, authorization +- **[Clients](docs/client/index.md)** — Client SDK for connecting to and communicating with MCP servers +- **[Protocol versions](docs/protocol-versions.md)** — The two protocol eras, and what revision `2026-07-28` changed +- **[Advanced](docs/advanced/index.md)** — Events, protocol extensions (including MCP Apps), and custom message handlers +- **[Examples](docs/examples.md)** — Runnable server and client examples +- **[API Reference](https://php.sdk.modelcontextprotocol.io/api/)** — Generated class reference ## External Resources @@ -312,9 +126,11 @@ Building something on top of the SDK? Open a pull request to add it to this list ## Contributing -We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. - -See the [Contributing Guide](CONTRIBUTING.md) to get started before you [report issues](https://github.com/modelcontextprotocol/php-sdk/issues) and [send pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). +We are passionate about supporting contributors of all levels of experience and would love to see you get involved in +the project. Start by [reporting issues](https://github.com/modelcontextprotocol/php-sdk/issues) or +[sending pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). See +[CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding standards, and what to run before +opening a PR. ## Credits diff --git a/ROADMAP.md b/ROADMAP.md index 5b67e14..5dfb9ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,6 +10,12 @@ This roadmap is a living document that outlines the planned features and improve - **Client** - [x] Implement client-side support - [x] Implement client examples and documentation +- [ ] Implement OAuth2 authentication for client - **Schema** - [ ] Implement schema generation based on TS or JSON Schema +- [ ] Achieve full spec conformance +- **Extensions** +- [ ] Implement the Tasks extension +- **Architecture** +- [ ] Split the SDK into multiple packages diff --git a/adr/0001-oauth-authorization-server-out-of-scope.md b/adr/0001-oauth-authorization-server-out-of-scope.md index fa5b091..ebb5947 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/composer.json b/composer.json index b883603..5c572b0 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "psr/http-server-handler": "^1.0", "psr/http-server-middleware": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "suggest": { diff --git a/docs/.overrides/.icons/mcp.svg b/docs/.overrides/.icons/mcp.svg new file mode 100644 index 0000000..67d800b --- /dev/null +++ b/docs/.overrides/.icons/mcp.svg @@ -0,0 +1 @@ + diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..c7735ad --- /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 0000000..a7fd28f --- /dev/null +++ b/docs/advanced/custom-handlers.md @@ -0,0 +1,98 @@ +# Custom Message Handlers + +**Low-level escape hatch.** Custom message handlers run before the SDK's built-in handlers and give you total control over +individual JSON-RPC messages. They do not receive the builder's registry, container, or discovery output unless you pass +those dependencies in yourself. + +> **Warning**: Custom message handlers bypass discovery, manual capability registration, and container lookups (unless +> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler +> loads and executes them manually. Reach for this API only when you need that level of control and are comfortable +> taking on the additional plumbing. + +## Request Handlers + +Handle JSON-RPC requests (messages with an `id` that expect a response). Request handlers **must** return either a +`Response` or an `Error` object. + +Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these +methods as many times as needed; handlers are collected in call order, and the whole custom list runs before the +built-in defaults: + +```php +$server = Server::builder() + ->addRequestHandler(new CustomListToolsHandler()) + ->addRequestHandlers([ + new CustomCallToolHandler(), + new CustomGetPromptHandler(), + ]) + ->build(); +``` + +Request handlers implement `RequestHandlerInterface`: + +```php +use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; +use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Session\SessionInterface; + +interface RequestHandlerInterface +{ + public function supports(Request $request): bool; + + public function handle(Request $request, SessionInterface $session): Response|Error; +} +``` + +- `supports()` decides if the handler should process the incoming request +- `handle()` **must** return a `Response` (on success) or an `Error` (on failure) + +## Notification Handlers + +Handle JSON-RPC notifications (messages without an `id` that don't expect a response). Notification handlers **do not** +return anything - they perform side effects only. + +Attach notification handlers with `addNotificationHandler()` (single) or `addNotificationHandlers()` (multiple): + +```php +// Handlers are your own classes implementing NotificationHandlerInterface; +// the SDK ships only its internal ones. +$server = Server::builder() + ->addNotificationHandler(new AuditNotificationHandler($auditLog)) + ->addNotificationHandlers([ + new MetricsNotificationHandler($metrics), + new CancellationNotificationHandler(), + ]) + ->build(); +``` + +Notification handlers implement `NotificationHandlerInterface`: + +```php +use Mcp\Schema\JsonRpc\Notification; +use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Session\SessionInterface; + +interface NotificationHandlerInterface +{ + public function supports(Notification $notification): bool; + + public function handle(Notification $notification, SessionInterface $session): void; +} +``` + +- `supports()` decides if the handler should process the incoming notification +- `handle()` performs side effects but **does not** return a value (notifications have no response) + +## Key Differences + +| Handler Type | Interface | Returns | Use Case | +|-------------|-----------|---------|----------| +| Request Handler | `RequestHandlerInterface` | `Response\|Error` | Handle requests that need responses (e.g., `tools/list`, `tools/call`) | +| Notification Handler | `NotificationHandlerInterface` | `void` | Handle fire-and-forget notifications (e.g., `notifications/initialized`, `notifications/progress`) | + +## Example + +Check out [`examples/server/custom-method-handlers/server.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/custom-method-handlers/server.php) for a complete example showing how to implement +custom `tools/list` and `tools/call` request handlers independently of the registry. diff --git a/docs/events.md b/docs/advanced/events.md similarity index 91% rename from docs/events.md rename to docs/advanced/events.md index ebd70ed..d6d7b44 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; @@ -44,6 +35,7 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob **Dispatched**: When any request is received from the client, before it's processed by handlers. **Properties**: + - `getRequest(): Request` - The incoming request - `setRequest(Request $request): void` - Modify the request before processing - `getSession(): SessionInterface` - The current session @@ -54,6 +46,7 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob **Dispatched**: When a successful response is ready to be sent to the client, after handler execution. **Properties**: + - `getResponse(): Response` - The response being sent - `setResponse(Response $response): void` - Modify the response before sending - `getRequest(): Request` - The original request @@ -65,9 +58,10 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob **Dispatched**: When an error occurs during request processing. **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 @@ -76,6 +70,7 @@ The SDK dispatches 4 broad event types at the protocol level, allowing you to ob **Dispatched**: When a notification is received from the client, before it's processed by handlers. **Properties**: + - `getNotification(): Notification` - The incoming notification - `setNotification(Notification $notification): void` - Modify the notification before processing - `getSession(): SessionInterface` - The current session diff --git a/docs/extensions.md b/docs/advanced/extensions.md similarity index 61% rename from docs/extensions.md rename to docs/advanced/extensions.md index f181702..041f986 100644 --- a/docs/extensions.md +++ b/docs/advanced/extensions.md @@ -1,7 +1,9 @@ # Protocol Extensions -MCP protocol extensions advertise additional, optional capabilities during the initialize handshake. -A server opts in via `Builder::enableExtension()`: +MCP protocol extensions advertise additional, optional capabilities alongside the regular ones — +during the `initialize` handshake, or on revision `2026-07-28` (which has no handshake) inside the +capabilities that travel with every request. A server opts in via `Builder::enableExtension()` and +the SDK places the advertisement correctly for whichever era the client speaks: ```php use Mcp\Schema\Extension\Apps\McpApps; @@ -13,14 +15,38 @@ $server = Server::builder() ->build(); ``` -Pass one or more `ServerExtensionInterface` instances; multiple extensions can +Pass one or more `ExtensionInterface` instances; multiple extensions can be enabled in a single call. Enabling the same extension twice throws a `LogicException`. +Clients (hosts) advertise the extensions they support the same way, via +`Client\Builder::enableExtension()`; the payload lands under +`capabilities.extensions` in the initialize request — or, on a modern revision, +under the client capabilities carried in each request's `_meta` envelope. + > Note: extensions enabled via `enableExtension()` are merged into the -> `extensions` capability even when you supply your own `ServerCapabilities` via -> `setCapabilities()`. An enabled extension overrides any entry under the same -> id already present in those capabilities. +> `extensions` capability even when you supply your own `ServerCapabilities` / +> `ClientCapabilities` via `setCapabilities()`. An enabled extension overrides +> any entry under the same id already present in those capabilities. + +## Checking what the client negotiated + +An extension is only in effect when both sides advertise it. On the server, a +handler can ask the `ClientGateway` from the injected `RequestContext` whether the +connected client did: + +```php +use Mcp\Schema\Extension\Apps\McpApps; +use Mcp\Server\RequestContext; + +public function getWeather(string $city, RequestContext $context): string +{ + if (!$context->getClientGateway()->supportsExtension(McpApps::EXTENSION_ID)) { + // text-only fallback for hosts without MCP Apps support + } + // ... +} +``` ## MCP Apps (`io.modelcontextprotocol/ui`) @@ -78,6 +104,30 @@ flagging it as an MCP App, while the resource *content* returned by `resources/r carries the structured `UiResourceContentMeta` with the actual CSP and permission configuration. +### Attribute-based discovery + +The same linkage works with `#[McpResource]` / `#[McpTool]`, since both accept a +`meta` array and PHP allows `new` in attribute arguments. The one difference is the +descriptor marker: `McpApps::resourceMarker()` is a method call and cannot appear +in an attribute, so spell it as `new \stdClass()` there. + +```php +use Mcp\Capability\Attribute\McpResource; +use Mcp\Capability\Attribute\McpTool; +use Mcp\Schema\Extension\Apps\McpApps; +use Mcp\Schema\Extension\Apps\ToolVisibility; +use Mcp\Schema\Extension\Apps\UiToolMeta; + +final class WeatherApp +{ + #[McpResource(uri: 'ui://my-app', mimeType: McpApps::MIME_TYPE, meta: ['ui' => new \stdClass()])] + public function view(): TextResourceContents { /* as above */ } + + #[McpTool(name: 'my_tool', meta: ['ui' => new UiToolMeta(resourceUri: 'ui://my-app', visibility: [ToolVisibility::Model, ToolVisibility::App])])] + public function myTool(string $city): string { /* ... */ } +} +``` + ### Server-side DTOs | Class | Purpose | @@ -105,6 +155,6 @@ handshake: See the [`ext-apps` repository][ext-apps] for the full protocol, official TypeScript SDK (`@modelcontextprotocol/ext-apps`), and view-side examples. A working minimal view is included in -[`examples/server/mcp-apps/weather-app.html`](../examples/server/mcp-apps/weather-app.html). +[`examples/server/mcp-apps/weather-app.html`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/mcp-apps/weather-app.html). [ext-apps]: https://github.com/modelcontextprotocol/ext-apps diff --git a/docs/advanced/index.md b/docs/advanced/index.md new file mode 100644 index 0000000..2a79a3c --- /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 494edf1..0000000 --- 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 0000000..5ba082e --- /dev/null +++ b/docs/client/capabilities.md @@ -0,0 +1,161 @@ +# Tools, resources & prompts + +Once connected, everything the server exposes is reachable through the client: list what +is there, then call it. Each list method returns the server's own descriptions and +schemas, so a generic client can build its UI from them. + +## Working with Tools + +### Listing Tools + +```php +$toolsResult = $client->listTools(); + +foreach ($toolsResult->tools as $tool) { + echo "- {$tool->name}: {$tool->description}\n"; +} + +// Handle pagination +if ($toolsResult->nextCursor) { + $moreTools = $client->listTools($toolsResult->nextCursor); +} +``` + +### Calling Tools + +```php +use Mcp\Schema\Content\TextContent; + +$result = $client->callTool( + name: 'calculate', + arguments: ['a' => 5, 'b' => 3, 'operation' => 'add'], +); + +// Access results +foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text; + } +} +``` + +### Progress Notifications + +Hook into tool execution progress (if server supports it): + +```php +$result = $client->callTool( + name: 'long_running_task', + arguments: ['data' => 'large_dataset'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : 0; + echo "Progress: {$percent}% - {$message}\n"; + } +); +``` + +!!! note + Progress notifications are only received if the server sends them. The callback will not be invoked if the server doesn't support or send progress updates. + +## Working with Resources + +### Listing Resources + +```php +$resourcesResult = $client->listResources(); + +foreach ($resourcesResult->resources as $resource) { + echo "- {$resource->uri}: {$resource->name}\n"; +} +``` + +### Listing Resource Templates + +```php +$templatesResult = $client->listResourceTemplates(); + +foreach ($templatesResult->resourceTemplates as $template) { + echo "- {$template->uriTemplate}: {$template->name}\n"; +} +``` + +### Reading Resources + +```php +use Mcp\Schema\Content\{TextResourceContents, BlobResourceContents}; + +$resourceResult = $client->readResource('config://app/settings'); + +foreach ($resourceResult->contents as $content) { + if ($content instanceof TextResourceContents) { + echo "Text: {$content->text}\n"; + } elseif ($content instanceof BlobResourceContents) { + echo "Binary data (base64): {$content->blob}\n"; + } +} +``` + +Resources also support progress notifications: + +```php +$result = $client->readResource( + uri: 'file://large-file.bin', + onProgress: static function (float $progress, ?float $total, ?string $message) { + echo "Reading: {$progress}/{$total} bytes\n"; + } +); +``` + +## Working with Prompts + +### Listing Prompts + +```php +$promptsResult = $client->listPrompts(); + +foreach ($promptsResult->prompts as $prompt) { + echo "- {$prompt->name}: {$prompt->description}\n"; +} +``` + +### Getting Prompts + +```php +$promptResult = $client->getPrompt( + name: 'code_review', + arguments: ['language' => 'php', 'code' => '...'], +); + +foreach ($promptResult->messages as $message) { + echo "{$message->role->value}: {$message->content->text}\n"; +} +``` + +Prompts also support progress notifications: + +```php +$result = $client->getPrompt( + name: 'generate_report', + arguments: ['topic' => 'quarterly_analysis'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + echo "Generating: {$message}\n"; + } +); +``` + +### Requesting Completions + +Request auto-completion suggestions for prompt or resource arguments: + +```php +use Mcp\Schema\PromptReference; + +$completionResult = $client->complete( + ref: new PromptReference('code_review'), + argument: ['name' => 'language', 'value' => 'ph'], +); + +foreach ($completionResult->values as $value) { + echo "Suggestion: {$value}\n"; +} +``` diff --git a/docs/client/connecting.md b/docs/client/connecting.md new file mode 100644 index 0000000..1fe01e1 --- /dev/null +++ b/docs/client/connecting.md @@ -0,0 +1,223 @@ +# Connecting to a server + +A client is configured once through its builder, then connected to a +[transport](transports.md). Connecting performs the MCP initialization handshake, after +which the server's capabilities are known and its elements can be used. On protocol +revision `2026-07-28` there is no handshake to perform — see +[Clients on this revision](../protocol-versions.md). + +## Client Builder + +The `Client\Builder` provides fluent configuration of client instances. + +### Basic Configuration + +```php +use Mcp\Client; + +$client = Client::builder() + ->setClientInfo('My Application', '1.0.0', 'Description of my client') + ->setInitTimeout(30) // Seconds to wait for initialization + ->setRequestTimeout(120) // Seconds to wait for request responses + ->setMaxRetries(3) // Retries for failed connections + ->build(); +``` + +### Connection Retries + +`setMaxRetries()` controls how often `connect()` retries a failed connection. It +counts retries rather than attempts, so the default of `3` means one initial +attempt plus up to three retries — four in total — before the `ConnectionException` +of the last attempt is rethrown: + +```php +$client = Client::builder() + ->setMaxRetries(0) // Fail on the first failed attempt + ->build(); +``` + +Between two attempts the transport is closed, so a retry never reuses a +half-established connection: a `StdioTransport` spawns a fresh server process and +an `HttpTransport` discards the session ID of the failed attempt. Each retry is +preceded by a short, linearly growing delay (100ms, 200ms, 300ms, …). + +Only the connection handshake is retried. Individual requests such as +`callTool()` are always sent once — retrying them is unsafe as tool calls are not +necessarily idempotent. + +### Client Information + +Set the client's identity reported to servers during initialization: + +```php +$client = Client::builder() + ->setClientInfo( + name: 'AI Assistant Client', + version: '2.1.0', + description: 'Client for automated AI workflows' + ) + ->build(); +``` + +### Protocol Version + +Specify the MCP protocol version to offer during the handshake (defaults to `V2025_11_25`, the +latest handshake revision — the modern `2026-07-28` revision must be chosen explicitly): + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +$client = Client::builder() + ->setProtocolVersion(ProtocolVersion::V2025_11_25) + ->build(); +``` + +This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as +described in the specification's +[protocol version negotiation](https://modelcontextprotocol.io/specification/latest/basic/versioning#protocol-version-negotiation) +section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK +cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed +on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated. + +Setting a modern revision such as `2026-07-28` selects the other lifecycle rather than making an offer: there is no +`initialize` to negotiate with, so `connect()` sends none and every request carries its own revision instead. Nothing +else about the client API changes. See [Clients on this revision](../protocol-versions.md) for what happens underneath. + +See [Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for the server side of the +exchange. + +### Capabilities + +Declare client capabilities to enable server features: + +```php +use Mcp\Schema\ClientCapabilities; + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities( + sampling: true, // Enable LLM sampling requests from server + roots: true, // Enable filesystem root listing + )) + ->build(); +``` + +### Notification Handlers + +Register handlers for server-initiated notifications: + +```php +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Schema\Notification\LoggingMessageNotification; + +$loggingHandler = new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) { + echo "[{$notification->level->value}] {$notification->data}\n"; + } +); + +$client = Client::builder() + ->addNotificationHandler($loggingHandler) + ->build(); +``` + +### Request Handlers + +Register handlers for server-initiated requests (e.g., sampling). The same handlers answer a +[multi round-trip](../handlers/input-required.md) `input_required` result on a modern revision, where the server +returns its ask instead of sending a request: + +```php +use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Client\Handler\Request\SamplingCallbackInterface; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Result\CreateSamplingMessageResult; + +$samplingCallback = new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + // Perform LLM sampling and return result + } +}; + +$client = Client::builder() + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); +``` + +### Logger + +Configure PSR-3 logging for debugging: + +```php +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +$logger = new Logger('mcp-client'); +$logger->pushHandler(new StreamHandler('client.log', Logger::DEBUG)); + +$client = Client::builder() + ->setLogger($logger) + ->build(); +``` + +## Connecting to Servers + +### Establishing Connection + +```php +$client->connect($transport); +``` + +The `connect()` method performs the MCP initialization handshake: + +1. Opens the transport connection +2. Sends InitializeRequest with client capabilities +3. Waits for InitializeResult from server +4. Sends InitializedNotification + +On a modern revision it opens the transport and asks `server/discover` for the server's identity instead; a server +that does not answer that optional method still yields a usable connection. + +!!! warning + Always wrap connection in try/catch to handle `ConnectionException` for failed connections. + +### Checking Connection State + +```php +if ($client->isConnected()) { + // Client is connected and initialized +} +``` + +### Disconnecting + +```php +$client->disconnect(); +``` + +Always disconnect when finished to clean up resources: + +```php +try { + $client->connect($transport); + // ... use the client ... +} finally { + $client->disconnect(); +} +``` + +## Server Information + +After successful connection, retrieve server metadata: + +```php +// Get server implementation info +$serverInfo = $client->getServerInfo(); +echo "Server: {$serverInfo->name} v{$serverInfo->version}\n"; + +// Get server instructions +$instructions = $client->getInstructions(); +if ($instructions) { + echo "Instructions: {$instructions}\n"; +} +``` diff --git a/docs/client/errors.md b/docs/client/errors.md new file mode 100644 index 0000000..94e27f9 --- /dev/null +++ b/docs/client/errors.md @@ -0,0 +1,155 @@ +# Error Handling + +The client throws exceptions for various error conditions: + +## ConnectionException + +Thrown when connection or initialization fails: + +```php +use Mcp\Exception\ConnectionException; + +try { + $client->connect($transport); +} catch (ConnectionException $e) { + echo "Failed to connect: {$e->getMessage()}\n"; +} +``` + +## RequestException + +Thrown when a request returns an error response: + +```php +use Mcp\Exception\RequestException; + +try { + $result = $client->callTool('unknown_tool', []); +} catch (RequestException $e) { + echo "Request failed: {$e->getMessage()}\n"; + echo "Error code: {$e->getCode()}\n"; +} +``` + +## Complete Example + +Here's a comprehensive example demonstrating client usage: + +```php-file +level->value}] {$notification->data}\n"; + } +); + +// Configure sampling callback +$samplingCallback = new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + echo "[SAMPLING] Processing request (max {$request->maxTokens} tokens)\n"; + + try { + // Integration with your LLM provider + $response = "This is a mock LLM response for: " . + json_encode($request->messages); + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($response), + model: 'mock-llm', + stopReason: 'endTurn', + ); + } catch (\Throwable $e) { + throw new SamplingException( + "Sampling failed: {$e->getMessage()}", + 0, + $e + ); + } + } +}; + +// Build client +$client = Client::builder() + ->setClientInfo('Example Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addNotificationHandler($loggingHandler) + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); + +// Create transport +$transport = new StdioTransport( + command: 'php', + args: [__DIR__ . '/server.php'], +); + +// Connect and use server +try { + echo "Connecting to server...\n"; + $client->connect($transport); + + // Get server info + $serverInfo = $client->getServerInfo(); + echo "Connected to: {$serverInfo->name} v{$serverInfo->version}\n\n"; + + // List capabilities + echo "Available tools:\n"; + $tools = $client->listTools(); + foreach ($tools->tools as $tool) { + echo " - {$tool->name}\n"; + } + + echo "\nAvailable resources:\n"; + $resources = $client->listResources(); + foreach ($resources->resources as $resource) { + echo " - {$resource->uri}\n"; + } + + // Set logging level + $client->setLoggingLevel(LoggingLevel::Debug); + + // Call tool with progress + echo "\nCalling tool with progress...\n"; + $result = $client->callTool( + name: 'process_data', + arguments: ['dataset' => 'large_file.csv'], + onProgress: static function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : 0; + echo " Progress: {$percent}% - {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text . "\n"; + } + } + +} catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString() . "\n"; +} finally { + $client->disconnect(); + echo "\nDisconnected.\n"; +} +``` diff --git a/docs/client/index.md b/docs/client/index.md new file mode 100644 index 0000000..f2a9848 --- /dev/null +++ b/docs/client/index.md @@ -0,0 +1,41 @@ +# Clients + +The client side is for applications that *use* MCP servers: you connect to a server, +discover what it offers, and call it. The API is synchronous — every method returns a +result or throws. + +```php +use Mcp\Client; +use Mcp\Client\Transport\StdioTransport; + +// Build and configure the client +$client = Client::builder() + ->setClientInfo('My Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->build(); + +// Create a transport +$transport = new StdioTransport( + command: 'php', + args: ['/path/to/server.php'], +); + +// Connect and use the server +$client->connect($transport); +$tools = $client->listTools(); +$client->disconnect(); +``` + +* **[Connecting to a server](connecting.md)** — the builder, the connection lifecycle, + and what the server told you about itself during initialization. +* **[Transports](transports.md)** — launching a local server process (STDIO) or talking + to a remote one (HTTP). +* **[Tools, resources & prompts](capabilities.md)** — listing and calling everything a + server exposes, including progress callbacks and completions. +* **[Server-initiated requests](server-requests.md)** — the other direction: log + messages, sampling requests, and elicitations the server sends *you*. +* **[Error handling](errors.md)** — which exception means what, plus a complete + end-to-end example. +* **[Clients on this revision](../protocol-versions.md)** — the one builder line that speaks + protocol revision `2026-07-28`, and what it changes underneath. diff --git a/docs/client/server-requests.md b/docs/client/server-requests.md new file mode 100644 index 0000000..d25cd5c --- /dev/null +++ b/docs/client/server-requests.md @@ -0,0 +1,265 @@ +# Server-Initiated Communication + +The client can receive requests and notifications from the server when configured with appropriate handlers. + +## Elicitation (User Input Requests) + +Handle server requests to elicit additional information from the user during tool +execution. The server sends an `elicitation/create` request describing the fields it +needs; your callback presents them to the user and returns an `ElicitResult` with one of +three actions — accept (with the collected content), decline, or cancel: + +```php +use Mcp\Client\Handler\Request\ElicitationRequestHandler; +use Mcp\Client\Handler\Request\ElicitationCallbackInterface; +use Mcp\Exception\ElicitationException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ElicitationMode; +use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Result\ElicitResult; + +class ConsoleElicitationCallback implements ElicitationCallbackInterface +{ + public function __invoke(ElicitRequest $request): ElicitResult + { + echo $request->message.\PHP_EOL; + + // In url mode there is no schema to fill in — the user completes the + // interaction in the browser instead. + if (ElicitationMode::Url === $request->mode) { + echo 'Continue in your browser: '.$request->url.\PHP_EOL; + + return new ElicitResult(ElicitAction::Accept); + } + + // Form mode: present $request->requestedSchema->properties and collect input. + $content = []; + foreach ($request->requestedSchema->properties as $name => $definition) { + $answer = readline($definition->title.': '); + + if (false === $answer) { + // No input available — let the server know the user cancelled. + return new ElicitResult(ElicitAction::Cancel); + } + + $content[$name] = $answer; + } + + return new ElicitResult(ElicitAction::Accept, $content); + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler(new ElicitationRequestHandler(new ConsoleElicitationCallback)) + ->build(); +``` + +Return `new ElicitResult(ElicitAction::Decline)` when the user refuses to provide the +information, and `new ElicitResult(ElicitAction::Cancel)` when they dismiss the request. +Only the `Accept` action carries content. + +!!! warning + **Error Handling in Elicitation Callbacks:** + + - **Throw `ElicitationException`** to forward a specific error message to the server + - **Any other exception** is logged but returns a generic error to the server + + ```php + // Good: Server receives "No interactive console available" message + throw new ElicitationException('No interactive console available'); + + // Bad: Server receives generic "Error while processing elicitation" message + throw new \RuntimeException('No interactive console available'); + ``` + +See [`examples/client/stdio_elicitation.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_elicitation.php) for a runnable example against the +elicitation demo server. + +## Sampling (LLM Requests) + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. + +Handle server requests for LLM completions: + +```php +use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Client\Handler\Request\SamplingCallbackInterface; +use Mcp\Exception\SamplingException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Result\CreateSamplingMessageResult; +use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\Role; + +class LlmSamplingCallback implements SamplingCallbackInterface +{ + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + try { + // Call your LLM provider + $response = $this->llmClient->complete( + messages: $request->messages, + maxTokens: $request->maxTokens, + temperature: $request->temperature ?? 0.7, + ); + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($response->text), + model: $response->model, + stopReason: $response->stopReason, + ); + } catch (\Throwable $e) { + // Throw SamplingException to surface error to server + throw new SamplingException( + "LLM sampling failed: {$e->getMessage()}", + (int) $e->getCode(), + $e + ); + } + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addRequestHandler(new SamplingRequestHandler(new LlmSamplingCallback)) + ->build(); +``` + +### Sampling with Tools + +Clients that support tool-enabled sampling should advertise that capability and forward the request's `tools` and +`toolChoice` fields to their LLM provider. A provider response that requests tools can be returned as one or more +`ToolUseContent` blocks: + +```php +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Content\ToolUseContent; +use Mcp\Schema\Enum\Role; +use Mcp\Schema\Result\CreateSamplingMessageResult; + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities( + sampling: true, + samplingContext: true, + samplingTools: true, + )) + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); + +// Inside the sampling callback, after invoking the LLM provider: +return new CreateSamplingMessageResult( + role: Role::Assistant, + content: array_map( + static fn ($call) => new ToolUseContent($call->id, $call->name, $call->input), + $providerResponse->toolCalls, + ), + model: $providerResponse->model, + stopReason: 'toolUse', +); +``` + +The server executes the requested tools and sends their results in a later sampling request as `ToolResultContent` +blocks in a user message. The client should pass those blocks back to the LLM provider to continue the sampling loop. + +!!! warning + **Error Handling in Sampling Callbacks:** + + When implementing sampling callbacks, error handling is critical: + + - **Throw `SamplingException`** to forward specific error messages to the server + - **Any other exception** will be logged but return a generic error to the server + + This distinction allows you to control what error information the server receives: + + ```php + // Good: Server receives "Rate limit exceeded" message + throw new SamplingException('Rate limit exceeded. Retry after 60 seconds.'); + + // Bad: Server receives generic "Error while sampling LLM" message + throw new \RuntimeException('Rate limit exceeded'); + ``` + +## Logging Notifications + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. + +Receive structured log messages from the server: + +```php +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Schema\Notification\LoggingMessageNotification; +use Mcp\Schema\Enum\LoggingLevel; + +$loggingHandler = new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) { + // Route to your application's logging system + $level = $notification->level; + $message = $notification->data; + + match ($level) { + LoggingLevel::Debug => logger()->debug($message), + LoggingLevel::Info => logger()->info($message), + LoggingLevel::Warning => logger()->warning($message), + LoggingLevel::Error => logger()->error($message), + default => logger()->info($message), + }; + } +); + +$client = Client::builder() + ->addNotificationHandler($loggingHandler) + ->build(); + +// Set minimum log level (optional) +$client->setLoggingLevel(LoggingLevel::Info); +``` + +## Roots + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Roots keep working until then; new integrations should pass directories or files through tool arguments, resource URIs or server configuration instead. + +Roots let the client expose a list of `file://` "workspace folders" that the server +is allowed to operate on. Advertise the `roots` capability and register a handler +that answers server `roots/list` requests: + +```php +use Mcp\Client\Handler\Request\ListRootsRequestHandler; +use Mcp\Client\Handler\Request\RootsCallbackInterface; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Request\ListRootsRequest; +use Mcp\Schema\Result\ListRootsResult; +use Mcp\Schema\Root; + +class WorkspaceRootsCallback implements RootsCallbackInterface +{ + public function __invoke(ListRootsRequest $request): ListRootsResult + { + return new ListRootsResult([ + new Root('file:///home/user/projects/app', 'Application'), + new Root('file:///home/user/projects/library', 'Library'), + ]); + } +} + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->addRequestHandler(new ListRootsRequestHandler(new WorkspaceRootsCallback)) + ->build(); +``` + +When the client's roots change, notify the server so it can request the updated +list via `roots/list`. This requires advertising the `roots.listChanged` +capability (`rootsListChanged: true` above); otherwise `sendRootsListChanged()` +throws a `RuntimeException`. On a client that is not connected it throws a +`ConnectionException`: + +```php +$client->sendRootsListChanged(); +``` + +See [`examples/client/stdio_roots.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_roots.php) for a runnable example: it calls the +`inspect_workspace_roots` tool of the client-communication demo server, which +answers by issuing the `roots/list` request back to the client. diff --git a/docs/client/transports.md b/docs/client/transports.md new file mode 100644 index 0000000..84e1500 --- /dev/null +++ b/docs/client/transports.md @@ -0,0 +1,64 @@ +# Transports + +Transports handle the communication layer between client and server. + +## STDIO Transport + +Spawns a server process and communicates via standard input/output: + +```php +use Mcp\Client\Transport\StdioTransport; + +$transport = new StdioTransport( + command: 'php', + args: ['/path/to/server.php'], + cwd: '/working/directory', // Optional working directory + env: ['KEY' => 'value'], // Optional environment variables +); +``` + +**Parameters:** + +- `command` (string): The command to execute +- `args` (array): Command arguments +- `cwd` (string|null): Working directory for the process +- `env` (array|null): Environment variables +- `logger` (LoggerInterface|null): Optional PSR-3 logger +- `maxBufferSize` (int): Maximum buffered bytes per message before the transport gives up + +## HTTP Transport + +Communicates with remote MCP servers over HTTP: + +```php +use Mcp\Client\Transport\HttpTransport; + +$transport = new HttpTransport( + endpoint: 'http://localhost:8000', + headers: ['Authorization' => 'Bearer token'], +); +``` + +**Parameters:** + +- `endpoint` (string): The MCP server URL +- `headers` (array): Additional HTTP headers +- `httpClient` (ClientInterface|null): PSR-18 HTTP client (auto-discovered) +- `requestFactory` (RequestFactoryInterface|null): PSR-17 request factory (auto-discovered) +- `streamFactory` (StreamFactoryInterface|null): PSR-17 stream factory (auto-discovered) +- `logger` (LoggerInterface|null): Optional PSR-3 logger +- `maxSseBufferBytes` (int): Maximum buffered bytes for a streamed SSE response + +**PSR-18 Auto-Discovery:** + +The transport automatically discovers PSR-18 HTTP clients from: + +- `php-http/guzzle7-adapter` +- `php-http/curl-client` +- `symfony/http-client` +- And other PSR-18 compatible implementations + +```bash +# Install any PSR-18 client - discovery works automatically +composer require php-http/guzzle7-adapter +``` diff --git a/docs/deprecation-policy.md b/docs/deprecation-policy.md new file mode 100644 index 0000000..4f512dd --- /dev/null +++ b/docs/deprecation-policy.md @@ -0,0 +1,28 @@ +# Deprecation policy + +The MCP specification (SEP-2596) defines a formal feature lifecycle: **Active → Deprecated → Removed**, +with a minimum of twelve months between a feature being marked deprecated and its earliest possible removal. +Each stage transition gets its own SEP — deprecating a feature and removing it are separate proposals, never +bundled into one. + +The PHP SDK mirrors that window for anything it deprecates, whether the deprecation originates in the spec +or is SDK-internal (an API shape the SDK wants to retire independently of the protocol). + +## What a deprecation looks like here + +A deprecated element carries a PHP `@deprecated` tag naming the protocol revision (or SDK version, for an +SDK-internal deprecation) that introduced the deprecation and the earliest removal date, twelve months out: + +```php +/** + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + */ +``` + +## The SDK's own BC promise + +The SDK is pre-1.0 and experimental, and the public +API can still change without a deprecation cycle where the spec itself hasn't moved. Once past 1.0, the SDK +follows [Symfony's backward-compatibility promise](https://symfony.com/doc/current/contributing/code/bc.html), +and the twelve-month deprecation window above becomes the +floor for any BC break the SDK introduces on its own, not just ones the spec forces. diff --git a/docs/examples.md b/docs/examples.md index 14e97fd..901b567 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,520 +1,97 @@ # Examples -The MCP PHP SDK includes comprehensive examples demonstrating different patterns and use cases. Each example showcases -specific features and can be run independently to understand how the SDK works. - -## Table of Contents - -- [Getting Started](#getting-started) -- [Running Examples](#running-examples) -- [Server Examples](#server-examples) -- [Client Examples](#client-examples) - -## Getting Started - -All examples are located in the `examples/` directory and use the SDK dependencies from the root project. Most examples -can be run directly without additional setup. - -### Prerequisites +Every example lives in [`examples/`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples) +and runs against the dependencies installed in the repository root. Each server example is a +`server.php` whose bootstrap picks the transport from the SAPI it runs under: ```bash -# Install dependencies (in project root) -composer install +# STDIO transport +php examples/server/discovery-calculator/server.php + +# Streamable HTTP transport +php -S 127.0.0.1:8000 examples/server/discovery-userprofile/server.php + +# Interactive testing with the MCP Inspector +npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php ``` -## Running Examples +## Server examples -The bootstrapping of the example will choose the used transport based on the SAPI you use. +| Example | What it shows | Docs | +| --- | --- | --- | +| [`discovery-calculator`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/discovery-calculator) | Attribute discovery of tools, resources and prompts; `ResourceLink` content | [Tools](servers/tools.md) | +| [`discovery-userprofile`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/discovery-userprofile) | Resource templates with completion providers; `FileSessionStore` | [Resource templates](servers/resource-templates.md), [Completions](servers/completions.md) | +| [`explicit-registration`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/explicit-registration) | Manual `addTool()`/`addResource()`/`addResourceTemplate()`/`addPrompt()` without discovery | [Registering elements](servers/registration.md) | +| [`combined-registration`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/combined-registration) | Discovery and manual registration combined, and which wins on conflict | [Registering elements](servers/registration.md) | +| [`cached-discovery`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/cached-discovery) | Caching the discovery scan in a PSR-16 cache | [Server builder](run/server-builder.md#discovery-configuration) | +| [`custom-dependencies`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/custom-dependencies) | Handlers pulling services from a PSR-11 container | [Server builder](run/server-builder.md#service-dependencies) | +| [`complex-tool-schema`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/complex-tool-schema) | Rich input schemas from typed parameters, PHP enums and defaults | [Schema generation](servers/schemas.md) | +| [`schema-showcase`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/schema-showcase) | `#[Schema]` constraint attributes: formats, patterns, ranges | [Schema generation](servers/schemas.md) | +| [`env-variables`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/env-variables) | Configuring a server through environment variables | [Server builder](run/server-builder.md) | +| [`client-communication`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-communication) | Sampling, roots, progress and log messages from inside a handler | [Talking back to the client](handlers/client-communication.md) | +| [`client-logging`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-logging) | Structured log notifications through the `ClientLogger` | [Logging](handlers/logging.md) | +| [`elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) | Asking the user for input mid-call with `ClientGateway::elicit()` and typed elicitation schemas, on either protocol era | [Asking for input](handlers/input-required.md) | +| [`custom-method-handlers`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/custom-method-handlers) | Registering handlers for custom JSON-RPC methods | [Custom message handlers](advanced/custom-handlers.md) | +| [`mcp-apps`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/mcp-apps) | The MCP Apps extension: a tool that ships an interactive HTML view | [Protocol extensions](advanced/extensions.md) | +| [`stateless-lifecycle`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/stateless-lifecycle) | Revision `2026-07-28`: cache policy, request state, notification bus | [Serving both eras](run/protocol-eras.md), [Caching](run/caching.md), [Subscriptions](run/subscriptions.md) | +| [`oauth-keycloak`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/oauth-keycloak) | OAuth authorization against a Keycloak instance (own README) | [Authorization](run/authorization.md) | +| [`oauth-microsoft`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/oauth-microsoft) | OAuth authorization against Microsoft Entra ID (own README) | [Authorization](run/authorization.md) | -### STDIO Transport +## Client examples -The STDIO transport will use standard input/output for communication: +| Example | What it shows | Docs | +| --- | --- | --- | +| [`stdio_discovery_calculator.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_discovery_calculator.php) | Connecting over STDIO, listing and calling tools, reading resources | [Connecting to a server](client/connecting.md) | +| [`http_discovery_calculator.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/http_discovery_calculator.php) | The same conversation over the Streamable HTTP transport | [Transports](client/transports.md) | +| [`stdio_client_communication.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_client_communication.php) | Answering server-initiated sampling, log and progress messages | [Server-initiated requests](client/server-requests.md) | +| [`http_client_communication.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/http_client_communication.php) | The same handlers over HTTP — see the note on PHP's built-in server below | [Server-initiated requests](client/server-requests.md) | +| [`stdio_elicitation.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_elicitation.php) | Answering elicitation requests from an interactive prompt | [Server-initiated requests](client/server-requests.md) | +| [`stdio_roots.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stdio_roots.php) | Exposing workspace roots and signalling `roots/list_changed` | [Server-initiated requests](client/server-requests.md) | +| [`stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) | A client pinned to revision `2026-07-28` — see [Modern-era client](#modern-era-client) | [Connecting to a server](client/connecting.md) | + +Client examples run directly: ```bash -# Interactive testing with MCP Inspector -npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php - -# Run with debugging enabled -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php - -# Or configure the script path in your MCP client -# Path: php examples/discovery-calculator/server.php -``` - -### HTTP Transport - -The Streamable HTTP transport will be chosen if running examples with a web servers: - -```bash -# Start the server -php -S localhost:8000 examples/discovery-userprofile/server.php - -# Test with MCP Inspector -npx @modelcontextprotocol/inspector http://localhost:8000 - -# Test with curl -curl -X POST http://localhost:8000 \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' -``` - -## Server Examples - -### Discovery Calculator - -**File**: `examples/discovery-calculator/` - -**What it demonstrates:** -- Attribute-based discovery using `#[McpTool]` and `#[McpResource]` -- Basic arithmetic operations -- Configuration management through resources -- State management between tool calls - -**Key Features:** -```php -#[McpTool(name: 'calculate')] -public function calculate(float $a, float $b, string $operation): float|string - -#[McpResource( - uri: 'config://calculator/settings', - name: 'calculator_config', - mimeType: 'application/json' -)] -public function getConfiguration(): array -``` - -**Usage:** -```bash -# Interactive testing -npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php - -# Or configure in MCP client: php examples/discovery-calculator/server.php -``` - -### Explicit Registration - -**File**: `examples/explicit-registration/` - -**What it demonstrates:** -- Manual registration of tools, resources, and prompts -- Alternative to attribute-based discovery -- Simple handler functions - -**Key Features:** -```php -$server = Server::builder() - ->addTool([SimpleHandlers::class, 'echoText'], 'echo_text') - ->addResource([SimpleHandlers::class, 'getAppVersion'], 'app://version') - ->addPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') -``` - -### Environment Variables - -**File**: `examples/env-variables/` - -**What it demonstrates:** -- Environment variable integration -- Server configuration from environment -- Environment-based tool behavior - -**Key Features:** -- Reading environment variables within tools -- Conditional behavior based on environment -- Environment validation and defaults - -### Custom Dependencies - -**File**: `examples/custom-dependencies/` - -**What it demonstrates:** -- Dependency injection with PSR-11 containers -- Service layer architecture -- Repository pattern implementation -- Complex business logic integration - -**Key Features:** -```php -$container->set(TaskRepositoryInterface::class, $taskRepo); -$container->set(StatsServiceInterface::class, $statsService); - -$server = Server::builder() - ->setContainer($container) - ->setDiscovery(__DIR__, ['.']) -``` - -### Cached Discovery - -**File**: `examples/cached-discovery/` - -**What it demonstrates:** -- Discovery caching for improved performance -- PSR-16 cache integration -- Cache invalidation strategies - -**Key Features:** -```php -use Symfony\Component\Cache\Adapter\FilesystemAdapter; -use Symfony\Component\Cache\Psr16Cache; - -$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); - -$server = Server::builder() - ->setDiscovery(__DIR__, ['.'], [], $cache) -``` - -### Client Communication - -**File**: `examples/client-communication/` - -**What it demonstrates:** -- Server initiated communication back to the client -- Logging, sampling, progress and notifications -- Using `ClientGateway` in tool method via method argument injection of `RequestContext` - -### Discovery User Profile - -**File**: `examples/discovery-userprofile/` - -**What it demonstrates:** -- HTTP transport with StreamableHttpTransport -- Resource templates with URI parameters -- Completion providers for parameter hints -- User profile management system -- Session persistence with FileSessionStore - -**Key Features:** -```php -#[McpResourceTemplate( - uriTemplate: 'user://{userId}/profile', - name: 'user_profile', - mimeType: 'application/json' -)] -public function getUserProfile( - #[CompletionProvider(values: ['101', '102', '103'])] - string $userId -): array - -#[McpPrompt(name: 'generate_bio_prompt')] -public function generateBio(string $userId, string $tone = 'professional'): array -``` - -**Usage:** -```bash -# Start the HTTP server -php -S localhost:8000 examples/discovery-userprofile/server.php - -# Test with MCP Inspector -npx @modelcontextprotocol/inspector http://localhost:8000 - -# Or configure in MCP client: http://localhost:8000 -``` - -### Combined Registration - -**File**: `examples/combined-registration/` - -**What it demonstrates:** -- Mixing attribute discovery with manual registration -- HTTP server with both discovered and manual capabilities -- All three handler styles: discovered, `[Class::class, 'method']`, and a pre-built `[$instance, 'method']` -- Pre-built instance handlers for classes the container cannot auto-wire (e.g. constructor scalars) - -**Key Features:** -```php -// Built here so its constructor dependencies are injected before registration; -// the SDK invokes this very instance instead of constructing one itself. -$preconfiguredGreeter = new PreconfiguredGreeter('Willkommen', logger()); - -$server = Server::builder() - ->setDiscovery(__DIR__, ['.']) // Automatic discovery - ->addTool([ManualHandlers::class, 'manualGreeter']) // Manual class-string handler - ->addTool([$preconfiguredGreeter, 'greet'], 'instance_greeter') // Pre-built instance handler - ->addResource([ManualHandlers::class, 'getPriorityConfigManual'], 'config://priority') -``` - -### Complex Tool Schema - -**File**: `examples/complex-tool-schema/` - -**What it demonstrates:** -- Advanced JSON schema definitions -- Complex data structures and validation -- Event scheduling and management -- Enum types and nested objects - -**Key Features:** -```php -#[Schema(definition: [ - 'type' => 'object', - 'properties' => [ - 'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], - 'eventType' => ['type' => 'string', 'enum' => ['meeting', 'deadline', 'reminder']], - 'priority' => ['type' => 'string', 'enum' => ['low', 'medium', 'high', 'urgent']] - ] -])] -public function scheduleEvent(array $eventData): array -``` - -### Schema Showcase - -**File**: `examples/schema-showcase/` - -**What it demonstrates:** -- Comprehensive JSON schema features -- Parameter-level schema validation -- String constraints (minLength, maxLength, pattern) -- Numeric constraints (minimum, maximum, multipleOf) -- Array and object validation - -**Key Features:** -```php -#[McpTool] -public function formatText( - #[Schema( - type: 'string', - minLength: 5, - maxLength: 100, - pattern: '^[a-zA-Z0-9\s\.,!?\-]+$' - )] - string $text, - - #[Schema(enum: ['uppercase', 'lowercase', 'title', 'sentence'])] - string $format = 'sentence' -): array -``` - -### Elicitation - -**File**: `examples/server/elicitation/` - -**What it demonstrates:** -- Server-to-client elicitation requests -- Interactive user input during tool execution -- Multi-field form schemas with validation -- Boolean confirmation dialogs -- Enum fields with human-readable labels -- Handling accept/decline/cancel responses -- Session persistence requirement for server-initiated requests - -**Key Features:** -```php -// Check client support before eliciting -if (!$context->getClientGateway()->supportsElicitation()) { - return ['status' => 'error', 'message' => 'Client does not support elicitation']; -} - -// Build schema with multiple field types -$schema = new ElicitationSchema( - properties: [ - 'party_size' => new NumberSchemaDefinition( - title: 'Party Size', - integerOnly: true, - minimum: 1, - maximum: 20 - ), - 'date' => new StringSchemaDefinition( - title: 'Reservation Date', - format: 'date' - ), - 'dietary' => new EnumSchemaDefinition( - title: 'Dietary Restrictions', - enum: ['none', 'vegetarian', 'vegan'], - enumNames: ['None', 'Vegetarian', 'Vegan'] - ), - ], - required: ['party_size', 'date'] -); - -// Send elicitation request -$result = $client->elicit( - message: 'Please provide your reservation details', - requestedSchema: $schema -); - -// Handle response -if ($result->isAccepted()) { - $data = $result->content; // User-provided data -} elseif ($result->isDeclined() || $result->isCancelled()) { - // User declined or cancelled -} -``` - -**Important Notes:** -- Elicitation requires a session store (e.g., `FileSessionStore`) -- Check client capabilities with `supportsElicitation()` before sending requests -- Schema supports primitive types: string, number/integer, boolean, enum -- String fields support format validation: date, date-time, email, uri -- Users can accept (providing data), decline, or cancel requests - -**Usage:** -```bash -# Interactive testing with MCP client that supports elicitation -npx @modelcontextprotocol/inspector php examples/server/elicitation/server.php - -# Test with Goose (confirmed working by reviewer) -# Or configure in Claude Desktop or other MCP clients -``` - -**Example Tools:** -1. **book_restaurant** - Multi-field reservation form with number, date, and enum fields -2. **confirm_action** - Simple boolean confirmation dialog -3. **collect_feedback** - Rating and comments form with optional fields - -### MCP Apps - -**File**: `examples/server/mcp-apps/` - -A weather app demonstrating the [MCP Apps extension](extensions.md): a `ui://` -HTML resource is opened by an MCP App-aware client (e.g. Goose) and bridged to -the `get_weather` tool. The bundled `weather-app.html` performs the -`ui/initialize` handshake, reports its size via `ui/notifications/size-changed`, -and calls back into the server. See the -[ext-apps repo](https://github.com/modelcontextprotocol/ext-apps) for the -TypeScript SDK and richer view-side patterns. - -## Client Examples - -### STDIO Discovery Calculator (Client) - -**File**: `examples/client/stdio_discovery_calculator.php` - -**What it demonstrates:** -- Basic MCP client usage with STDIO transport -- Connecting to a local MCP server process -- Listing and calling tools -- Reading resources - -**Key Features:** -```php -$client = Client::builder() - ->setClientInfo('STDIO Example Client', '1.0.0') - ->setInitTimeout(30) - ->setRequestTimeout(60) - ->build(); - -$transport = new StdioTransport( - command: 'php', - args: [__DIR__.'/../server/discovery-calculator/server.php'], -); - -$client->connect($transport); -$tools = $client->listTools(); -$result = $client->callTool('calculate', ['a' => 5, 'b' => 3, 'operation' => 'add']); -$resourceContent = $client->readResource('config://calculator/settings'); -``` - -**Usage:** -```bash -# Run the client (automatically starts the server) php examples/client/stdio_discovery_calculator.php ``` -### HTTP Discovery Calculator (Client) +> **Note**: PHP's built-in development server handles one request at a time, so the sampling +> round-trip in `http_client_communication.php` will not complete under a plain `php -S`. +> Start it with worker processes instead: `PHP_CLI_SERVER_WORKERS=2 php -S 127.0.0.1:8000 …`. -**File**: `examples/client/http_discovery_calculator.php` +## The 2026-07-28 lifecycle -**What it demonstrates:** -- MCP client with HTTP transport -- Connecting to remote MCP servers -- Listing tools, resources, and prompts +`stateless-lifecycle/server.php` answers both eras on the same URL. On revision `2026-07-28` +every request carries its own protocol version and client capabilities, so a call is a single +POST with no handshake before it: -**Key Features:** -```php -$transport = new HttpTransport('http://localhost:8000'); -$client->connect($transport); - -$tools = $client->listTools(); -$resources = $client->listResources(); -$prompts = $client->listPrompts(); -``` - -**Usage:** ```bash -# Start the server first -php -S localhost:8000 examples/server/http-discovery-calculator/server.php - -# Then run the client -php examples/client/http_discovery_calculator.php +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php ``` -### STDIO Client Communication - -**File**: `examples/client/stdio_client_communication.php` - -**What it demonstrates:** -- Server-to-client communication (logging, progress, sampling) -- Handling logging notifications from server -- Implementing sampling callbacks for LLM requests -- Progress tracking during tool execution - -**Key Features:** -```php -use Mcp\Client\Handler\Notification\LoggingNotificationHandler; -use Mcp\Client\Handler\Request\SamplingRequestHandler; -use Mcp\Client\Handler\Request\SamplingCallbackInterface; -use Mcp\Schema\ClientCapabilities; - -$loggingHandler = new LoggingNotificationHandler( - static function (LoggingMessageNotification $n) { - echo "[LOG {$n->level->value}] {$n->data}\n"; - } -); - -$samplingHandler = new SamplingRequestHandler(new class implements SamplingCallbackInterface { - public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult - { - // Perform LLM sampling and return result - } -}); - -$client = Client::builder() - ->setCapabilities(new ClientCapabilities(sampling: true)) - ->addNotificationHandler($loggingHandler) - ->addRequestHandler($samplingHandler) - ->build(); - -// Call tool with progress tracking -$result = $client->callTool( - name: 'run_dataset_quality_checks', - arguments: ['dataset' => 'customer_orders_2024'], - onProgress: static function (float $progress, ?float $total, ?string $message) { - $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; - echo "[PROGRESS {$percent}%] {$message}\n"; - } -); -``` - -**Usage:** ```bash -# Run the client (automatically starts the communication server) -php examples/client/stdio_client_communication.php +curl -sS http://127.0.0.1:8000/ \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: server/discover' \ + -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientCapabilities":{}}}}' ``` -### HTTP Client Communication +The example's header comments document the equivalent handshake-era conversation against the +same endpoint. [Serving both eras](run/protocol-eras.md) explains how the routing works. -**File**: `examples/client/http_client_communication.php` +## Modern-era client -**What it demonstrates:** -- Server-to-client communication over HTTP -- Receiving logging and progress notifications via SSE streaming -- Implementing sampling for HTTP-based servers -- Progress tracking with long-running operations +`stateless_lifecycle_client.php` drives the server above from PHP: it pins +`ProtocolVersion::V2026_07_28`, skips the handshake, discovers the server through +`server/discover` and calls a tool — one process, no session: -**Key Features:** -- Same client-side code as STDIO version -- Uses HttpTransport instead of StdioTransport -- Demonstrates SSE-based real-time notifications -- Shows HTTP session management - -**Usage:** ```bash -# Start the server -php -S 127.0.0.1:8000 examples/server/client-communication/server.php - -# Run the client -php examples/client/http_client_communication.php +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php & +php examples/client/stateless_lifecycle_client.php ``` -> [!NOTE] -> For sampling with HTTP transport, the server must support concurrent request processing (e.g., using Symfony CLI, PHP-FPM, or a production web server). PHP's built-in development server cannot handle the concurrent requests required for sampling. +See [Clients on the modern revision](client/connecting.md) for the API it uses. diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 0000000..a280d7f --- /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 0000000..1fbb8a3 --- /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 0000000..eada9d3 --- /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 0000000..27e146a --- /dev/null +++ b/docs/get-started/inspector.md @@ -0,0 +1,88 @@ +# Try it with the Inspector + +The [MCP Inspector](https://modelcontextprotocol.io/docs/latest/tools/inspector) is an interactive +UI for poking at a server: it lists what the server exposes and lets you call it by +hand. It is the fastest way to see whether your server does what you think it does. + +It is a Node.js application, so this needs `npx` on your `PATH`. + +## STDIO + +Point the Inspector at the command that starts your server — it launches the process +itself: + +```bash +npx @modelcontextprotocol/inspector php server.php +``` + +Open the URL it prints. Under **Tools**, call `add` with `a=1` and `b=2`; you get `3` +back. The form the Inspector built for you — a required integer field for each argument +— came from the type hints on the method. So will the schema every other MCP host sees. + +Under **Resources**, read `config://calculator/settings` to get the array back as JSON. + +## HTTP + +A server behind a web server speaks the [HTTP transport](../run/http.md) instead, so the +last lines of `server.php` change — `StdioTransport` reads stdin and would just block +under `php -S`: + +```php title="server.php (HTTP variant)" +use Http\Discovery\Psr17Factory; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StreamableHttpTransport; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; + +$request = (new Psr17Factory())->createServerRequestFromGlobals(); + +$response = Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->build() + ->run(new StreamableHttpTransport($request)); + +(new SapiEmitter())->emit($response); +``` + +That needs a PSR-17 implementation and an emitter +(`composer require nyholm/psr7 laminas/laminas-httphandlerrunner`). Start it, then give +the Inspector its URL: + +```bash +php -S localhost:8000 server.php +npx @modelcontextprotocol/inspector http://localhost:8000 +``` + +`curl` works too, if you would rather see the wire format: + +```bash +curl -X POST http://localhost:8000 \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' +``` + +## Connect a real host + +Hosts that launch local servers take the same command the Inspector did. For Claude +Desktop, that is an entry in its configuration file: + +```json +{ + "mcpServers": { + "calculator": { + "command": "php", + "args": ["/absolute/path/to/server.php"] + } + } +} +``` + +Use an absolute path: the host does not run the command from your project directory. + +## Next + +* Add more of what a server can expose: **[Servers](../servers/index.md)**. +* Put it on the web instead of stdin/stdout: **[HTTP transport](../run/http.md)**. +* Drive a server from PHP instead of a UI: **[Clients](../client/index.md)**. diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md new file mode 100644 index 0000000..4edca46 --- /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/handlers/client-communication.md b/docs/handlers/client-communication.md new file mode 100644 index 0000000..1b84409 --- /dev/null +++ b/docs/handlers/client-communication.md @@ -0,0 +1,143 @@ +# Talking back to the client + +MCP supports various ways a server can communicate back to a client on top of the main +request-response flow. + +> **Protocol revision `2026-07-28`.** Logging, progress and notifications work as described +> below on every revision; under the modern lifecycle they travel on the request's own +> response stream and the client opts into each. Sampling is the exception — see its section. +> Asking the user something has a page of its own: [Asking for input](input-required.md). + +## ClientGateway + +Every communication back to client is handled using the `Mcp\Server\ClientGateway` and its dedicated methods per +operation. 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: + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Server\RequestContext; + +class MyService +{ + #[McpTool(name: 'my_tool', description: 'My Tool Description')] + public function myTool(RequestContext $context): string + { + $context->getClientGateway()->log(...); +``` + +## Request metadata + +`RequestContext` also carries what the current request said about itself, which is useful when a feature is only +available from a certain revision on: + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +if ($context->getProtocolVersion()->isAtLeast(ProtocolVersion::V2026_07_28)) { + // e.g. a bare list is only valid as `structuredContent` from this revision on +} +``` + +Two more accessors read what the client declared, and both work in either +[protocol era](../protocol-versions.md) — negotiated once during the handshake, or declared per request from +`2026-07-28` on: + +```php +$context->getClientCapabilities(); // what this client declared, or null in the handshake era +$context->getTraceContext(); // traceparent / tracestate / baggage, verbatim +``` + +`ClientGateway`'s capability probes — `supportsElicitation()`, `supportsSampling()`, `supportsRoots()` and the +sub-capability variants — read the same declaration. + +W3C trace context is passed through exactly as it arrived, and echoed onto every notification the request causes, +so a span stays joined across the response stream. Reading it adds no OpenTelemetry dependency — the values are +strings. + +## Sampling + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. It keeps working on a handshake-era connection until then, but that revision removed server-initiated requests outright, so `sample()` — like `listRoots()` — raises a `LogicException` when a modern-era client made the call. New integrations should call an LLM provider's API directly instead. + +With [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) servers can request clients to +execute "completions" or "generations" with a language model for them: + +```php +$result = $clientGateway->sample('Roses are red, violets are', 350, 90, ['temperature' => 0.5]); +``` + +The `sample` method accepts four arguments: + +1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `Mcp\Schema\Content\SamplingMessage` instances. +2. `maxTokens`, which defaults to `1000` +3. `timeout` in seconds, which defaults to `120` +4. `options` which might include `systemPrompt`, `preferences` for model choice, `includeContext`, `temperature`, + `stopSequences`, `metadata`, `tools`, and `toolChoice` + +Both `tools`/`toolChoice` and `includeContext` are gated on what the client advertised, so check before sending: + +```php +if ($clientGateway->supportsSamplingTools()) { + $result = $clientGateway->sample($messages, options: ['tools' => $tools]); +} +``` + +A server **must not** send `tools` or `toolChoice` to a client that did not advertise `sampling.tools`. The +`includeContext` values other than `none` are soft-deprecated and should only be sent when the client advertises +`sampling.context` — `supportsSamplingContext()` reports that one. + +### Tool loops + +When the model wants to call a tool, the result comes back with `stopReason: 'toolUse'` and one or more +`ToolUseContent` blocks. Execute them, then send a follow-up request with the assistant's message and a user message +carrying a matching `ToolResultContent` for every `ToolUseContent`: + +```php +$messages[] = new SamplingMessage(Role::Assistant, $result->content); +$messages[] = new SamplingMessage(Role::User, [new ToolResultContent($toolUse->id, [new TextContent($output)])]); +``` + +The specification is strict about the shape of that exchange: tool results may not be mixed with other content in a +message, and every tool use must be answered before the conversation continues. `sample()` checks these rules before +sending and throws an `InvalidArgumentException` rather than letting the client reject the request with `-32602`. +Use `$result->getContentBlocks()` to iterate the response regardless of whether it holds one block or a list. + +[Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#protocol-messages) + +## Logging + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. + +The [Logging](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging) utility enables servers +to send structured log messages as notification to clients: + +```php +use Mcp\Schema\Enum\LoggingLevel; + +$clientGateway->log(LoggingLevel::Warning, 'The end is near.'); +``` + +## Progress + +With a [Progress](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress#progress) +notification a server can update a client while an operation is ongoing: + +```php +$clientGateway->progress(4.2, 10, 'Downloading needed images.'); +``` + +Progress is opt-in by the client in both eras: it sends a `progressToken` with the request, and without one this +call sends nothing. + +## Notification + +Lastly, the server can push all kind of notifications, that extend the abstract `Mcp\Schema\JsonRpc\Notification` class +to the client to: + +```php +$clientGateway->notify($yourNotification); +``` diff --git a/docs/handlers/index.md b/docs/handlers/index.md new file mode 100644 index 0000000..e0db0a4 --- /dev/null +++ b/docs/handlers/index.md @@ -0,0 +1,42 @@ +# Inside your handler + +The methods you register are ordinary PHP methods, but they are not cut off from the +protocol. Type-hint a `Mcp\Server\RequestContext` argument anywhere in the signature and +the SDK passes it in — that object is the way back to the client mid-request. + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Schema\Content\TextContent; +use Mcp\Server\RequestContext; + +#[McpTool] +public function summarize(string $text, RequestContext $context): string +{ + $context->getClientLogger()->info(\sprintf('Summarizing %d characters', \strlen($text))); + + $result = $context->getClientGateway()->sample("Summarize:\n\n".$text, 500); + + // `content` is TextContent|ImageContent|AudioContent (or ToolUseContent + // blocks when the client samples with tools) + return $result->content instanceof TextContent ? $result->content->text : ''; +} +``` + +Two caveats on this example: clients drop log messages below `warning` unless they raise +the level first (see [Logging](logging.md)), and `sample()` belongs to the features +[deprecated by revision `2026-07-28`](../protocol-versions.md#deprecations) — on that +revision it throws, and [Asking for input](input-required.md) is the way to write it +instead. + +* **[Talking back to the client](client-communication.md)** — the `ClientGateway`: + asking the client's model for a completion (sampling), reporting progress on a long + call, and sending notifications. +* **[Logging](logging.md)** — structured PSR-3 log messages that surface in the client, + not in your server's log file. +* **[Asking for input](input-required.md)** — `ClientGateway::elicit()`, or returning an + `InputRequiredResult` when a handler needs several answers at once. Either way, one + handler serves both [protocol eras](../protocol-versions.md). + +Handlers that need application services (a database connection, an API client) get them +from the container instead; see +[Service dependencies](../run/server-builder.md#service-dependencies). diff --git a/docs/handlers/input-required.md b/docs/handlers/input-required.md new file mode 100644 index 0000000..39ebe11 --- /dev/null +++ b/docs/handlers/input-required.md @@ -0,0 +1,111 @@ +# Asking for input + +Some handlers cannot finish in one go: they need the user to confirm something, fill in a +form, name a directory, or have the client's model draft a paragraph. There are two ways to +write that: ask for it, or return the ask. + +## Just asking + +For elicitation, ask and use the answer: + +```php +static function (RequestContext $context): string { + $answer = $context->getClientGateway()->elicit('Your name?', $schema, key: 'who'); + + return "Hello, {$answer->content['name']}!"; +} +``` + +`key` names an ask, so its answer keeps finding the question it belongs to. Leave it out and +asks are keyed by position — `elicitation_1`, `elicitation_2`, … — which holds as long as the +handler reaches them in the same order every time. + +**Write the handler so it can run more than once.** Some clients answer inside the open +request; others answer by re-sending the whole call, which enters your handler again from the +top, once per question. Everything above an ask therefore has to be safe to repeat — put side +effects after the last one, and re-derive where you are from the answers rather than from +anything you kept. + +Answers given in an earlier round travel in the [`requestState`](#requeststate), so a handler +asking more than once needs `Builder::setRequestState()` configured. +[`examples/server/elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) +is written this way. + +## Returning the ask + +The explicit form: **return** an `InputRequiredResult` naming what you need, and read the +answer off `RequestContext` when the call comes back. It is more to write, and it is the only +way to ask several things in **one** round trip, to carry your own state, or to ask for +anything other than elicitation. + +> **Revision `2026-07-28`.** Multi round-trip requests (MRTR) are that revision's feature, and +> only there does the protocol itself carry this shape. Over a handshake-era connection the SDK +> emulates it: the input-required shim sends each ask as the real `elicitation/create` / +> `sampling/createMessage` / `roots/list` and re-enters your handler with the answers. That is +> on by default and bounded by `setInputRequiredLimits()` — each round holds the originating +> request open, so it holds a worker for as long as the user takes — and +> `withoutInputRequiredShim()` turns it off, after which such a handler fails there. See +> [Server builder](../run/server-builder.md). + +```php +use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\InputRequiredResult; + +static function (RequestContext $context): CallToolResult|InputRequiredResult { + $answer = $context->getInputContext()?->elicitResult('who'); + + if (null === $answer) { + return new InputRequiredResult( + ['who' => new ElicitRequest('Your name?', $schema)], + requestState: $context->mintRequestState(['asked' => 'who']), + ); + } + + return new CallToolResult([new TextContent("Hello, {$answer->content['name']}!")]); +} +``` + +`tools/call`, `prompts/get` and `resources/read` may answer this way; nothing else may. + +## Reading the answers + +`InputContext` hands them back typed — `elicitResult()`, `samplingResult()`, +`rootsResult()` — and returns `null` for an answer that is absent *or* malformed. Both mean +the same thing to a handler: ask again. `response()` is still there for the raw array. + +## `requestState` + +Whatever the server needs to remember between rounds. It travels through the client, so it +is attacker-controlled on return; `mintRequestState()` seals it with an HMAC and a TTL, and +a state that fails verification never reaches a handler. Configure the key with +`Builder::setRequestState()`: + +```php +->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) +``` + +The **same key must reach every process that might serve the retry**. A per-process random +value works only for a single-process deployment. Nothing secret belongs in the payload — +it is signed, not encrypted. + +## Capabilities + +A server must not ask for input the client cannot provide. The SDK checks each ask against +the request's declared capabilities and answers `-32021` — with the missing set in +`data.requiredCapabilities` — rather than sending an ask that could never be answered. +Url-mode elicitation needs its own `elicitation.url` declaration; a bare `elicitation` means +form mode only. + +## What not to call + +`ClientGateway::sample()` and `listRoots()` belong to the handshake era — revision +`2026-07-28` removed both outright, so calling one there raises a `LogicException`. Take what +they gave you from tool arguments, resource URIs or server configuration instead. `elicit()` +and `elicitUrl()` are unaffected: elicitation survived that revision, as an ask carried in the +result. + +## Which revision called + +Nothing above forks on it. `elicit()` works the same on every revision — only the mechanics +underneath differ, and the SDK picks them. The one thing to keep in mind is the rule already +stated: a handler that asks may be entered again from the top, so let it repeat safely. diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md new file mode 100644 index 0000000..1a10e50 --- /dev/null +++ b/docs/handlers/logging.md @@ -0,0 +1,39 @@ +# Logging + +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Logging keeps working until then; new integrations should log to stderr (stdio) or use OpenTelemetry instead. + +The SDK provides support to send log messages to clients. All standard PSR-3 log levels are supported. +Level **warning** is the default level, so anything below it is dropped until the client raises the level with +`logging/setLevel`. + +!!! note + Under the [2026-07-28 lifecycle](../run/protocol-eras.md#what-a-modern-request-carries) there is no + `logging/setLevel`: the client names its level in each request's + `_meta["io.modelcontextprotocol/logLevel"]`, and a request naming none receives no log + notifications at all. + +!!! note + Only the message is forwarded to the client. A PSR-3 `$context` array is accepted for interface compatibility + but is **not** sent — interpolate anything you need into the message itself. + +## Usage + +The SDK automatically injects a `RequestContext` instance into handlers. This can be used to create a `ClientLogger`. + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Server\RequestContext; + +#[McpTool] +public function processData(string $input, RequestContext $context): array { + $logger = $context->getClientLogger(); + + $logger->info(\sprintf('Processing started for "%s"', $input)); + $logger->warning('Deprecated API used'); + + // ... processing logic ... + + $logger->info('Processing completed'); + return ['result' => 'processed']; +} +``` diff --git a/docs/index.md b/docs/index.md index 9116229..4b31a92 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,11 +1,105 @@ -# MCP PHP SDK Guides +# MCP PHP SDK -- [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. +The **Model Context Protocol (MCP)** lets applications provide context to LLMs in a +standardized way, separating the concern of *providing* context from the LLM +interaction itself. + +This is the official PHP SDK for it, a collaboration between +[the PHP Foundation](https://thephp.foundation/) and the +[Symfony project](https://symfony.com/). With it you can: + +* **Build MCP servers** that expose tools, resources, and prompts to any MCP host. +* **Build MCP clients** that connect to any MCP server. +* Speak both standard transports: STDIO and Streamable HTTP. + +!!! warning "Experimental until 1.0" + This SDK is [experimental](https://symfony.com/doc/current/contributing/code/experimental.html) + until the first major release; see the + [roadmap](https://github.com/modelcontextprotocol/php-sdk/blob/main/ROADMAP.md) + for what is planned next. + +## Requirements + +PHP 8.1+. + +## Installation + +```bash +composer require mcp/sdk +``` + +See [Installation](get-started/installation.md) for the optional PSR packages an HTTP +server or client needs. + +## Example + +Create a file `server.php`: + +```php-file title="server.php" + 2]; + } +} + +Server::builder() + ->setServerInfo('Calculator', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build() + ->run(new StdioTransport()); +``` + +That's a complete MCP server. It exposes one **tool**, `add`, and one **resource**, +`config://calculator/settings`. + +Attribute discovery needs `symfony/finder` (`composer require symfony/finder`). Without +it the server still starts, but discovers nothing and only logs a warning. + +Look at what you did *not* write: no JSON Schema — `int $a, int $b` *is* the schema — +no request parsing, no serialization, no protocol handling. You wrote a PHP class with +type hints and a docblock; the SDK does the rest. + +[First server](get-started/first-server.md) walks through running it, and +[Try it with the Inspector](get-started/inspector.md) opens it in a UI you can click +around in. + +## Where to go next + +* **[Get started](get-started/index.md)** takes you from `composer require` to a server + a real MCP host can talk to. +* What a server exposes — tools, resources, prompts — is **[Servers](servers/index.md)**. +* Getting it in front of clients (STDIO, HTTP, your existing Symfony or Laravel app) is + **[Running your server](run/index.md)**. +* What is available *inside* the functions you register is + **[Inside your handler](handlers/index.md)**. +* Building the other side, an application that *uses* MCP servers, is + **[Clients](client/index.md)**. +* The two protocol eras, and what revision `2026-07-28` changed, are + **[Protocol versions](protocol-versions.md)**. +* What gets deprecated, for how long, and where the SDK stands on spec-tier + support are under **Project**: **[Deprecation policy](deprecation-policy.md)** + and **[SDK tier target](sdk-tier.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 0f8c9eb..0000000 --- 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/protocol-versions.md b/docs/protocol-versions.md new file mode 100644 index 0000000..09081ba --- /dev/null +++ b/docs/protocol-versions.md @@ -0,0 +1,178 @@ +# Protocol versions + +MCP has two eras. Everything up to `2025-11-25` opens with an `initialize` handshake and +keeps the negotiated revision on a session. Protocol revision `2026-07-28` removed both: +everything a server needs to answer a request travels *in* that request, so any process can +answer any request and none of them need to share state. + +The SDK serves both, and a server built the ordinary way answers either. This page is the +map; the mechanics live with the task they belong to. + +## The two eras + +| | Handshake era (`2025-11-25` and earlier) | Modern era (`2026-07-28`) | +| --- | --- | --- | +| Opening | `initialize` / `notifications/initialized` | none | +| Version | negotiated once, kept on the session | declared on every request | +| Capabilities | exchanged once | declared on every request | +| Discovery | `initialize` result | `server/discover` | +| Sessions | `Mcp-Session-Id` | removed | +| Server → client requests | sent as JSON-RPC requests | returned in the result (MRTR) | +| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` | +| Dispatcher | `Protocol` | `StatelessProtocol` | +| HTTP entry | `StreamableHttpTransport` — the same one, for both | + +`ProtocolVersion::isModern()` tells the two apart, and +`Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` is where the boundary sits. + +## Negotiating in the handshake era + +Revisions up to `2025-11-25` agree on one during `initialize`. The client names the revision it wants to speak, and +the server answers with the revision the connection will actually use. Both sides disconnect if they cannot agree. +This follows the +[protocol version negotiation](https://modelcontextprotocol.io/specification/latest/basic/versioning#protocol-version-negotiation) +section of the specification. The modern era negotiates nothing — each request names its own revision. + +The SDK's known revisions live in `Mcp\Schema\Enum\ProtocolVersion`, declared oldest to newest: + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +ProtocolVersion::latestHandshake(); // newest revision reachable via `initialize` +ProtocolVersion::handshakeVersions(); // every revision the server will negotiate, oldest first +ProtocolVersion::modernVersions(); // every revision served without a handshake +ProtocolVersion::V2025_11_25->isAtLeast(ProtocolVersion::V2025_06_18); // true +``` + +Comparisons go through declaration order rather than string collation. The identifiers happen to be ISO dates today, +but they are an enumerated set rather than an ordered scalar, so nothing should assume they sort chronologically. + +### How the server answers + +| Client requests | Server responds with | +| --- | --- | +| A revision the server supports | That same revision | +| An unknown or malformed revision | `ProtocolVersion::latestHandshake()` as a counter-offer | +| A modern revision such as `2026-07-28` | `ProtocolVersion::latestHandshake()` as a counter-offer | + +A counter-offer is not an error: the client decides whether it can continue on the offered revision or must close the +connection. The negotiated revision is stored on the session under `protocol_version`. + +The last row is not a rejection of an unknown revision — the SDK knows `2026-07-28`, it just cannot be reached through +this handshake. The modern era replaced `initialize` with per-request metadata, so answering with one of its revisions +would leave a connection neither side could use. A client speaking it never gets here: it sends the envelope instead +of an `initialize` request, and the transport routes it to the modern dispatcher before any negotiation is attempted. +See [Serving both eras](run/protocol-eras.md). + + +### Pinning a revision + +`Builder::setProtocolVersion()` pins the handshake to exactly one revision instead of negotiating across the supported +set. The pin wins over the client's request, so a client asking for anything else receives the pinned revision as a +counter-offer and has to decide whether to continue. Leave it unset unless you have a reason to refuse other +revisions. + +It pins the handshake era only. `setModernVersions()` narrows what the modern leg answers for, and +`withoutModernEra()` removes that leg altogether — see +[Serving one era only](run/protocol-eras.md#serving-one-era-only). + +!!! note + On the Streamable HTTP transport, every handshake-era request after the handshake also carries an + `MCP-Protocol-Version` header, which is validated separately by `ProtocolVersionMiddleware`. The pin does not reach + that check: the transport builds the middleware without access to the server configuration, so the header keeps + being accepted for every revision in `ProtocolVersion::handshakeVersions()`. To narrow it too, construct the + middleware yourself with the same revision — see + [Protocol Version Validation](run/http.md#protocol-version-validation). + +## What changes, and where it is written down + +Tools, resources, prompts and their handlers are unaffected — the same registrations serve +either lifecycle. What changes: + +* **[Asking for input](handlers/input-required.md)** — a handler that needs elicitation, + sampling or roots *returns* the ask instead of calling out. Write handlers this way and + they serve both eras. +* **[Serving both eras](run/protocol-eras.md)** — what a modern request carries, how one + endpoint classifies and routes each request, and how to serve one era only. +* **[Caching](run/caching.md)** — the `ttlMs` / `cacheScope` hints a cacheable result must + carry. +* **[Subscriptions](run/subscriptions.md)** — `subscriptions/listen` and the notification bus + behind it. +* **[Sessions](run/sessions.md)** — handshake-era only; the modern era has none. +* Progress and logging become per-request opt-ins — see + [Talking back to the client](handlers/client-communication.md#progress) and + [Logging](handlers/logging.md). + +## Speaking it from a client + +One line selects the lifecycle; nothing else about the [client API](client/index.md) changes. + +```php +$client = Client::builder() + ->setClientInfo('my-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($myElicitationHandler) + ->build(); + +$client->connect(new HttpTransport('https://example.com/mcp')); + +$client->callTool('greet', []); +``` + +What that changes underneath: + +- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the + server's identity, and a server that does not answer it still yields a usable connection — + the method is optional. If discovery *does* report `supportedVersions` and the configured + revision is not among them, the client moves to a modern revision the server lists, or + refuses the connection outright rather than talking past it. +- **An envelope on every request**, carrying the revision, the declared capabilities and the + client identity. The capabilities are what let a server decide, per request, whether it may + ask for input. +- **Headers on every POST** — `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` where the + method addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into + `Mcp-Param-*`, which requires the client to have listed the tool first; `tools/list` is what + populates that knowledge. A tool whose annotations are malformed is dropped from the listing + and refused if called, since the client cannot produce the headers it demands. +- **[Multi round-trip calls](handlers/input-required.md) are answered by the client.** A result + of `resultType: "input_required"` is resolved through the same + [request handlers](client/server-requests.md) that served server-initiated requests in the + handshake era, and the call is re-sent with `inputResponses` and the server's `requestState` + echoed back byte for byte, under a new JSON-RPC id. The caller sees one call and one result. + +Headers are an HTTP concern, so a transport opts into them by implementing +`HeaderAwareTransportInterface`; `HttpTransport` does, `StdioTransport` has nothing to carry +them on. Everything else — the envelope, the skipped handshake, the round-trip loop — applies +to both. + +See +[`examples/client/stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) +for a runnable version, described in [Examples](examples.md#modern-era-client). + +## What was removed + +Answered with `404` and `-32601` by a modern server: + +- `initialize`, `notifications/initialized` +- `ping` +- `logging/setLevel` — replaced by `_meta["io.modelcontextprotocol/logLevel"]` +- `resources/subscribe`, `resources/unsubscribe` — replaced by the `resourceSubscriptions` + filter of `subscriptions/listen` +- `notifications/roots/list_changed` + +Also gone: `Mcp-Session-Id`, the HTTP `GET` stream, and SSE resumability (`Last-Event-ID`). +A broken response stream loses the request; the client re-issues it with a new id. + +Error code `-32002` (resource not found) is retired in favour of `-32602`, and must not be +emitted by a server of this revision. The SDK picks the code from the revision serving the +request, so a handshake-era client still gets `-32002`. + +## Deprecations + +Roots, sampling and logging are all **deprecated** as of this revision +([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest +removal `2027-07-28`. They remain functional until then; new servers should pass directories +through tool arguments or resource URIs instead of roots, integrate with an LLM provider +directly instead of sampling, and log to `stderr` or OpenTelemetry instead of +`notifications/message`. diff --git a/docs/authorization.md b/docs/run/authorization.md similarity index 91% rename from docs/authorization.md rename to docs/run/authorization.md index 184eb7e..0985656 100644 --- a/docs/authorization.md +++ b/docs/run/authorization.md @@ -3,18 +3,6 @@ The PHP MCP SDK provides OAuth 2.1 authorization support for HTTP transports, implementing the [MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). -## Table of Contents - -- [Scope: what this SDK does and does not do](#scope-what-this-sdk-does-and-does-not-do) -- [Overview](#overview) -- [Quick Start](#quick-start) -- [Components](#components) -- [JWT Token Validation](#jwt-token-validation) -- [Protected Resource Metadata](#protected-resource-metadata) -- [Custom Token Validators](#custom-token-validators) -- [Scope-Based Access Control](#scope-based-access-control) -- [Examples](#examples) - ## Scope: what this SDK does and does not do The MCP server is an OAuth 2.1 **Resource Server**. It validates the tokens it receives and may @@ -30,7 +18,7 @@ and it does not issue tokens.** To issue tokens, front the MCP server with an external IdP (Keycloak, Auth0, Microsoft Entra ID, Okta) or run `league/oauth2-server` in your own application, and let the MCP server validate those tokens as a Resource Server. See -[adr/0001-oauth-authorization-server-out-of-scope.md](../adr/0001-oauth-authorization-server-out-of-scope.md). +[adr/0001-oauth-authorization-server-out-of-scope.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/adr/0001-oauth-authorization-server-out-of-scope.md). ## Overview @@ -62,6 +50,7 @@ Authorization in MCP is implemented at the transport level using PSR-15 middlewa ```php use Mcp\Server; use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware; +use Mcp\Server\Transport\Http\Middleware\OAuthRequestMetaMiddleware; use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware; use Mcp\Server\Transport\Http\OAuth\JwksProvider; use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; @@ -99,13 +88,20 @@ $metadataMiddleware = new ProtectedResourceMetadataMiddleware( // 5. Create transport with middleware $transport = new StreamableHttpTransport( $request, - middlewares: [$metadataMiddleware, $authMiddleware], + middleware: [ + ...StreamableHttpTransport::defaultMiddleware(), + $metadataMiddleware, + $authMiddleware, + // Bridges the OAuth attributes onto the JSON-RPC request meta, which is + // what makes them reachable from a handler (see Scope-Based Access Control). + new OAuthRequestMetaMiddleware(), + ], ); // 6. Run server $server = Server::builder() ->setServerInfo('Protected MCP Server', '1.0.0') - ->setDiscovery(__DIR__) + ->setDiscovery(__DIR__, excludeDirs: ['vendor']) ->build(); $response = $server->run($transport); @@ -155,7 +151,7 @@ $validator = new JwtTokenValidator( audience: 'mcp-server', // Expected audience (string or array) jwksProvider: $jwksProvider, // JwksProviderInterface jwksUri: null, // Explicit JWKS URI (auto-discovered) - algorithms: ['RS256', 'RS384'], // Allowed algorithms + algorithms: ['RS256', 'RS384', 'RS512'], // Allowed algorithms (this is the default) scopeClaim: 'scope', // Claim name for scopes ); ``` @@ -363,7 +359,10 @@ AuthorizationResult::badRequest('invalid_request', 'Malformed header'); #[McpTool(name: 'admin_action')] public function adminAction(RequestContext $context): array { - $scopes = $context->getRequest()?->getAttribute('oauth.scopes') ?? []; + // The OAuth attributes arrive on the request meta, under the `oauth` key. + // This requires OAuthRequestMetaMiddleware in the transport's middleware stack. + $meta = $context->getRequest()->getMeta() ?? []; + $scopes = $meta['oauth']['oauth.scopes'] ?? []; if (!in_array('mcp:admin', $scopes, true)) { throw new \RuntimeException('Admin scope required'); @@ -403,7 +402,7 @@ docker-compose up -d # Test credentials: demo / demo123 ``` -See [oauth-keycloak/README.md](../examples/server/oauth-keycloak/README.md) +See [oauth-keycloak/README.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/oauth-keycloak/README.md) ### Microsoft Entra ID Example @@ -414,7 +413,7 @@ cp env.example .env docker-compose up -d ``` -See [oauth-microsoft/README.md](../examples/server/oauth-microsoft/README.md) +See [oauth-microsoft/README.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/oauth-microsoft/README.md) ## Security Considerations diff --git a/docs/run/caching.md b/docs/run/caching.md new file mode 100644 index 0000000..777b66c --- /dev/null +++ b/docs/run/caching.md @@ -0,0 +1,28 @@ +# Caching + +> Caching hints belong to protocol revision `2026-07-28`; see [Protocol versions](../protocol-versions.md). +> A handshake-era client is served by the same server and simply never sees them. + +`server/discover`, the four list methods and `resources/read` **must** carry `ttlMs` and +`cacheScope`. The default is `ttlMs: 0, cacheScope: "private"` — conformant, and a flat +refusal to let anything be cached. Say what you actually mean: + +```php +use Mcp\Schema\Enum\CacheScope; +use Mcp\Server\Wire\CachePolicy; + +->setCachePolicy( + CachePolicy::default(30_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), +) +``` + +`public` lets a shared proxy serve one caller's answer to another, so use it only for +results that do not vary by caller. Only the operator can make that call, which is why the +conservative default stands until you change it. + +A `ReadResourceResult` may set its own `ttlMs`/`cacheScope`, which win over the policy. + +Results produced by an [MRTR retry](../handlers/input-required.md) are never given hints: their inputs +are not part of any cache key. diff --git a/docs/run/framework-integration.md b/docs/run/framework-integration.md new file mode 100644 index 0000000..f461773 --- /dev/null +++ b/docs/run/framework-integration.md @@ -0,0 +1,202 @@ +# Framework integration + +The HTTP transport is a PSR-7 request handler, not a web server. This page +covers how it fits into an application you already have. + +## Architecture + +The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that +your application can handle however it needs to: + +``` +Your Web App → PSR-7 Request → StreamableHttpTransport → PSR-7 Response → Your Web App +``` + +This design allows integration with any PHP framework or application that supports PSR-7. + +## Basic Usage (Standalone) + +Here's a simplified example using PSR-17 discovery and Laminas emitter: + +```php +use Http\Discovery\Psr17Factory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; +use Mcp\Server\Session\FileSessionStore; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; + +$psr17Factory = new Psr17Factory(); +$request = $psr17Factory->createServerRequestFromGlobals(); + +$server = Server::builder() + ->setServerInfo('HTTP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions + ->build(); + +$transport = new StreamableHttpTransport($request); + +$response = $server->run($transport); + +(new SapiEmitter())->emit($response); +``` + +## Framework Integration + +### Symfony Integration + +First install the required PSR libraries: + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` + +Then create a controller that uses Symfony's PSR-7 bridge: + +> **Note**: This example assumes your MCP `Server` instance is configured in Symfony's service container. + +```php +// In a Symfony controller +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +class McpController +{ + #[Route('/mcp', name: 'mcp_endpoint')] + public function handle(Request $request, Server $server): Response + { + // Convert Symfony request to PSR-7 (PSR-17 factories auto-discovered) + $psrHttpFactory = new PsrHttpFactory(); + $httpFoundationFactory = new HttpFoundationFactory(); + $psrRequest = $psrHttpFactory->createRequest($request); + + // Process with MCP (factories auto-discovered) + $transport = new StreamableHttpTransport($psrRequest); + $psrResponse = $server->run($transport); + + // Convert PSR-7 response back to Symfony + return $httpFoundationFactory->createResponse($psrResponse); + } +} +``` + +### Laravel Integration + +First install the required PSR libraries: + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` + +Then create a controller that type-hints `ServerRequestInterface`: + +> **Note**: This example assumes your MCP `Server` instance is constructed and bound in a Laravel service provider for dependency injection. + +```php +// In a Laravel controller +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +class McpController +{ + public function handle(ServerRequestInterface $request, Server $server): ResponseInterface + { + // Create the MCP HTTP transport + $transport = new StreamableHttpTransport($request); + + // Process MCP request and return PSR-7 response + // Laravel automatically handles PSR-7 responses + return $server->run($transport); + } +} + +// Route registration +Route::any('/mcp', [McpController::class, 'handle']); +``` + +### Slim Framework Integration + +Slim Framework works natively with PSR-7. + +Create a route handler using Slim's built-in factories and container: + +```php +use Slim\Factory\AppFactory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +$app = AppFactory::create(); + +$app->any('/mcp', function ($request, $response) { + $server = Server::builder() + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build(); + + $transport = new StreamableHttpTransport($request); + + return $server->run($transport); +}); +``` + +## HTTP Method Handling + +The transport handles all HTTP methods automatically: + +- **POST**: Send MCP requests +- **GET**: Not implemented (returns 405) +- **DELETE**: End session +- **OPTIONS**: CORS preflight + +You should route **all methods** to your MCP endpoint, not just POST. + +## Session Management + +HTTP transport requires persistent sessions since PHP doesn't maintain state between requests. Unlike STDIO transport +where in-memory sessions work fine, HTTP transport needs a persistent session store: + +```php +use Mcp\Server\Session\FileSessionStore; + +// ✅ Good for HTTP +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// ❌ Not recommended for HTTP (sessions lost between requests) +$server = Server::builder() + ->setSession(new InMemorySessionStore()) + ->build(); +``` + +## Recommended Route + +It's recommended to mount the MCP endpoint at `/mcp`, but this is not enforced: + +```php +// Recommended +Route::any('/mcp', [McpController::class, 'handle']); + +// Also valid +Route::any('/', [McpController::class, 'handle']); +Route::any('/api/mcp', [McpController::class, 'handle']); +``` + +## Testing HTTP Transport + +Use the MCP Inspector to test HTTP servers: + +```bash +# Start your PHP server +php -S localhost:8000 server.php + +# Connect with MCP Inspector +npx @modelcontextprotocol/inspector http://localhost:8000 +``` diff --git a/docs/run/http.md b/docs/run/http.md new file mode 100644 index 0000000..a57a8db --- /dev/null +++ b/docs/run/http.md @@ -0,0 +1,260 @@ +# 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. + +```php +use Psr\Http\Message\ServerRequestInterface; + +// PSR-17 factories are automatically discovered +$transport = new StreamableHttpTransport( + request: $serverRequest, // PSR-7 server request + responseFactory: null, // Optional: PSR-17 response factory (auto-discovered if null) + streamFactory: null, // Optional: PSR-17 stream factory (auto-discovered if null) + logger: $logger // Optional PSR-3 logger +); +``` + +## 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. +- **`streamFactory`** (optional): `StreamFactoryInterface` - PSR-17 factory for creating response body streams. Auto-discovered if not provided. +- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. +- **`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 + +The transport automatically discovers PSR-17 factory implementations from these popular packages: + +- `nyholm/psr7` +- `guzzlehttp/psr7` +- `slim/psr7` +- `laminas/laminas-diactoros` +- And other PSR-17 compatible implementations + +```bash +# Install any PSR-17 package - discovery works automatically +composer require nyholm/psr7 +``` + +If auto-discovery fails or you want to use a specific implementation, you can pass factories explicitly: + +```php +use Nyholm\Psr7\Factory\Psr17Factory; + +$psr17Factory = new Psr17Factory(); +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); +``` + +## Default Middleware + +When the `middleware` argument is omitted (or set to `null`), the transport installs a secure default stack: + +| Order | Middleware | Purpose | +|-------|------------|---------| +| 1 | `CorsMiddleware` | Applies CORS headers to every response. By default does **not** set `Access-Control-Allow-Origin` (cross-origin requests are blocked). | +| 2 | `DnsRebindingProtectionMiddleware` | Validates `Origin`/`Host` against an allowlist. Defaults to localhost variants only. | + +```php +// Zero-config, secure-by-default — local servers get full protection automatically. +$transport = new StreamableHttpTransport($request); +``` + +The default stack can be inspected and recomposed via the public factory: + +```php +$middleware = StreamableHttpTransport::defaultMiddleware(); +``` + +These run at the edge, before the request's protocol era is known, because what they enforce is true of +both eras. `ProtocolVersionMiddleware` is not in that stack: the `MCP-Protocol-Version` header rule belongs +to the handshake era, so the transport applies it only to requests it classified as handshake-era traffic, +and the modern leg answers for its own revisions. It is available as +`StreamableHttpTransport::handshakeMiddleware()` and is applied whether or not you replace the edge stack. +See [Serving both eras](protocol-eras.md). + +## 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: + +```php +use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; +use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; +use Mcp\Server\Transport\StreamableHttpTransport; + +// Reflect a specific origin +$transport = new StreamableHttpTransport( + $request, + middleware: [ + new CorsMiddleware(allowedOrigins: ['https://myapp.com']), + new DnsRebindingProtectionMiddleware(), + ], +); + +// Allow all origins (development only) +$transport = new StreamableHttpTransport( + $request, + middleware: [ + new CorsMiddleware(allowedOrigins: ['*']), + new DnsRebindingProtectionMiddleware(), + ], +); +``` + +When the allowlist is a concrete set of origins (not `['*']`), `CorsMiddleware` automatically adds `Vary: Origin` +so shared caches/CDNs do not serve a response generated for one origin to a request from another. + +Headers already present on a response (e.g. set by inner middleware) are preserved — `CorsMiddleware` only adds +defaults when they are absent. + +!!! 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 + +`DnsRebindingProtectionMiddleware` validates the `Origin` header against an allowlist (falling back to `Host` +when `Origin` is absent). The default allowlist is localhost-only: + +```php +use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; + +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 + +`ProtocolVersionMiddleware` rejects requests whose `MCP-Protocol-Version` header is not in the SDK's supported +set with `400 Bad Request`. Requests without the header pass through, since the `initialize` round-trip and some +legacy clients do not send it. + +It is applied by the transport itself, to handshake-era traffic only — do not add it to a custom `middleware` +list, where it would run before the era is classified and reject every modern-era request. Construct it +yourself only to narrow the supported set on a server that also pins its handshake: + +```php +use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; + +// Only accept the latest spec version +new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]); +``` + +The default set is `ProtocolVersion::handshakeVersions()` — every revision the server can actually negotiate over +`initialize`, rather than every revision the enum declares. A request without the header is treated as +`ProtocolVersion::DEFAULT_HEADER_VERSION` (`2025-03-26`), the revision that introduced both Streamable HTTP and the +header itself, so a header-less request cannot be newer than that. + +This header check is separate from, and happens after, the handshake itself. See +[Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for how the revision is agreed in the +first place. Being separate also means it is unaffected by `setProtocolVersion()`: the middleware validates against +the set it was constructed with, not against the revision a given session negotiated, so a server that pins the +handshake has to pass that revision here as well. + +Modern revisions never reach it. A `2026-07-28` request declares its revision in `params._meta` rather than in a +header, and is routed away from this check entirely — see +[Serving both eras](protocol-eras.md). + +## Request Body Size Limit + +`StreamableHttpTransport` caps the POST body it reads to guard against memory exhaustion from an oversized or +unbounded (chunked) payload. The default cap is 4 MiB. A body over the cap is rejected with `413` and never reaches +message parsing. + +```php +use Mcp\Server\Transport\StreamableHttpTransport; + +// Raise the cap to 16 MiB +$transport = new StreamableHttpTransport($request, maxBodyBytes: 16 * 1024 * 1024); +``` + +When the request stream advertises a size, the transport rejects it up-front. Otherwise (e.g. chunked transfer with +unknown size) the body is read incrementally and aborted as soon as it crosses the cap, so an unbounded stream cannot +exhaust memory. A value below `1` throws `InvalidArgumentException`. + +## JSON-RPC Batch Size Limit + +A JSON-RPC batch (top-level array) is capped at 100 messages. Oversized batches are rejected before any +message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap +lives on `Mcp\JsonRpc\MessageFactory` and is not currently configurable through the builder — a server built +with `Server::builder()` always uses the default of 100. + +Single-message vs batch is determined from the decoded JSON type — a JSON object is a single message, a JSON array +is a batch. Scalars, empty payloads, and non-object batch elements are returned as `InvalidInputMessageException` +entries (the existing per-message error contract), not parse errors or crashes. A `maxBatchSize` below `1` throws +`InvalidArgumentException`. + +## Custom PSR-15 Middleware + +`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 +short-circuited ones: + +```php +use Mcp\Server\Transport\StreamableHttpTransport; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; + +final class AuthMiddleware implements MiddlewareInterface +{ + public function __construct(private ResponseFactoryInterface $responses) + { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if (!$request->hasHeader('Authorization')) { + return $this->responses->createResponse(401); + } + + return $handler->handle($request); + } +} + +$transport = new StreamableHttpTransport( + $request, + logger: $logger, + middleware: [ + ...StreamableHttpTransport::defaultMiddleware(), + new AuthMiddleware($responseFactory), + ], +); +``` + +To selectively drop one default (for example DNS rebinding when running behind a proxy), filter the default list: + +```php +use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; +use Mcp\Server\Transport\StreamableHttpTransport; + +$transport = new StreamableHttpTransport( + $request, + middleware: [ + ...array_filter( + StreamableHttpTransport::defaultMiddleware(), + fn ($m) => !$m instanceof DnsRebindingProtectionMiddleware, + ), + new AuthMiddleware($responseFactory), + ], +); +``` + +Pass `middleware: []` to disable every default and run only your own chain: + +```php +$transport = new StreamableHttpTransport( + $request, + middleware: [new AuthMiddleware($responseFactory)], +); +``` diff --git a/docs/run/index.md b/docs/run/index.md new file mode 100644 index 0000000..208f22a --- /dev/null +++ b/docs/run/index.md @@ -0,0 +1,41 @@ +# Running your server + +`Server::builder()` configures a server; `run()` puts it on a transport and starts +answering. Every transport implements `TransportInterface` and is used the same way: + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build(); + +$transport = new SomeTransport(); + +$result = $server->run($transport); // Blocks for STDIO, returns a response for HTTP +``` + +## Choosing a transport + +The choice depends on the client you want to integrate with: + +* The client runs **locally** and launches your server as a subprocess (Claude Desktop, + most editors) → **[STDIO](stdio.md)**. +* The client is **remote**, or your server lives in a web application → + **[HTTP](http.md)**, the Streamable HTTP transport. + +The rest of this section: + +* **[Server builder](server-builder.md)** — every configuration knob: server info, + discovery, dependency injection, logging, pagination. +* **[Framework integration](framework-integration.md)** — mounting the HTTP transport in + a Symfony, Laravel, or Slim application, or running it standalone. +* **[Sessions](sessions.md)** — where per-client state lives, which matters as soon as + you serve HTTP from more than one process. +* **[Serving both eras](protocol-eras.md)** — the same endpoint also answers protocol + revision `2026-07-28`, which has no handshake and no sessions; what a modern request + carries, and how each one is routed. +* **[Caching](caching.md)** — the `ttlMs` / `cacheScope` hints a modern-era result carries. +* **[Subscriptions](subscriptions.md)** — `subscriptions/listen` and the notification bus + behind it. +* **[Authorization](authorization.md)** — validating OAuth 2 access tokens in front of + the HTTP transport. diff --git a/docs/run/protocol-eras.md b/docs/run/protocol-eras.md new file mode 100644 index 0000000..9c6cb28 --- /dev/null +++ b/docs/run/protocol-eras.md @@ -0,0 +1,136 @@ +# Serving both eras + +`Server::builder()->build()` produces a server carrying a dispatcher for each +[protocol era](../protocol-versions.md), and `StreamableHttpTransport` decides per request +which of them answers. There is nothing to configure: + +```php +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->addTool(static fn (string $city): string => "17°C in {$city}", name: 'get_weather', description: '…') + ->build(); + +(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request))); +``` + +That one endpoint answers `initialize` and `server/discover` alike. Modern-era requests +accept `POST` only; a `GET` or `DELETE` is a handshake-era session operation and is routed as +one. + +A full example lives in +[`examples/server/stateless-lifecycle/server.php`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/stateless-lifecycle), +described in [Examples](../examples.md#the-2026-07-28-lifecycle). + +## What a modern request carries + +There is no handshake to remember anything, so every request carries what the server needs to +answer it. Two members in `params._meta` are **required**, and the HTTP layer mirrors some of +them into headers so an intermediary can route without parsing the body: + +| `_meta` key | Required | Header | +| --- | --- | --- | +| `io.modelcontextprotocol/protocolVersion` | yes | `MCP-Protocol-Version` | +| `io.modelcontextprotocol/clientCapabilities` | yes | — | +| `io.modelcontextprotocol/clientInfo` | no | — | +| `io.modelcontextprotocol/logLevel` | no | — | +| `progressToken` | no | — | +| `traceparent`, `tracestate`, `baggage` | no | — | + +Plus `Mcp-Method` on every request, and `Mcp-Name` on `tools/call`, `prompts/get` and +`resources/read`. A header that disagrees with the body is refused with `-32020`; a missing +required `_meta` member with `-32602`; an unsupported version with `-32022`, carrying the +supported set for the client to retry from. + +What a handler can read off all this is +[Talking back to the client](../handlers/client-communication.md#request-metadata). + +### Mirroring a tool argument into a header + +A tool parameter annotated with `x-mcp-header` is mirrored into `Mcp-Param-{Name}` by the +client, and the server checks that the two agree: + +```php +->addTool( + static fn (string $region, string $query): string => …, + name: 'execute_sql', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'query' => ['type' => 'string'], + ], + 'required' => ['region', 'query'], + ], +) +``` + +The annotation must name a valid HTTP field, be unique case-insensitively, and sit on a +`string`, `integer` or `boolean` property reachable through `properties` keys alone. `Tool` +refuses a definition that breaks any of those rather than letting it fail later as a header +mismatch. See [Schema generation](../servers/schemas.md) for where a hand-written +`inputSchema` fits. + +## How a request is routed + +Every request is classified once, before anything else looks at it. The decision is +**body-primary**: + +| Evidence | Routed to | +| --- | --- | +| `params._meta` names a modern revision | modern era | +| `params._meta` names a handshake revision | handshake era | +| no such member | handshake era — `initialize` included | +| a notification with no member, under a modern header | modern era | +| `GET` / `DELETE` | handshake era | + +The `MCP-Protocol-Version` header never decides. It is cross-checked against the body, and a +request whose header contradicts its `_meta` is refused with `-32020` before either leg sees +it — the check has to happen at the edge, because a body claiming a handshake revision routes +to a leg that has no such check of its own. A modern header on a request carrying no envelope +is refused with `-32602` naming the member it wants. + +An unrecognised revision goes to whichever leg can answer it best: claimed in the envelope, +the modern leg answers, naming the modern revisions it serves; named only in a header, the +handshake leg answers, naming the handshake ones. + +Both legs come from **one** builder configuration — one registry, one set of handler +instances, one session manager. A tool registered once is reachable from both, and a change +made through one is visible to the other. + +## Middleware + +The [default middleware stack](http.md#default-middleware) runs at the edge, before the +request's era is known, because what it enforces is true of both. `ProtocolVersionMiddleware` +is not in that stack: the `MCP-Protocol-Version` header rule belongs to the handshake era, so +the transport applies it only to requests it classified as handshake-era traffic, and the +modern leg answers for its own revisions. It is available as +`StreamableHttpTransport::handshakeMiddleware()` and is applied whether or not you replace the +edge stack. + +## Serving one era only + +To serve the handshake era alone, say so: + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->withoutModernEra() + ->build(); +``` + +That server refuses a modern claim with `-32022`, naming the handshake revisions it does +serve. `setModernVersions()` narrows the modern leg instead of removing it. + +For the opposite — an endpoint that serves the modern era and nothing else — build the +dispatcher on its own and mount it on `StatelessHttpTransport`: + +```php +$protocol = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->buildStateless([ProtocolVersion::V2026_07_28]); + +(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request)); +``` diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md new file mode 100644 index 0000000..017d4a8 --- /dev/null +++ b/docs/run/server-builder.md @@ -0,0 +1,340 @@ +# Server builder + +The server `Builder` is a fluent builder class that simplifies the creation and configuration of an MCP server instance. +It provides methods for setting server information, configuring discovery, registering capabilities, and customizing +various aspects of the server behavior. + +## Basic Usage + +There are two ways to obtain a server builder instance: + +### Method 1: Static Builder Method (Recommended) + +```php +use Mcp\Server; + +$server = Server::builder() + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build(); +``` + +### Method 2: Direct Instantiation + +```php +use Mcp\Server\Builder; + +$server = (new Builder()) + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) + ->build(); +``` + +Both methods return a `Builder` instance that you can configure with fluent methods. The `build()` method returns the +final `Server` instance ready for use. + +## Server Configuration + +### Server Information + +Set the server's identity with name, version, and optional description: + +```php +use Mcp\Schema\Icon; +use Mcp\Server; + +$server = Server::builder() + ->setServerInfo( + name: 'Calculator Server', + version: '1.2.0', + description: 'Advanced mathematical calculations', + icons: [new Icon('https://example.com/icon.png', 'image/png', ['64x64'])], + websiteUrl: 'https://example.com', + ); +``` + +**Parameters:** + +- `$name` (string): The server name +- `$version` (string): Version string (semantic versioning recommended) +- `$description` (string|null): Optional description +- `$icons` (Icon[]|null): Optional array of server icons +- `$websiteUrl` (string|null): Optional server website URL +- `$title` (string|null): Optional human-readable display title, distinct from `$name` + +### Pagination Limit + +Configure the maximum number of items returned in paginated responses: + +```php +$server = Server::builder() + ->setPaginationLimit(100); // Default: 50 +``` + +### Instructions + +Provide hints to help AI models understand how to use your server: + +```php +$server = Server::builder() + ->setInstructions('This calculator supports basic arithmetic operations. Use the calculate tool for math operations and check the config resource for current settings.'); +``` + +### Protocol Version + +By default the server negotiates the protocol revision with each client during the `initialize` handshake, and you do +not need to configure anything. `setProtocolVersion()` pins that handshake to exactly one revision instead: + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +$server = Server::builder() + ->setProtocolVersion(ProtocolVersion::V2025_06_18); +``` + +See [Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for how negotiation resolves and +what pinning changes. It only pins the handshake era; to narrow or remove what the modern leg answers for, use +`setModernVersions()` / `withoutModernEra()` — see +[Serving both eras](protocol-eras.md#serving-one-era-only). + +## Modern-Era Options + +`build()` returns a server that answers both [protocol eras](../protocol-versions.md), and none of the following is +required to serve either one. Each knob is covered in depth elsewhere: +[Asking for input](../handlers/input-required.md) for the first two, +[Caching](caching.md), [Subscriptions](subscriptions.md), and +[Serving both eras](protocol-eras.md) for the last. + +```php +use Mcp\Schema\Enum\CacheScope; +use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Server\Subscription\Psr16NotificationBus; +use Mcp\Server\Wire\CachePolicy; + +$server = Server::builder() + // Signs the state a multi round-trip request carries through the client. + // The same key must reach every process that might serve the retry. + ->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) + + // Bounds the input-required shim, which fulfils an `InputRequiredResult` + // over a handshake-era connection. `withoutInputRequiredShim()` turns it off. + ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) + + // Caching hints stamped on cacheable results. Defaults to `ttlMs: 0, private`. + ->setCachePolicy(CachePolicy::default(30_000)->withMethod('tools/list', 3_600_000, CacheScope::Public)) + + // Delivery for `subscriptions/listen`, and how long such a stream is held. + ->setNotificationBus(new Psr16NotificationBus($cache)) + ->setSubscriptionLifetime(0) + + // Narrow the modern leg, or drop it entirely. + ->setModernVersions([ProtocolVersion::V2026_07_28]) + ->build(); +``` + +`buildStateless()` returns the modern dispatcher alone, for an endpoint that serves no handshake-era traffic at all. +Its requests are checked against the SEP-2243 standard headers — that `Mcp-Method`, `Mcp-Name` and `Mcp-Param-*` agree +with the body they travel with. `setHeaderValidator(false)` turns that check off, which is needed only when the +dispatcher is served by a transport that has no header layer to check. + +## Discovery Configuration + +**Required when using MCP attributes.** If you're using PHP attributes (`#[McpTool]`, `#[McpResource]`, `#[McpResourceTemplate]`, `#[McpPrompt]`) to define your MCP elements, you **MUST** configure discovery to tell the server where to look for these attributes. + +```php +$server = Server::builder() + ->setDiscovery( + basePath: __DIR__, + scanDirs: ['.', 'src', 'lib'], // Where to look for MCP attributes + excludeDirs: ['vendor', 'tests'], // Where NOT to look + cache: $cacheInstance, // Optional: cache discovered elements + namePatterns: ['*.php', '*.inc'], // Optional: list of filename patterns to match + ); +``` + +**Parameters:** + +- `$basePath` (string): Base directory for discovery (typically `__DIR__`) +- `$scanDirs` (array): Directories to recursively scan for `#[McpTool]`, `#[McpResource]`, etc. All subdirectories are included. (default: `['.', 'src']`) +- `$excludeDirs` (array): Directory names to exclude **within** the scanned directories during recursive scanning +- `$cache` (CacheInterface|null): Optional PSR-16 cache to store discovered elements for performance +- `$namePatterns` (array): Optional list of patterns (regexp, glob, or string) for file names (default: `['*.php']`) + +**Basic Discovery (scans current directory and `src/`):** +```php +$server = Server::builder() + ->setDiscovery(__DIR__, excludeDirs: ['vendor']) // Scans '.' and 'src' + ->build(); +``` + +!!! warning + Always exclude `vendor/` when the scanned directories contain it. The recursive scan + tries to load every class it finds, and a single file that cannot be loaded makes the + scan abort — the server then reports an **empty** element list instead of an error. + +**Production Setup with Caching:** +```php +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; + +// Cache discovered elements to avoid filesystem scanning on every server start +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); + +$server = Server::builder() + ->setDiscovery( + basePath: __DIR__, + scanDirs: ['src', 'lib'], // Scan these directories recursively + excludeDirs: ['vendor', 'tests', 'temp'], // Skip these directory names within scanned dirs + cache: $cache // Cache for performance + ) + ->build(); +``` + +**How `excludeDirs` works:** + +- If scanning `src/` and there's `src/vendor/`, it will be excluded +- If scanning `lib/` and there's `lib/tests/`, it will be excluded +- But if `vendor/` and `tests/` are at the same level as `src/`, they're not scanned anyway (not in `scanDirs`) + +> **Performance**: Always use a cache in production. The first run scans and caches all discovered MCP elements, making +> subsequent server startups nearly instantaneous. + +## Service Dependencies + +### Container + +The container is used to resolve handlers and their dependencies when handlers inject dependencies in their constructors. +The SDK includes a basic container with simple auto-wiring capabilities. + +```php +use Mcp\Capability\Registry\Container; + +// Use the default basic container +$container = new Container(); +$container->set(DatabaseService::class, new DatabaseService($pdo)); +$container->set(\PDO::class, $pdo); + +$server = Server::builder() + ->setContainer($container) + ->build(); +``` + +**Basic Container Features:** + +- Supports constructor auto-wiring for classes with parameterless constructors +- Resolves dependencies where all parameters are type-hinted classes/interfaces known to the container +- Supports parameters with default values +- Does NOT support scalar/built-in type injection without defaults +- Detects circular dependencies + +You can also use any PSR-11 compatible container (Symfony DI, PHP-DI, Laravel Container, etc.). + +### Logger + +Provide a PSR-3 logger instance for internal server logging (request/response processing, errors, session management, transport events): + +```php +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +$logger = new Logger('mcp-server'); +$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO)); + +$server = Server::builder() + ->setLogger($logger); +``` + +### Event Dispatcher + +Configure event dispatching: + +```php +$server = Server::builder() + ->setEventDispatcher($eventDispatcher); +``` + +## Complete Example + +Here's a comprehensive example showing all major configuration options: + +```php +use Mcp\Server; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Capability\Registry\Container; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +// Setup dependencies +$logger = new Logger('mcp-server'); +$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO)); + +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); +$sessionStore = new FileSessionStore(__DIR__ . '/sessions'); + +// Setup container with dependencies +$container = new Container(); +$container->set(\PDO::class, new \PDO('sqlite::memory:')); +$container->set(DatabaseService::class, new DatabaseService($container->get(\PDO::class))); + +// Build server +$server = Server::builder() + // Server identity + ->setServerInfo('Advanced Calculator', '2.1.0') + + // Performance and behavior + ->setPaginationLimit(100) + ->setInstructions('Use calculate tool for math operations. Check config resource for current settings.') + + // Discovery with caching + ->setDiscovery(__DIR__, ['src'], ['vendor', 'tests'], $cache) + + // Session management + ->setSession($sessionStore) + + // Services + ->setLogger($logger) + ->setContainer($container) + + // Manual capability registration + ->addTool([Calculator::class, 'advancedCalculation'], 'advanced_calc') + ->addResource([Config::class, 'getSettings'], 'config://app/settings', 'app_settings') + + // Build the server + ->build(); +``` + +## Method Reference + +| Method | Parameters | Description | +|--------|------------|-------------| +| `setServerInfo()` | name, version, description? | Set server identity | +| `setPaginationLimit()` | limit | Set max items per page | +| `setInstructions()` | instructions | Set usage instructions | +| `setProtocolVersion()` | protocolVersion | Pin the handshake to one protocol revision | +| `setModernVersions()` | versions | Narrow the revisions the modern (2026-07-28) leg answers for | +| `withoutModernEra()` | - | Serve the handshake era only | +| `setRequestState()` | key, ttl? | Signing key and lifetime for multi round-trip request state | +| `setInputRequiredLimits()` | maxRounds, roundTimeout | Bound the input-required shim on handshake-era connections | +| `withoutInputRequiredShim()` | - | Do not fulfil an `InputRequiredResult` over a handshake-era connection | +| `setCachePolicy()` | policy | Set the `ttlMs`/`cacheScope` hints on cacheable results | +| `setNotificationBus()` | bus | Delivery for `subscriptions/listen` streams | +| `setSubscriptionLifetime()` | seconds | How long a subscription stream is held open (`0` = unbounded) | +| `setHeaderValidator()` | enabled | Toggle the SEP-2243 standard-header check on `buildStateless()` | +| `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery | +| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management | +| `setLogger()` | logger | Set PSR-3 logger | +| `setContainer()` | container | Set PSR-11 container | +| `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher | +| `addRequestHandler()` | handler | Prepend a single custom request handler | +| `addRequestHandlers()` | handlers | Prepend multiple custom request handlers | +| `addNotificationHandler()` | handler | Prepend a single custom notification handler | +| `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers | +| `addTool()` | handler, name?, title?, description?, annotations?, inputSchema?, ... | Register tool | +| `addResource()` | handler, uri, name?, title?, description?, mimeType?, size?, annotations?, icons?, meta? | Register resource | +| `addResourceTemplate()` | handler, uriTemplate, name?, title?, description?, mimeType?, annotations?, meta? | Register resource template | +| `addPrompt()` | handler, name?, title?, description?, icons?, meta? | Register prompt | +| `add()` | definition, handler | Register an element from a schema VO + handler pair | +| `build()` | - | Create the server instance | +| `buildStateless()` | supportedVersions? | Create the modern dispatcher alone, for `StatelessHttpTransport` | diff --git a/docs/run/sessions.md b/docs/run/sessions.md new file mode 100644 index 0000000..93f01c7 --- /dev/null +++ b/docs/run/sessions.md @@ -0,0 +1,129 @@ +# Session Management + +> Sessions belong to the handshake era. Protocol revision `2026-07-28` removed them, along with +> `Mcp-Session-Id`, because every request carries what is needed to answer it — see +> [The 2026-07-28 lifecycle](../protocol-versions.md). One server serves both, so the configuration below still +> applies to the handshake-era clients it answers. + +Configure session storage and lifecycle. By default, the SDK uses `InMemorySessionStore`: + +```php +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Session\InMemorySessionStore; +use Mcp\Server\Session\Psr16SessionStore; +use Symfony\Component\Cache\Psr16Cache; +use Symfony\Component\Cache\Adapter\RedisAdapter; + +// Override with file-based storage +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// Override with in-memory storage and custom TTL +$server = Server::builder() + ->setSession(new InMemorySessionStore(3600)) + ->build(); + +// Override with PSR-16 cache-based storage +// Requires psr/simple-cache and symfony/cache (or any other PSR-16 implementation) +// composer require psr/simple-cache symfony/cache +$redisAdapter = new RedisAdapter( + RedisAdapter::createConnection('redis://localhost:6379'), + 'mcp_sessions' +); + +$server = Server::builder() + ->setSession(new Psr16SessionStore( + cache: new Psr16Cache($redisAdapter), + prefix: 'mcp-', + ttl: 3600 + )) + ->build(); +``` + +## Garbage Collection Configuration + +The SDK periodically runs garbage collection to clean up expired sessions, similar to PHP's native +`session.gc_probability` and `session.gc_divisor` settings. The probability that GC runs on any given +request is `gcProbability / gcDivisor`. + +```php +// Default: 1/100 (1% chance per request) +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// Higher frequency: 1/10 (10% chance per request) +$server = Server::builder() + ->setSession( + new FileSessionStore(__DIR__ . '/sessions'), + gcProbability: 1, + gcDivisor: 10, + ) + ->build(); + +// Run GC on every request +$server = Server::builder() + ->setSession(gcProbability: 1, gcDivisor: 1) + ->build(); + +// Disable GC entirely (e.g. when using an external cleanup process) +$server = Server::builder() + ->setSession(gcProbability: 0) + ->build(); +``` + +**Parameters:** + +- `$gcProbability` (int): The numerator of the GC probability fraction (default: `1`). Set to `0` to disable GC. +- `$gcDivisor` (int): The denominator of the GC probability fraction (default: `100`). Must be >= 1. + +> **Note**: When providing a custom `SessionManagerInterface` via the `$sessionManager` parameter, +> the `gcProbability` and `gcDivisor` settings are ignored — you control GC behavior in your own implementation. + +**Available Session Stores:** + +- `InMemorySessionStore`: Fast in-memory storage (default) +- `FileSessionStore`: Persistent file-based storage +- `Psr16SessionStore`: PSR-16 compliant cache-based storage + +**Custom Session Stores:** + +Implement `SessionStoreInterface` to create custom session storage: + +```php +use Mcp\Server\Session\SessionStoreInterface; +use Symfony\Component\Uid\Uuid; + +class RedisSessionStore implements SessionStoreInterface +{ + public function __construct(private $redis, private int $ttl = 3600) {} + + public function exists(Uuid $id): bool + { + return $this->redis->exists($id->toRfc4122()); + } + + public function read(Uuid $sessionId): string|false + { + $data = $this->redis->get($sessionId->toRfc4122()); + return $data !== false ? $data : false; + } + + public function write(Uuid $sessionId, string $data): bool + { + return $this->redis->setex($sessionId->toRfc4122(), $this->ttl, $data); + } + + public function destroy(Uuid $sessionId): bool + { + return $this->redis->del($sessionId->toRfc4122()) > 0; + } + + public function gc(): array + { + // Redis handles TTL automatically + return []; + } +} +``` diff --git a/docs/run/stdio.md b/docs/run/stdio.md new file mode 100644 index 0000000..2f6ccca --- /dev/null +++ b/docs/run/stdio.md @@ -0,0 +1,64 @@ +# STDIO Transport + +The STDIO transport communicates via standard input/output streams, ideal for command-line tools and MCP client integrations. + +```php +$transport = new StdioTransport( + input: STDIN, // Input stream (default: STDIN) + output: STDOUT, // Output stream (default: STDOUT) + logger: $logger // Optional PSR-3 logger +); +``` + +## Parameters + +- **`input`** (optional): Input stream resource. Defaults to `STDIN`. +- **`output`** (optional): Output stream resource. Defaults to `STDOUT`. +- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. +- **`runnerControl`** (optional): `RunnerControlInterface` - controls the read loop; the default runs until the input stream closes. +- **`maxLineBytes`** (optional): Maximum accepted line length in bytes. Oversized lines are rejected as invalid messages. + +!!! warning + When using STDIO transport, **never** write to `STDOUT` in your handlers as it's reserved for JSON-RPC communication. + Use `STDERR` for debugging instead. + +## Example Server Script + +```php-file +#!/usr/bin/env php +setServerInfo('STDIO Calculator', '1.0.0') + ->addTool(function(int $a, int $b): int { return $a + $b; }, 'add_numbers') + ->addTool(InvokableCalculator::class) + ->build(); + +$transport = new StdioTransport(); + +$status = $server->run($transport); + +exit($status); // listen() returns 0 when the input stream closes +``` + +## Client Configuration + +For MCP clients like Claude Desktop: + +```json +{ + "mcpServers": { + "my-php-server": { + "command": "php", + "args": ["/absolute/path/to/server.php"] + } + } +} +``` diff --git a/docs/run/subscriptions.md b/docs/run/subscriptions.md new file mode 100644 index 0000000..4c3e989 --- /dev/null +++ b/docs/run/subscriptions.md @@ -0,0 +1,40 @@ +# Subscriptions + +> `subscriptions/listen` belongs to protocol revision `2026-07-28`; see +> [Protocol versions](../protocol-versions.md). Handshake-era clients keep using the HTTP `GET` stream and +> `resources/subscribe`, which the same server still answers. + +`subscriptions/listen` replaces the HTTP `GET` stream and `resources/subscribe`. The client +opens a long-lived POST whose response stream carries the notification types it asked for; +the server acknowledges first with `notifications/subscriptions/acknowledged`, reporting the +subset it agreed to honour. + +## The notification bus + +Delivery needs a bus, because the process that publishes and the process holding the stream +open are often not the same one: + +```php +use Mcp\Server\Subscription\InMemoryNotificationBus; +use Mcp\Server\Subscription\Psr16NotificationBus; + +// stdio, or a persistent runtime where the whole server is one process +->setNotificationBus(new InMemoryNotificationBus()) + +// PHP-FPM: the publisher and the stream are different workers +->setNotificationBus(new Psr16NotificationBus($cache)) +``` + +Registry changes (`registerTool()`, `unregisterPrompt()`, …) are published automatically. +Anything else — `notifications/resources/updated` above all — is published by the +application: + +```php +$bus->publish(new ResourceUpdatedNotification('file:///project/config.json')); +``` + +## How long a stream lives + +`Builder::setSubscriptionLifetime()` bounds how long a stream is held before the server +closes it gracefully. The real ceiling is the runtime's: under PHP-FPM a stream cannot +outlive `max_execution_time`. Pass `0` for "until the client or the runtime ends it". diff --git a/docs/sdk-tier.md b/docs/sdk-tier.md new file mode 100644 index 0000000..8fe7e8d --- /dev/null +++ b/docs/sdk-tier.md @@ -0,0 +1,68 @@ +# SDK Tier Target (SEP-1730) + +The MCP spec defines three SDK tiers: Tier 1 (Fully Supported), Tier 2 +(Commitment to Full Support), and Tier 3 (Experimental). This document tracks +where the PHP SDK stands and what's still missing to move up a tier. + +## Current standing + +**Tier 3**, per the official audit in +[modelcontextprotocol/modelcontextprotocol#3274](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/3274) +(2026-08-19, superseding the earlier Tier 3 assessment in #2305). Two things +block Tier 2: + +- **Client conformance is 20% (10/50)**, against the ≥80% bar. Almost + entirely OAuth: 38 of 39 scored auth scenarios fail. Every one of those + failures is pre-declared in the SDK's own + [`tests/Conformance/conformance-baseline-*.yml`](https://github.com/modelcontextprotocol/php-sdk/tree/main/tests/Conformance) + files and tracked in `ROADMAP.md` — a known, scoped gap, not silent + breakage. Server conformance is 100% (67/67). +- **No stable release ≥ 1.0.0 has ever shipped** (latest: v0.7.1). Tier 2 + requires at least one. + +Tier 1 needs both of those plus full (not ≥80%) client conformance and +closing 10 documentation gaps the audit lists by name (mostly small +per-feature additions — legacy SSE transport and the elicitation +complete-notification need either an implementation or an explicit +"intentionally not implemented" note). + +## Target + +**Tier 2 next, Tier 1 after the 1.0 release.** Tier 1's "stable release" +requirement is structurally out of reach until 1.0 ships regardless of how +complete every other Tier 1 criterion is. + +## Path to Tier 2 + +Roughly in priority order, per the audit's own recommendation: + +1. **OAuth client conformance.** The single highest-leverage fix — it + accounts for 38 of 40 client failures and blocks both tiers on its own. + Already scoped: token endpoint auth methods, scope handling (step-up, + retry-limit, from-`WWW-Authenticate`, from `scopes_supported`), dynamic + client registration, issuer validation, `offline_access`, + authorization-server migration — see `ROADMAP.md` and the + `2026-07-28`-labeled auth issues. +2. **Fix the two non-auth client failures** (`sse-retry`, + `elicitation-sep1034-client-defaults`, both scored at 2025-11-25). +3. **Ship a stable 1.0.0+ release.** + +## Versioning + +The SDK follows [Semantic Versioning](https://semver.org/): +`MAJOR.MINOR.PATCH`. Pre-1.0, that means any digit can carry a breaking +change per SemVer §4 — every one is logged with a `[BC Break]` marker in +[`CHANGELOG.md`](https://github.com/modelcontextprotocol/php-sdk/blob/main/CHANGELOG.md). + +Once at 1.0, the SDK adopts Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html): +PATCH releases never break BC, MINOR releases only add functionality (gated +by the deprecation window in [Deprecation policy](deprecation-policy.md)), +and a breaking change ships only in a MAJOR release. + +## Roadmap + +See [ROADMAP.md](https://github.com/modelcontextprotocol/php-sdk/blob/main/ROADMAP.md) +for the feature-level plan toward 1.0. This document only tracks the +process/tier-classification gap, which is narrower and more mechanical than +the feature roadmap. diff --git a/docs/server-builder.md b/docs/server-builder.md deleted file mode 100644 index 5016526..0000000 --- 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/server-client-communication.md b/docs/server-client-communication.md deleted file mode 100644 index b37ff63..0000000 --- a/docs/server-client-communication.md +++ /dev/null @@ -1,78 +0,0 @@ -# Client Communication - -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) - -## 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`. - -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: - -```php -use Mcp\Capability\Attribute\McpTool; -use Mcp\Server\RequestContext; - -class MyService -{ - #[McpTool(name: 'my_tool', description: 'My Tool Description')] - public function myTool(RequestContext $context): string - { - $context->getClientGateway()->log(...); -``` - -## Sampling - -With [sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) servers can request clients to -execute "completions" or "generations" with a language model for them: - -```php -$result = $clientGateway->sample('Roses are red, violets are', 350, 90, ['temperature' => 0.5]); -``` - -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. -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` - -[Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling#protocol-messages) - -## Logging - -The [Logging](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging) utility enables servers -to send structured log messages as notifcation to clients: - -```php -use Mcp\Schema\Enum\LoggingLevel; - -$clientGateway->log(LoggingLevel::Warning, 'The end is near.'); -``` - -## Progress - -With a [Progress](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress#progress) -notification a server can update a client while an operation is ongoing: - -```php -$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 -to the client to: - -```php -$clientGateway->notify($yourNotification); -``` diff --git a/docs/servers/completions.md b/docs/servers/completions.md new file mode 100644 index 0000000..e1f6086 --- /dev/null +++ b/docs/servers/completions.md @@ -0,0 +1,99 @@ +# 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 0000000..616acac --- /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 0000000..bb46ea5 --- /dev/null +++ b/docs/servers/prompts.md @@ -0,0 +1,127 @@ +# Prompts + +Prompts generate templates for AI interactions. + +```php +use Mcp\Capability\Attribute\McpPrompt; + +class PromptGenerator +{ + /** + * Generates a code review request prompt. + */ + #[McpPrompt(name: 'code_review')] + public function reviewCode(string $language, string $code, string $focus = 'general'): array + { + return [ + ['role' => 'assistant', 'content' => 'You are an expert code reviewer.'], + ['role' => 'user', 'content' => "Review this {$language} code focusing on {$focus}:\n\n```{$language}\n{$code}\n```"] + ]; + } +} +``` + +## Parameters + +- **`name`** (optional): Prompt identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Prompt description. Falls back to the docblock (summary plus long description). +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +## Prompt Return Values + +Prompt handlers must return an array of message structures that are automatically formatted into MCP prompt messages. + +### Supported Return Formats + +```php +// Array of message objects with role and content +public function basicPrompt(): array +{ + return [ + ['role' => 'assistant', 'content' => 'You are a helpful assistant'], + ['role' => 'user', 'content' => 'Hello, how are you?'] + ]; +} + +// Single message (automatically wrapped in array) +public function singleMessage(): array +{ + return [ + ['role' => 'user', 'content' => 'Write a poem about PHP'] + ]; +} + +// Associative array with user/assistant keys +public function userAssistantFormat(): array +{ + return [ + 'user' => 'Explain how arrays work in PHP', + 'assistant' => 'Arrays in PHP are ordered maps...' + ]; +} + +// Non-text content — each message carries exactly one content block, +// so an image goes into its own message +use Mcp\Schema\Content\{TextContent, ImageContent}; + +public function mixedContent(): array +{ + return [ + ['role' => 'user', 'content' => new TextContent('Analyze this image:')], + ['role' => 'user', 'content' => new ImageContent(data: $imageData, mimeType: 'image/png')] + ]; +} + +// Using explicit PromptMessage objects +use Mcp\Schema\Content\PromptMessage; +use Mcp\Schema\Enum\Role; + +public function explicitMessages(): array +{ + return [ + new PromptMessage(Role::Assistant, new TextContent('System instructions')), + new PromptMessage(Role::User, new TextContent('User question')) + ]; +} +``` + +The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. + +### Valid Message Roles + +- **`user`**: User input or questions +- **`assistant`**: Assistant responses, including system-style instructions + +Those two are the only valid roles — MCP has no `system` role, and any other value +makes the prompt handler throw. + +### Error Handling + +Prompt handlers can throw any exception, but the type determines how it's handled: + +- **`PromptGetException`**: Converted to JSON-RPC error response with the actual exception message +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message + +```php +use Mcp\Exception\PromptGetException; + +#[McpPrompt] +public function generatePrompt(string $topic, string $style): array +{ + $validStyles = ['casual', 'formal', 'technical']; + + if (!in_array($style, $validStyles)) { + throw new PromptGetException( + "Invalid style '{$style}'. Must be one of: " . implode(', ', $validStyles) + ); + } + + return [ + ['role' => 'user', 'content' => "Write about {$topic} in a {$style} style"] + ]; +} +``` + +**Recommendation**: Use `PromptGetException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. diff --git a/docs/servers/registration.md b/docs/servers/registration.md new file mode 100644 index 0000000..896333b --- /dev/null +++ b/docs/servers/registration.md @@ -0,0 +1,249 @@ +# Registering elements + +Every tool, resource, resource template, and prompt has to reach the server's +registry somehow. There are three ways to get it there, and they mix freely. + +## Attribute-Based Discovery + +**Advantages:** + +- Declarative and readable +- Automatic parameter inference +- DocBlock integration +- Type-safe by default +- Caching support + +**Example:** +```php +$server = Server::builder() + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) // Automatic discovery + ->build(); +``` + +## Manual Registration + +Register MCP elements programmatically without using attributes. The handler is the most important parameter and can be +any PHP callable. + +**Advantages:** + +- Fine-grained control +- Runtime configuration +- Conditional registration +- External handler support + +**Example:** +```php +$server = Server::builder() + ->addTool([Calculator::class, 'add'], 'add_numbers') + ->addResource([Config::class, 'get'], 'config://app') + ->addPrompt([Prompts::class, 'email'], 'write_email') + ->build(); +``` + + +### Handler Types + +**Handler** can be any PHP callable: + +1. **Closure**: `function(int $a, int $b): int { return $a + $b; }` +2. **Class and method name pair**: `[ClassName::class, 'methodName']` - the class is instantiated lazily on first call, so it must be constructable through the container (or have a no-arg constructor) +3. **Class instance and method name**: `[$instance, 'methodName']` - the given, already-constructed object is invoked as-is. Use this for handlers the container cannot build, e.g. those with scalar constructor arguments or dependencies wired at runtime +4. **Invokable class name**: `InvokableClass::class` - class must be constructable through the container and have `__invoke` method + +### Manual Tool Registration + +```php +$server = Server::builder() + // Using closure + ->addTool( + handler: function(int $a, int $b): int { return $a + $b; }, + name: 'add_numbers', + description: 'Adds two numbers together' + ) + + // Using class method pair + ->addTool( + handler: [Calculator::class, 'multiply'], + name: 'multiply_numbers' + // name and description are optional - derived from method name and docblock + ) + + // Using instance method + ->addTool( + handler: [$calculatorInstance, 'divide'] + ) + + // Using invokable class + ->addTool( + handler: InvokableCalculator::class + ); +``` + +#### Parameters + +- `handler` (callable|string): The tool handler +- `name` (string|null): Optional tool name +- `title` (string|null): Optional human-readable title for display in UI +- `description` (string|null): Optional tool description +- `annotations` (ToolAnnotations|null): Optional annotations for the tool +- `inputSchema` (array|null): Optional input schema for the tool +- `icons` (Icon[]|null): Optional array of icons for the tool +- `meta` (array|null): Optional metadata for the tool +- `outputSchema` (array|null): Optional JSON schema describing the tool's `structuredContent` + +### Manual Resource Registration + +Register static resources: + +```php +$server = Server::builder() + ->addResource( + handler: [Config::class, 'getSettings'], + uri: 'config://app/settings', + name: 'app_config', + description: 'Application configuration', + mimeType: 'application/json' + ); +``` + +#### Parameters + +- `handler` (callable|string): The resource handler +- `uri` (string): The resource URI +- `name` (string|null): Optional resource name +- `title` (string|null): Optional human-readable title for display in UI +- `description` (string|null): Optional resource description +- `mimeType` (string|null): Optional MIME type of the resource +- `size` (int|null): Optional size of the resource in bytes +- `annotations` (Annotations|null): Optional annotations for the resource +- `icons` (Icon[]|null): Optional array of icons for the resource +- `meta` (array|null): Optional metadata for the resource + +### Manual Resource Template Registration + +Register dynamic resources with URI templates: + +```php +$server = Server::builder() + ->addResourceTemplate( + handler: [UserService::class, 'getUserProfile'], + uriTemplate: 'user://{userId}/profile', + name: 'user_profile', + description: 'User profile by ID', + mimeType: 'application/json' + ); +``` + +#### Parameters + +- `handler` (callable|string): The resource template handler +- `uriTemplate` (string): The resource URI template +- `name` (string|null): Optional resource template name +- `title` (string|null): Optional human-readable title for display in UI +- `description` (string|null): Optional resource template description +- `mimeType` (string|null): Optional MIME type of the resource +- `annotations` (Annotations|null): Optional annotations for the resource template +- `meta` (array|null): Optional metadata for the resource template + +### Manual Prompt Registration + +Register prompt generators: + +```php +$server = Server::builder() + ->addPrompt( + handler: [PromptService::class, 'generatePrompt'], + name: 'custom_prompt', + description: 'A custom prompt generator' + ); +``` + +#### Parameters + +- `handler` (callable|string): The prompt handler +- `name` (string|null): Optional prompt name +- `title` (string|null): Optional human-readable title for display in UI +- `description` (string|null): Optional prompt description +- `icons` (Icon[]|null): Optional array of icons for the prompt +- `meta` (array|null): Optional metadata for the prompt + +**Note:** `name` and `description` are optional when the handler is a method or an invokable class — they are then +derived from the method name and its docblock. A **closure** handler has neither, so it gets a generated name +(`closure_tool_`) and no description; name your closures explicitly. + +For more details on the elements themselves, see [Tools](tools.md), [Resources](resources.md), [Resource templates](resource-templates.md), and [Prompts](prompts.md). + +### Explicit element registration + +When an element's name, schema, or description is only known at runtime, pair an `Mcp\Schema\*` value object with one of +the four handler interfaces below and register it through `Builder::add()`. + +| Element kind | Handler interface | +|-------------------|-------------------------------------------------------| +| Tool | `Mcp\Server\Handler\ToolHandlerInterface` | +| Resource | `Mcp\Server\Handler\ResourceHandlerInterface` | +| Resource template | `Mcp\Server\Handler\ResourceTemplateHandlerInterface` | +| Prompt | `Mcp\Server\Handler\PromptHandlerInterface` | + +Each handler interface declares a single execution method. Tool and prompt handlers receive an arguments map and a +`ClientGateway`. Resource handlers receive the requested URI; resource template handlers additionally receive the parsed +template variables. + +```php +use Mcp\Schema\Tool; +use Mcp\Server; +use Mcp\Server\ClientGateway; +use Mcp\Server\Handler\ToolHandlerInterface; + +final class WeatherHandler implements ToolHandlerInterface +{ + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['temperature' => 21, 'unit' => 'C']; + } +} + +$tool = new Tool( + name: 'get_weather', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ], + description: 'Returns the current weather for a city.', + annotations: null, +); + +$server = Server::builder() + ->add($tool, new WeatherHandler()) + ->build(); +``` + +`Builder::add()` validates the pairing at registration time. Pairing a `Tool` definition with, for example, a +`PromptHandlerInterface` raises `Mcp\Exception\InvalidArgumentException`. The schema value objects validate some of +their own input as well — `Tool` requires an object-typed input schema, `ResourceDefinition` and `ResourceTemplate` +check the name pattern and URI — but an invalid tool or prompt *name* is not rejected, it is only logged as a warning +when the element is registered. + +Use `add()` when the metadata cannot be inferred from a handler class via reflection. For statically-known elements, +prefer `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from the handler's signature and +docblock. + +## Hybrid Approach + +Combine both methods for maximum flexibility: + +```php +$server = Server::builder() + ->setDiscovery(__DIR__, ['.'], excludeDirs: ['vendor']) // Discover most capabilities + ->addTool([ExternalService::class, 'process'], 'external') // Add specific ones + ->build(); +``` + +Manual registrations always take precedence over discovered elements with the same identifier — same `name` for tools +and prompts, same `uri` for resources, same `uriTemplate` for resource templates. + +For runtime, config-driven elements whose shape is not known at compile time, see +[Explicit element registration](#explicit-element-registration). diff --git a/docs/servers/resource-templates.md b/docs/servers/resource-templates.md new file mode 100644 index 0000000..2cff71e --- /dev/null +++ b/docs/servers/resource-templates.md @@ -0,0 +1,49 @@ +# Resource Templates + +Resource templates are **dynamic resources** that use parameterized URIs with variables. They follow all the same rules +as static resources (URI schemas, return values, MIME types, etc.) but accept `{variable}` placeholders in the URI. + +Only simple [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) variable expansion +is supported — `{var}`, one path segment each. Operators such as `{+var}`, `{#var}`, +`{/path}`, `{?query}` and explode (`{list*}`) are not parsed, and a variable's value +cannot contain `/`. + +```php +use Mcp\Capability\Attribute\McpResourceTemplate; + +class UserProvider +{ + /** + * Retrieves user profile information by ID. + */ + #[McpResourceTemplate( + uriTemplate: 'user://{userId}/profile/{section}', + name: 'user_profile', + description: 'User profile data by section', + mimeType: 'application/json' + )] + public function getUserProfile(string $userId, string $section): array + { + return $this->users[$userId][$section] ?? throw new \InvalidArgumentException("Profile section not found"); + } +} +``` + +## Parameters + +- **`uriTemplate`** (required): URI with `{variables}`. Must start with a scheme (`file://`, `user://`, …) and contain at least one variable. +- **`name`** (optional): Short resource template identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Template description. Falls back to the docblock (summary plus long description). +- **`mimeType`** (optional): MIME type of the resource content. +- **`annotations`** (optional): Additional metadata. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +## Variable Rules + +1. **Variable names must match exactly** between URI template and method parameters — + they are bound by name, so the parameter order is free +2. **All variables are required** - no optional parameters supported +3. **Type hints work normally** - parameters can be typed (string, int, etc.) + +**Example mapping**: `user://123/profile/settings` → `getUserProfile("123", "settings")` diff --git a/docs/servers/resources.md b/docs/servers/resources.md new file mode 100644 index 0000000..10736c4 --- /dev/null +++ b/docs/servers/resources.md @@ -0,0 +1,152 @@ +# Resources + +Resources provide access to static data that clients can read. + +```php +use Mcp\Capability\Attribute\McpResource; + +class ConfigProvider +{ + /** + * Provides the current application configuration. + */ + #[McpResource(uri: 'config://app/settings', name: 'app_settings')] + public function getSettings(): array + { + return [ + 'version' => '1.0.0', + 'debug' => false, + 'features' => ['auth', 'logging'] + ]; + } +} +``` + +## Parameters + +- **`uri`** (required): Unique resource identifier. Must comply with [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). +- **`name`** (optional): Short resource identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Resource description. Falls back to the docblock (summary plus long description). +- **`mimeType`** (optional): MIME type of the resource content. +- **`size`** (optional): Size in bytes if known. +- **`annotations`** (optional): Additional metadata. +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +**Standard Protocol URI Schemes**: `https://` (web resources), `file://` (filesystem), `git://` (version control). +**Custom schemes**: `config://`, `data://`, `db://`, `api://` or any RFC 3986 compliant scheme. + +## Resource Return Values + +Resource handlers can return various data types that are automatically formatted into appropriate MCP resource content types. + +### Supported Return Types + +```php +// String content - converted to text resource +public function getTextFile(): string +{ + return "File content here"; +} + +// Array content - converted to JSON +public function getConfig(): array +{ + return ['debug' => true, 'version' => '1.0']; +} + +// Stream resource - read and converted to blob. +// `resource` is not a PHP type declaration, so the return type is left off. +/** @return resource */ +public function getImageStream() +{ + return fopen('image.png', 'r'); +} + +// SplFileInfo - file content with MIME type detection +public function getFileInfo(): \SplFileInfo +{ + return new \SplFileInfo('document.pdf'); +} +``` + +**Explicit resource content types** + +```php +use Mcp\Schema\Content\{TextResourceContents, BlobResourceContents}; + +public function getExplicitText(): TextResourceContents +{ + return new TextResourceContents( + uri: 'config://app/settings', + mimeType: 'application/json', + text: json_encode(['setting' => 'value']) + ); +} + +public function getExplicitBlob(): BlobResourceContents +{ + return new BlobResourceContents( + uri: 'file://image.png', + mimeType: 'image/png', + blob: base64_encode(file_get_contents('image.png')) + ); +} +``` + +**Special Array Formats** + +```php +// Array with 'text' key - used as text content +public function getTextArray(): array +{ + return ['text' => 'Content here', 'mimeType' => 'text/plain']; +} + +// Array with 'blob' key - used as blob content +public function getBlobArray(): array +{ + return ['blob' => base64_encode($data), 'mimeType' => 'image/png']; +} + +// Multiple resource contents +public function getMultipleResources(): array +{ + return [ + new TextResourceContents('file://readme.txt', 'text/plain', 'README content'), + new TextResourceContents('file://config.json', 'application/json', '{"key": "value"}') + ]; +} +``` + +### Error Handling + +Resource handlers can throw any exception, but the type determines how it's handled: + +- **`ResourceReadException`**: Converted to JSON-RPC error response with the actual exception message +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message + +```php +use Mcp\Capability\Attribute\McpResourceTemplate; +use Mcp\Exception\ResourceReadException; + +// A URI with variables is a resource *template*; `#[McpResource]` registers a +// fixed URI and would never receive `$path`. Note a variable matches a single +// segment, so `$path` here cannot contain `/`. +#[McpResourceTemplate(uriTemplate: 'file://{path}')] +public function getFile(string $path): string +{ + if (!file_exists($path)) { + throw new ResourceReadException("File not found: {$path}"); + } + + if (!is_readable($path)) { + throw new ResourceReadException("File not readable: {$path}"); + } + + return file_get_contents($path); +} +``` + +**Recommendation**: Use `ResourceReadException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. diff --git a/docs/servers/schemas.md b/docs/servers/schemas.md new file mode 100644 index 0000000..d6d11d7 --- /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 0000000..a919b40 --- /dev/null +++ b/docs/servers/tools.md @@ -0,0 +1,236 @@ +# Tools + +Tools are callable functions that perform actions and return results. + +```php +use Mcp\Capability\Attribute\McpTool; + +class Calculator +{ + /** + * Performs arithmetic operations with validation. + */ + #[McpTool(name: 'calculate')] + public function performCalculation(float $a, float $b, string $operation): float + { + return match($operation) { + 'add' => $a + $b, + 'subtract' => $a - $b, + 'multiply' => $a * $b, + 'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Division by zero'), + default => throw new \InvalidArgumentException('Invalid operation') + }; + } +} +``` + +## Parameters + +- **`name`** (optional): Tool identifier. Defaults to method name if not provided. +- **`title`** (optional): Human-readable display title shown in client UI. Distinct from `name`. +- **`description`** (optional): Tool description. Falls back to the docblock (summary plus long description); stays unset if there is no docblock. +- **`annotations`** (optional): `ToolAnnotations` object for additional metadata. +- **`outputSchema`** (optional): JSON schema describing `structuredContent`; see [Structured Output](#structured-output). +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +**Priority**: `name` is the attribute parameter, else the method name. `description` is the attribute parameter, else the docblock — the method name is never used as a description. + +For tool parameter validation and JSON schema generation, see [Schema generation](schemas.md). + +## Tool Return Values + +Tools can return any data type and the SDK will automatically wrap them in appropriate MCP content types. + +### Automatic Content Wrapping + +```php +// Primitive types → TextContent +public function getString(): string { return "Hello"; } // TextContent +public function getNumber(): int { return 42; } // TextContent +public function getBool(): bool { return true; } // TextContent +public function getArray(): array { return ['key' => 'value']; } // TextContent (JSON) + +// Special cases +public function getNull(): ?string { return null; } // TextContent("(null)") +public function returnVoid(): void { /* no return */ } // TextContent("(null)") +``` + +### Explicit Content Types + +For fine control over output formatting: + +```php +use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, ResourceLink, EmbeddedResource}; + +public function getFormattedCode(): TextContent +{ + return TextContent::code(' 'object', + 'properties' => [ + 'temperature' => ['type' => 'number'], + 'conditions' => ['type' => 'string'], + ], + 'required' => ['temperature', 'conditions'], + ] +)] +public function getWeather(string $city): array +{ + // Sent as `structuredContent`, and JSON-encoded into `content` for clients that ignore it + return ['temperature' => 22.5, 'conditions' => 'sunny']; +} +``` + +The same schema can be passed to manual registration: + +```php +$builder->addTool([WeatherHandler::class, 'getWeather'], outputSchema: [/* ... */]); +``` + +The SDK fills `structuredContent` whenever the return value qualifies — `outputSchema` is what tells clients to expect it +and lets them validate it. What qualifies depends on the protocol revision the call is served under: + +| Return value | `structuredContent` | +|---|---| +| Associative array (`['temperature' => 22.5]`) | The array | +| Object (`stdClass`, DTO, `JsonSerializable`) that serializes to a JSON object | Its JSON representation | +| List (`[1, 2, 3]`, `[['id' => 1], ['id' => 2]]`), or an object serializing to one | Omitted before `2026-07-28`, kept from it on | +| Array holding `Content` instances | Omitted (already carried in `content`) | +| Scalars, `null`, `Content` instances | Omitted | + +Up to revision `2025-11-25`, `structuredContent` had to be a JSON object, so a PHP list — which serializes to a JSON +array — was not emittable and strict clients rejected the whole tool call over one. [SEP-2106][sep-2106], part of +revision `2026-07-28`, widened `outputSchema` to any JSON Schema 2020-12 and `structuredContent` to any JSON value +conforming to it. The SDK picks the rule from the revision negotiated for the call, so a tool serving both eras needs the +object shape to produce structured output everywhere. Wrap the list in a key for that: + +```php +// Structured content only from 2026-07-28 on: a bare list is not a JSON object +public function listUsersFlat(): array +{ + return [['id' => 1], ['id' => 2]]; +} + +#[McpTool(outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'items' => ['type' => 'array', 'items' => ['type' => 'object']], + ], + 'required' => ['items'] +])] +public function listUsers(): array +{ + return ['items' => [['id' => 1], ['id' => 2]]]; +} +``` + +Either way the data reaches the client: a return value with no structured representation is still JSON-encoded into +`content` as a `TextContent`. When a tool declares an `outputSchema` but returns something that cannot be sent as +`structuredContent`, the SDK logs a warning — the value is not silently dropped. + +A tool that wants to branch on the revision itself can read it from the injected `RequestContext`, see +[Talking back to the client](../handlers/client-communication.md#clientgateway). + +[sep-2106]: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content + +### Error Handling + +Tool handlers can throw any exception, but the type determines how it's handled: + +- **`ToolCallException`**: Converted to JSON-RPC response with `CallToolResult` where `isError: true`, allowing the LLM to see the error message and self-correct +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message + +```php +use Mcp\Exception\ToolCallException; + +#[McpTool] +public function divideNumbers(float $a, float $b): float +{ + if ($b === 0.0) { + throw new ToolCallException('Division by zero is not allowed'); + } + + return $a / $b; +} + +#[McpTool] +public function processFile(string $filename): string +{ + if (!file_exists($filename)) { + throw new ToolCallException("File not found: {$filename}"); + } + + return file_get_contents($filename); +} +``` + +**Recommendation**: Use `ToolCallException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. diff --git a/docs/transports.md b/docs/transports.md deleted file mode 100644 index 049ca2d..0000000 --- a/docs/transports.md +++ /dev/null @@ -1,537 +0,0 @@ -# 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 - -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. - -```php -use Psr\Http\Message\ServerRequestInterface; - -// PSR-17 factories are automatically discovered -$transport = new StreamableHttpTransport( - request: $serverRequest, // PSR-7 server request - responseFactory: null, // Optional: PSR-17 response factory (auto-discovered if null) - streamFactory: null, // Optional: PSR-17 stream factory (auto-discovered if null) - logger: $logger // Optional PSR-3 logger -); -``` - -### 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. -- **`streamFactory`** (optional): `StreamFactoryInterface` - PSR-17 factory for creating response body streams. Auto-discovered if not provided. -- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. -- **`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 - -The transport automatically discovers PSR-17 factory implementations from these popular packages: - -- `nyholm/psr7` -- `guzzlehttp/psr7` -- `slim/psr7` -- `laminas/laminas-diactoros` -- And other PSR-17 compatible implementations - -```bash -# Install any PSR-17 package - discovery works automatically -composer require nyholm/psr7 -``` - -If auto-discovery fails or you want to use a specific implementation, you can pass factories explicitly: - -```php -use Nyholm\Psr7\Factory\Psr17Factory; - -$psr17Factory = new Psr17Factory(); -$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); -``` - -### Default Middleware - -When the `middleware` argument is omitted (or set to `null`), the transport installs a secure default stack: - -| Order | Middleware | Purpose | -|-------|------------|---------| -| 1 | `CorsMiddleware` | Applies CORS headers to every response. By default does **not** set `Access-Control-Allow-Origin` (cross-origin requests are blocked). | -| 2 | `DnsRebindingProtectionMiddleware` | Validates `Origin`/`Host` against an allowlist. Defaults to localhost variants only. | -| 3 | `ProtocolVersionMiddleware` | Rejects requests carrying an unsupported `MCP-Protocol-Version` header with `400 Bad Request`. | - -```php -// Zero-config, secure-by-default — local servers get full protection automatically. -$transport = new StreamableHttpTransport($request); -``` - -The default stack can be inspected and recomposed via the public factory: - -```php -$middleware = StreamableHttpTransport::defaultMiddleware(); -``` - -### 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: - -```php -use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; -use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; -use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; -use Mcp\Server\Transport\StreamableHttpTransport; - -// Reflect a specific origin -$transport = new StreamableHttpTransport( - $request, - middleware: [ - new CorsMiddleware(allowedOrigins: ['https://myapp.com']), - new DnsRebindingProtectionMiddleware(), - new ProtocolVersionMiddleware(), - ], -); - -// Allow all origins (development only) -$transport = new StreamableHttpTransport( - $request, - middleware: [ - new CorsMiddleware(allowedOrigins: ['*']), - new DnsRebindingProtectionMiddleware(), - new ProtocolVersionMiddleware(), - ], -); -``` - -When the allowlist is a concrete set of origins (not `['*']`), `CorsMiddleware` automatically adds `Vary: Origin` -so shared caches/CDNs do not serve a response generated for one origin to a request from another. - -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. - -### 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: - -```php -use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; - -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 - -`ProtocolVersionMiddleware` rejects requests whose `MCP-Protocol-Version` header is not in the SDK's supported -set with `400 Bad Request`. Requests without the header pass through, since the `initialize` round-trip and some -legacy clients do not send it. - -```php -use Mcp\Schema\Enum\ProtocolVersion; -use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; - -// Only accept the latest spec version -new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]); -``` - -### 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 -message parsing. - -```php -use Mcp\Server\Transport\StreamableHttpTransport; - -// Raise the cap to 16 MiB -$transport = new StreamableHttpTransport($request, maxBodyBytes: 16 * 1024 * 1024); -``` - -When the request stream advertises a size, the transport rejects it up-front. Otherwise (e.g. chunked transfer with -unknown size) the body is read incrementally and aborted as soon as it crosses the cap, so an unbounded stream cannot -exhaust memory. A value below `1` throws `InvalidArgumentException`. - -### JSON-RPC Batch Size Limit - -A JSON-RPC batch (top-level array) is capped at 100 messages by default. Oversized batches are rejected before any -message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap lives on -`MessageFactory`: - -```php -use Mcp\JsonRpc\MessageFactory; - -$factory = MessageFactory::make(maxBatchSize: 50); -``` - -Single-message vs batch is determined from the decoded JSON type — a JSON object is a single message, a JSON array -is a batch. Scalars, empty payloads, and non-object batch elements are returned as `InvalidInputMessageException` -entries (the existing per-message error contract), not parse errors or crashes. A `maxBatchSize` below `1` throws -`InvalidArgumentException`. - -### Custom PSR-15 Middleware - -`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 -short-circuited ones: - -```php -use Mcp\Server\Transport\StreamableHttpTransport; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; - -final class AuthMiddleware implements MiddlewareInterface -{ - public function __construct(private ResponseFactoryInterface $responses) - { - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - if (!$request->hasHeader('Authorization')) { - return $this->responses->createResponse(401); - } - - return $handler->handle($request); - } -} - -$transport = new StreamableHttpTransport( - $request, - logger: $logger, - middleware: [ - ...StreamableHttpTransport::defaultMiddleware(), - new AuthMiddleware($responseFactory), - ], -); -``` - -To selectively drop one default (for example DNS rebinding when running behind a proxy), filter the default list: - -```php -use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; -use Mcp\Server\Transport\StreamableHttpTransport; - -$transport = new StreamableHttpTransport( - $request, - middleware: [ - ...array_filter( - StreamableHttpTransport::defaultMiddleware(), - fn ($m) => !$m instanceof DnsRebindingProtectionMiddleware, - ), - new AuthMiddleware($responseFactory), - ], -); -``` - -Pass `middleware: []` to disable every default and run only your own chain: - -```php -$transport = new StreamableHttpTransport( - $request, - 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/examples/client/README.md b/examples/client/README.md index 3e3bc09..0a6dd8f 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -22,6 +22,38 @@ php -S localhost:8000 examples/server/discovery-calculator/server.php php examples/client/http_discovery_calculator.php ``` +## Modern-era client (2026-07-28) + +Speaks the stateless lifecycle: no `initialize`, a `_meta` envelope and SEP-2243 headers on every +request, and multi round-trip calls answered by the client without the caller noticing. + +```bash +# First, start the matching server +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php + +# Then run the client +php examples/client/stateless_lifecycle_client.php +``` + +## Server-initiated requests + +The remaining examples answer requests the server sends back during a call: + +```bash +# Sampling, log and progress messages (also available over HTTP) +php examples/client/stdio_client_communication.php + +# Elicitation: answering the server's questions from an interactive prompt +php examples/client/stdio_elicitation.php + +# Roots: exposing workspace folders and signalling roots/list_changed +php examples/client/stdio_roots.php +``` + +> **Note**: `http_client_communication.php` needs a server that can answer a second request +> mid-call; PHP's built-in web server only does that with worker processes, e.g. +> `PHP_CLI_SERVER_WORKERS=2 php -S 127.0.0.1:8000 …`. + ## Requirements All examples require the server examples to be available. The STDIO examples spawn the server process, while the HTTP examples connect to a running HTTP server. diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php index f5a39ea..4b6e166 100644 --- a/examples/client/http_client_communication.php +++ b/examples/client/http_client_communication.php @@ -61,7 +61,7 @@ $samplingRequestHandler = new SamplingRequestHandler(new class implements Sampli role: Role::Assistant, content: new TextContent($mockResponse), model: 'mock-gpt-4', - stopReason: 'end_turn', + stopReason: 'endTurn', ); } }); diff --git a/examples/client/http_discovery_calculator.php b/examples/client/http_discovery_calculator.php index ffcfa67..06de879 100644 --- a/examples/client/http_discovery_calculator.php +++ b/examples/client/http_discovery_calculator.php @@ -18,7 +18,7 @@ * Usage: php examples/client/http_discovery_calculator.php * * Before running, start an HTTP MCP server: - * php -S localhost:8080 examples/server/http-discovery-calculator/server.php + * php -S localhost:8000 examples/server/discovery-calculator/server.php */ require_once __DIR__.'/../../vendor/autoload.php'; diff --git a/examples/client/stateless_lifecycle_client.php b/examples/client/stateless_lifecycle_client.php new file mode 100644 index 0000000..917028e --- /dev/null +++ b/examples/client/stateless_lifecycle_client.php @@ -0,0 +1,109 @@ +message}\n"; + + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, ['name' => 'Ada'])); + } +}; + +$client = Client::builder() + ->setClientInfo('stateless-example-client', '1.0.0') + // The only line that selects the modern lifecycle. + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + // Declared in the envelope of every request, so the server knows what it + // may ask for before it decides how to answer. + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($answerWithAName) + ->build(); + +$client->connect(new HttpTransport('http://127.0.0.1:8000/')); + +printf("Connected to %s (revision %s)\n\n", $client->getServerInfo()?->name, $client->getProtocolVersion()?->value); + +echo "Tools:\n"; +foreach ($client->listTools()->tools as $tool) { + printf(" %-12s %s\n", $tool->name, $tool->description ?? ''); +} + +echo "\nA plain call:\n"; +echo ' '.text($client->callTool('get_weather', ['city' => 'Munich']))."\n"; + +echo "\nA call the server cannot finish in one round:\n"; +// One call from here. Two on the wire: the server returns its question, the +// handler above answers it, and the client retries carrying both the answer and +// the server's sealed `requestState`. +echo ' '.text($client->callTool('greet', []))."\n"; + +$client->disconnect(); + +/** The first block of text in a tool result. */ +function text(CallToolResult $result): string +{ + $first = $result->content[0] ?? null; + + return $first instanceof TextContent ? $first->text : '(no text)'; +} diff --git a/examples/client/stdio_client_communication.php b/examples/client/stdio_client_communication.php index 8b71cf0..28e41b6 100644 --- a/examples/client/stdio_client_communication.php +++ b/examples/client/stdio_client_communication.php @@ -50,7 +50,7 @@ $samplingRequestHandler = new SamplingRequestHandler(new class implements Sampli role: Role::Assistant, content: new TextContent($mockResponse), model: 'mock-gpt-4', - stopReason: 'end_turn', + stopReason: 'endTurn', ); } }); diff --git a/examples/client/stdio_roots.php b/examples/client/stdio_roots.php new file mode 100644 index 0000000..b10e743 --- /dev/null +++ b/examples/client/stdio_roots.php @@ -0,0 +1,85 @@ +setClientInfo('STDIO Roots Test', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->addRequestHandler($rootsRequestHandler) + ->build(); + +$transport = new StdioTransport( + command: 'php', + args: [__DIR__.'/../server/client-communication/server.php'], +); + +echo "Connecting to MCP server...\n"; +$client->connect($transport); + +$serverInfo = $client->getServerInfo(); +echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n"; + +// The server tool asks the client for its roots, which triggers the handler above. +echo "Calling 'inspect_workspace_roots'...\n"; +$result = $client->callTool(name: 'inspect_workspace_roots'); + +echo "\nResult:\n"; +foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text."\n"; + } +} + +// Whenever the client's workspace folders change, notify the server so it can +// request an updated list via roots/list. +echo "\nNotifying the server that the roots list changed...\n"; +$client->sendRootsListChanged(); + +$client->disconnect(); diff --git a/examples/server/README.md b/examples/server/README.md index a932639..8d60b4d 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -22,6 +22,33 @@ Run with Inspector: npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php ``` +## The 2026-07-28 lifecycle + +`stateless-lifecycle/server.php` demonstrates protocol revision `2026-07-28`, which removed the +`initialize` handshake and protocol-level sessions. Like every example it answers both eras — the +Inspector's `initialize` still works — but the interesting part is the modern one: + +```bash +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php +``` + +Every request carries its own protocol version and client capabilities, so a call is a single POST with +no handshake before it: + +```bash +curl -sS http://127.0.0.1:8000/ \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: server/discover' \ + -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientCapabilities":{}}}}' +``` + +`tests/Integration/StatelessLifecycleTest.php` drives this example end to end — discovery, a tool call, +the multi round-trip flow, and the response stream carrying progress and log notifications. + ## Debugging You can enable debug output by setting the `DEBUG` environment variable to `1`, and additionally log to a file by diff --git a/examples/server/bootstrap.php b/examples/server/bootstrap.php index cbe3fb5..99fcfda 100644 --- a/examples/server/bootstrap.php +++ b/examples/server/bootstrap.php @@ -28,6 +28,13 @@ set_exception_handler(static function (Throwable $t): never { }); /** + * The transport every example runs on. + * + * Over HTTP that is one endpoint serving both protocol eras: `StreamableHttpTransport` + * classifies each request and routes it to the lifecycle it belongs to, so every + * example here answers an `initialize` handshake and a 2026-07-28 envelope alike. + * Over stdio there is no such choice to make — that binding carries the handshake era. + * * @return TransportInterface|TransportInterface */ function transport(): TransportInterface diff --git a/examples/server/client-communication/ClientAwareService.php b/examples/server/client-communication/ClientAwareService.php index 2a3d834..01cfc07 100644 --- a/examples/server/client-communication/ClientAwareService.php +++ b/examples/server/client-communication/ClientAwareService.php @@ -12,8 +12,13 @@ namespace Mcp\Example\Server\ClientCommunication; use Mcp\Capability\Attribute\McpTool; +use Mcp\Schema\Content\SamplingMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; +use Mcp\Schema\Enum\Role; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Request\ListRootsRequest; +use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\RequestContext; use Psr\Log\LoggerInterface; @@ -26,35 +31,91 @@ final class ClientAwareService } /** - * @return array{incident: string, recommended_actions: string, model: string} + * Ask the client which workspace folders the server is allowed to operate on. + * + * Demonstrates the server side of the "roots" client capability: the tool + * issues a roots/list request that the client answers from its own handler. + * + * Written the 2026-07-28 way: the ask is returned and the answer read off + * the retry. A handshake-era client reaches the same tool — the SDK sends + * the `roots/list` request over that connection and re-enters this method. + * + * @return array{status: string, message: string, roots?: list}|InputRequiredResult */ - #[McpTool(name: 'coordinate_incident_response', description: 'Coordinate an incident response with logging, progress, and sampling.')] - public function coordinateIncident(RequestContext $context, string $incidentTitle): array + #[McpTool(name: 'inspect_workspace_roots', description: 'Ask the client for its workspace roots via a roots/list request.')] + public function inspectWorkspaceRoots(RequestContext $context): array|InputRequiredResult { $clientGateway = $context->getClientGateway(); - $clientGateway->log(LoggingLevel::Warning, \sprintf('Incident triage started: %s', $incidentTitle)); - $steps = [ - 'Collecting telemetry', - 'Assessing scope', - 'Coordinating responders', - ]; - - foreach ($steps as $index => $step) { - $progress = ($index + 1) / \count($steps); - - $clientGateway->progress($progress, 1, $step); - - usleep(180_000); // Simulate work being done + if (!$clientGateway->supportsRoots()) { + return [ + 'status' => 'unsupported', + 'message' => 'Client does not expose roots. Advertise the "roots" capability and register a ListRootsRequestHandler to let the server discover your workspace folders.', + ]; } - $prompt = \sprintf( - 'Provide a concise response strategy for incident "%s" based on the steps completed: %s.', - $incidentTitle, - implode(', ', $steps) - ); + $result = $context->getInputContext()?->rootsResult('roots'); - $result = $clientGateway->sample($prompt, 350, 90, ['temperature' => 0.5]); + if (null === $result) { + return new InputRequiredResult(['roots' => new ListRootsRequest()]); + } + + $roots = []; + foreach ($result->roots as $root) { + $roots[] = ['uri' => $root->uri, 'name' => $root->name]; + } + + $clientGateway->log(LoggingLevel::Info, \sprintf('Client exposed %d root(s).', \count($roots))); + + return [ + 'status' => 'ok', + 'message' => \sprintf('Client exposed %d root(s).', \count($roots)), + 'roots' => $roots, + ]; + } + + /** + * @return array{incident: string, recommended_actions: string, model: string}|InputRequiredResult + */ + #[McpTool(name: 'coordinate_incident_response', description: 'Coordinate an incident response with logging, progress, and sampling.')] + public function coordinateIncident(RequestContext $context, string $incidentTitle): array|InputRequiredResult + { + $clientGateway = $context->getClientGateway(); + + // A retry re-enters this method from the top, so the triage work below + // must run only once: check for the answer first, before repeating logs, + // progress notifications and simulated work the client already saw. + $result = $context->getInputContext()?->samplingResult('recommendation'); + + if (null === $result) { + $clientGateway->log(LoggingLevel::Warning, \sprintf('Incident triage started: %s', $incidentTitle)); + + $steps = [ + 'Collecting telemetry', + 'Assessing scope', + 'Coordinating responders', + ]; + + foreach ($steps as $index => $step) { + $progress = ($index + 1) / \count($steps); + + $clientGateway->progress($progress, 1, $step); + + usleep(180_000); // Simulate work being done + } + + $prompt = \sprintf( + 'Provide a concise response strategy for incident "%s" based on the steps completed: %s.', + $incidentTitle, + implode(', ', $steps) + ); + + return new InputRequiredResult(['recommendation' => new CreateSamplingMessageRequest( + messages: [new SamplingMessage(Role::User, new TextContent($prompt))], + maxTokens: 350, + temperature: 0.5, + )]); + } $recommendation = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; diff --git a/examples/server/discovery-userprofile/McpElements.php b/examples/server/discovery-userprofile/McpElements.php index 8ab6c7d..c853254 100644 --- a/examples/server/discovery-userprofile/McpElements.php +++ b/examples/server/discovery-userprofile/McpElements.php @@ -18,6 +18,9 @@ use Mcp\Capability\Attribute\McpResourceTemplate; use Mcp\Capability\Attribute\McpTool; use Mcp\Exception\PromptGetException; use Mcp\Exception\ResourceReadException; +use Mcp\Schema\Content\Content; +use Mcp\Schema\Content\ResourceLink; +use Mcp\Schema\Content\TextContent; use Psr\Log\LoggerInterface; /** @@ -87,6 +90,44 @@ final class McpElements return array_keys($this->users); } + /** + * Looks up a user and returns a reference to their profile resource. + * + * Rather than embedding the full profile (as `resources/read` on + * `user://{userId}/profile` would), this returns a `resource_link` block + * pointing at that resource template so the caller can fetch it + * separately if needed. This mirrors how a tool like a search returning + * many hits would reference each matching resource by URI instead of + * inlining every one of them. + * + * @param string $userId the ID of the user to look up + * + * @return Content[] a short summary plus a resource_link to the user's profile + */ + #[McpTool(name: 'lookup_user')] + public function lookupUser( + #[CompletionProvider(values: ['101', '102', '103'])] + string $userId, + ): array { + $this->logger->info('Executing tool: lookup_user', ['userId' => $userId]); + + if (!isset($this->users[$userId])) { + return [new TextContent("User ID {$userId} not found.")]; + } + + $user = $this->users[$userId]; + + return [ + new TextContent("Found user {$user['name']} ({$user['role']})."), + new ResourceLink( + uri: "user://{$userId}/profile", + name: 'user_profile', + description: "Full profile for {$user['name']}.", + mimeType: 'application/json', + ), + ]; + } + /** * Sends a welcome message to a user. * (This is a placeholder - in a real app, it might queue an email). diff --git a/examples/server/elicitation/ElicitationHandlers.php b/examples/server/elicitation/ElicitationHandlers.php index aaa1328..9ae87f9 100644 --- a/examples/server/elicitation/ElicitationHandlers.php +++ b/examples/server/elicitation/ElicitationHandlers.php @@ -17,6 +17,7 @@ use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Elicitation\EnumSchemaDefinition; use Mcp\Schema\Elicitation\NumberSchemaDefinition; use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Result\ElicitResult; use Mcp\Server\RequestContext; use Psr\Log\LoggerInterface; @@ -55,8 +56,6 @@ final class ElicitationHandlers ]; } - $client = $context->getClientGateway(); - $this->logger->info(\sprintf('Starting reservation process for restaurant: %s', $restaurantName)); $schema = new ElicitationSchema( @@ -85,10 +84,11 @@ final class ElicitationHandlers required: ['party_size', 'date'], ); - $result = $client->elicit( - message: \sprintf('Please provide your reservation details for %s:', $restaurantName), - requestedSchema: $schema, - timeout: 120, + $result = $this->ask( + $context, + 'details', + \sprintf('Please provide your reservation details for %s:', $restaurantName), + $schema, ); if ($result->isDeclined()) { @@ -166,8 +166,6 @@ final class ElicitationHandlers ]; } - $client = $context->getClientGateway(); - $schema = new ElicitationSchema( properties: [ 'confirm' => new BooleanSchemaDefinition( @@ -179,9 +177,11 @@ final class ElicitationHandlers required: ['confirm'], ); - $result = $client->elicit( - message: \sprintf('Are you sure you want to: %s?', $actionDescription), - requestedSchema: $schema, + $result = $this->ask( + $context, + 'confirmation', + \sprintf('Are you sure you want to: %s?', $actionDescription), + $schema, ); if (!$result->isAccepted()) { @@ -234,8 +234,6 @@ final class ElicitationHandlers ]; } - $client = $context->getClientGateway(); - $schema = new ElicitationSchema( properties: [ 'rating' => new EnumSchemaDefinition( @@ -253,9 +251,11 @@ final class ElicitationHandlers required: ['rating'], ); - $result = $client->elicit( - message: \sprintf('Please provide your feedback about: %s', $topic), - requestedSchema: $schema, + $result = $this->ask( + $context, + 'feedback', + \sprintf('Please provide your feedback about: %s', $topic), + $schema, ); if (!$result->isAccepted()) { @@ -288,4 +288,23 @@ final class ElicitationHandlers ], ]; } + + /** + * Ask the user one question. + * + * One call, every revision. Where the client can be interrupted mid-tool it + * is, and this returns the answer to that; from 2026-07-28 on there is no + * interrupting, so the SDK ends the call with the ask and returns here when + * the client re-sends it with the answer — which means everything above this + * line runs once per question. The `$key` is what ties an answer to the + * question it belongs to across those rounds. + */ + private function ask( + RequestContext $context, + string $key, + string $message, + ElicitationSchema $schema, + ): ElicitResult { + return $context->getClientGateway()->elicit($message, $schema, key: $key); + } } diff --git a/examples/server/mcp-apps/WeatherApp.php b/examples/server/mcp-apps/WeatherApp.php index 5facead..159e8c4 100644 --- a/examples/server/mcp-apps/WeatherApp.php +++ b/examples/server/mcp-apps/WeatherApp.php @@ -16,6 +16,7 @@ use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Schema\Extension\Apps\UiResourceContentMeta; use Mcp\Schema\Extension\Apps\UiResourceCsp; use Mcp\Schema\Extension\Apps\UiResourcePermissions; +use Mcp\Server\RequestContext; final class WeatherApp { @@ -39,7 +40,7 @@ final class WeatherApp ); } - public function getWeather(string $city): string + public function getWeather(string $city, RequestContext $context): string { $weather = [ 'london' => ['temp' => '15°C', 'condition' => 'Cloudy', 'humidity' => '78%'], @@ -56,12 +57,20 @@ final class WeatherApp $key = strtolower($city); $data = $weather[$key] ?? ['temp' => '20°C', 'condition' => 'Clear', 'humidity' => '60%']; - return \sprintf( + $text = \sprintf( 'Weather in %s: %s, %s, Humidity: %s', $city, $data['temp'], $data['condition'], $data['humidity'], ); + + // Hosts without MCP Apps support only ever see this text; the ones that + // negotiated the extension render the ui://weather-app view on top of it. + if (!$context->getClientGateway()->supportsExtension(McpApps::EXTENSION_ID)) { + return $text.' (text-only, host does not support MCP Apps)'; + } + + return $text; } } diff --git a/examples/server/mcp-apps/server.php b/examples/server/mcp-apps/server.php index d8ca259..d61c15a 100644 --- a/examples/server/mcp-apps/server.php +++ b/examples/server/mcp-apps/server.php @@ -18,12 +18,14 @@ use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Schema\Extension\Apps\ToolVisibility; use Mcp\Schema\Extension\Apps\UiToolMeta; use Mcp\Server; +use Mcp\Server\Session\FileSessionStore; logger()->info('Starting MCP Apps Example Server...'); $server = Server::builder() ->setServerInfo('MCP Apps Weather Example', '1.0.0') ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->enableExtension(new McpApps()) ->addResource( [WeatherApp::class, 'getWeatherApp'], diff --git a/examples/server/stateless-lifecycle/server.php b/examples/server/stateless-lifecycle/server.php new file mode 100644 index 0000000..e08a870 --- /dev/null +++ b/examples/server/stateless-lifecycle/server.php @@ -0,0 +1,157 @@ +setServerInfo('Stateless Lifecycle Demo', '1.0.0', title: 'Stateless Lifecycle Demo') + ->setLogger(logger()) + ->setCapabilities(new ServerCapabilities(tools: true, toolsListChanged: true, resources: true, logging: true)) + + // Only the handshake leg has anything to keep here: its clients open a + // session and come back to it. The modern leg never mints one, so under + // `php -S` — where nothing survives between requests — this is what lets + // both eras reach the same tools. + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + + // How long an answer stays fresh. Lists are the same for every caller here, + // so they are public and long-lived; anything user-shaped stays private. + ->setCachePolicy( + CachePolicy::default(30_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), + ) + + // Signs the `requestState` a multi round-trip answer carries. The same key + // must reach every process that might serve the retry — a per-process + // random value only works for a single-process deployment. + ->setRequestState(getenv('MCP_REQUEST_STATE_KEY') ?: str_repeat('example-development-key-', 2)) + + // Carries change notifications to open `subscriptions/listen` streams. + // In-memory suits `php -S` and worker runtimes; under PHP-FPM use + // Psr16NotificationBus, since the publisher and the stream are different + // processes there. + ->setNotificationBus($bus) + ->setSubscriptionLifetime(20.0) + + // An ordinary tool: nothing about it is lifecycle-specific. + ->addTool( + static fn (string $city = 'Berlin'): string => sprintf('It is 17°C and cloudy in %s.', $city), + name: 'get_weather', + description: 'Reports the weather for a city', + ) + + // Progress and logging both travel on this request's own response stream. + // The client opts into each: progress by sending `_meta.progressToken`, + // logging by sending `_meta["io.modelcontextprotocol/logLevel"]`. Without + // those the server must stay silent, and does. + ->addTool( + static function (RequestContext $context, int $steps = 3): string { + $client = $context->getClientGateway(); + + for ($step = 1; $step <= $steps; ++$step) { + $client->log(LoggingLevel::Info, sprintf('Reindexing shard %d of %d', $step, $steps)); + $client->progress($step, $steps, sprintf('Shard %d of %d', $step, $steps)); + } + + return sprintf('Reindexed %d shards.', $steps); + }, + name: 'reindex', + description: 'Reindexes shards, reporting progress as it goes', + ) + + // A tool that needs something from the user. There are no server-initiated + // requests in this revision: instead of asking the client and waiting, the + // server *returns* the ask and the client retries the whole call with the + // answer. Nothing is kept between the two rounds — what the server needs to + // remember it seals into `requestState`, which comes back verified. + // + // No fork for the handshake era, and none needed: there the SDK fulfils the + // same ask over that connection's own channel and re-enters this closure + // with the answer. Which is why it re-derives where it is from what came + // back rather than keeping anything of its own. + ->addTool( + static function (RequestContext $context): CallToolResult|InputRequiredResult { + $answer = $context->getInputContext()?->elicitResult('who'); + + if (null === $answer) { + return new InputRequiredResult( + ['who' => new ElicitRequest( + 'What name should the greeting use?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + )], + requestState: $context->mintRequestState(['asked' => 'who']), + ); + } + + return new CallToolResult([new TextContent(sprintf('Hello, %s!', $answer->content['name'] ?? 'friend'))]); + }, + name: 'greet', + description: 'Greets you by name, asking for it first if it has to', + ) + ->build(); + +shutdown($server->run(transport())); diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..d5e4543 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,172 @@ +site_name: MCP PHP SDK +site_description: The official PHP SDK for the Model Context Protocol +site_url: https://php.sdk.modelcontextprotocol.io/ +repo_name: modelcontextprotocol/php-sdk +repo_url: https://github.com/modelcontextprotocol/php-sdk +edit_uri: edit/main/docs/ + +# The class-level API reference is still generated by phpDocumentor and copied +# into `site/api/` by `make docs`; Zensical owns everything under `docs/`. +# A relative nav entry would have to resolve to a page in this build, so the +# reference is linked by its absolute URL. +nav: + - MCP PHP SDK: index.md + - Get started: + - get-started/index.md + - Installation: get-started/installation.md + - First server: get-started/first-server.md + - Try it with the Inspector: get-started/inspector.md + - Servers: + - servers/index.md + - Tools: servers/tools.md + - Resources: servers/resources.md + - Resource templates: servers/resource-templates.md + - Prompts: servers/prompts.md + - Completions: servers/completions.md + - Schema generation: servers/schemas.md + - Registering elements: servers/registration.md + - Inside your handler: + - handlers/index.md + - Talking back to the client: handlers/client-communication.md + - Asking for input: handlers/input-required.md + - Logging: handlers/logging.md + - Running your server: + - run/index.md + - Server builder: run/server-builder.md + - STDIO transport: run/stdio.md + - HTTP transport: run/http.md + - Framework integration: run/framework-integration.md + - Sessions: run/sessions.md + - Serving both eras: run/protocol-eras.md + - Caching: run/caching.md + - Subscriptions: run/subscriptions.md + - Authorization: run/authorization.md + - Clients: + - client/index.md + - Connecting to a server: client/connecting.md + - Transports: client/transports.md + - "Tools, resources & prompts": client/capabilities.md + - Server-initiated requests: client/server-requests.md + - Error handling: client/errors.md + - Protocol versions: protocol-versions.md + - Advanced: + - advanced/index.md + - Events: advanced/events.md + - Protocol extensions: advanced/extensions.md + - Custom message handlers: advanced/custom-handlers.md + - Project: + - Deprecation policy: deprecation-policy.md + - SDK tier target: sdk-tier.md + - Examples: examples.md + - API Reference: https://php.sdk.modelcontextprotocol.io/api/ + +# Look and feel is kept in sync with the Python SDK's documentation +# (https://py.sdk.modelcontextprotocol.io/) so the language SDKs read as one +# set of docs: same MCP mark, same black/slate palette, same navigation. +theme: + name: material + # Zensical's own look, and its default; `classic` renders the older + # Material for MkDocs styling instead. + variant: modern + custom_dir: docs/.overrides + font: + text: Inter + code: JetBrains Mono + icon: + logo: mcp + favicon: favicon.svg + palette: + - media: "(prefers-color-scheme)" + scheme: default + primary: black + accent: black + toggle: + icon: material/lightbulb + name: "Switch to light mode" + - media: "(prefers-color-scheme: light)" + scheme: default + primary: black + accent: black + toggle: + icon: material/lightbulb-outline + name: "Switch to dark mode" + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + accent: white + toggle: + icon: material/lightbulb-auto-outline + name: "Switch to system preference" + features: + - search.suggest + - search.highlight + - content.tabs.link + - content.code.annotate + - content.code.copy + - content.code.select + - navigation.footer + - navigation.indexes + - navigation.instant + - navigation.instant.prefetch + - navigation.instant.progress + - navigation.path + - navigation.prune + - navigation.sections + - navigation.top + - navigation.tracking + - toc.follow + + +# Zensical natively re-implements `search`; it does not run arbitrary MkDocs +# plugins or hooks. +plugins: + - search + +markdown_extensions: + - tables + - abbr + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - sane_lists # this means you can start a list from any number + - pymdownx.betterem + - pymdownx.caret + - pymdownx.details + - pymdownx.inlinehilite + - pymdownx.mark + - pymdownx.tilde + - pymdownx.highlight: + pygments_lang_class: true + # Pygments' PHP lexer only starts highlighting after a ` MCP PHP SDK + - .phpdoc/build + .phpdoc/build/api - latest src - api vendor/**/* tests/**/* @@ -31,14 +34,8 @@ implements - - - docs - - / - - + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f5901b2..a84b39c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,7 +1,7 @@ parameters: ignoreErrors: - - message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\\} but returns array\{contents\: array\\}\.$#' + message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\, ttlMs\?\: int, cacheScope\?\: string\} but returns array\{contents\: array\, ttlMs\?\: int\<0, max\>, cacheScope\?\: ''private''\|''public''\}\.$#' identifier: return.type count: 1 path: src/Schema/Result/ReadResourceResult.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 54c2a8e..eb07b79 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,6 +12,9 @@ tests/Unit + + tests/Integration + examples/server/oauth-microsoft/tests diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..ffc1be8 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,3 @@ +# Toolchain for the documentation site under `docs/`, built by `make docs`. +# +zensical==0.0.50 diff --git a/src/Capability/Attribute/CompletionProvider.php b/src/Capability/Attribute/CompletionProvider.php index 9e8dc80..a21963c 100644 --- a/src/Capability/Attribute/CompletionProvider.php +++ b/src/Capability/Attribute/CompletionProvider.php @@ -24,9 +24,9 @@ class CompletionProvider * @param class-string|ProviderInterface|null $provider if a class-string, it will be resolved * from the container at the point of use * @param ?array $values a list of values to use for completion + * @param class-string|null $enum an enum whose cases are the completions */ public function __construct( - public ?string $providerClass = null, public string|ProviderInterface|null $provider = null, public ?array $values = null, public ?string $enum = null, diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index dab6590..c70559f 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -217,7 +217,6 @@ final class Discoverer implements DiscovererInterface private function processMethod(\ReflectionMethod $method, array &$discoveredCount, \ReflectionAttribute $attribute, array &$tools, array &$resources, array &$prompts, array &$resourceTemplates): void { $className = $method->getDeclaringClass()->getName(); - $classShortName = $method->getDeclaringClass()->getShortName(); $methodName = $method->getName(); $attributeClassName = $attribute->getName(); @@ -226,9 +225,8 @@ final class Discoverer implements DiscovererInterface switch ($attributeClassName) { case McpTool::class: - $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); - $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($method, $instance->name); + $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); $inputSchema = $this->schemaGenerator->generate($method); $outputSchema = $this->schemaGenerator->generateOutputSchema($method); $tool = new Tool( @@ -246,9 +244,8 @@ final class Discoverer implements DiscovererInterface break; case McpResource::class: - $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); - $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($method, $instance->name); + $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); $resource = new ResourceDefinition( $instance->uri, $name, @@ -267,8 +264,8 @@ final class Discoverer implements DiscovererInterface case McpPrompt::class: $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); - $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($method, $instance->name); + $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); $arguments = []; $paramTags = $this->docBlockParser->getParamTags($docBlock); foreach ($method->getParameters() as $param) { @@ -286,9 +283,8 @@ final class Discoverer implements DiscovererInterface break; case McpResourceTemplate::class: - $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); - $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($method, $instance->name); + $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); $mimeType = $instance->mimeType; $annotations = $instance->annotations; $meta = $instance->meta ?? null; @@ -329,8 +325,6 @@ final class Discoverer implements DiscovererInterface if ($attributeInstance->provider) { $completionProviders[$param->getName()] = $attributeInstance->provider; - } elseif ($attributeInstance->providerClass) { - $completionProviders[$param->getName()] = $attributeInstance->provider; } elseif ($attributeInstance->values) { $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); } elseif ($attributeInstance->enum) { diff --git a/src/Capability/Discovery/ElementMetadataResolver.php b/src/Capability/Discovery/ElementMetadataResolver.php new file mode 100644 index 0000000..3c755dd --- /dev/null +++ b/src/Capability/Discovery/ElementMetadataResolver.php @@ -0,0 +1,44 @@ + + */ +final class ElementMetadataResolver +{ + private function __construct() + { + } + + /** + * Explicit names win; otherwise invokable classes are named after the class, + * regular handlers after the method. + */ + public static function resolveName(\ReflectionMethod $method, ?string $name): string + { + $methodName = $method->getName(); + + return $name ?? ('__invoke' === $methodName ? $method->getDeclaringClass()->getShortName() : $methodName); + } + + /** + * Explicit descriptions win; otherwise the summary of the method's doc block is used. + */ + public static function resolveDescription(\ReflectionMethod $method, ?string $description, DocBlockParser $docBlockParser): ?string + { + return $description ?? $docBlockParser->getDescription($docBlockParser->parseDocBlock($method->getDocComment() ?? null)); + } +} diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index a4efdd6..357c9e9 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -13,9 +13,9 @@ namespace Mcp\Capability\Discovery; use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; +use Mcp\Capability\Registry\InjectableParameters; use Mcp\Exception\BadMethodCallException; use Mcp\Exception\InvalidArgumentException; -use Mcp\Server\RequestContext; use phpDocumentor\Reflection\DocBlock\Tags\Param; /** @@ -237,7 +237,17 @@ final class SchemaGenerator implements SchemaGeneratorInterface // Parameter-level takes highest precedence $parameterLevelSchema = $paramInfo['parameter_schema']; if (!empty($parameterLevelSchema)) { - $mergedSchema = array_merge($mergedSchema, $parameterLevelSchema); + // A complete definition replaces the schema, as it does at method level. + if (isset($parameterLevelSchema['definition']) && \is_array($parameterLevelSchema['definition'])) { + $mergedSchema = $parameterLevelSchema['definition']; + + // The default comes from the signature, not from the schema. + if (!\array_key_exists('default', $mergedSchema) && \array_key_exists('default', $inferredSchema)) { + $mergedSchema['default'] = $inferredSchema['default']; + } + } else { + $mergedSchema = array_merge($mergedSchema, $parameterLevelSchema); + } } // Run after all merges so that when a Schema attribute reshapes the parameter @@ -324,17 +334,24 @@ final class SchemaGenerator implements SchemaGeneratorInterface { $paramSchema = ['type' => 'array']; - // Apply parameter-level Schema attributes first - if (!empty($paramInfo['parameter_schema'])) { - $paramSchema = array_merge($paramSchema, $paramInfo['parameter_schema']); - // Ensure type is always array for variadic - $paramSchema['type'] = 'array'; - } - + // Add description from docblock before parameter-level schema is merged in, + // so a definition's own description takes precedence (matches buildParameterSchema). if ($paramInfo['description']) { $paramSchema['description'] = $paramInfo['description']; } + // Apply parameter-level Schema attributes first + if (!empty($paramInfo['parameter_schema'])) { + $parameterLevelSchema = $paramInfo['parameter_schema']; + + $paramSchema = isset($parameterLevelSchema['definition']) && \is_array($parameterLevelSchema['definition']) + ? $parameterLevelSchema['definition'] + : array_merge($paramSchema, $parameterLevelSchema); + + // Ensure type is always array for variadic + $paramSchema['type'] = 'array'; + } + // If no items specified by Schema attribute, infer from type if (!isset($paramSchema['items'])) { $itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']); @@ -443,7 +460,23 @@ final class SchemaGenerator implements SchemaGeneratorInterface } // Handle regular arrays elseif (\in_array('array', $this->mapPhpTypeToJsonSchemaType($typeString))) { - $itemsType = $this->inferArrayItemsType($typeString); + // `inferArrayItemsType()` expects a single array type, not a union — for + // `string[]|int` it would see the whole string, match nothing, and lose + // the element-type constraint. Isolate the branch(es) that are arrays. + $arrayBranches = array_values(array_filter( + self::splitUnion($typeString), + fn (string $branch): bool => \in_array('array', $this->mapPhpTypeToJsonSchemaType($branch), true), + )); + if ([] === $arrayBranches) { + $arrayBranches = [$typeString]; + } + + // Several array branches (`string[]|int[]`) can disagree on their + // element type. Applying just one would reject values valid under + // the other, so `items` only applies when every array branch agrees. + $itemsTypes = array_map($this->inferArrayItemsType(...), $arrayBranches); + $itemsType = 1 === \count(array_unique(array_map(json_encode(...), $itemsTypes))) ? $itemsTypes[0] : 'any'; + if ('any' !== $itemsType) { if (\is_string($itemsType)) { $paramSchema['items'] = ['type' => $itemsType]; @@ -455,11 +488,25 @@ final class SchemaGenerator implements SchemaGeneratorInterface } } - if ($allowsNull) { - $paramSchema['type'] = ['array', 'null']; + // Only when the parameter is an array and nothing else. A union + // like `string[]|int` keeps both branches: `items` constrains the + // instance only when it *is* an array, so the two coexist and + // overwriting the type here would drop the scalar branch — + // rejecting a value the handler accepts. + $otherTypes = array_values(array_diff( + (array) $paramSchema['type'], + ['array', 'null'], + )); + + if ([] === $otherTypes) { + $paramSchema['type'] = $allowsNull ? ['array', 'null'] : 'array'; + + if (\is_array($paramSchema['type'])) { + sort($paramSchema['type']); + } + } elseif ($allowsNull && !\in_array('null', (array) $paramSchema['type'], true)) { + $paramSchema['type'] = [...(array) $paramSchema['type'], 'null']; sort($paramSchema['type']); - } else { - $paramSchema['type'] = 'array'; } } @@ -481,12 +528,10 @@ final class SchemaGenerator implements SchemaGeneratorInterface foreach ($reflection->getParameters() as $rp) { $reflectionType = $rp->getType(); - if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { - $typeName = $reflectionType->getName(); - - if (is_a($typeName, RequestContext::class, true)) { - continue; - } + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin() + && InjectableParameters::supports($reflectionType->getName()) + ) { + continue; } $paramName = $rp->getName(); @@ -655,6 +700,42 @@ final class SchemaGenerator implements SchemaGeneratorInterface return $typeString ?: 'mixed'; } + /** + * Splits a type string on its top-level `|`, leaving generics and shapes + * intact. + * + * `int|string` splits; `array` and `array{a: int|string}` do + * not, because the `|` there is a parameter of the outer type rather than + * an alternative to it. + * + * @return list + */ + public static function splitUnion(string $type): array + { + $branches = []; + $depth = 0; + $current = ''; + + foreach (str_split($type) as $character) { + if (\in_array($character, ['<', '{', '('], true)) { + ++$depth; + } elseif (\in_array($character, ['>', '}', ')'], true)) { + $depth = max(0, $depth - 1); + } elseif ('|' === $character && 0 === $depth) { + $branches[] = trim($current); + $current = ''; + + continue; + } + + $current .= $character; + } + + $branches[] = trim($current); + + return array_values(array_filter($branches, static fn (string $branch): bool => '' !== $branch)); + } + /** * Maps a PHP type string (potentially a union) to an array of JSON Schema type names. * @@ -664,12 +745,28 @@ final class SchemaGenerator implements SchemaGeneratorInterface { $normalizedType = strtolower(trim($phpTypeString)); - // PRIORITY 1: Check for array{} syntax which should be treated as object + // PRIORITY 1: Handle unions before anything else. A `|` at the top + // level joins alternatives; one inside `<>` or `{}` belongs to a + // generic (`array`) and is not a union of the whole type. + // Checked first because a branch may itself be an array shape — and + // `string[]|int` read as "an array" silently loses the `int`. + $branches = self::splitUnion($normalizedType); + + if (\count($branches) > 1) { + $jsonTypes = []; + foreach ($branches as $branch) { + $jsonTypes = array_merge($jsonTypes, $this->mapPhpTypeToJsonSchemaType($branch)); + } + + return array_values(array_unique($jsonTypes)); + } + + // PRIORITY 2: Check for array{} syntax which should be treated as object if (preg_match('/^array\s*{/i', $normalizedType)) { return ['object']; } - // PRIORITY 2: Check for array syntax first (T[] or generics) + // PRIORITY 3: Check for array syntax (T[] or generics) if ( str_contains($normalizedType, '[]') || preg_match('/^(array|list|iterable|collection)mapPhpTypeToJsonSchemaType(trim($type)); - $jsonTypes = array_merge($jsonTypes, $mapped); - } - - return array_values(array_unique($jsonTypes)); + // PRIORITY 4: Treat PHPStan/Psalm integer ranges as their base type + if (preg_match('/^int\s*<\s*[^<>]+\s*>$/i', $normalizedType)) { + return ['integer']; } - // PRIORITY 4: Handle simple built-in types + // PRIORITY 5: Handle simple built-in types return match ($normalizedType) { 'string', 'scalar' => ['string'], '?string' => ['null', 'string'], diff --git a/src/Capability/Formatter/PromptResultFormatter.php b/src/Capability/Formatter/PromptResultFormatter.php index 871bded..207c7e2 100644 --- a/src/Capability/Formatter/PromptResultFormatter.php +++ b/src/Capability/Formatter/PromptResultFormatter.php @@ -11,15 +11,15 @@ namespace Mcp\Capability\Formatter; +use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RuntimeException; use Mcp\Schema\Content\AudioContent; -use Mcp\Schema\Content\BlobResourceContents; use Mcp\Schema\Content\Content; use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\PromptMessage; +use Mcp\Schema\Content\ResourceLink; use Mcp\Schema\Content\TextContent; -use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\Enum\Role; /** @@ -142,18 +142,19 @@ final class PromptResultFormatter /** * Formats content into a proper Content object. */ - private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource { $indexStr = null !== $index ? " at index {$index}" : ''; if ($content instanceof Content) { if ( $content instanceof TextContent || $content instanceof ImageContent - || $content instanceof AudioContent || $content instanceof EmbeddedResource + || $content instanceof AudioContent || $content instanceof ResourceLink + || $content instanceof EmbeddedResource ) { return $content; } - throw new RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource."); + throw new RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, ResourceLink, or EmbeddedResource."); } if (\is_string($content)) { @@ -178,90 +179,33 @@ final class PromptResultFormatter /** * Formats typed content arrays into Content objects. * + * Delegates to the schema classes' fromArray() so optional fields + * (annotations, mimeType, _meta, ...) carry over instead of being dropped. + * * @param array $content */ - private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource { $indexStr = null !== $index ? " at index {$index}" : ''; $type = $content['type']; - return match ($type) { - 'text' => $this->formatTextContent($content, $indexStr), - 'image' => $this->formatImageContent($content, $indexStr), - 'audio' => $this->formatAudioContent($content, $indexStr), - 'resource' => $this->formatResourceContent($content, $indexStr), - default => throw new RuntimeException("Invalid content type '{$type}'{$indexStr}."), - }; - } - - /** - * @param array $content - */ - private function formatTextContent(array $content, string $indexStr): TextContent - { - if (!isset($content['text']) || !\is_string($content['text'])) { - throw new RuntimeException(\sprintf('Invalid "text" content%s: Missing or invalid "text" string.', $indexStr)); + if ('resource' === $type && isset($content['resource']) && \is_array($content['resource']) && !isset($content['resource']['mimeType'])) { + // EmbeddedResource::fromArray() leaves a missing mimeType unset; this + // formatter has always defaulted it, so keep that for compatibility. + $content['resource']['mimeType'] = isset($content['resource']['text']) ? 'text/plain' : 'application/octet-stream'; } - return new TextContent($content['text']); - } - - /** - * @param array $content - */ - private function formatImageContent(array $content, string $indexStr): ImageContent - { - if (!isset($content['data']) || !\is_string($content['data'])) { - throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'data' string (base64)."); + try { + return match ($type) { + 'text' => TextContent::fromArray($content), + 'image' => ImageContent::fromArray($content), + 'audio' => AudioContent::fromArray($content), + 'resource' => EmbeddedResource::fromArray($content), + 'resource_link' => ResourceLink::fromArray($content), + default => throw new RuntimeException("Invalid content type '{$type}'{$indexStr}."), + }; + } catch (InvalidArgumentException $e) { + throw new RuntimeException("Invalid '{$type}' content{$indexStr}: {$e->getMessage()}", 0, $e); } - if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) { - throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'mimeType' string."); - } - - return new ImageContent($content['data'], $content['mimeType']); - } - - /** - * @param array $content - */ - private function formatAudioContent(array $content, string $indexStr): AudioContent - { - if (!isset($content['data']) || !\is_string($content['data'])) { - throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'data' string (base64)."); - } - if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) { - throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'mimeType' string."); - } - - return new AudioContent($content['data'], $content['mimeType']); - } - - /** - * @param array $content - */ - private function formatResourceContent(array $content, string $indexStr): EmbeddedResource - { - if (!isset($content['resource']) || !\is_array($content['resource'])) { - throw new RuntimeException("Invalid 'resource' content{$indexStr}: Missing or invalid 'resource' object."); - } - - $resource = $content['resource']; - if (!isset($resource['uri']) || !\is_string($resource['uri'])) { - throw new RuntimeException("Invalid resource{$indexStr}: Missing or invalid 'uri'."); - } - - if (isset($resource['text']) && \is_string($resource['text'])) { - $resourceObj = new TextResourceContents($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); - } elseif (isset($resource['blob']) && \is_string($resource['blob'])) { - $resourceObj = new BlobResourceContents( - $resource['uri'], - $resource['mimeType'] ?? 'application/octet-stream', - $resource['blob'] - ); - } else { - throw new RuntimeException("Invalid resource{$indexStr}: Must contain 'text' or 'blob'."); - } - - return new EmbeddedResource($resourceObj); } } diff --git a/src/Capability/Logger/ClientLogger.php b/src/Capability/Logger/ClientLogger.php index 1e0a959..f94deb6 100644 --- a/src/Capability/Logger/ClientLogger.php +++ b/src/Capability/Logger/ClientLogger.php @@ -22,6 +22,9 @@ use Psr\Log\AbstractLogger; * * @author Adam Jamiu * @author Tobias Nyholm + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ final class ClientLogger extends AbstractLogger { @@ -48,7 +51,7 @@ final class ClientLogger extends AbstractLogger $minimumLevel = $this->session->get(Protocol::SESSION_LOGGING_LEVEL, ''); $minimumLevel = LoggingLevel::tryFrom($minimumLevel) ?? LoggingLevel::Warning; - if ($this->getSeverityIndex($minimumLevel) > $this->getSeverityIndex($mcpLevel)) { + if (!$mcpLevel->isAtLeast($minimumLevel)) { return; } @@ -76,24 +79,4 @@ final class ClientLogger extends AbstractLogger default => null, }; } - - /** - * Gets the severity index for this log level. - * Higher values indicate more severe log levels. - * - * @return int Severity index (0-7, where 7 is most severe) - */ - private function getSeverityIndex(LoggingLevel $level): int - { - return match ($level) { - LoggingLevel::Debug => 0, - LoggingLevel::Info => 1, - LoggingLevel::Notice => 2, - LoggingLevel::Warning => 3, - LoggingLevel::Error => 4, - LoggingLevel::Critical => 5, - LoggingLevel::Alert => 6, - LoggingLevel::Emergency => 7, - }; - } } diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index f90a4ca..9784043 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -88,10 +88,27 @@ final class Registry implements RegistryInterface return; } + $this->loadFrom($this->loader); + + $this->loaded = true; + } + + /** + * Runs $loader with the change events its registrations would dispatch suppressed, since they + * describe the registry filling up rather than changing. + * + * Re-entrant-safe, and failure propagates. Does not mark the registry loaded: $loader is the + * caller's, and the one the constructor took is still owed its run. + */ + public function loadFrom(LoaderInterface $loader): void + { + if ($this->loading) { + return; + } + $this->loading = true; try { - $this->loader->load($this); - $this->loaded = true; + $loader->load($this); } finally { $this->loading = false; } @@ -108,7 +125,7 @@ final class Registry implements RegistryInterface $reference = new ToolReference($tool, $handler); $this->tools[$tool->name] = $reference; - $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); + $this->dispatch(new ToolListChangedEvent()); return $reference; } @@ -118,7 +135,7 @@ final class Registry implements RegistryInterface $reference = new ResourceReference($resource, $handler); $this->resources[$resource->uri] = $reference; - $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); + $this->dispatch(new ResourceListChangedEvent()); return $reference; } @@ -131,7 +148,7 @@ final class Registry implements RegistryInterface $reference = new ResourceTemplateReference($template, $handler, $completionProviders); $this->resourceTemplates[$template->uriTemplate] = $reference; - $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); + $this->dispatch(new ResourceTemplateListChangedEvent()); return $reference; } @@ -144,7 +161,7 @@ final class Registry implements RegistryInterface $reference = new PromptReference($prompt, $handler, $completionProviders); $this->prompts[$prompt->name] = $reference; - $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); + $this->dispatch(new PromptListChangedEvent()); return $reference; } @@ -157,7 +174,7 @@ final class Registry implements RegistryInterface unset($this->tools[$name]); - $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); + $this->dispatch(new ToolListChangedEvent()); } public function unregisterResource(string $uri): void @@ -168,7 +185,7 @@ final class Registry implements RegistryInterface unset($this->resources[$uri]); - $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); + $this->dispatch(new ResourceListChangedEvent()); } public function unregisterResourceTemplate(string $uriTemplate): void @@ -179,7 +196,7 @@ final class Registry implements RegistryInterface unset($this->resourceTemplates[$uriTemplate]); - $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); + $this->dispatch(new ResourceTemplateListChangedEvent()); } public function unregisterPrompt(string $name): void @@ -190,7 +207,7 @@ final class Registry implements RegistryInterface unset($this->prompts[$name]); - $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); + $this->dispatch(new PromptListChangedEvent()); } public function hasTool(string $name): bool @@ -390,6 +407,18 @@ final class Registry implements RegistryInterface return $this->prompts[$name] ?? throw new PromptNotFoundException($name); } + /** + * Suppressed while the deferred loader runs, since it only sets up initial state. + */ + private function dispatch(object $event): void + { + if ($this->loading) { + return; + } + + $this->eventDispatcher?->dispatch($event); + } + /** * Calculate next cursor for pagination. * diff --git a/src/Capability/Registry/InjectableParameters.php b/src/Capability/Registry/InjectableParameters.php new file mode 100644 index 0000000..4e8eba2 --- /dev/null +++ b/src/Capability/Registry/InjectableParameters.php @@ -0,0 +1,63 @@ + $arguments the raw argument bag including the internal "_session" and "_request" entries + */ + public static function resolve(string $typeName, array $arguments): ?object + { + if (RequestContext::class === $typeName && isset($arguments['_session'], $arguments['_request'])) { + return new RequestContext($arguments['_session'], $arguments['_request']); + } + + if (ClientGateway::class === $typeName && isset($arguments['_session'])) { + return new ClientGateway($arguments['_session']); + } + + return null; + } +} diff --git a/src/Capability/Registry/Loader/ReflectedElementLoader.php b/src/Capability/Registry/Loader/ReflectedElementLoader.php index a87dd58..209644c 100644 --- a/src/Capability/Registry/Loader/ReflectedElementLoader.php +++ b/src/Capability/Registry/Loader/ReflectedElementLoader.php @@ -16,6 +16,7 @@ use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Capability\Completion\ListCompletionProvider; use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Discovery\DocBlockParser; +use Mcp\Capability\Discovery\ElementMetadataResolver; use Mcp\Capability\Discovery\HandlerResolver; use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Capability\Discovery\SchemaGeneratorInterface; @@ -106,12 +107,8 @@ final class ReflectedElementLoader implements LoaderInterface $name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($reflection, $data['name'] ?? null); + $description = ElementMetadataResolver::resolveDescription($reflection, $data['description'] ?? null, $docBlockParser); } $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); @@ -148,12 +145,8 @@ final class ReflectedElementLoader implements LoaderInterface $name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($reflection, $data['name'] ?? null); + $description = ElementMetadataResolver::resolveDescription($reflection, $data['description'] ?? null, $docBlockParser); } $resource = new ResourceDefinition( @@ -189,12 +182,8 @@ final class ReflectedElementLoader implements LoaderInterface $name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($reflection, $data['name'] ?? null); + $description = ElementMetadataResolver::resolveDescription($reflection, $data['description'] ?? null, $docBlockParser); } $template = new ResourceTemplate( @@ -229,12 +218,8 @@ final class ReflectedElementLoader implements LoaderInterface $name = $data['name'] ?? 'closure_prompt_'.spl_object_id($data['handler']); $description = $data['description'] ?? null; } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; + $name = ElementMetadataResolver::resolveName($reflection, $data['name'] ?? null); + $description = ElementMetadataResolver::resolveDescription($reflection, $data['description'] ?? null, $docBlockParser); } $arguments = []; @@ -302,7 +287,7 @@ final class ReflectedElementLoader implements LoaderInterface } /** - * @return array + * @return array|ProviderInterface> */ private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array { @@ -322,8 +307,6 @@ final class ReflectedElementLoader implements LoaderInterface if ($attributeInstance->provider) { $completionProviders[$param->getName()] = $attributeInstance->provider; - } elseif ($attributeInstance->providerClass) { - $completionProviders[$param->getName()] = $attributeInstance->providerClass; } elseif ($attributeInstance->values) { $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); } elseif ($attributeInstance->enum) { diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 99e5844..c0084cb 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,8 +13,6 @@ namespace Mcp\Capability\Registry; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; -use Mcp\Server\ClientGateway; -use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -99,22 +97,38 @@ final class ReferenceHandler implements ReferenceHandlerInterface $finalArgs = []; foreach ($reflection->getParameters() as $parameter) { - // TODO: Handle variadic parameters. $paramName = $parameter->getName(); $paramPosition = $parameter->getPosition(); + if ($parameter->isVariadic()) { + // Each element is cast individually below; a non-array value is + // wrapped as a single element, since prompt arguments arrive as + // plain strings (Record per the protocol) even + // though tool arguments follow the advertised "array" schema. + $values = $arguments[$paramName] ?? []; + if (!\is_array($values)) { + $values = [$values]; + } elseif (!array_is_list($values)) { + throw RegistryException::invalidParams(\sprintf('Parameter `%s` must be a list of values, not an object.', $paramName)); + } + foreach ($values as $index => $value) { + try { + $finalArgs[] = $this->castArgumentType($value, $parameter); + } catch (InvalidArgumentException $e) { + throw RegistryException::invalidParams(\sprintf('Parameter `%s[%d]`: %s', $paramName, $index, $e->getMessage()), $e); + } catch (\Throwable $e) { + throw RegistryException::internalError(\sprintf('Error processing parameter `%s[%d]`: %s', $paramName, $index, $e->getMessage()), $e); + } + } + continue; + } + // Check if parameter is a special injectable type $type = $parameter->getType(); if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { - $typeName = $type->getName(); - - if (RequestContext::class === $typeName && isset($arguments['_session'], $arguments['_request'])) { - $finalArgs[$paramPosition] = new RequestContext($arguments['_session'], $arguments['_request']); - continue; - } - - if (ClientGateway::class === $typeName && isset($arguments['_session'])) { - $finalArgs[$paramPosition] = new ClientGateway($arguments['_session']); + $injected = InjectableParameters::resolve($type->getName(), $arguments); + if (null !== $injected) { + $finalArgs[$paramPosition] = $injected; continue; } } diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index 36c0835..97ace43 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -13,6 +13,7 @@ namespace Mcp\Capability\Registry; use Mcp\Capability\Formatter\ToolResultFormatter; use Mcp\Schema\Content\Content; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Tool; /** @@ -59,15 +60,43 @@ class ToolReference extends ElementReference /** * Extracts structured content from a tool result using the output schema. * - * @param mixed $toolExecutionResult the raw value returned by the tool's PHP method + * What may be sent as `structuredContent` depends on the protocol revision in + * use. Up to `2025-11-25` it has to be a JSON object, and `outputSchema` is + * restricted to `type: "object"` to match. From `2026-07-28` on (SEP-2106) + * `outputSchema` is any JSON Schema 2020-12 and `structuredContent` is any JSON + * value conforming to it — a list included. * - * @return array|null the structured content, or null if not extractable + * @param mixed $toolExecutionResult the raw value returned by the tool's PHP method + * @param ?ProtocolVersion $protocolVersion revision the result is produced for; defaults to the + * newest handshake revision, whose stricter rule is what + * every revision reachable through `initialize` requires + * + * @return mixed the structured content, or null if not extractable * * @throws \JsonException if JSON encoding fails for non-Content array/object results */ - public function extractStructuredContent(mixed $toolExecutionResult): ?array + public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVersion $protocolVersion = null): mixed { + $objectOnly = ($protocolVersion ?? ProtocolVersion::latestHandshake())->requiresObjectStructuredContent(); + if (\is_array($toolExecutionResult)) { + // A PHP list serializes to a JSON array, which the revisions predating + // SEP-2106 do not allow as `structuredContent` — strict clients reject + // the whole tool call when one is sent. + if ($objectOnly && array_is_list($toolExecutionResult)) { + return null; + } + + foreach ($toolExecutionResult as $item) { + if ($item instanceof Content) { + // Content items are already reflected in the result's `content` + // array; an array holding one or more of them isn't structured + // data. This holds in every revision — it is a duplication rule, + // not a shape rule. + return null; + } + } + return $toolExecutionResult; } @@ -77,11 +106,37 @@ class ToolReference extends ElementReference \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE ); - return json_decode( + $decoded = json_decode( $jsonResult, true, 512, \JSON_THROW_ON_ERROR ); + + // A plain object always encodes to a JSON object, but `JsonSerializable` + // can hand back anything, scalars included. + if (!\is_array($decoded)) { + return $this->acceptsScalarStructuredContent($objectOnly) ? $decoded : null; + } + + if ($objectOnly && array_is_list($decoded)) { + return null; + } + + return $decoded; } - return null; + // A scalar is structured content only from SEP-2106 on, and only when the + // tool declared an outputSchema: without one, every string-returning tool + // would start advertising a duplicate of its own `content`. + return $this->acceptsScalarStructuredContent($objectOnly) && \is_scalar($toolExecutionResult) + ? $toolExecutionResult + : null; + } + + /** + * Whether the negotiated revision and the tool's own declaration together allow + * a non-object `structuredContent`. + */ + private function acceptsScalarStructuredContent(bool $objectOnly): bool + { + return !$objectOnly && null !== $this->tool->outputSchema; } } diff --git a/src/Client.php b/src/Client.php index dd29069..532f60a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -17,11 +17,14 @@ use Mcp\Client\Protocol; use Mcp\Client\Transport\TransportInterface; use Mcp\Exception\ConnectionException; use Mcp\Exception\RequestException; +use Mcp\Exception\RuntimeException; use Mcp\Schema\Enum\LoggingLevel; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Notification\RootsListChangedNotification; use Mcp\Schema\PromptReference; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\CompletionCompleteRequest; @@ -55,6 +58,8 @@ use Psr\Log\NullLogger; */ class Client { + private const RETRY_BASE_DELAY_MS = 100; + private ?TransportInterface $transport = null; public function __construct( @@ -75,16 +80,44 @@ class Client /** * Connect to an MCP server using the provided transport. * - * @throws ConnectionException If connection or initialization fails + * A failed attempt is closed and retried, see {@see Builder::setMaxRetries()}. + * + * @throws ConnectionException If connection or initialization fails on every attempt */ public function connect(TransportInterface $transport): void { $this->transport = $transport; $this->protocol->connect($transport, $this->config); - $transport->connect(); + $maxAttempts = $this->config->maxRetries + 1; - $this->logger->info('Client connected and initialized'); + for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) { + try { + $transport->connect(); + + $this->logger->info('Client connected and initialized', ['attempt' => $attempt]); + + return; + } catch (ConnectionException $e) { + // initialize() flags the session before sending the initialized + // notification, so a failure in between leaves the flag set. + $this->protocol->getState()->setInitialized(false); + + $transport->close(); + + if ($attempt === $maxAttempts) { + throw $e; + } + + $this->logger->warning('Connection attempt failed, retrying', [ + 'attempt' => $attempt, + 'max_attempts' => $maxAttempts, + 'exception' => $e, + ]); + + usleep($attempt * self::RETRY_BASE_DELAY_MS * 1000); + } + } } /** @@ -111,6 +144,18 @@ class Client return $this->protocol->getState()->getInstructions(); } + /** + * Protocol revision negotiated during the handshake. + * + * This is the version the server answered with, which is not necessarily the + * one configured on the builder: a server that cannot speak the requested + * revision counter-offers one it supports. Null until the handshake completed. + */ + public function getProtocolVersion(): ?ProtocolVersion + { + return $this->protocol->getState()->getProtocolVersion(); + } + /** * Send a ping request to the server. */ @@ -130,7 +175,16 @@ class Client $response = $this->sendRequest($request); - return ListToolsResult::fromArray($response->result); + $result = $response->result; + + // Filtered before parsing, because parsing is where a malformed + // `x-mcp-header` annotation throws. One broken definition must cost the + // caller that tool, not the whole listing (SEP-2243). + if (\is_array($result['tools'] ?? null)) { + $result['tools'] = $this->protocol->getToolCatalog()->record($result['tools']); + } + + return ListToolsResult::fromArray($result); } /** @@ -143,6 +197,15 @@ class Client */ public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult { + $catalog = $this->protocol->getToolCatalog(); + + // A tool the listing showed to be malformed is refused here rather than + // sent: the client cannot produce the headers its annotations demand, + // so the call could only go out misdescribed (SEP-2243). + if ($catalog->isRejected($name)) { + throw new RuntimeException(\sprintf('Refusing to call tool "%s": its "x-mcp-header" annotations are invalid (%s).', $name, $catalog->reasonFor($name))); + } + $request = new CallToolRequest($name, $arguments); $response = $this->sendRequest($request, $onProgress); @@ -244,6 +307,27 @@ class Client $this->sendRequest($request); } + /** + * Notify the server that the client's list of roots has changed. + * + * The server should react by requesting an updated list via roots/list. + * + * @throws RuntimeException if the client did not advertise the `roots.listChanged` capability + * @throws ConnectionException if the client is not connected + */ + public function sendRootsListChanged(): void + { + if (true !== $this->config->capabilities->rootsListChanged) { + throw new RuntimeException('Cannot send a "roots/list_changed" notification without advertising the "roots.listChanged" capability. Build the client with new ClientCapabilities(roots: true, rootsListChanged: true).'); + } + + if (!$this->isConnected()) { + throw new ConnectionException('Client is not connected. Call connect() first.'); + } + + $this->protocol->sendNotification(new RootsListChangedNotification()); + } + /** * Send a request to the server and wait for response. * diff --git a/src/Client/Builder.php b/src/Client/Builder.php index 35f7f5d..098b6e6 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -14,8 +14,11 @@ namespace Mcp\Client; use Mcp\Client; use Mcp\Client\Handler\Notification\NotificationHandlerInterface; use Mcp\Client\Handler\Request\RequestHandlerInterface; +use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\LogicException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Extension\ExtensionInterface; use Mcp\Schema\Implementation; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -30,8 +33,12 @@ final class Builder private string $name = 'mcp-php-client'; private string $version = '1.0.0'; private ?string $description = null; + private ?string $title = null; private ?ProtocolVersion $protocolVersion = null; private ?ClientCapabilities $capabilities = null; + + /** @var array> */ + private array $extensions = []; private int $initTimeout = 30; private int $requestTimeout = 120; private int $maxRetries = 3; @@ -45,12 +52,15 @@ final class Builder /** * Set the client name and version. + * + * @param ?string $title Display name for UI and end-user contexts. Falls back to $name when absent. */ - public function setClientInfo(string $name, string $version, ?string $description = null): self + public function setClientInfo(string $name, string $version, ?string $description = null, ?string $title = null): self { $this->name = $name; $this->version = $version; $this->description = $description; + $this->title = $title; return $this; } @@ -75,6 +85,28 @@ final class Builder return $this; } + /** + * Enable one or more MCP protocol extensions, announced to the server under + * `capabilities.extensions` in the initialize request. + * + * @throws InvalidArgumentException if the identifier is not a valid `_meta` prefix + * @throws LogicException if the same extension is enabled more than once + */ + public function enableExtension(ExtensionInterface ...$extensions): self + { + foreach ($extensions as $extension) { + $id = (string) $extension->getId(); + + if (isset($this->extensions[$id])) { + throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id)); + } + + $this->extensions[$id] = $extension->getCapabilities(); + } + + return $this; + } + /** * Set initialization timeout in seconds. */ @@ -96,7 +128,11 @@ final class Builder } /** - * Set maximum retry attempts for failed connections. + * Set the number of times a failed connection attempt is retried. + * + * Counts retries, not attempts: 3 means one initial attempt plus up to three + * retries. Pass 0 to fail on the first failure. Only applies to + * {@see Client::connect()}; individual requests are never retried. */ public function setMaxRetries(int $retries): self { @@ -148,11 +184,20 @@ final class Builder $this->name, $this->version, $this->description, + title: $this->title, ); + $capabilities = $this->capabilities ?? new ClientCapabilities(); + + // Extensions enabled via enableExtension() are folded into caller-supplied + // capabilities too, so setCapabilities() does not silently drop them. + if ([] !== $this->extensions) { + $capabilities = $capabilities->withExtensions($this->extensions); + } + $config = new Configuration( clientInfo: $clientInfo, - capabilities: $this->capabilities ?? new ClientCapabilities(), + capabilities: $capabilities, protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2025_11_25, initTimeout: $this->initTimeout, requestTimeout: $this->requestTimeout, diff --git a/src/Client/Configuration.php b/src/Client/Configuration.php index 8c7e909..f0ed6f7 100644 --- a/src/Client/Configuration.php +++ b/src/Client/Configuration.php @@ -11,6 +11,7 @@ namespace Mcp\Client; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; @@ -30,5 +31,16 @@ class Configuration public readonly int $requestTimeout = 120, public readonly int $maxRetries = 3, ) { + if ($initTimeout < 1) { + throw new InvalidArgumentException(\sprintf('The initialization timeout must be a positive number of seconds, got %d.', $initTimeout)); + } + + if ($requestTimeout < 1) { + throw new InvalidArgumentException(\sprintf('The request timeout must be a positive number of seconds, got %d.', $requestTimeout)); + } + + if ($maxRetries < 0) { + throw new InvalidArgumentException(\sprintf('The maximum number of retries must be zero or greater, got %d.', $maxRetries)); + } } } diff --git a/src/Client/Handler/Notification/LoggingNotificationHandler.php b/src/Client/Handler/Notification/LoggingNotificationHandler.php index c160ccd..a6853d4 100644 --- a/src/Client/Handler/Notification/LoggingNotificationHandler.php +++ b/src/Client/Handler/Notification/LoggingNotificationHandler.php @@ -18,6 +18,9 @@ use Mcp\Schema\Notification\LoggingMessageNotification; * Handler for logging message notifications from the server. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ class LoggingNotificationHandler implements NotificationHandlerInterface { @@ -27,6 +30,7 @@ class LoggingNotificationHandler implements NotificationHandlerInterface public function __construct( private readonly mixed $callback, ) { + trigger_deprecation('mcp/sdk', '0.8', 'MCP logging is deprecated since protocol revision 2026-07-28 (SEP-2577); log to stderr or use OpenTelemetry instead.'); } public function supports(Notification $notification): bool diff --git a/src/Client/Handler/Request/ListRootsRequestHandler.php b/src/Client/Handler/Request/ListRootsRequestHandler.php new file mode 100644 index 0000000..50806c1 --- /dev/null +++ b/src/Client/Handler/Request/ListRootsRequestHandler.php @@ -0,0 +1,73 @@ + + * + * @author Johannes Wachter + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource + * URIs or server configuration instead. + */ +class ListRootsRequestHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RootsCallbackInterface $callback, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + trigger_deprecation('mcp/sdk', '0.8', 'MCP roots are deprecated since protocol revision 2026-07-28 (SEP-2577); pass directories through tool arguments or resource URIs instead.'); + } + + public function supports(Request $request): bool + { + return $request instanceof ListRootsRequest; + } + + /** + * @return Response|Error + */ + public function handle(Request $request): Response|Error + { + \assert($request instanceof ListRootsRequest); + + try { + $result = $this->callback->__invoke($request); + + return new Response($request->getId(), $result); + } catch (RootsException $e) { + $this->logger->error('Listing roots failed: '.$e->getMessage(), ['exception' => $e]); + + return Error::forInternalError($e->getMessage(), $request->getId()); + } catch (\Throwable $e) { + $this->logger->error('Unexpected error while listing roots', ['exception' => $e]); + + return Error::forInternalError('Error while listing roots', $request->getId()); + } + } +} diff --git a/src/Client/Handler/Request/RootsCallbackInterface.php b/src/Client/Handler/Request/RootsCallbackInterface.php new file mode 100644 index 0000000..9dcccb2 --- /dev/null +++ b/src/Client/Handler/Request/RootsCallbackInterface.php @@ -0,0 +1,32 @@ + + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource + * URIs or server configuration instead. + */ +interface RootsCallbackInterface +{ + public function __invoke(ListRootsRequest $request): ListRootsResult; +} diff --git a/src/Client/Handler/Request/SamplingCallbackInterface.php b/src/Client/Handler/Request/SamplingCallbackInterface.php index bc0d554..0761de3 100644 --- a/src/Client/Handler/Request/SamplingCallbackInterface.php +++ b/src/Client/Handler/Request/SamplingCallbackInterface.php @@ -18,6 +18,9 @@ use Mcp\Schema\Result\CreateSamplingMessageResult; * Contract for callbacks used by SamplingRequestHandler. * * Implementations perform the actual LLM sampling when requested by the server. + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ interface SamplingCallbackInterface { diff --git a/src/Client/Handler/Request/SamplingRequestHandler.php b/src/Client/Handler/Request/SamplingRequestHandler.php index a00fa47..3a7f862 100644 --- a/src/Client/Handler/Request/SamplingRequestHandler.php +++ b/src/Client/Handler/Request/SamplingRequestHandler.php @@ -11,6 +11,7 @@ namespace Mcp\Client\Handler\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\SamplingException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; @@ -29,6 +30,9 @@ use Psr\Log\NullLogger; * @implements RequestHandlerInterface * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ class SamplingRequestHandler implements RequestHandlerInterface { @@ -36,6 +40,7 @@ class SamplingRequestHandler implements RequestHandlerInterface private readonly SamplingCallbackInterface $callback, private readonly LoggerInterface $logger = new NullLogger(), ) { + trigger_deprecation('mcp/sdk', '0.8', 'MCP sampling is deprecated since protocol revision 2026-07-28 (SEP-2577); integrate with an LLM provider\'s API directly instead.'); } public function supports(Request $request): bool @@ -50,6 +55,14 @@ class SamplingRequestHandler implements RequestHandlerInterface { \assert($request instanceof CreateSamplingMessageRequest); + try { + $request->validateToolFlow(); + } catch (InvalidArgumentException $e) { + $this->logger->warning('Rejecting sampling request violating the tool flow', ['exception' => $e]); + + return Error::forInvalidParams($e->getMessage(), $request->getId()); + } + try { $result = $this->callback->__invoke($request); diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 7564845..281aedc 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -16,15 +16,25 @@ use Mcp\Client\Handler\Notification\ProgressNotificationHandler; use Mcp\Client\Handler\Request\RequestHandlerInterface; use Mcp\Client\State\ClientState; use Mcp\Client\State\ClientStateInterface; +use Mcp\Client\Stateless\HeaderFactory; +use Mcp\Client\Stateless\InputRequestResolver; +use Mcp\Client\Stateless\RequestEnvelope; +use Mcp\Client\Stateless\ToolCatalog; +use Mcp\Client\Transport\HeaderAwareTransportInterface; use Mcp\Client\Transport\TransportInterface; +use Mcp\Exception\ConnectionException; use Mcp\JsonRpc\MessageFactory; +use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\InitializedNotification; +use Mcp\Schema\Request\DiscoverRequest; use Mcp\Schema\Request\InitializeRequest; use Mcp\Schema\Result\InitializeResult; +use Mcp\Server\Stateless\RequestMeta; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -40,6 +50,16 @@ use Psr\Log\NullLogger; */ class Protocol { + /** + * How many times a request may be re-sent before the client gives up. + * + * Both loops that re-send are bounded by it: a server that keeps asking for + * input, and one that keeps rejecting the offered revision. Neither is + * expected to run more than a round or two, so the cap is only there to + * stop a broken or hostile server from spinning the client forever. + */ + private const MAX_ROUND_TRIPS = 10; + private ?TransportInterface $transport = null; private ClientStateInterface $state; private MessageFactory $messageFactory; @@ -48,6 +68,21 @@ class Protocol /** @var NotificationHandlerInterface[] */ private array $notificationHandlers; + /** Set only when the configured revision has no handshake. */ + private ?RequestEnvelope $envelope = null; + + private ?HeaderFactory $headers = null; + + private ToolCatalog $tools; + + private readonly InputRequestResolver $inputRequests; + + /** + * Progress tokens are only required to be unique within a connection, and + * a retry keeps the caller's one — the work being reported on is the same. + */ + private int $progressTokens = 0; + /** * @param RequestHandlerInterface[] $requestHandlers * @param NotificationHandlerInterface[] $notificationHandlers @@ -66,6 +101,20 @@ class Protocol new ProgressNotificationHandler($this->state), ...$notificationHandlers, ]; + + $this->tools = new ToolCatalog($this->logger); + $this->inputRequests = new InputRequestResolver($this->requestHandlers, $this->logger); + } + + /** + * What the client knows about the server's tools, from `tools/list`. + * + * Kept on the protocol rather than the facade because it is what makes the + * SEP-2243 headers derivable at send time. + */ + public function getToolCatalog(): ToolCatalog + { + return $this->tools; } /** @@ -79,18 +128,57 @@ class Protocol public function connect(TransportInterface $transport, Configuration $config): void { $this->transport = $transport; + + // A fresh catalog per connection: it is what a server told this client + // about its tools, and a server reached by reconnecting — the same one + // or another — has said nothing yet. + $this->tools = new ToolCatalog($this->logger); + + if ($config->protocolVersion->isModern()) { + $this->envelope = new RequestEnvelope( + $config->protocolVersion, + $config->capabilities, + $config->clientInfo, + ); + $this->headers = new HeaderFactory($this->tools); + } + $transport->setState($this->state); $transport->onInitialize(fn () => $this->initialize($config)); $transport->onMessage($this->processMessage(...)); $transport->onError(fn (\Throwable $e) => $this->logger->error('Transport error', ['exception' => $e])); + if ($transport instanceof HeaderAwareTransportInterface) { + $transport->onHeaders($this->headersFor(...)); + } + $this->logger->info('Protocol connected to transport', ['transport' => $transport::class]); } /** - * Perform the MCP initialization handshake. + * The headers belonging to an encoded message, for a transport that has any. * - * Sends InitializeRequest and waits for response, then sends InitializedNotification. + * @return array + */ + private function headersFor(string $payload): array + { + if (null === $this->headers || null === $this->envelope) { + return []; + } + + $decoded = json_decode($payload, true); + + return \is_array($decoded) + ? $this->headers->forMessage($decoded, $this->envelope->protocolVersion()) + : []; + } + + /** + * Ready the connection for use. + * + * Up to 2025-11-25 that means the `initialize` handshake: offer a revision, + * take the server's answer, confirm with `notifications/initialized`. From + * 2026-07-28 there is no handshake at all — see {@see self::discover()}. * * @param Configuration $config The client configuration * @@ -98,8 +186,14 @@ class Protocol */ public function initialize(Configuration $config): Response|Error { + if (null !== $this->envelope) { + return $this->discover($config); + } + + $offered = $config->protocolVersion; + $request = new InitializeRequest( - $config->protocolVersion->value, + $offered->value, $config->capabilities, $config->clientInfo, ); @@ -108,6 +202,26 @@ class Protocol if ($response instanceof Response) { $initResult = InitializeResult::fromArray($response->result); + + // A counter-offer this SDK cannot speak leaves nothing to fall back to, + // so the handshake fails rather than continuing on a revision neither + // side agrees on. + $negotiated = $initResult->protocolVersion; + if (null === $negotiated || $negotiated->isModern()) { + // fromArray() above already rejected a missing or non-string revision. + $counterOffer = (string) $response->result['protocolVersion']; + + return Error::forInvalidParams(\sprintf( + 'Server responded with unsupported protocol version "%s". Supported versions: %s.', + $counterOffer, + implode(', ', array_map( + static fn (ProtocolVersion $v): string => $v->value, + ProtocolVersion::handshakeVersions(), + )), + ), $response->id); + } + + $this->state->setProtocolVersion($negotiated); $this->state->setServerInfo($initResult->serverInfo); $this->state->setInstructions($initResult->instructions); $this->state->setInitialized(true); @@ -116,18 +230,134 @@ class Protocol $this->logger->info('Initialization complete', [ 'server' => $initResult->serverInfo->name, + 'protocolVersion' => $negotiated->value, ]); } return $response; } + /** + * Stand in for the handshake in the modern era. + * + * There is nothing to negotiate: the revision travels on every request, so + * the connection is usable the moment the transport is. `server/discover` + * is only asked because the facade exposes `getServerInfo()`, and a server + * that will not answer it still serves every other method — so a failure + * here is logged and the connection proceeds. + * + * @return Response> + */ + private function discover(Configuration $config): Response + { + $this->state->setProtocolVersion($config->protocolVersion); + $this->state->setInitialized(true); + + $response = $this->request(new DiscoverRequest(), $config->initTimeout); + + if ($response instanceof Error) { + $this->logger->info('Server did not answer "server/discover"; continuing without its metadata.', [ + 'code' => $response->code, + 'message' => $response->message, + ]); + + return new Response(0, []); + } + + $this->readDiscovery($response->result); + + return $response; + } + + /** + * Read defensively: `server/discover` is optional, so a server may answer + * with something that is not a DiscoverResult at all, and none of it is + * load-bearing for the requests that follow. + * + * @param array $result + */ + private function readDiscovery(array $result): void + { + // Identity is wire vocabulary in this revision, so it rides in `_meta` + // rather than the result body. The top level is read as a fallback + // because that is where the handshake era put it. + $meta = \is_array($result['_meta'] ?? null) ? $result['_meta'] : []; + $serverInfo = $meta[RequestMeta::SERVER_INFO] ?? $result['serverInfo'] ?? null; + + if (\is_array($serverInfo)) { + try { + $this->state->setServerInfo(Implementation::fromArray($serverInfo)); + } catch (\Throwable $e) { + $this->logger->debug('Ignoring unreadable serverInfo from "server/discover".', ['exception' => $e]); + } + } + + if (\is_string($result['instructions'] ?? null)) { + $this->state->setInstructions($result['instructions']); + } + + $this->reconcileVersion($result['supportedVersions'] ?? null); + + $this->logger->info('Discovery complete', [ + 'supportedVersions' => $result['supportedVersions'] ?? null, + ]); + } + + /** + * Move to a revision the server actually speaks, if it said which. + * + * `server/discover` reports rather than negotiates, so a client that asked + * for something the server does not list learns it here — and learning it + * now is far better than a stream of refusals later. A server that stays + * silent about its versions is left alone; the method is optional and + * saying nothing is not the same as saying no. + */ + private function reconcileVersion(mixed $supportedVersions): void + { + if (!\is_array($supportedVersions) || [] === $supportedVersions || null === $this->envelope) { + return; + } + + $current = $this->envelope->protocolVersion(); + + if (\in_array($current->value, $supportedVersions, true)) { + return; + } + + foreach ($supportedVersions as $candidate) { + $version = \is_string($candidate) ? ProtocolVersion::tryFrom($candidate) : null; + + if (null === $version || !$version->isModern()) { + continue; + } + + $this->logger->warning('Server does not speak the configured revision; continuing on one it advertises.', [ + 'configured' => $current->value, + 'using' => $version->value, + ]); + + $this->envelope = $this->envelope->withProtocolVersion($version); + $this->state->setProtocolVersion($version); + + return; + } + + // Everything it offers is handshake era, which this connection cannot + // reach — it has already skipped the handshake. + throw new ConnectionException(\sprintf('Server does not support any modern protocol revision (it advertises %s); the configured "%s" cannot be used against it.', implode(', ', array_map(strval(...), $supportedVersions)), $current->value)); + } + /** * Send a request to the server and wait for response. * * If a response is immediately available (sync HTTP), returns it. * Otherwise, suspends the Fiber and waits for the transport to resume it. * + * In the modern era this is also where the two loops that re-send live: + * answering a server's request for input (SEP-2322), and retrying under a + * revision the server accepts (SEP-2575). Both re-send the same call, so + * they belong together and above the single exchange. + * * @param Request $request The request to send * @param int $timeout The timeout in seconds * @param bool $withProgress Whether to attach a progress token to the request @@ -136,45 +366,146 @@ class Protocol */ public function request(Request $request, int $timeout, bool $withProgress = false): Response|Error { - $requestId = $this->state->nextRequestId(); - $request = $request->withId($requestId); + $payload = $request->withId(0)->jsonSerialize(); + unset($payload['id']); if ($withProgress) { - $progressToken = "prog-{$requestId}"; - $request = $request->withMeta(['progressToken' => $progressToken]); + $payload = self::withMeta($payload, ['progressToken' => 'prog-'.++$this->progressTokens]); } - $this->state->addPendingRequest($requestId, $timeout); - $this->sendRequest($request); - - $immediate = $this->state->consumeResponse($requestId); - if (null !== $immediate) { - $this->logger->debug('Received immediate response', ['id' => $requestId]); - - return $immediate; + if (null === $this->envelope) { + return $this->exchange($payload, $timeout); } - $this->logger->debug('Suspending fiber for response', ['id' => $requestId]); + for ($attempt = 0; $attempt < self::MAX_ROUND_TRIPS; ++$attempt) { + $response = $this->exchange($payload, $timeout); - return \Fiber::suspend([ - 'type' => 'await_response', - 'request_id' => $requestId, - 'timeout' => $timeout, - ]); + if ($response instanceof Error) { + $retry = $this->withAcceptedVersion($response); + + if (null === $retry) { + return $response; + } + + continue; + } + + $asked = InputRequestResolver::asked($response->result); + + if (null === $asked) { + return $response; + } + + // A fresh `inputResponses`/`requestState` pair every round, never + // merged with the last: the answers belong to the ask that just + // arrived, and carrying an old one forward is how state leaks + // between rounds. + // + // Cast to object: inputResponses is a JSON object keyed by the + // server's ids, but a PHP array with no entries or with sequential + // numeric-string keys encodes as a JSON array instead. + $payload['params'] = [ + ...($payload['params'] ?? []), + 'inputResponses' => (object) $this->inputRequests->resolve($asked), + ]; + + unset($payload['params']['requestState']); + + // Echoed byte-for-byte, and only when the server sent one: the + // value is the server's to read, and inventing or reshaping it + // would break whatever it encodes. + if (\is_string($response->result['requestState'] ?? null)) { + $payload['params']['requestState'] = $response->result['requestState']; + } + + $this->logger->debug('Retrying request with resolved input', [ + 'method' => $payload['method'] ?? null, + 'round' => $attempt + 1, + ]); + } + + return Error::forInternalError(\sprintf('Server asked for input more than %d times without completing the request.', self::MAX_ROUND_TRIPS)); } /** - * Send a request to the server. + * Switches the offered revision when the server refuses the current one, + * or null when there is nothing to retry with. + * + * @param Error $error the server's refusal */ - private function sendRequest(Request $request): void + private function withAcceptedVersion(Error $error): ?ProtocolVersion { - $this->logger->debug('Sending request', [ - 'id' => $request->getId(), - 'method' => $request::getMethod(), - ]); + if (Error::UNSUPPORTED_PROTOCOL_VERSION !== $error->code || null === $this->envelope) { + return null; + } - $encoded = json_encode($request, \JSON_THROW_ON_ERROR); - $this->transport?->send($encoded); + $data = \is_array($error->data) ? $error->data : []; + $supported = \is_array($data['supported'] ?? null) ? $data['supported'] : []; + $current = $this->envelope->protocolVersion(); + + foreach ($supported as $candidate) { + $version = \is_string($candidate) ? ProtocolVersion::tryFrom($candidate) : null; + + // Only another modern revision is reachable from here: falling back + // to a handshake era one would mean opening a connection this + // transport already decided it was not going to open. + if (null === $version || !$version->isModern() || $version === $current) { + continue; + } + + $this->logger->info('Server rejected the offered protocol revision, retrying with one it supports.', [ + 'offered' => $current->value, + 'retrying' => $version->value, + ]); + + $this->envelope = $this->envelope->withProtocolVersion($version); + $this->state->setProtocolVersion($version); + + return $version; + } + + return null; + } + + /** + * One request on the wire: assign an id, send, and wait for its answer. + * + * A retry gets a new id, because the previous one is spent — the server has + * already answered it, and reusing it would make the two indistinguishable. + * + * @param array $payload + * + * @return Response>|Error + */ + private function exchange(array $payload, int $timeout): Response|Error + { + $requestId = $this->state->nextRequestId(); + $payload['id'] = $requestId; + + $this->state->addPendingRequest($requestId, $timeout); + + try { + $this->send($payload, 'request'); + + $immediate = $this->state->consumeResponse($requestId); + if (null !== $immediate) { + $this->logger->debug('Received immediate response', ['id' => $requestId]); + + return $immediate; + } + + $this->logger->debug('Suspending fiber for response', ['id' => $requestId]); + + return \Fiber::suspend([ + 'type' => 'await_response', + 'request_id' => $requestId, + 'timeout' => $timeout, + ]); + } finally { + // Only the response path clears it, so a request that timed out or + // whose send() threw would stay pending and fail every later one. + $this->state->removePendingRequest($requestId); + } } /** @@ -182,10 +513,44 @@ class Protocol */ public function sendNotification(Notification $notification): void { - $this->logger->debug('Sending notification', ['method' => $notification::getMethod()]); + $this->send($notification->jsonSerialize(), 'notification'); + } - $encoded = json_encode($notification, \JSON_THROW_ON_ERROR); - $this->transport?->send($encoded); + /** + * Encode and hand a message to the transport, stamping the per-request + * envelope on the way out when the revision calls for one. + * + * @param array $payload + */ + private function send(array $payload, string $kind): void + { + if (null !== $this->envelope) { + $payload = $this->envelope->stamp($payload); + } + + $this->logger->debug('Sending '.$kind, [ + 'id' => $payload['id'] ?? null, + 'method' => $payload['method'] ?? null, + ]); + + $this->transport?->send(json_encode($payload, \JSON_THROW_ON_ERROR)); + } + + /** + * @param array $payload + * @param array $meta + * + * @return array + */ + private static function withMeta(array $payload, array $meta): array + { + $params = \is_array($payload['params'] ?? null) ? $payload['params'] : []; + $existing = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; + + $params['_meta'] = [...$existing, ...$meta]; + $payload['params'] = $params; + + return $payload; } /** @@ -240,6 +605,12 @@ class Protocol { $requestId = $response->getId(); + if (null === $requestId) { + $this->logger->warning('Received an id-less error response; cannot correlate it to a request.', ['response' => $response->jsonSerialize()]); + + return; + } + $this->logger->debug('Handling response', ['id' => $requestId]); $this->state->storeResponse($requestId, $response->jsonSerialize()); diff --git a/src/Client/State/ClientState.php b/src/Client/State/ClientState.php index b3e6ba8..a1e1f89 100644 --- a/src/Client/State/ClientState.php +++ b/src/Client/State/ClientState.php @@ -11,6 +11,7 @@ namespace Mcp\Client\State; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; @@ -28,6 +29,7 @@ class ClientState implements ClientStateInterface { private int $requestIdCounter = 1; private bool $initialized = false; + private ?ProtocolVersion $protocolVersion = null; private ?Implementation $serverInfo = null; private ?string $instructions = null; @@ -96,6 +98,16 @@ class ClientState implements ClientStateInterface return $this->initialized; } + public function setProtocolVersion(ProtocolVersion $protocolVersion): void + { + $this->protocolVersion = $protocolVersion; + } + + public function getProtocolVersion(): ?ProtocolVersion + { + return $this->protocolVersion; + } + public function setServerInfo(Implementation $serverInfo): void { $this->serverInfo = $serverInfo; diff --git a/src/Client/State/ClientStateInterface.php b/src/Client/State/ClientStateInterface.php index 5be054d..a5de42d 100644 --- a/src/Client/State/ClientStateInterface.php +++ b/src/Client/State/ClientStateInterface.php @@ -11,6 +11,7 @@ namespace Mcp\Client\State; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; @@ -76,6 +77,18 @@ interface ClientStateInterface */ public function isInitialized(): bool; + /** + * Store the protocol version negotiated during initialization. + */ + public function setProtocolVersion(ProtocolVersion $protocolVersion): void; + + /** + * Get the protocol version negotiated during initialization. + * + * Null until the handshake has completed. + */ + public function getProtocolVersion(): ?ProtocolVersion; + /** * Store the server info from initialization. */ diff --git a/src/Client/Stateless/HeaderFactory.php b/src/Client/Stateless/HeaderFactory.php new file mode 100644 index 0000000..428e1c7 --- /dev/null +++ b/src/Client/Stateless/HeaderFactory.php @@ -0,0 +1,102 @@ + + */ +final class HeaderFactory +{ + public function __construct( + private readonly ToolCatalog $tools, + ) { + } + + /** + * @param array $payload a serialized JSON-RPC message + * + * @return array + */ + public function forMessage(array $payload, ProtocolVersion $protocolVersion): array + { + $method = $payload['method'] ?? null; + $params = \is_array($payload['params'] ?? null) ? $payload['params'] : null; + + // A response to a server-initiated request carries no method to mirror; + // the version header is unconditional and still applies. Trace context + // still rides along if the application put it in `_meta` — the party + // that started the trace is not necessarily the one with a method to + // report. + if (!\is_string($method)) { + return [McpHeader::PROTOCOL_VERSION => $protocolVersion->value, ...$this->traceHeaders($params)]; + } + + $headers = [ + McpHeader::PROTOCOL_VERSION => $protocolVersion->value, + McpHeader::METHOD => $method, + ...$this->traceHeaders($params), + ]; + + if (null !== $name = McpHeader::nameFor($method, $params)) { + // Tool and prompt names are only SHOULD-constrained to header-safe + // characters and a resource URI is not constrained at all, so + // anything unsafe is wrapped rather than dropped or mangled. + $headers[McpHeader::NAME] = McpHeader::encode($name) ?? $name; + } + + if ('tools/call' === $method && \is_string($params['name'] ?? null)) { + $arguments = \is_array($params['arguments'] ?? null) ? $params['arguments'] : []; + + foreach ($this->tools->headersFor($params['name'], $arguments) as $suffix => $value) { + $headers[McpHeader::PARAM_PREFIX.$suffix] = $value; + } + } + + return $headers; + } + + /** + * Mirrors W3C trace context from `_meta` onto its native HTTP headers, so + * an application that only knows how to set `_meta` — the transport-agnostic + * surface — still gets header-based propagation for free on HTTP. + * + * @param array|null $params + * + * @return array + */ + private function traceHeaders(?array $params): array + { + $meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; + + $headers = []; + foreach (RequestMeta::TRACE_KEYS as $key) { + if (\is_string($meta[$key] ?? null)) { + $headers[$key] = $meta[$key]; + } + } + + return $headers; + } +} diff --git a/src/Client/Stateless/InputRequestResolver.php b/src/Client/Stateless/InputRequestResolver.php new file mode 100644 index 0000000..a592542 --- /dev/null +++ b/src/Client/Stateless/InputRequestResolver.php @@ -0,0 +1,204 @@ + + */ +final class InputRequestResolver +{ + /** + * The only requests a server may park in `inputRequests`. Anything else is + * a server fault, and answering it would be inventing protocol. + * + * @var array> + */ + private const RESOLVABLE = [ + 'elicitation/create' => ElicitRequest::class, + 'sampling/createMessage' => CreateSamplingMessageRequest::class, + 'roots/list' => ListRootsRequest::class, + ]; + + /** + * @param RequestHandlerInterface[] $handlers + */ + public function __construct( + private readonly array $handlers, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Reads the `inputRequests` map off a result, or null when the result is + * not an ask. + * + * A result with no `resultType` MUST be read as complete, so an absent + * member is not an ask — only the explicit `input_required` is. + * + * @param array $result + * + * @return array|null + */ + public static function asked(array $result): ?array + { + if (($result['resultType'] ?? null) !== 'input_required') { + return null; + } + + return \is_array($result['inputRequests'] ?? null) ? $result['inputRequests'] : []; + } + + /** + * Resolves every ask into the `inputResponses` map the retry carries. + * + * Keys are the server's, and each answer goes back under the key it was + * asked under — the client never reorders or renames them, since that map + * is how the server correlates answers to questions. + * + * @param array $inputRequests + * + * @return array + * + * @throws RuntimeException when an ask cannot be answered at all + */ + public function resolve(array $inputRequests): array + { + $responses = []; + + foreach ($inputRequests as $key => $ask) { + $responses[(string) $key] = $this->answer((string) $key, $ask); + } + + return $responses; + } + + /** + * @return array + */ + private function answer(string $key, mixed $ask): array + { + if (!\is_array($ask) || !\is_string($ask['method'] ?? null)) { + throw new RuntimeException(\sprintf('Server asked for input under "%s" without a method to answer.', $key)); + } + + $method = $ask['method']; + $class = self::RESOLVABLE[$method] ?? null; + + if (null === $class) { + throw new RuntimeException(\sprintf('Server asked for input under "%s" using "%s", which is not a request a client can answer.', $key, $method)); + } + + $params = $ask['params'] ?? null; + if ($params instanceof \stdClass) { + $params = (array) $params; + } + + // The ask is a bare method/params pair, but the handlers speak in + // messages. The id never reaches the wire — the answer is keyed by + // $key — so any id will do. + $request = $class::fromArray([ + 'jsonrpc' => '2.0', + 'id' => $key, + 'method' => $method, + 'params' => \is_array($params) ? $params : null, + ]); + + $this->logger->debug('Resolving multi round-trip input request', [ + 'key' => $key, + 'method' => $method, + ]); + + $result = $this->dispatch($request); + + if ($result instanceof Error) { + throw new RuntimeException(\sprintf('Cannot answer the server\'s "%s" input request under "%s": %s', $method, $key, $result->message)); + } + + return $result; + } + + /** + * @return array|Error + */ + private function dispatch(Request $request): array|Error + { + foreach ($this->handlers as $handler) { + if (!$handler->supports($request)) { + continue; + } + + try { + $response = $handler->handle($request); + } catch (\Throwable $e) { + $this->logger->error('Input request handler failed', [ + 'method' => $request::getMethod(), + 'exception' => $e, + ]); + + return Error::forInternalError($e->getMessage(), $request->getId()); + } + + if ($response instanceof Error) { + return $response; + } + + return self::resultOf($response); + } + + return Error::forMethodNotFound( + \sprintf('Client does not handle "%s" requests.', $request::getMethod()), + $request->getId(), + ); + } + + /** + * @param Response $response + * + * @return array + */ + private static function resultOf(Response $response): array + { + $result = $response->result; + + if ($result instanceof \JsonSerializable) { + $result = $result->jsonSerialize(); + } + + if ($result instanceof \stdClass) { + $result = (array) $result; + } + + return \is_array($result) ? $result : []; + } +} diff --git a/src/Client/Stateless/RequestEnvelope.php b/src/Client/Stateless/RequestEnvelope.php new file mode 100644 index 0000000..fcc1cac --- /dev/null +++ b/src/Client/Stateless/RequestEnvelope.php @@ -0,0 +1,78 @@ + + */ +final class RequestEnvelope +{ + public function __construct( + private readonly ProtocolVersion $protocolVersion, + private readonly ClientCapabilities $capabilities, + private readonly Implementation $clientInfo, + ) { + } + + public function protocolVersion(): ProtocolVersion + { + return $this->protocolVersion; + } + + public function withProtocolVersion(ProtocolVersion $protocolVersion): self + { + return new self($protocolVersion, $this->capabilities, $this->clientInfo); + } + + /** + * Merges the envelope into an encoded message, preserving whatever `_meta` + * the caller already put there — a `progressToken` most of all, which would + * otherwise be dropped and take every progress notification with it. + * + * @param array $payload a serialized JSON-RPC message + * + * @return array + */ + public function stamp(array $payload): array + { + $params = \is_array($payload['params'] ?? null) ? $payload['params'] : []; + $meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; + + $params['_meta'] = [ + ...$meta, + RequestMeta::PROTOCOL_VERSION => $this->protocolVersion->value, + // ClientCapabilities encodes an empty set as `{}` already; `[]` + // would reach the server as a JSON array and fail its check. + RequestMeta::CLIENT_CAPABILITIES => $this->capabilities, + RequestMeta::CLIENT_INFO => $this->clientInfo, + ]; + + $payload['params'] = $params; + + return $payload; + } +} diff --git a/src/Client/Stateless/ToolCatalog.php b/src/Client/Stateless/ToolCatalog.php new file mode 100644 index 0000000..3348db0 --- /dev/null +++ b/src/Client/Stateless/ToolCatalog.php @@ -0,0 +1,206 @@ + + */ +final class ToolCatalog +{ + /** @var array> tool name to input schema */ + private array $schemas = []; + + /** @var array tool name to the reason it was refused */ + private array $rejected = []; + + public function __construct( + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Records a listing page and returns the tools a caller may actually use. + * + * A tool whose annotations are malformed is dropped from the result, which + * is how the client "rejects" it: it never reaches the caller, so it cannot + * be called, and the tools listed beside it are untouched. + * + * @param list> $tools raw `tools/list` entries + * + * @return list> + */ + public function record(array $tools): array + { + $usable = []; + + foreach ($tools as $tool) { + $name = $tool['name'] ?? null; + $schema = $tool['inputSchema'] ?? null; + + if (!\is_string($name) || !\is_array($schema)) { + $usable[] = $tool; + + continue; + } + + unset($this->rejected[$name], $this->schemas[$name]); + + if (null !== $reason = McpHeader::checkAnnotations($schema)) { + $this->rejected[$name] = $reason; + + $this->logger->warning('Excluding tool with an invalid "x-mcp-header" annotation', [ + 'tool' => $name, + 'reason' => $reason, + ]); + + continue; + } + + $this->schemas[$name] = $schema; + $usable[] = $tool; + } + + return $usable; + } + + /** + * Whether the client refuses to call this tool. + * + * Only a tool that was listed and failed validation is refused; an unknown + * name is not, since the client may legitimately call a tool it never + * listed and the server is the authority on whether it exists. + */ + public function isRejected(string $name): bool + { + return isset($this->rejected[$name]); + } + + public function reasonFor(string $name): ?string + { + return $this->rejected[$name] ?? null; + } + + /** + * The `Mcp-Param-*` headers a call to $name must carry, given its arguments. + * + * An argument that is absent or null contributes no header — the + * specification reads a missing header as a missing value, so sending an + * empty one would assert something different. + * + * @param array $arguments + * + * @return array + */ + public function headersFor(string $name, array $arguments): array + { + $schema = $this->schemas[$name] ?? null; + + if (null === $schema) { + return []; + } + + $headers = []; + + foreach (self::annotations($schema) as $header => $path) { + $value = self::valueAt($arguments, $path); + + if (null === $value) { + continue; + } + + $encoded = McpHeader::encode($value); + + if (null === $encoded) { + continue; + } + + $headers[$header] = $encoded; + } + + return $headers; + } + + /** + * Every `x-mcp-header` annotation in $schema, as header name to the property + * path it mirrors. + * + * Only statically reachable properties count, matching the server's reader: + * a chain through `items`, a composition keyword or a `$ref` cannot be + * resolved without the instance, so an annotation there is out of bounds. + * + * @param array $schema + * @param list $path + * + * @return array> + */ + private static function annotations(array $schema, array $path = []): array + { + $properties = $schema['properties'] ?? null; + + if (!\is_array($properties)) { + return []; + } + + $found = []; + + foreach ($properties as $property => $definition) { + if (!\is_array($definition)) { + continue; + } + + $here = [...$path, (string) $property]; + + if (\is_string($definition['x-mcp-header'] ?? null)) { + $found[$definition['x-mcp-header']] = $here; + } + + $found = [...$found, ...self::annotations($definition, $here)]; + } + + return $found; + } + + /** + * @param array $arguments + * @param list $path + */ + private static function valueAt(array $arguments, array $path): mixed + { + $node = $arguments; + + foreach ($path as $segment) { + if (!\is_array($node) || !\array_key_exists($segment, $node)) { + return null; + } + + $node = $node[$segment]; + } + + return $node; + } +} diff --git a/src/Client/Transport/HeaderAwareTransportInterface.php b/src/Client/Transport/HeaderAwareTransportInterface.php new file mode 100644 index 0000000..998a044 --- /dev/null +++ b/src/Client/Transport/HeaderAwareTransportInterface.php @@ -0,0 +1,37 @@ + + */ +interface HeaderAwareTransportInterface extends TransportInterface +{ + /** + * Register the source of per-message headers. + * + * @param callable(string $payload): array $callback receives the encoded message + */ + public function onHeaders(callable $callback): void; +} diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 33869f7..3c12ea7 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -14,6 +14,7 @@ namespace Mcp\Client\Transport; use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; use Mcp\Exception\ConnectionException; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Psr\Http\Client\ClientInterface; @@ -31,7 +32,7 @@ use Psr\Log\LoggerInterface; * * @author Kyrian Obikwelu */ -class HttpTransport extends BaseTransport +class HttpTransport extends BaseTransport implements HeaderAwareTransportInterface { private ClientInterface $httpClient; private RequestFactoryInterface $requestFactory; @@ -39,6 +40,9 @@ class HttpTransport extends BaseTransport private ?string $sessionId = null; + /** @var (callable(string): array)|null */ + private $headerCallback; + /** @var McpFiber|null */ private ?\Fiber $activeFiber = null; @@ -52,11 +56,24 @@ class HttpTransport extends BaseTransport private string $sseBuffer = ''; /** - * @param string $endpoint The MCP server endpoint URL - * @param array $headers Additional headers to send - * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) - * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) - * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + * Default cap on the bytes buffered while waiting for a complete SSE event. + */ + public const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024; + + private readonly int $maxSseBufferBytes; + + /** + * @param string $endpoint The MCP server endpoint URL + * @param array $headers Additional headers to send + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + * @param int $maxSseBufferBytes Maximum bytes buffered while waiting for a complete + * SSE event. A server that never sends the "\n\n" event + * delimiter would otherwise grow the buffer without bound + * and exhaust client memory; reaching the cap aborts the + * stream instead. Raise it for servers that legitimately + * emit single events larger than the default. */ public function __construct( private readonly string $endpoint, @@ -65,9 +82,15 @@ class HttpTransport extends BaseTransport ?RequestFactoryInterface $requestFactory = null, ?StreamFactoryInterface $streamFactory = null, ?LoggerInterface $logger = null, + int $maxSseBufferBytes = self::DEFAULT_MAX_SSE_BUFFER_BYTES, ) { parent::__construct($logger); + if ($maxSseBufferBytes < 1) { + throw new InvalidArgumentException(\sprintf('The maximum SSE buffer size must be a positive number of bytes, got %d.', $maxSseBufferBytes)); + } + + $this->maxSseBufferBytes = $maxSseBufferBytes; $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); @@ -93,6 +116,27 @@ class HttpTransport extends BaseTransport $this->logger->info('HTTP client connected and initialized', ['endpoint' => $this->endpoint]); } + public function onHeaders(callable $callback): void + { + $this->headerCallback = $callback; + } + + /** + * The session ID minted by the server for this connection, if any. + * + * Captured from the Mcp-Session-Id response header of a handshake-era + * server; always null on 2026-07-28, which removed protocol-level sessions + * (SEP-2567). A caller that cannot keep the transport alive between + * interactions can persist it and pass it back through the constructor's + * $headers on a later transport, where it wins over the tracked value. + * + * @return string|null null until the server mints a session, and again after close() + */ + public function getSessionId(): ?string + { + return $this->sessionId; + } + public function send(string $data): void { $request = $this->requestFactory->createRequest('POST', $this->endpoint) @@ -104,6 +148,13 @@ class HttpTransport extends BaseTransport $request = $request->withHeader('Mcp-Session-Id', $this->sessionId); } + // Protocol-derived first, so an explicitly configured header still wins: + // the caller passing one is making a deliberate choice about this + // connection, and a proxy credential is the usual reason. + foreach ($this->protocolHeaders($data) as $name => $value) { + $request = $request->withHeader($name, $value); + } + foreach ($this->headers as $name => $value) { $request = $request->withHeader($name, $value); } @@ -179,6 +230,27 @@ class HttpTransport extends BaseTransport $this->handleClose('Transport closed'); } + /** + * @return array + */ + private function protocolHeaders(string $payload): array + { + if (!\is_callable($this->headerCallback)) { + return []; + } + + try { + return ($this->headerCallback)($payload); + } catch (\Throwable $e) { + // Headers mirror the body; failing to derive them is a bug worth + // reporting, but dropping the request would be a worse outcome than + // sending it the way an earlier revision would have. + $this->logger->error('Could not derive protocol headers', ['exception' => $e]); + + return []; + } + } + private function tick(): void { $this->processSSEStream(); @@ -200,24 +272,93 @@ class HttpTransport extends BaseTransport if (!$this->activeStream->eof()) { $chunk = $this->activeStream->read(4096); if ('' !== $chunk) { + if (\strlen($this->sseBuffer) + \strlen($chunk) > $this->maxSseBufferBytes) { + $this->abortSseStream(\sprintf('buffered %d bytes without a complete event, exceeding the %d byte limit', \strlen($this->sseBuffer) + \strlen($chunk), $this->maxSseBufferBytes)); + + return; + } + $this->sseBuffer .= $chunk; } } - while (false !== ($pos = strpos($this->sseBuffer, "\n\n"))) { - $event = substr($this->sseBuffer, 0, $pos); - $this->sseBuffer = substr($this->sseBuffer, $pos + 2); - + while (null !== ($event = $this->extractSSEEvent())) { if (!empty(trim($event))) { $this->processSSEEvent($event); } } - if ($this->activeStream->eof() && empty($this->sseBuffer)) { + if ($this->activeStream->eof()) { + // The stream ended without a trailing blank line: dispatch what is left. + if (!empty(trim($this->sseBuffer))) { + $this->processSSEEvent($this->sseBuffer); + } + + $this->sseBuffer = ''; $this->activeStream = null; } } + /** + * Tear down the active SSE stream and fail any in-flight request. + * + * The waiting fiber is resolved with an error immediately so the caller + * fails fast, rather than spinning until the request timeout elapses. + */ + private function abortSseStream(string $reason): void + { + $bufferedBytes = \strlen($this->sseBuffer); + $this->sseBuffer = ''; + $this->activeStream = null; + + $this->logger->warning('Aborting SSE stream: '.$reason, [ + 'session_id' => $this->sessionId, + 'buffered_bytes' => $bufferedBytes, + 'max_sse_buffer_bytes' => $this->maxSseBufferBytes, + ]); + + if (null === $this->state) { + return; + } + + foreach ($this->state->getPendingRequests() as $pending) { + $requestId = $pending['request_id']; + $error = Error::forInternalError('SSE stream aborted: '.$reason, $requestId); + $this->state->storeResponse($requestId, $error->jsonSerialize()); + } + } + + /** + * Take the next complete event off the buffer, or null if none is complete yet. + * + * Per the SSE specification, lines are terminated by CRLF, LF or CR, so an + * event is delimited by any pair of those. Servers built on sse-starlette + * (the MCP Python SDK) use CRLF. + */ + private function extractSSEEvent(): ?string + { + $position = null; + $length = 0; + + foreach (["\r\n\r\n", "\n\n", "\r\r"] as $delimiter) { + $found = strpos($this->sseBuffer, $delimiter); + + if (false !== $found && (null === $position || $found < $position)) { + $position = $found; + $length = \strlen($delimiter); + } + } + + if (null === $position) { + return null; + } + + $event = substr($this->sseBuffer, 0, $position); + $this->sseBuffer = substr($this->sseBuffer, $position + $length); + + return $event; + } + /** * Parse a single SSE event and handle the message. */ @@ -225,7 +366,7 @@ class HttpTransport extends BaseTransport { $data = ''; - foreach (explode("\n", $event) as $line) { + foreach (preg_split("/\r\n|\r|\n/", $event) ?: [] as $line) { if (str_starts_with($line, 'data:')) { $data .= trim(substr($line, 5)); } diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index 4807461..f102961 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -12,6 +12,7 @@ namespace Mcp\Client\Transport; use Mcp\Exception\ConnectionException; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Psr\Log\LoggerInterface; @@ -45,6 +46,11 @@ class StdioTransport extends BaseTransport private string $inputBuffer = ''; + /** + * Default cap on the bytes buffered while waiting for a complete line. + */ + public const DEFAULT_MAX_BUFFER_SIZE = 4 * 1024 * 1024; + /** @var McpFiber|null */ private ?\Fiber $activeFiber = null; @@ -52,10 +58,16 @@ class StdioTransport extends BaseTransport private $activeProgressCallback; /** - * @param string $command The command to run - * @param array $args Command arguments - * @param string|null $cwd Working directory - * @param array|null $env Environment variables + * @param string $command The command to run + * @param array $args Command arguments + * @param string|null $cwd Working directory + * @param array|null $env Environment variables + * @param int $maxBufferSize Maximum bytes buffered while waiting for a complete line. The + * buffer is only drained on a "\n"; a spawned server that streams + * stdout without ever emitting a newline would otherwise grow it + * without bound and exhaust client memory. Reaching the cap aborts + * the read instead. Raise it for servers that emit single frames + * larger than the default. */ public function __construct( private readonly string $command, @@ -63,8 +75,13 @@ class StdioTransport extends BaseTransport private readonly ?string $cwd = null, private readonly ?array $env = null, ?LoggerInterface $logger = null, + private readonly int $maxBufferSize = self::DEFAULT_MAX_BUFFER_SIZE, ) { parent::__construct($logger); + + if ($maxBufferSize < 1) { + throw new InvalidArgumentException(\sprintf('The maximum buffer size must be a positive number of bytes, got %d.', $maxBufferSize)); + } } public function connect(): void @@ -223,6 +240,12 @@ class StdioTransport extends BaseTransport $data = fread($this->stdout, 8192); if (false !== $data && '' !== $data) { + if (\strlen($this->inputBuffer) + \strlen($data) > $this->maxBufferSize) { + $this->abortInput(\sprintf('buffered %d bytes without a newline, exceeding the %d byte limit', \strlen($this->inputBuffer) + \strlen($data), $this->maxBufferSize)); + + return; + } + $this->inputBuffer .= $data; } @@ -237,6 +260,33 @@ class StdioTransport extends BaseTransport } } + /** + * Discard the input buffer and fail any in-flight request. + * + * The waiting fiber is resolved with an error immediately so the caller + * fails fast, rather than spinning until the request timeout elapses. + */ + private function abortInput(string $reason): void + { + $bufferedBytes = \strlen($this->inputBuffer); + $this->inputBuffer = ''; + + $this->logger->warning('Aborting stdio input: '.$reason, [ + 'buffered_bytes' => $bufferedBytes, + 'max_buffer_size' => $this->maxBufferSize, + ]); + + if (null === $this->state) { + return; + } + + foreach ($this->state->getPendingRequests() as $pending) { + $requestId = $pending['request_id']; + $error = Error::forInternalError('stdio input aborted: '.$reason, $requestId); + $this->state->storeResponse($requestId, $error->jsonSerialize()); + } + } + private function processFiber(): void { if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) { diff --git a/src/Exception/InvalidInputMessageException.php b/src/Exception/InvalidInputMessageException.php index 4ab485a..0f96122 100644 --- a/src/Exception/InvalidInputMessageException.php +++ b/src/Exception/InvalidInputMessageException.php @@ -16,4 +16,17 @@ namespace Mcp\Exception; */ class InvalidInputMessageException extends \InvalidArgumentException implements ExceptionInterface { + private string|int|null $requestId = null; + + public function getRequestId(): string|int|null + { + return $this->requestId; + } + + public function setRequestId(string|int|null $requestId): self + { + $this->requestId = $requestId; + + return $this; + } } diff --git a/src/Exception/MissingRequestMetaException.php b/src/Exception/MissingRequestMetaException.php new file mode 100644 index 0000000..1499205 --- /dev/null +++ b/src/Exception/MissingRequestMetaException.php @@ -0,0 +1,22 @@ + + */ +class MissingRequestMetaException extends InvalidArgumentException +{ +} diff --git a/src/Exception/MissingRequiredClientCapabilityException.php b/src/Exception/MissingRequiredClientCapabilityException.php new file mode 100644 index 0000000..aa8b031 --- /dev/null +++ b/src/Exception/MissingRequiredClientCapabilityException.php @@ -0,0 +1,33 @@ + + */ +class MissingRequiredClientCapabilityException extends \RuntimeException implements ExceptionInterface +{ + public function __construct( + public readonly ClientCapabilities $requiredCapabilities, + string $message = 'Request requires a client capability that was not declared.', + ) { + parent::__construct($message); + } +} diff --git a/src/Exception/RequestStateException.php b/src/Exception/RequestStateException.php new file mode 100644 index 0000000..2dde880 --- /dev/null +++ b/src/Exception/RequestStateException.php @@ -0,0 +1,24 @@ + + */ +class RequestStateException extends InvalidArgumentException +{ +} diff --git a/src/Exception/RootsException.php b/src/Exception/RootsException.php new file mode 100644 index 0000000..8d12cb0 --- /dev/null +++ b/src/Exception/RootsException.php @@ -0,0 +1,24 @@ + + */ +final class RootsException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index 97bac3e..27860a2 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -95,10 +95,12 @@ final class MessageFactory /** * Creates a new Factory instance with all the protocol's default messages. + * + * @param list|class-string> $additional message classes an extension defines */ - public static function make(int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE): self + public static function make(int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE, array $additional = []): self { - return new self(self::REGISTERED_MESSAGES, $maxBatchSize); + return new self([...self::REGISTERED_MESSAGES, ...$additional], $maxBatchSize); } /** @@ -148,6 +150,11 @@ final class MessageFactory $messages[] = $this->createMessage($message); } catch (InvalidInputMessageException $e) { + // Recover the id only when it's a valid JSON-RPC scalar; + // a null or malformed id is left at the exception's null default. + if (\is_array($message) && isset($message['id']) && (\is_string($message['id']) || \is_int($message['id']))) { + $e->setRequestId($message['id']); + } $messages[] = $e; } } @@ -177,6 +184,10 @@ final class MessageFactory throw new InvalidInputMessageException('Invalid JSON-RPC message: missing "method", "result", or "error" field.'); } + if (!\is_string($data['method'])) { + throw new InvalidInputMessageException('Invalid JSON-RPC message: "method" must be a string.'); + } + $messageClass = $this->findMessageClassByMethod($data['method']); return $messageClass::fromArray($data); diff --git a/src/Schema/Annotations.php b/src/Schema/Annotations.php index a448ba5..ec64e7a 100644 --- a/src/Schema/Annotations.php +++ b/src/Schema/Annotations.php @@ -60,7 +60,20 @@ class Annotations implements \JsonSerializable { $audience = null; if (isset($data['audience']) && \is_array($data['audience'])) { - $audience = array_map(static fn (string $r) => Role::from($r), $data['audience']); + $audience = array_map( + static function (mixed $role): Role { + if (!\is_string($role) || null === $case = Role::tryFrom($role)) { + throw new InvalidArgumentException('Each entry in "audience" must be a valid role.'); + } + + return $case; + }, + $data['audience'], + ); + } + + if (isset($data['priority']) && !\is_float($data['priority']) && !\is_int($data['priority'])) { + throw new InvalidArgumentException('Invalid "priority" in Annotations data; expected a number.'); } return new self( @@ -69,6 +82,24 @@ class Annotations implements \JsonSerializable ); } + /** + * Hydrates an optional "annotations" field, rejecting a value that is present but not an object. + * + * @param string $context the surrounding schema type, used for the error message + */ + public static function tryFromArray(mixed $data, string $context): ?self + { + if (null === $data) { + return null; + } + + if (!\is_array($data)) { + throw new InvalidArgumentException(\sprintf('Invalid "annotations" in %s data; expected an array.', $context)); + } + + return self::fromArray($data); + } + /** * @return AnnotationsData */ diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index 3bb6081..75d50ff 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -21,7 +21,15 @@ class ClientCapabilities implements \JsonSerializable { /** * @param array $experimental - * @param ?array $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui) + * @param ?array $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui) + * @param ?bool $samplingContext the `sampling.context` sub-capability + * @param ?bool $samplingTools the `sampling.tools` sub-capability + * @param ?bool $elicitationForm The `elicitation.form` sub-capability. Implied by declaring + * `elicitation` without naming any mode. + * @param ?bool $elicitationUrl the `elicitation.url` sub-capability + * + * The sub-capabilities trail `extensions` rather than sitting next to `sampling` and + * `elicitation` so that existing positional calls keep working. Pass them by name. */ public function __construct( public readonly ?bool $roots = false, @@ -30,6 +38,10 @@ class ClientCapabilities implements \JsonSerializable public readonly ?bool $elicitation = null, public readonly ?array $experimental = null, public readonly ?array $extensions = null, + public readonly ?bool $samplingContext = null, + public readonly ?bool $samplingTools = null, + public readonly ?bool $elicitationForm = null, + public readonly ?bool $elicitationUrl = null, ) { } @@ -38,8 +50,8 @@ class ClientCapabilities implements \JsonSerializable * roots?: array{ * listChanged?: bool, * }, - * sampling?: bool, - * elicitation?: bool, + * sampling?: array{context?: mixed, tools?: mixed}|object, + * elicitation?: array{form?: mixed, url?: mixed}|object|bool, * experimental?: array, * extensions?: array, * } $data @@ -57,13 +69,29 @@ class ClientCapabilities implements \JsonSerializable } $sampling = null; + $samplingContext = null; + $samplingTools = null; if (isset($data['sampling'])) { $sampling = true; + if (\is_array($data['sampling'])) { + $samplingContext = isset($data['sampling']['context']); + $samplingTools = isset($data['sampling']['tools']); + } elseif (\is_object($data['sampling'])) { + $samplingContext = property_exists($data['sampling'], 'context'); + $samplingTools = property_exists($data['sampling'], 'tools'); + } } $elicitation = null; + $elicitationForm = null; + $elicitationUrl = null; if (isset($data['elicitation'])) { $elicitation = true; + $elicitationUrl = self::namesMode($data['elicitation'], 'url'); + // Form mode is the backwards-compatible default: an `elicitation` capability + // naming no mode at all means form, the only shape that existed before `url`. + // Naming any mode is an explicit statement, so `{"url": {}}` is not form. + $elicitationForm = self::namesMode($data['elicitation'], 'form') || !$elicitationUrl; } return new self( @@ -73,6 +101,46 @@ class ClientCapabilities implements \JsonSerializable $elicitation, \is_array($data['experimental'] ?? null) ? $data['experimental'] : null, \is_array($data['extensions'] ?? null) ? $data['extensions'] : null, + $samplingContext, + $samplingTools, + $elicitationForm, + $elicitationUrl, + ); + } + + /** + * A mode is declared by the presence of a (possibly empty) object, so only the + * key matters — not whatever it holds. A boolean `elicitation` names none. + */ + private static function namesMode(mixed $capability, string $name): bool + { + if (\is_array($capability)) { + return \array_key_exists($name, $capability); + } + + if (\is_object($capability)) { + return property_exists($capability, $name); + } + + return false; + } + + /** + * @param array $extensions + */ + public function withExtensions(array $extensions): self + { + return new self( + $this->roots, + $this->rootsListChanged, + $this->sampling, + $this->elicitation, + $this->experimental, + array_replace($this->extensions ?? [], $extensions), + $this->samplingContext, + $this->samplingTools, + $this->elicitationForm, + $this->elicitationUrl, ); } @@ -95,12 +163,24 @@ class ClientCapabilities implements \JsonSerializable } } - if ($this->sampling) { + if ($this->sampling || $this->samplingContext || $this->samplingTools) { $data['sampling'] = new \stdClass(); + if ($this->samplingContext) { + $data['sampling']->context = new \stdClass(); + } + if ($this->samplingTools) { + $data['sampling']->tools = new \stdClass(); + } } - if ($this->elicitation) { + if ($this->elicitation || $this->elicitationForm || $this->elicitationUrl) { $data['elicitation'] = new \stdClass(); + if ($this->elicitationForm) { + $data['elicitation']->form = new \stdClass(); + } + if ($this->elicitationUrl) { + $data['elicitation']->url = new \stdClass(); + } } if ($this->experimental) { @@ -108,7 +188,10 @@ class ClientCapabilities implements \JsonSerializable } if ($this->extensions) { - $data['extensions'] = (object) $this->extensions; + $data['extensions'] = (object) array_map( + static fn (mixed $settings): mixed => \is_array($settings) ? (object) $settings : $settings, + $this->extensions, + ); } return $data ?: new \stdClass(); diff --git a/src/Schema/Content/AudioContent.php b/src/Schema/Content/AudioContent.php index a23db24..cf7418c 100644 --- a/src/Schema/Content/AudioContent.php +++ b/src/Schema/Content/AudioContent.php @@ -44,14 +44,17 @@ class AudioContent extends Content */ public static function fromArray(array $data): self { - if (!isset($data['data']) || !isset($data['mimeType'])) { - throw new InvalidArgumentException('Invalid or missing "data" or "mimeType" in AudioContent data.'); + if (!isset($data['data']) || !\is_string($data['data'])) { + throw new InvalidArgumentException('Missing or invalid "data" in AudioContent data.'); + } + if (!isset($data['mimeType']) || !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Missing or invalid "mimeType" in AudioContent data.'); } return new self( $data['data'], $data['mimeType'], - isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null + Annotations::tryFromArray($data['annotations'] ?? null, 'AudioContent') ); } diff --git a/src/Schema/Content/BlobResourceContents.php b/src/Schema/Content/BlobResourceContents.php index 0d6016d..ee126fb 100644 --- a/src/Schema/Content/BlobResourceContents.php +++ b/src/Schema/Content/BlobResourceContents.php @@ -54,6 +54,13 @@ class BlobResourceContents extends ResourceContents throw new InvalidArgumentException('Missing or invalid "blob" for BlobResourceContents.'); } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" for BlobResourceContents.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" for BlobResourceContents.'); + } + return new self($data['uri'], $data['mimeType'] ?? null, $data['blob'], $data['_meta'] ?? null); } diff --git a/src/Schema/Content/EmbeddedResource.php b/src/Schema/Content/EmbeddedResource.php index eb84985..76c4d67 100644 --- a/src/Schema/Content/EmbeddedResource.php +++ b/src/Schema/Content/EmbeddedResource.php @@ -62,7 +62,7 @@ class EmbeddedResource extends Content return new self( $resourceInstance, - isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, + Annotations::tryFromArray($data['annotations'] ?? null, 'EmbeddedResource'), ); } diff --git a/src/Schema/Content/ImageContent.php b/src/Schema/Content/ImageContent.php index b64d007..9e2cbff 100644 --- a/src/Schema/Content/ImageContent.php +++ b/src/Schema/Content/ImageContent.php @@ -12,12 +12,16 @@ namespace Mcp\Schema\Content; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Annotations; /** + * @phpstan-import-type AnnotationsData from Annotations + * * @phpstan-type ImageContentData array{ * type: 'image', * data: string, - * mimeType: string + * mimeType: string, + * annotations?: AnnotationsData, * } * * @author Kyrian Obikwelu @@ -27,12 +31,14 @@ class ImageContent extends Content /** * Create a new ImageContent instance. * - * @param string $data Base64-encoded image data - * @param string $mimeType The MIME type of the image + * @param string $data Base64-encoded image data + * @param string $mimeType The MIME type of the image + * @param ?Annotations $annotations Optional annotations describing the content */ public function __construct( public readonly string $data, public readonly string $mimeType, + public readonly ?Annotations $annotations = null, ) { parent::__construct('image'); } @@ -49,18 +55,23 @@ class ImageContent extends Content throw new InvalidArgumentException('Missing or invalid "mimeType" in ImageContent data.'); } - return new self($data['data'], $data['mimeType']); + return new self( + $data['data'], + $data['mimeType'], + isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null + ); } /** * Create a new ImageContent from a file path. * - * @param string $path Path to the image file - * @param string|null $mimeType Optional MIME type override + * @param string $path Path to the image file + * @param string|null $mimeType Optional MIME type override + * @param ?Annotations $annotations Optional annotations describing the content * * @throws InvalidArgumentException If the file doesn't exist */ - public static function fromFile(string $path, ?string $mimeType = null): self + public static function fromFile(string $path, ?string $mimeType = null, ?Annotations $annotations = null): self { if (!file_exists($path)) { throw new InvalidArgumentException(\sprintf('Image file not found: "%s".', $path)); @@ -69,25 +80,36 @@ class ImageContent extends Content $data = base64_encode(file_get_contents($path)); $detectedMime = $mimeType ?? mime_content_type($path) ?: 'image/png'; - return new self($data, $detectedMime); + return new self($data, $detectedMime, $annotations); } - public static function fromString(string $data, string $mimeType): self + public static function fromString(string $data, string $mimeType, ?Annotations $annotations = null): self { - return new self(base64_encode($data), $mimeType); + return new self(base64_encode($data), $mimeType, $annotations); } /** * Convert the content to an array. * - * @return ImageContentData + * @return array{ + * type: 'image', + * data: string, + * mimeType: string, + * annotations?: Annotations, + * } */ public function jsonSerialize(): array { - return [ + $result = [ 'type' => $this->type, 'data' => $this->data, 'mimeType' => $this->mimeType, ]; + + if (null !== $this->annotations) { + $result['annotations'] = $this->annotations; + } + + return $result; } } diff --git a/src/Schema/Content/PromptMessage.php b/src/Schema/Content/PromptMessage.php index 075d113..d47f5ec 100644 --- a/src/Schema/Content/PromptMessage.php +++ b/src/Schema/Content/PromptMessage.php @@ -20,11 +20,12 @@ use Mcp\Schema\Enum\Role; * @phpstan-import-type TextContentData from TextContent * @phpstan-import-type ImageContentData from ImageContent * @phpstan-import-type AudioContentData from AudioContent + * @phpstan-import-type ResourceLinkData from ResourceLink * @phpstan-import-type EmbeddedResourceData from EmbeddedResource * * @phpstan-type PromptMessageData array{ * role: string, - * content: TextContentData|ImageContentData|AudioContentData|EmbeddedResourceData, + * content: TextContentData|ImageContentData|AudioContentData|ResourceLinkData|EmbeddedResourceData, * } * * @author Kyrian Obikwelu @@ -34,12 +35,12 @@ class PromptMessage extends Content /** * Create a new PromptMessage instance. * - * @param Role $role The role of the message - * @param TextContent|ImageContent|AudioContent|EmbeddedResource $content The content of the message + * @param Role $role The role of the message + * @param TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource $content The content of the message */ public function __construct( public readonly Role $role, - public readonly TextContent|ImageContent|AudioContent|EmbeddedResource $content, + public readonly TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource $content, ) { parent::__construct('prompt'); } @@ -58,16 +59,24 @@ class PromptMessage extends Content $contentData = $data['content']; $contentType = $contentData['type'] ?? null; + if (!\is_string($contentType)) { + throw new InvalidArgumentException('Missing or invalid content "type" for PromptMessage.'); + } $content = match ($contentType) { 'text' => TextContent::fromArray($contentData), 'image' => ImageContent::fromArray($contentData), 'audio' => AudioContent::fromArray($contentData), 'resource' => EmbeddedResource::fromArray($contentData), + 'resource_link' => ResourceLink::fromArray($contentData), default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for PromptMessage.', $contentType)), }; - return new self(Role::from($data['role']), $content); + if (null === $role = Role::tryFrom($data['role'])) { + throw new InvalidArgumentException(\sprintf('Invalid "role" value "%s" in PromptMessage data.', $data['role'])); + } + + return new self($role, $content); } /** @@ -75,7 +84,7 @@ class PromptMessage extends Content * * @return array{ * role: string, - * content: TextContent|ImageContent|AudioContent|EmbeddedResource + * content: TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource * } */ public function jsonSerialize(): array diff --git a/src/Schema/Content/ResourceLink.php b/src/Schema/Content/ResourceLink.php new file mode 100644 index 0000000..946c9b8 --- /dev/null +++ b/src/Schema/Content/ResourceLink.php @@ -0,0 +1,155 @@ +, + * } + * + * @author Alex Rothberg + */ +class ResourceLink extends Content +{ + /** + * @param string $uri the URI of this resource + * @param string $name a short identifier for this resource + * @param ?string $title optional human-readable title for display in UI + * @param ?string $description a description of what this resource represents. This can be used by clients to improve the LLM's understanding of available resources + * @param ?string $mimeType the MIME type of this resource, if known + * @param ?Annotations $annotations optional annotations for the client + * @param ?int $size the size of the raw resource content, in bytes (before base64 encoding or any tokenization), if known + * @param ?Icon[] $icons optional icons representing the resource + * @param ?array $meta optional metadata + */ + public function __construct( + public readonly string $uri, + public readonly string $name, + public readonly ?string $title = null, + public readonly ?string $description = null, + public readonly ?string $mimeType = null, + public readonly ?Annotations $annotations = null, + public readonly ?int $size = null, + public readonly ?array $icons = null, + public readonly ?array $meta = null, + ) { + parent::__construct('resource_link'); + } + + /** + * @param ResourceLinkData $data + */ + public static function fromArray(array $data): self + { + if (($data['type'] ?? null) !== 'resource_link') { + throw new InvalidArgumentException('Invalid type for ResourceLink.'); + } + if (empty($data['uri']) || !\is_string($data['uri'])) { + throw new InvalidArgumentException('Invalid or missing "uri" in ResourceLink data.'); + } + if (empty($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Invalid or missing "name" in ResourceLink data.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in ResourceLink data.'); + } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in ResourceLink data.'); + } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" in ResourceLink data.'); + } + if (isset($data['size']) && !\is_int($data['size'])) { + throw new InvalidArgumentException('Invalid "size" in ResourceLink data; expected an integer.'); + } + + return new self( + uri: $data['uri'], + name: $data['name'], + title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, + description: $data['description'] ?? null, + mimeType: $data['mimeType'] ?? null, + annotations: Annotations::tryFromArray($data['annotations'] ?? null, 'ResourceLink'), + size: $data['size'] ?? null, + icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'ResourceLink') : null, + meta: $data['_meta'] ?? null, + ); + } + + /** + * @return array{ + * type: 'resource_link', + * uri: string, + * name: string, + * title?: string, + * description?: string, + * mimeType?: string, + * annotations?: Annotations, + * size?: int, + * icons?: Icon[], + * _meta?: array, + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => $this->type, + 'uri' => $this->uri, + 'name' => $this->name, + ]; + if (null !== $this->title) { + $data['title'] = $this->title; + } + if (null !== $this->description) { + $data['description'] = $this->description; + } + if (null !== $this->mimeType) { + $data['mimeType'] = $this->mimeType; + } + if (null !== $this->annotations) { + $data['annotations'] = $this->annotations; + } + if (null !== $this->size) { + $data['size'] = $this->size; + } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } +} diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php index 48aaa71..17343d0 100644 --- a/src/Schema/Content/SamplingMessage.php +++ b/src/Schema/Content/SamplingMessage.php @@ -17,22 +17,69 @@ use Mcp\Schema\Enum\Role; /** * Describes a message issued to or received from an LLM API during sampling. * - * @phpstan-type SamplingMessageData = array{ + * Structural validity is enforced here, but the spec's tool-flow rules (which role may + * carry which block, tool results not being mixed with other content, every tool use + * being answered) are not: they span the whole message list and must be reportable as + * an "invalid params" error rather than as a parse failure. They live in + * {@see \Mcp\Schema\Request\CreateSamplingMessageRequest::validateToolFlow()}. + * + * @phpstan-type SamplingContent TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent + * @phpstan-type SamplingMessageData array{ * role: 'user'|'assistant', - * content: TextContent|ImageContent|AudioContent + * content: array|list>, + * _meta?: array, * } * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ class SamplingMessage extends Content { + /** + * @var SamplingContent|list + */ + public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content; + + /** + * @param SamplingContent|array $content keys are discarded, the property always holds a list + * @param ?array $meta + */ public function __construct( public readonly Role $role, - public readonly TextContent|ImageContent|AudioContent $content, + TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content, + public readonly ?array $meta = null, ) { + if (\is_array($content)) { + if ([] === $content) { + throw new InvalidArgumentException('Sampling message content must not be empty.'); + } + + foreach ($content as $item) { + if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent && !$item instanceof ToolResultContent) { + throw new InvalidArgumentException('Sampling message content contains an unsupported content block.'); + } + } + + // array_filter() and friends preserve keys, and a keyed array serializes + // as a JSON object rather than the array the schema requires. + $content = array_values($content); + } + + $this->content = $content; + parent::__construct('sampling'); } + /** + * @return list + */ + public function getContentBlocks(): array + { + return \is_array($this->content) ? $this->content : [$this->content]; + } + /** * @param SamplingMessageData $data */ @@ -41,32 +88,75 @@ class SamplingMessage extends Content if (!isset($data['role']) || !\is_string($data['role'])) { throw new InvalidArgumentException('Missing or invalid "role" in SamplingMessage data.'); } - if (!isset($data['content']) || !\is_array($data['content'])) { + if (!isset($data['content']) || !\is_array($data['content']) || [] === $data['content']) { throw new InvalidArgumentException('Missing or invalid "content" in SamplingMessage data.'); } - $role = Role::from($data['role']); + if (null === $role = Role::tryFrom($data['role'])) { + throw new InvalidArgumentException(\sprintf('Invalid "role" value "%s" in SamplingMessage data.', $data['role'])); + } + $contentData = $data['content']; $contentType = $contentData['type'] ?? null; + if (null !== $contentType && !\is_string($contentType)) { + throw new InvalidArgumentException('Missing or invalid content "type" for SamplingMessage.'); + } - $contentInstance = match ($contentType) { - 'text' => TextContent::fromArray($contentData), - 'image' => ImageContent::fromArray($contentData), - 'audio' => AudioContent::fromArray($contentData), - default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for SamplingMessage.', $contentType)), - }; + $isSingleContent = null !== $contentType; + $contentItems = $isSingleContent ? [$contentData] : $contentData; + $content = []; - return new self($role, $contentInstance); + foreach ($contentItems as $item) { + if (!\is_array($item)) { + throw new InvalidArgumentException('Invalid content block in SamplingMessage data.'); + } + $content[] = self::hydrateContent($item); + } + + return new self( + $role, + $isSingleContent ? $content[0] : $content, + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); } /** - * @return SamplingMessageData + * @return array{ + * role: string, + * content: SamplingContent|list, + * _meta?: array, + * } */ public function jsonSerialize(): array { - return [ + $data = [ 'role' => $this->role->value, 'content' => $this->content, ]; + + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } + + /** + * @param array $contentData + * + * @return SamplingContent + */ + private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent + { + $contentType = $contentData['type'] ?? null; + + return match ($contentType) { + 'text' => TextContent::fromArray($contentData), + 'image' => ImageContent::fromArray($contentData), + 'audio' => AudioContent::fromArray($contentData), + 'tool_use' => ToolUseContent::fromArray($contentData), + 'tool_result' => ToolResultContent::fromArray($contentData), + default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for SamplingMessage.', $contentType)), + }; } } diff --git a/src/Schema/Content/TextContent.php b/src/Schema/Content/TextContent.php index 14a6402..b743af5 100644 --- a/src/Schema/Content/TextContent.php +++ b/src/Schema/Content/TextContent.php @@ -56,7 +56,7 @@ class TextContent extends Content return new self( $data['text'], - isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null + Annotations::tryFromArray($data['annotations'] ?? null, 'TextContent') ); } diff --git a/src/Schema/Content/TextResourceContents.php b/src/Schema/Content/TextResourceContents.php index 47ee31f..9beff81 100644 --- a/src/Schema/Content/TextResourceContents.php +++ b/src/Schema/Content/TextResourceContents.php @@ -54,6 +54,13 @@ class TextResourceContents extends ResourceContents throw new InvalidArgumentException('Missing or invalid "text" for TextResourceContents.'); } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" for TextResourceContents.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" for TextResourceContents.'); + } + return new self($data['uri'], $data['mimeType'] ?? null, $data['text'], $data['_meta'] ?? null); } diff --git a/src/Schema/Content/ToolResultContent.php b/src/Schema/Content/ToolResultContent.php new file mode 100644 index 0000000..7f2b11c --- /dev/null +++ b/src/Schema/Content/ToolResultContent.php @@ -0,0 +1,121 @@ + + */ + public readonly array $content; + + /** + * @param array $content keys are discarded, the property always holds a list + * @param ?array $structuredContent + * @param ?array $meta + */ + public function __construct( + public readonly string $toolUseId, + array $content, + public readonly ?array $structuredContent = null, + public readonly bool $isError = false, + public readonly ?array $meta = null, + ) { + foreach ($content as $item) { + if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ResourceLink && !$item instanceof EmbeddedResource) { + throw new InvalidArgumentException('Tool result content must contain standard content blocks.'); + } + } + + // array_filter() and friends preserve keys, and a keyed array serializes + // as a JSON object rather than the array the schema requires. + $this->content = array_values($content); + + parent::__construct('tool_result'); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['toolUseId']) || !\is_string($data['toolUseId'])) { + throw new InvalidArgumentException('Missing or invalid "toolUseId" in ToolResultContent data.'); + } + if (!isset($data['content']) || !\is_array($data['content'])) { + throw new InvalidArgumentException('Missing or invalid "content" in ToolResultContent data.'); + } + + $content = []; + foreach ($data['content'] as $item) { + if (!\is_array($item)) { + throw new InvalidArgumentException('Invalid content block in ToolResultContent data.'); + } + + $content[] = match ($item['type'] ?? null) { + 'text' => TextContent::fromArray($item), + 'image' => ImageContent::fromArray($item), + 'audio' => AudioContent::fromArray($item), + 'resource_link' => ResourceLink::fromArray($item), + 'resource' => EmbeddedResource::fromArray($item), + default => throw new InvalidArgumentException(\sprintf('Unsupported tool result content type "%s".', $item['type'] ?? null)), + }; + } + + return new self( + $data['toolUseId'], + $content, + isset($data['structuredContent']) && \is_array($data['structuredContent']) ? $data['structuredContent'] : null, + isset($data['isError']) && true === $data['isError'], + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => $this->type, + 'toolUseId' => $this->toolUseId, + 'content' => $this->content, + ]; + + // Optional in the schema with a default of false, so only sent when it is true. + if ($this->isError) { + $data['isError'] = true; + } + + if (null !== $this->structuredContent) { + $data['structuredContent'] = $this->structuredContent; + } + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } +} diff --git a/src/Schema/Content/ToolUseContent.php b/src/Schema/Content/ToolUseContent.php new file mode 100644 index 0000000..8952e8c --- /dev/null +++ b/src/Schema/Content/ToolUseContent.php @@ -0,0 +1,85 @@ + $input + * @param ?array $meta + */ + public function __construct( + public readonly string $id, + public readonly string $name, + public readonly array $input, + public readonly ?array $meta = null, + ) { + if ([] !== $input && array_is_list($input)) { + throw new InvalidArgumentException('ToolUseContent "input" must be a map of argument names, not a list.'); + } + + parent::__construct('tool_use'); + } + + /** + * @param array{id?: mixed, name?: mixed, input?: mixed, _meta?: mixed} $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['id']) || !\is_string($data['id'])) { + throw new InvalidArgumentException('Missing or invalid "id" in ToolUseContent data.'); + } + if (!isset($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Missing or invalid "name" in ToolUseContent data.'); + } + if (!isset($data['input']) || !\is_array($data['input'])) { + throw new InvalidArgumentException('Missing or invalid "input" in ToolUseContent data.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in ToolUseContent data.'); + } + + return new self( + $data['id'], + $data['name'], + $data['input'], + $data['_meta'] ?? null, + ); + } + + /** + * @return array{type: 'tool_use', id: string, name: string, input: array|\stdClass, _meta?: array} + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => $this->type, + 'id' => $this->id, + 'name' => $this->name, + 'input' => $this->input ?: new \stdClass(), + ]; + + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } +} diff --git a/src/Schema/Elicitation/AbstractSchemaDefinition.php b/src/Schema/Elicitation/AbstractSchemaDefinition.php index 63fdc21..a05135f 100644 --- a/src/Schema/Elicitation/AbstractSchemaDefinition.php +++ b/src/Schema/Elicitation/AbstractSchemaDefinition.php @@ -21,13 +21,13 @@ use Mcp\Exception\InvalidArgumentException; abstract class AbstractSchemaDefinition implements \JsonSerializable { public function __construct( - public readonly string $title, + public readonly ?string $title = null, public readonly ?string $description = null, ) { } /** - * Validate that title exists and is a string in the data array. + * Reject a title that is present but not a string. * * @param array $data * @@ -35,22 +35,23 @@ abstract class AbstractSchemaDefinition implements \JsonSerializable */ protected static function validateTitle(array $data, string $schemaType): void { - if (!isset($data['title']) || !\is_string($data['title'])) { - throw new InvalidArgumentException(\sprintf('Missing or invalid "title" for %s schema definition.', $schemaType)); + if (\array_key_exists('title', $data) && null !== $data['title'] && !\is_string($data['title'])) { + throw new InvalidArgumentException(\sprintf('Invalid "title" for %s schema definition.', $schemaType)); } } /** - * Build the base JSON structure with type, title, and optional description. + * Build the base JSON structure with type, optional title and description. * * @return array */ protected function buildBaseJson(string $type): array { - $data = [ - 'type' => $type, - 'title' => $this->title, - ]; + $data = ['type' => $type]; + + if (null !== $this->title) { + $data['title'] = $this->title; + } if (null !== $this->description) { $data['description'] = $this->description; diff --git a/src/Schema/Elicitation/BooleanSchemaDefinition.php b/src/Schema/Elicitation/BooleanSchemaDefinition.php index 9cfaa8a..3976685 100644 --- a/src/Schema/Elicitation/BooleanSchemaDefinition.php +++ b/src/Schema/Elicitation/BooleanSchemaDefinition.php @@ -19,12 +19,12 @@ namespace Mcp\Schema\Elicitation; final class BooleanSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param string|null $description Optional description/help text * @param bool|null $default Optional default value */ public function __construct( - string $title, + ?string $title = null, ?string $description = null, public readonly ?bool $default = null, ) { @@ -33,7 +33,7 @@ final class BooleanSchemaDefinition extends AbstractSchemaDefinition /** * @param array{ - * title: string, + * title?: string, * description?: string, * default?: bool, * } $data @@ -43,7 +43,7 @@ final class BooleanSchemaDefinition extends AbstractSchemaDefinition self::validateTitle($data, 'boolean'); return new self( - title: $data['title'], + title: $data['title'] ?? null, description: $data['description'] ?? null, default: isset($data['default']) ? (bool) $data['default'] : null, ); @@ -52,7 +52,7 @@ final class BooleanSchemaDefinition extends AbstractSchemaDefinition /** * @return array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: bool, * } diff --git a/src/Schema/Elicitation/ElicitationSchema.php b/src/Schema/Elicitation/ElicitationSchema.php index 1e4486c..36d130a 100644 --- a/src/Schema/Elicitation/ElicitationSchema.php +++ b/src/Schema/Elicitation/ElicitationSchema.php @@ -35,6 +35,9 @@ final class ElicitationSchema implements \JsonSerializable } foreach ($required as $name) { + if (!\is_string($name)) { + throw new InvalidArgumentException('Each entry in "required" must be a string.'); + } if (!\array_key_exists($name, $properties)) { throw new InvalidArgumentException(\sprintf('Required property "%s" is not defined in properties.', $name)); } @@ -68,6 +71,10 @@ final class ElicitationSchema implements \JsonSerializable $properties[$name] = self::createSchemaDefinition($propertyData); } + if (isset($data['required']) && !\is_array($data['required'])) { + throw new InvalidArgumentException('Invalid "required" for elicitation schema; expected an array.'); + } + return new self( properties: $properties, required: $data['required'] ?? [], diff --git a/src/Schema/Elicitation/EnumSchemaDefinition.php b/src/Schema/Elicitation/EnumSchemaDefinition.php index 003bf35..1eaef8b 100644 --- a/src/Schema/Elicitation/EnumSchemaDefinition.php +++ b/src/Schema/Elicitation/EnumSchemaDefinition.php @@ -23,14 +23,14 @@ use Mcp\Exception\InvalidArgumentException; final class EnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param string[] $enum Array of allowed string values * @param string|null $description Optional description/help text * @param string|null $default Optional default value (must be in enum) * @param string[]|null $enumNames Optional human-readable labels for each enum value */ public function __construct( - string $title, + ?string $title, public readonly array $enum, ?string $description = null, public readonly ?string $default = null, @@ -59,7 +59,7 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition /** * @param array{ - * title: string, + * title?: string, * enum: string[], * description?: string, * default?: string, @@ -75,7 +75,7 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition } return new self( - title: $data['title'], + title: $data['title'] ?? null, enum: $data['enum'], description: $data['description'] ?? null, default: $data['default'] ?? null, @@ -86,7 +86,7 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition /** * @return array{ * type: string, - * title: string, + * title?: string, * enum: string[], * description?: string, * default?: string, @@ -95,11 +95,13 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition */ public function jsonSerialize(): array { - $data = [ - 'type' => 'string', - 'title' => $this->title, - 'enum' => $this->enum, - ]; + $data = ['type' => 'string']; + + if (null !== $this->title) { + $data['title'] = $this->title; + } + + $data['enum'] = $this->enum; if (null !== $this->description) { $data['description'] = $this->description; diff --git a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php index 28046bc..ce2ab08 100644 --- a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php @@ -23,7 +23,7 @@ use Mcp\Exception\InvalidArgumentException; final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param string[] $enum Array of allowed string values * @param string|null $description Optional description/help text * @param string[]|null $default Optional default selected values (must be subset of enum) @@ -31,7 +31,7 @@ final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition * @param int|null $maxItems Optional maximum number of selections */ public function __construct( - string $title, + ?string $title, public readonly array $enum, ?string $description = null, public readonly ?array $default = null, @@ -73,7 +73,7 @@ final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition /** * @param array{ - * title: string, + * title?: string, * items: array{type: string, enum: string[]}, * description?: string, * default?: string[], @@ -90,7 +90,7 @@ final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition } return new self( - title: $data['title'], + title: $data['title'] ?? null, enum: $data['items']['enum'], description: $data['description'] ?? null, default: $data['default'] ?? null, diff --git a/src/Schema/Elicitation/NumberSchemaDefinition.php b/src/Schema/Elicitation/NumberSchemaDefinition.php index 0ad3277..d2bf540 100644 --- a/src/Schema/Elicitation/NumberSchemaDefinition.php +++ b/src/Schema/Elicitation/NumberSchemaDefinition.php @@ -23,7 +23,7 @@ use Mcp\Exception\InvalidArgumentException; final class NumberSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param bool $integerOnly Whether to restrict to integer values only * @param string|null $description Optional description/help text * @param int|float|null $default Optional default value @@ -31,7 +31,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition * @param int|float|null $maximum Optional maximum value (inclusive) */ public function __construct( - string $title, + ?string $title = null, public readonly bool $integerOnly = false, ?string $description = null, public readonly int|float|null $default = null, @@ -60,7 +60,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition /** * @param array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: int|float, * minimum?: int|float, @@ -75,7 +75,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition $integerOnly = 'integer' === $type; return new self( - title: $data['title'], + title: $data['title'] ?? null, integerOnly: $integerOnly, description: $data['description'] ?? null, default: $data['default'] ?? null, @@ -87,7 +87,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition /** * @return array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: int|float, * minimum?: int|float, diff --git a/src/Schema/Elicitation/StringSchemaDefinition.php b/src/Schema/Elicitation/StringSchemaDefinition.php index 7631926..36c5e44 100644 --- a/src/Schema/Elicitation/StringSchemaDefinition.php +++ b/src/Schema/Elicitation/StringSchemaDefinition.php @@ -25,7 +25,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition private const VALID_FORMATS = ['date', 'date-time', 'email', 'uri']; /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param string|null $description Optional description/help text * @param string|null $default Optional default value * @param string|null $format Optional format constraint (date, date-time, email, uri) @@ -33,7 +33,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition * @param int|null $maxLength Optional maximum string length */ public function __construct( - string $title, + ?string $title = null, ?string $description = null, public readonly ?string $default = null, public readonly ?string $format = null, @@ -61,7 +61,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition /** * @param array{ - * title: string, + * title?: string, * description?: string, * default?: string, * format?: string, @@ -74,7 +74,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition self::validateTitle($data, 'string'); return new self( - title: $data['title'], + title: $data['title'] ?? null, description: $data['description'] ?? null, default: $data['default'] ?? null, format: $data['format'] ?? null, @@ -86,7 +86,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition /** * @return array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: string, * format?: string, diff --git a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php index 54eb568..f6c47d4 100644 --- a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php @@ -24,13 +24,13 @@ use Mcp\Exception\InvalidArgumentException; final class TitledEnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param list $oneOf Array of const/title pairs * @param string|null $description Optional description/help text * @param string|null $default Optional default value (must match a const) */ public function __construct( - string $title, + ?string $title, public readonly array $oneOf, ?string $description = null, public readonly ?string $default = null, @@ -59,7 +59,7 @@ final class TitledEnumSchemaDefinition extends AbstractSchemaDefinition /** * @param array{ - * title: string, + * title?: string, * oneOf: list, * description?: string, * default?: string, @@ -74,7 +74,7 @@ final class TitledEnumSchemaDefinition extends AbstractSchemaDefinition } return new self( - title: $data['title'], + title: $data['title'] ?? null, oneOf: $data['oneOf'], description: $data['description'] ?? null, default: $data['default'] ?? null, diff --git a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php index baab9b0..ee49a05 100644 --- a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php @@ -23,7 +23,7 @@ use Mcp\Exception\InvalidArgumentException; final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param list $anyOf Array of const/title pairs * @param string|null $description Optional description/help text * @param string[]|null $default Optional default selected values (must be subset of anyOf consts) @@ -31,7 +31,7 @@ final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefiniti * @param int|null $maxItems Optional maximum number of selections */ public function __construct( - string $title, + ?string $title, public readonly array $anyOf, ?string $description = null, public readonly ?array $default = null, @@ -78,7 +78,7 @@ final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefiniti /** * @param array{ - * title: string, + * title?: string, * items: array{anyOf: list}, * description?: string, * default?: string[], @@ -95,7 +95,7 @@ final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefiniti } return new self( - title: $data['title'], + title: $data['title'] ?? null, anyOf: $data['items']['anyOf'], description: $data['description'] ?? null, default: $data['default'] ?? null, diff --git a/src/Schema/Enum/CacheScope.php b/src/Schema/Enum/CacheScope.php new file mode 100644 index 0000000..2c71cf8 --- /dev/null +++ b/src/Schema/Enum/CacheScope.php @@ -0,0 +1,30 @@ + + */ +enum CacheScope: string +{ + /** Contains no caller-specific data; any cache may share it. */ + case Public = 'public'; + + /** Reusable only within the same authorization context. */ + case Private = 'private'; +} diff --git a/src/Schema/Enum/ElicitationMode.php b/src/Schema/Enum/ElicitationMode.php new file mode 100644 index 0000000..adb0493 --- /dev/null +++ b/src/Schema/Enum/ElicitationMode.php @@ -0,0 +1,31 @@ + + */ +enum ElicitationMode: string +{ + /** + * Present a form built from the requested schema, and return the filled values. + */ + case Form = 'form'; + + /** + * Send the user to a URL to complete the interaction out of band. The result + * carries no content — only whether the user accepted, declined, or cancelled. + */ + case Url = 'url'; +} diff --git a/src/Schema/Enum/IconTheme.php b/src/Schema/Enum/IconTheme.php new file mode 100644 index 0000000..abfac9e --- /dev/null +++ b/src/Schema/Enum/IconTheme.php @@ -0,0 +1,25 @@ + + */ +enum IconTheme: string +{ + case Light = 'light'; + case Dark = 'dark'; +} diff --git a/src/Schema/Enum/LoggingLevel.php b/src/Schema/Enum/LoggingLevel.php index e9aecef..d6031f9 100644 --- a/src/Schema/Enum/LoggingLevel.php +++ b/src/Schema/Enum/LoggingLevel.php @@ -18,6 +18,9 @@ namespace Mcp\Schema\Enum; * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ enum LoggingLevel: string { @@ -29,4 +32,31 @@ enum LoggingLevel: string case Critical = 'critical'; case Alert = 'alert'; case Emergency = 'emergency'; + + /** + * RFC 5424 ordering, inverted so a larger number is more severe — which is + * the direction a minimum-level comparison reads in. + */ + public function severity(): int + { + return match ($this) { + self::Debug => 0, + self::Info => 1, + self::Notice => 2, + self::Warning => 3, + self::Error => 4, + self::Critical => 5, + self::Alert => 6, + self::Emergency => 7, + }; + } + + /** + * Whether a message at this level should be emitted when $minimum was + * requested. + */ + public function isAtLeast(self $minimum): bool + { + return $this->severity() >= $minimum->severity(); + } } diff --git a/src/Schema/Enum/ProtocolVersion.php b/src/Schema/Enum/ProtocolVersion.php index b580e70..03ebddf 100644 --- a/src/Schema/Enum/ProtocolVersion.php +++ b/src/Schema/Enum/ProtocolVersion.php @@ -11,15 +11,144 @@ namespace Mcp\Schema\Enum; +use Mcp\Exception\LogicException; + /** - * Available protocol versions for MCP. + * Registry of the MCP protocol revisions this SDK knows about. + * + * Cases are declared oldest to newest, and that declaration order — not the + * lexicographic order of the values — is what every comparison here relies on: + * revision identifiers are an enumerated set, not an ordered scalar, and a + * future one is not guaranteed to be date-shaped. + * + * The revisions split into two eras: + * + * - **handshake** (`2025-11-25` and earlier) negotiate a version through the + * `initialize` round-trip and keep session state for the connection; + * - **modern** ({@see self::FIRST_MODERN_VERSION} and later) drop `initialize` + * entirely — every request carries its own version in `_meta`, and servers + * advertise what they speak through `server/discover`. + * + * @see https://modelcontextprotocol.io/specification/draft/basic/versioning * * @author Illia Vasylevskyi */ enum ProtocolVersion: string { + // Declaration order is also the era boundary: everything declared from + // FIRST_MODERN_VERSION onwards is modern. A new handshake-era revision has + // to be inserted above that case — appending it here would silently drop it + // out of the handshake negotiation. case V2024_11_05 = '2024-11-05'; case V2025_03_26 = '2025-03-26'; case V2025_06_18 = '2025-06-18'; case V2025_11_25 = '2025-11-25'; + case V2026_07_28 = '2026-07-28'; + + /** + * First revision of the modern era, in which the `initialize` handshake was + * replaced by per-request metadata. + */ + public const FIRST_MODERN_VERSION = self::V2026_07_28; + + /** + * Version a server assumes when a client omits the `MCP-Protocol-Version` + * header on the Streamable HTTP transport. + * + * This is the revision that introduced both Streamable HTTP and the header + * itself, so a request without the header cannot be newer than this. + */ + public const DEFAULT_HEADER_VERSION = self::V2025_03_26; + + /** + * Newest revision reachable through the `initialize` handshake. + * + * This is what a server counter-offers when it cannot honour the version a + * client asked for, and what a handshake-era client offers by default. + */ + public static function latestHandshake(): self + { + $versions = self::handshakeVersions(); + + return $versions[\count($versions) - 1]; + } + + /** + * Revisions reachable through the `initialize` handshake, oldest to newest. + * + * @return non-empty-list + */ + public static function handshakeVersions(): array + { + return array_values(array_filter(self::cases(), static fn (self $v): bool => !$v->isModern())); + } + + /** + * Revisions using the per-request metadata envelope, oldest to newest. + * + * @return non-empty-list + */ + public static function modernVersions(): array + { + return array_values(array_filter(self::cases(), static fn (self $v): bool => $v->isModern())); + } + + /** + * Whether this revision belongs to the modern, per-request-metadata era. + */ + public function isModern(): bool + { + return $this->isAtLeast(self::FIRST_MODERN_VERSION); + } + + /** + * Whether this revision restricts `structuredContent` to a JSON object. + * + * SEP-2106, part of {@see self::V2026_07_28}, widened `outputSchema` to any + * JSON Schema 2020-12 and `structuredContent` to any JSON value conforming to + * it. Up to `2025-11-25` both are restricted to an object. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content + */ + public function requiresObjectStructuredContent(): bool + { + return !$this->isAtLeast(self::V2026_07_28); + } + + /** + * Whether this revision answers a missing resource with `-32602`. + * + * SEP-2164, part of {@see self::V2026_07_28}, retired the bespoke `-32002` + * in favour of the JSON-RPC code that already meant this, and reserved + * `-32002` so it is never reused. Earlier revisions still expect it, and + * clients are told to keep accepting it from them. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes + */ + public function usesInvalidParamsForResourceNotFound(): bool + { + return $this->isAtLeast(self::V2026_07_28); + } + + /** + * Whether this revision is at least as new as $minimum. + */ + public function isAtLeast(self $minimum): bool + { + return $this->position() >= $minimum->position(); + } + + /** + * Index of this revision in the chronological declaration order. + */ + private function position(): int + { + foreach (self::cases() as $index => $case) { + if ($case === $this) { + return $index; + } + } + + throw new LogicException(\sprintf('Protocol version "%s" is not a declared case.', $this->value)); + } } diff --git a/src/Schema/Enum/ResultType.php b/src/Schema/Enum/ResultType.php new file mode 100644 index 0000000..8319bc4 --- /dev/null +++ b/src/Schema/Enum/ResultType.php @@ -0,0 +1,30 @@ + + */ +enum ResultType: string +{ + /** The request finished; the result holds the final content. */ + case Complete = 'complete'; + + /** The request needs more input before it can finish (MRTR). */ + case InputRequired = 'input_required'; +} diff --git a/src/Schema/Enum/SamplingContext.php b/src/Schema/Enum/SamplingContext.php index 4c1f185..8ffe6ec 100644 --- a/src/Schema/Enum/SamplingContext.php +++ b/src/Schema/Enum/SamplingContext.php @@ -11,6 +11,15 @@ namespace Mcp\Schema\Enum; +/** + * Which servers' context a sampling request asks the client to attach. + * + * `thisServer` and `allServers` are themselves deprecated since 2025-11-25 + * (SEP-2596): omit the field or use `none`. + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. + */ enum SamplingContext: string { case NONE = 'none'; diff --git a/src/Schema/Enum/ToolChoiceMode.php b/src/Schema/Enum/ToolChoiceMode.php new file mode 100644 index 0000000..46a3332 --- /dev/null +++ b/src/Schema/Enum/ToolChoiceMode.php @@ -0,0 +1,25 @@ + + */ +abstract class AbstractExtension implements ExtensionInterface +{ + public function getMessages(): array + { + return []; + } + + public function getRequestHandlers(): iterable + { + return []; + } +} diff --git a/src/Schema/Extension/Apps/McpApps.php b/src/Schema/Extension/Apps/McpApps.php index bd702c8..dcf3c9e 100644 --- a/src/Schema/Extension/Apps/McpApps.php +++ b/src/Schema/Extension/Apps/McpApps.php @@ -11,7 +11,8 @@ namespace Mcp\Schema\Extension\Apps; -use Mcp\Schema\Extension\ServerExtensionInterface; +use Mcp\Schema\Extension\AbstractExtension; +use Mcp\Schema\Extension\ExtensionIdentifier; /** * The MCP Apps extension (io.modelcontextprotocol/ui). @@ -19,21 +20,22 @@ use Mcp\Schema\Extension\ServerExtensionInterface; * MCP Apps allows servers to expose interactive HTML UI applications as resources. * Clients that support the extension render these in sandboxed iframes. * - * Enable on the server via {@see \Mcp\Server\Builder::enableExtension()}. + * Enable on the server via {@see \Mcp\Server\Builder::enableExtension()} and on the + * client (host) via {@see \Mcp\Client\Builder::enableExtension()}. * * @see https://github.com/modelcontextprotocol/ext-apps * * @author Christopher Hertel */ -final class McpApps implements ServerExtensionInterface +final class McpApps extends AbstractExtension { public const EXTENSION_ID = 'io.modelcontextprotocol/ui'; public const MIME_TYPE = 'text/html;profile=mcp-app'; public const URI_SCHEME = 'ui'; - public function getId(): string + public function getId(): ExtensionIdentifier { - return self::EXTENSION_ID; + return new ExtensionIdentifier(self::EXTENSION_ID); } /** diff --git a/src/Schema/Extension/Apps/UiResourceContentMeta.php b/src/Schema/Extension/Apps/UiResourceContentMeta.php index e507d80..666a7e9 100644 --- a/src/Schema/Extension/Apps/UiResourceContentMeta.php +++ b/src/Schema/Extension/Apps/UiResourceContentMeta.php @@ -11,6 +11,8 @@ namespace Mcp\Schema\Extension\Apps; +use Mcp\Exception\InvalidArgumentException; + /** * Metadata for the _meta.ui field on resource content in a resources/read response. * @@ -43,6 +45,19 @@ final class UiResourceContentMeta implements \JsonSerializable */ public static function fromArray(array $data): self { + if (isset($data['csp']) && !\is_array($data['csp'])) { + throw new InvalidArgumentException('Invalid "csp" in UiResourceContentMeta data; expected an array.'); + } + if (isset($data['permissions']) && !\is_array($data['permissions'])) { + throw new InvalidArgumentException('Invalid "permissions" in UiResourceContentMeta data; expected an array.'); + } + if (isset($data['domain']) && !\is_string($data['domain'])) { + throw new InvalidArgumentException('Invalid "domain" in UiResourceContentMeta data.'); + } + if (isset($data['prefersBorder']) && !\is_bool($data['prefersBorder'])) { + throw new InvalidArgumentException('Invalid "prefersBorder" in UiResourceContentMeta data.'); + } + return new self( csp: isset($data['csp']) ? UiResourceCsp::fromArray($data['csp']) : null, permissions: isset($data['permissions']) ? UiResourcePermissions::fromArray($data['permissions']) : null, diff --git a/src/Schema/Extension/Apps/UiResourceCsp.php b/src/Schema/Extension/Apps/UiResourceCsp.php index d487d72..e3550a6 100644 --- a/src/Schema/Extension/Apps/UiResourceCsp.php +++ b/src/Schema/Extension/Apps/UiResourceCsp.php @@ -11,6 +11,8 @@ namespace Mcp\Schema\Extension\Apps; +use Mcp\Exception\InvalidArgumentException; + /** * Content Security Policy configuration for MCP App resources. * @@ -47,6 +49,12 @@ final class UiResourceCsp implements \JsonSerializable */ public static function fromArray(array $data): self { + foreach (['connectDomains', 'resourceDomains', 'frameDomains', 'baseUriDomains'] as $key) { + if (isset($data[$key]) && !\is_array($data[$key])) { + throw new InvalidArgumentException(\sprintf('Invalid "%s" in UiResourceCsp data; expected an array.', $key)); + } + } + return new self( connectDomains: $data['connectDomains'] ?? null, resourceDomains: $data['resourceDomains'] ?? null, diff --git a/src/Schema/Extension/Apps/UiToolMeta.php b/src/Schema/Extension/Apps/UiToolMeta.php index ecefa19..235df2a 100644 --- a/src/Schema/Extension/Apps/UiToolMeta.php +++ b/src/Schema/Extension/Apps/UiToolMeta.php @@ -11,6 +11,8 @@ namespace Mcp\Schema\Extension\Apps; +use Mcp\Exception\InvalidArgumentException; + /** * Metadata for the _meta.ui field on a Tool, linking it to a UI resource. * @@ -39,9 +41,25 @@ final class UiToolMeta implements \JsonSerializable */ public static function fromArray(array $data): self { + if (isset($data['resourceUri']) && !\is_string($data['resourceUri'])) { + throw new InvalidArgumentException('Invalid "resourceUri" in UiToolMeta data.'); + } + if (isset($data['visibility']) && !\is_array($data['visibility'])) { + throw new InvalidArgumentException('Invalid "visibility" in UiToolMeta data; expected an array.'); + } + return new self( resourceUri: $data['resourceUri'] ?? null, - visibility: isset($data['visibility']) ? array_map(ToolVisibility::from(...), $data['visibility']) : null, + visibility: isset($data['visibility']) ? array_map( + static function (mixed $entry): ToolVisibility { + if (!\is_string($entry) || null === $case = ToolVisibility::tryFrom($entry)) { + throw new InvalidArgumentException('Each entry in "visibility" of UiToolMeta data must be a valid tool visibility.'); + } + + return $case; + }, + $data['visibility'], + ) : null, ); } diff --git a/src/Schema/Extension/ExtensionIdentifier.php b/src/Schema/Extension/ExtensionIdentifier.php new file mode 100644 index 0000000..2d714d6 --- /dev/null +++ b/src/Schema/Extension/ExtensionIdentifier.php @@ -0,0 +1,98 @@ + + */ +final class ExtensionIdentifier implements \Stringable +{ + /** Second labels only the specification may use. */ + public const RESERVED_LABELS = ['modelcontextprotocol', 'mcp']; + + /** Prefixes the specification itself allocates. */ + public const OFFICIAL_PREFIX = 'io.modelcontextprotocol/'; + + /** A label: starts with a letter, ends alphanumeric, hyphens inside. */ + private const LABEL = '[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?'; + + /** A name: alphanumeric at both ends, `-`, `_` and `.` inside. */ + private const NAME = '[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?'; + + /** + * @throws InvalidArgumentException if $identifier is not a valid `_meta` prefix + */ + public function __construct( + private readonly string $identifier, + ) { + if (null !== $reason = self::check($identifier)) { + throw new InvalidArgumentException($reason); + } + } + + /** + * Whether this identifier claims a prefix the specification reserves. + * + * Not an error on its own — the official extensions legitimately use it — + * but a third party doing so is misrepresenting itself, so callers that are + * not the SDK should refuse. + */ + public function isReserved(): bool + { + $labels = explode('.', strstr($this->identifier, '/', true) ?: ''); + + return \in_array($labels[1] ?? '', self::RESERVED_LABELS, true); + } + + public function __toString(): string + { + return $this->identifier; + } + + /** + * @return string|null the reason $identifier is invalid, or null when it is well-formed + */ + private static function check(string $identifier): ?string + { + $slash = strpos($identifier, '/'); + + if (false === $slash) { + return \sprintf('"%s" has no prefix; an extension identifier must be prefixed, e.g. "com.example/my-extension".', $identifier); + } + + $prefix = substr($identifier, 0, $slash); + $name = substr($identifier, $slash + 1); + + if (1 !== preg_match('/^'.self::LABEL.'(?:\.'.self::LABEL.')*$/', $prefix)) { + return \sprintf('"%s" is not a valid prefix: labels must start with a letter, end alphanumeric, and be separated by dots.', $prefix); + } + + if ('' === $name || 1 !== preg_match('/^'.self::NAME.'$/', $name)) { + return \sprintf('"%s" is not a valid extension name: it must start and end alphanumeric.', $name); + } + + return null; + } +} diff --git a/src/Schema/Extension/ExtensionInterface.php b/src/Schema/Extension/ExtensionInterface.php new file mode 100644 index 0000000..a663525 --- /dev/null +++ b/src/Schema/Extension/ExtensionInterface.php @@ -0,0 +1,70 @@ +]`. The same + * extension object can be enabled on a server (initialize response) and on a client + * (initialize request); the side that enables it decides which. + * + * An extension that only announces a capability, without adding RPC methods of its + * own, can extend {@see AbstractExtension} and skip {@see self::getMessages()} and + * {@see self::getRequestHandlers()} entirely. + * + * @author Christopher Hertel + */ +interface ExtensionInterface +{ + /** + * The reverse-DNS identifier used as the key under `capabilities.extensions`. + */ + public function getId(): ExtensionIdentifier; + + /** + * The capability payload announced for this extension. + * + * The returned array is cast to an object and embedded under + * `capabilities.extensions[]`, so every value must be JSON-serializable + * (scalars, arrays, or `JsonSerializable` objects). + * + * @return array + */ + public function getCapabilities(): array; + + /** + * Every message class this extension defines. + * + * These are registered with the {@see \Mcp\JsonRpc\MessageFactory}, without + * which an extension's method cannot be decoded off the wire at all, and + * their method names are what let a server distinguish an extension it does + * not serve from a method that does not exist. An extension with no methods + * of its own returns an empty array. + * + * @return list|class-string> + */ + public function getMessages(): array; + + /** + * The handlers serving those methods. + * + * @return iterable> + */ + public function getRequestHandlers(): iterable; +} diff --git a/src/Schema/Extension/ServerExtensionInterface.php b/src/Schema/Extension/ServerExtensionInterface.php deleted file mode 100644 index 8793a96..0000000 --- a/src/Schema/Extension/ServerExtensionInterface.php +++ /dev/null @@ -1,40 +0,0 @@ -]` in the initialize - * response. - * - * @author Christopher Hertel - */ -interface ServerExtensionInterface -{ - /** - * The reverse-DNS identifier used as the key under `capabilities.extensions`. - */ - public function getId(): string; - - /** - * The capability payload announced for this extension. - * - * The returned array is cast to an object and embedded under - * `capabilities.extensions[]` in the initialize response, so every value - * must be JSON-serializable (scalars, arrays, or `JsonSerializable` objects). - * - * @return array - */ - public function getCapabilities(): array; -} diff --git a/src/Schema/Icon.php b/src/Schema/Icon.php index b3cdd72..2751343 100644 --- a/src/Schema/Icon.php +++ b/src/Schema/Icon.php @@ -12,6 +12,7 @@ namespace Mcp\Schema; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\IconTheme; /** * A url pointing to an icon URL or a base64-encoded data URI. @@ -20,6 +21,7 @@ use Mcp\Exception\InvalidArgumentException; * src: string, * mimeType?: string, * sizes?: string[], + * theme?: string, * } * * @author Christopher Hertel @@ -27,16 +29,19 @@ use Mcp\Exception\InvalidArgumentException; class Icon implements \JsonSerializable { /** - * @param string $src a standard URI pointing to an icon resource - * @param ?string $mimeType optional override if the server's MIME type is missing or generic - * @param ?string[] $sizes optional array of strings that specify sizes at which the icon can be used. - * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for - * scalable formats like SVG. + * @param string $src a standard URI pointing to an icon resource + * @param ?string $mimeType optional override if the server's MIME type is missing or generic + * @param ?string[] $sizes optional array of strings that specify sizes at which the icon can be used. + * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for + * scalable formats like SVG. + * @param ?IconTheme $theme Optional background this icon is designed for. When omitted, the icon is + * assumed to work against any background. */ public function __construct( public readonly string $src, public readonly ?string $mimeType = null, public readonly ?array $sizes = null, + public readonly ?IconTheme $theme = null, ) { if (empty($src)) { throw new InvalidArgumentException('Icon "src" must be a non-empty string.'); @@ -65,8 +70,43 @@ class Icon implements \JsonSerializable if (empty($data['src']) || !\is_string($data['src'])) { throw new InvalidArgumentException('Invalid or missing "src" in Icon data.'); } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" in Icon data.'); + } + if (isset($data['sizes']) && !\is_array($data['sizes'])) { + throw new InvalidArgumentException('Invalid "sizes" in Icon data.'); + } - return new self($data['src'], $data['mimeTypes'] ?? null, $data['sizes'] ?? null); + $theme = null; + if (isset($data['theme'])) { + if (!\is_string($data['theme']) || null === $theme = IconTheme::tryFrom($data['theme'])) { + throw new InvalidArgumentException('Invalid "theme" in Icon data.'); + } + } + + return new self($data['src'], $data['mimeType'] ?? null, $data['sizes'] ?? null, $theme); + } + + /** + * Hydrates an "icons" list, rejecting entries that are not objects. + * + * @param array $icons + * @param string $context the surrounding schema type, used for the error message + * + * @return self[] + */ + public static function listFromArray(array $icons, string $context): array + { + return array_map( + static function (mixed $icon) use ($context): self { + if (!\is_array($icon)) { + throw new InvalidArgumentException(\sprintf('Each entry in "icons" of %s data must be an array.', $context)); + } + + return self::fromArray($icon); + }, + $icons, + ); } /** @@ -86,6 +126,10 @@ class Icon implements \JsonSerializable $data['sizes'] = $this->sizes; } + if (null !== $this->theme) { + $data['theme'] = $this->theme->value; + } + return $data; } } diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php index 0216f05..b48be54 100644 --- a/src/Schema/Implementation.php +++ b/src/Schema/Implementation.php @@ -24,6 +24,7 @@ class Implementation implements \JsonSerializable { /** * @param ?Icon[] $icons + * @param ?string $title Display name for UI and end-user contexts. Falls back to $name when absent. */ public function __construct( public readonly string $name = 'app', @@ -31,6 +32,7 @@ class Implementation implements \JsonSerializable public readonly ?string $description = null, public readonly ?array $icons = null, public readonly ?string $websiteUrl = null, + public readonly ?string $title = null, ) { } @@ -41,6 +43,7 @@ class Implementation implements \JsonSerializable * description?: string, * icons?: IconData[], * websiteUrl?: string, + * title?: string, * } $data */ public static function fromArray(array $data): self @@ -57,7 +60,17 @@ class Implementation implements \JsonSerializable throw new InvalidArgumentException('Invalid "icons" in Implementation data; expected an array.'); } - $data['icons'] = array_map(Icon::fromArray(...), $data['icons']); + $data['icons'] = Icon::listFromArray($data['icons'], 'Implementation'); + } + + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in Implementation data.'); + } + if (isset($data['websiteUrl']) && !\is_string($data['websiteUrl'])) { + throw new InvalidArgumentException('Invalid "websiteUrl" in Implementation data.'); + } + if (isset($data['title']) && !\is_string($data['title'])) { + throw new InvalidArgumentException('Invalid "title" in Implementation data.'); } return new self( @@ -66,6 +79,7 @@ class Implementation implements \JsonSerializable $data['description'] ?? null, $data['icons'] ?? null, $data['websiteUrl'] ?? null, + $data['title'] ?? null, ); } @@ -76,6 +90,7 @@ class Implementation implements \JsonSerializable * description?: string, * icons?: Icon[], * websiteUrl?: string, + * title?: string, * } */ public function jsonSerialize(): array @@ -97,6 +112,10 @@ class Implementation implements \JsonSerializable $data['websiteUrl'] = $this->websiteUrl; } + if (null !== $this->title) { + $data['title'] = $this->title; + } + return $data; } } diff --git a/src/Schema/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php index 532e406..7749fa8 100644 --- a/src/Schema/JsonRpc/Error.php +++ b/src/Schema/JsonRpc/Error.php @@ -12,13 +12,15 @@ namespace Mcp\Schema\JsonRpc; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ProtocolVersion; /** * A response to a request that indicates an error occurred. * * @phpstan-type ErrorData array{ * jsonrpc: string, - * id: string|int, + * id: string|int|null, * code: int, * message: string, * data?: mixed, @@ -37,12 +39,33 @@ class Error implements MessageInterface public const RESOURCE_NOT_FOUND = -32002; /** - * @param int $code the error type that occurred - * @param string $message a short description of the error - * @param mixed|null $data additional information about the error + * Values in the HTTP headers contradict the request body, or a required + * header is missing or malformed. Answered with `400 Bad Request`. + */ + public const HEADER_MISMATCH = -32020; + + /** + * Handling the request needs a client capability the client never declared. + * Answered with `400 Bad Request`. + */ + public const MISSING_REQUIRED_CLIENT_CAPABILITY = -32021; + + /** + * The request's protocol version is unknown to this server, or is a version + * it has chosen not to implement. Answered with `400 Bad Request`. + */ + public const UNSUPPORTED_PROTOCOL_VERSION = -32022; + + /** + * @param string|int|null $id The id of the request this answers. `null` only when it could not be + * read — a malformed body, or a notification that was refused — in which + * case the member is omitted rather than sent as an id nobody issued. + * @param int $code the error type that occurred + * @param string $message a short description of the error + * @param mixed|null $data additional information about the error */ public function __construct( - public readonly string|int $id, + public readonly string|int|null $id, public readonly int $code, public readonly string $message, public readonly mixed $data = null, @@ -57,10 +80,9 @@ class Error implements MessageInterface if (!isset($data['jsonrpc']) || MessageInterface::JSONRPC_VERSION !== $data['jsonrpc']) { throw new InvalidArgumentException('Invalid or missing "jsonrpc" in Error data.'); } - if (!isset($data['id'])) { - throw new InvalidArgumentException('Invalid or missing "id" in Error data.'); - } - if (!\is_string($data['id']) && !\is_int($data['id'])) { + // An error response carrying no id is well-formed: it is what a + // receiver sends when the id could not be read off the request. + if (isset($data['id']) && !\is_string($data['id']) && !\is_int($data['id'])) { throw new InvalidArgumentException('Invalid "id" type in Error data.'); } if (!isset($data['error']) || !\is_array($data['error'])) { @@ -73,45 +95,81 @@ class Error implements MessageInterface throw new InvalidArgumentException('Invalid or missing "message" in Error data.'); } - return new self($data['id'], $data['error']['code'], $data['error']['message'], $data['error']['data'] ?? null); + return new self($data['id'] ?? null, $data['error']['code'], $data['error']['message'], $data['error']['data'] ?? null); } - final public static function forParseError(string $message, string|int $id = ''): self + final public static function forParseError(string $message, string|int|null $id = null): self { return new self($id, self::PARSE_ERROR, $message); } - final public static function forInvalidRequest(string $message, string|int $id = ''): self + final public static function forInvalidRequest(string $message, string|int|null $id = null): self { return new self($id, self::INVALID_REQUEST, $message); } - final public static function forMethodNotFound(string $message, string|int $id = ''): self + final public static function forMethodNotFound(string $message, string|int|null $id = null): self { return new self($id, self::METHOD_NOT_FOUND, $message); } - final public static function forInvalidParams(string $message, string|int $id = '', mixed $data = null): self + final public static function forInvalidParams(string $message, string|int|null $id = null, mixed $data = null): self { return new self($id, self::INVALID_PARAMS, $message, $data); } - final public static function forInternalError(string $message, string|int $id = ''): self + final public static function forInternalError(string $message, string|int|null $id = null): self { return new self($id, self::INTERNAL_ERROR, $message); } - final public static function forServerError(string $message, string|int $id = ''): self + final public static function forServerError(string $message, string|int|null $id = null): self { return new self($id, self::SERVER_ERROR, $message); } - final public static function forResourceNotFound(string $message, string|int $id = ''): self + final public static function forResourceNotFound(string $message, string|int|null $id = null): self { return new self($id, self::RESOURCE_NOT_FOUND, $message); } - public function getId(): string|int + final public static function forHeaderMismatch(string $message, string|int|null $id = null): self + { + return new self($id, self::HEADER_MISMATCH, $message); + } + + /** + * @param ClientCapabilities $requiredCapabilities the capabilities the server needs to process the request + */ + final public static function forMissingRequiredClientCapability( + string $message, + ClientCapabilities $requiredCapabilities, + string|int|null $id = null, + ): self { + return new self($id, self::MISSING_REQUIRED_CLIENT_CAPABILITY, $message, [ + 'requiredCapabilities' => $requiredCapabilities, + ]); + } + + /** + * The client is expected to pick a mutually supported version out of + * $supported and retry, so the list travels with the error rather than + * only in the message. + * + * @param list $supported versions this server does support + */ + final public static function forUnsupportedProtocolVersion( + string $requested, + array $supported, + string|int|null $id = null, + ): self { + return new self($id, self::UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', [ + 'requested' => $requested, + 'supported' => array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $supported)), + ]); + } + + public function getId(): string|int|null { return $this->id; } @@ -119,12 +177,12 @@ class Error implements MessageInterface /** * @return array{ * jsonrpc: string, - * id: string|int, + * id?: string|int, * error: array{ * code: int, * message: string, + * data?: mixed, * }, - * data?: mixed, * } */ public function jsonSerialize(): array @@ -138,10 +196,17 @@ class Error implements MessageInterface $error['data'] = $this->data; } - return [ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => $this->id, - 'error' => $error, - ]; + $data = ['jsonrpc' => MessageInterface::JSONRPC_VERSION]; + + // Omitted, not empty: `"id": ""` claims the sender issued a request + // with an empty-string id, which is a different statement from "the + // id could not be read". + if (null !== $this->id) { + $data['id'] = $this->id; + } + + $data['error'] = $error; + + return $data; } } diff --git a/src/Schema/JsonRpc/Notification.php b/src/Schema/JsonRpc/Notification.php index bfd37f1..3ec0883 100644 --- a/src/Schema/JsonRpc/Notification.php +++ b/src/Schema/JsonRpc/Notification.php @@ -74,7 +74,7 @@ abstract class Notification implements HasMethodInterface, MessageInterface $array['params'] = $params; } - if (null !== $this->meta && !isset($params['meta'])) { + if (null !== $this->meta && !isset($params['_meta'])) { $array['params']['_meta'] = $this->meta; } diff --git a/src/Schema/JsonRpc/Request.php b/src/Schema/JsonRpc/Request.php index cb8ed83..afdf9e9 100644 --- a/src/Schema/JsonRpc/Request.php +++ b/src/Schema/JsonRpc/Request.php @@ -122,7 +122,7 @@ abstract class Request implements HasMethodInterface, MessageInterface $array['params'] = $params; } - if (null !== $this->meta && !isset($params['meta'])) { + if (null !== $this->meta && !isset($params['_meta'])) { $array['params']['_meta'] = $this->meta; } diff --git a/src/Schema/JsonRpc/Response.php b/src/Schema/JsonRpc/Response.php index 7f2d82b..4c64881 100644 --- a/src/Schema/JsonRpc/Response.php +++ b/src/Schema/JsonRpc/Response.php @@ -14,7 +14,11 @@ namespace Mcp\Schema\JsonRpc; use Mcp\Exception\InvalidArgumentException; /** - * @template TResult + * Covariant because a Response only hands its result out, never consumes it, + * so a handler can declare the union of results it may answer with while each + * return path constructs one concrete type. + * + * @template-covariant TResult * * @phpstan-type ResponseData array{ * jsonrpc: string, diff --git a/src/Schema/ModelHint.php b/src/Schema/ModelHint.php index 0ad04cc..5bdd88b 100644 --- a/src/Schema/ModelHint.php +++ b/src/Schema/ModelHint.php @@ -17,6 +17,9 @@ namespace Mcp\Schema; * Keys not declared here are currently left unspecified by the spec and are up to the client to interpret. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ class ModelHint implements \JsonSerializable { diff --git a/src/Schema/ModelPreferences.php b/src/Schema/ModelPreferences.php index f2f30db..e668429 100644 --- a/src/Schema/ModelPreferences.php +++ b/src/Schema/ModelPreferences.php @@ -11,6 +11,8 @@ namespace Mcp\Schema; +use Mcp\Exception\InvalidArgumentException; + /** * The server's preferences for model selection, requested of the client during sampling. * @@ -32,6 +34,9 @@ namespace Mcp\Schema; * } * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ class ModelPreferences implements \JsonSerializable { @@ -61,14 +66,35 @@ class ModelPreferences implements \JsonSerializable */ public static function fromArray(array $preferences): self { + if (isset($preferences['hints']) && !\is_array($preferences['hints'])) { + throw new InvalidArgumentException('Invalid "hints" in ModelPreferences data.'); + } + return new self( $preferences['hints'] ?? null, - $preferences['costPriority'] ?? null, - $preferences['speedPriority'] ?? null, - $preferences['intelligencePriority'] ?? null, + self::priority($preferences, 'costPriority'), + self::priority($preferences, 'speedPriority'), + self::priority($preferences, 'intelligencePriority'), ); } + /** + * @param array $preferences + */ + private static function priority(array $preferences, string $key): ?float + { + if (!isset($preferences[$key])) { + return null; + } + + // JSON numbers decode to int when they have no fractional part. + if (!\is_float($preferences[$key]) && !\is_int($preferences[$key])) { + throw new InvalidArgumentException(\sprintf('Invalid "%s" in ModelPreferences data; expected a number.', $key)); + } + + return (float) $preferences[$key]; + } + /** * @return ModelPreferencesData */ diff --git a/src/Schema/Notification/CancelledNotification.php b/src/Schema/Notification/CancelledNotification.php index 14d4e59..4ae3257 100644 --- a/src/Schema/Notification/CancelledNotification.php +++ b/src/Schema/Notification/CancelledNotification.php @@ -48,6 +48,10 @@ class CancelledNotification extends Notification throw new InvalidArgumentException('Invalid or missing "requestId" parameter for "notifications/cancelled" notification.'); } + if (isset($params['reason']) && !\is_string($params['reason'])) { + throw new InvalidArgumentException('Invalid "reason" parameter for "notifications/cancelled" notification.'); + } + return new self($params['requestId'], $params['reason'] ?? null); } diff --git a/src/Schema/Notification/LoggingMessageNotification.php b/src/Schema/Notification/LoggingMessageNotification.php index 29e00fc..17cbd70 100644 --- a/src/Schema/Notification/LoggingMessageNotification.php +++ b/src/Schema/Notification/LoggingMessageNotification.php @@ -17,6 +17,9 @@ use Mcp\Schema\JsonRpc\Notification; /** * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ class LoggingMessageNotification extends Notification { @@ -47,7 +50,14 @@ class LoggingMessageNotification extends Notification throw new InvalidArgumentException('Missing "data" parameter for "notifications/message" notification.'); } - $level = LoggingLevel::from($params['level']); + if (null === $level = LoggingLevel::tryFrom($params['level'])) { + throw new InvalidArgumentException(\sprintf('Invalid "level" parameter "%s" for "notifications/message" notification.', $params['level'])); + } + + if (isset($params['logger']) && !\is_string($params['logger'])) { + throw new InvalidArgumentException('Invalid "logger" parameter for "notifications/message" notification.'); + } + $data = \is_string($params['data']) ? $params['data'] : json_encode($params['data']); return new self($level, $data, $params['logger'] ?? null); diff --git a/src/Schema/Notification/ProgressNotification.php b/src/Schema/Notification/ProgressNotification.php index d3365a9..15fc50a 100644 --- a/src/Schema/Notification/ProgressNotification.php +++ b/src/Schema/Notification/ProgressNotification.php @@ -44,18 +44,27 @@ class ProgressNotification extends Notification protected static function fromParams(?array $params): Notification { - if (!isset($params['progressToken']) || !\is_string($params['progressToken'])) { + // JSON numbers decode to int when they have no fractional part. + if (!isset($params['progressToken']) || !\is_string($params['progressToken']) && !\is_int($params['progressToken'])) { throw new InvalidArgumentException('Missing or invalid "progressToken" parameter for "notifications/progress" notification.'); } - if (!isset($params['progress']) || !\is_float($params['progress'])) { + if (!isset($params['progress']) || !\is_float($params['progress']) && !\is_int($params['progress'])) { throw new InvalidArgumentException('Missing or invalid "progress" parameter for "notifications/progress" notification.'); } + if (isset($params['total']) && !\is_float($params['total']) && !\is_int($params['total'])) { + throw new InvalidArgumentException('Invalid "total" parameter for "notifications/progress" notification.'); + } + + if (isset($params['message']) && !\is_string($params['message'])) { + throw new InvalidArgumentException('Invalid "message" parameter for "notifications/progress" notification.'); + } + return new self( $params['progressToken'], - $params['progress'], - $params['total'] ?? null, + (float) $params['progress'], + isset($params['total']) ? (float) $params['total'] : null, $params['message'] ?? null, ); } diff --git a/src/Schema/Notification/RootsListChangedNotification.php b/src/Schema/Notification/RootsListChangedNotification.php index 3c95829..89a0e47 100644 --- a/src/Schema/Notification/RootsListChangedNotification.php +++ b/src/Schema/Notification/RootsListChangedNotification.php @@ -19,6 +19,10 @@ use Mcp\Schema\JsonRpc\Notification; * The server should then request an updated list of roots using the ListRootsRequest. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource + * URIs or server configuration instead. */ class RootsListChangedNotification extends Notification { diff --git a/src/Schema/Prompt.php b/src/Schema/Prompt.php index 68ae773..1f33e41 100644 --- a/src/Schema/Prompt.php +++ b/src/Schema/Prompt.php @@ -67,19 +67,34 @@ class Prompt implements \JsonSerializable } $arguments = null; if (isset($data['arguments']) && \is_array($data['arguments'])) { - $arguments = array_map(static fn (array $argData) => PromptArgument::fromArray($argData), $data['arguments']); + $arguments = array_map( + static function (mixed $argData): PromptArgument { + if (!\is_array($argData)) { + throw new InvalidArgumentException('Each entry in "arguments" of Prompt data must be an array.'); + } + + return PromptArgument::fromArray($argData); + }, + $data['arguments'], + ); } - if (!empty($data['_meta']) && !\is_array($data['_meta'])) { + if (isset($data['_meta']) && !\is_array($data['_meta'])) { throw new InvalidArgumentException('Invalid "_meta" in Prompt data.'); } + if (isset($data['title']) && !\is_string($data['title'])) { + throw new InvalidArgumentException('Invalid "title" in Prompt data.'); + } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in Prompt data.'); + } return new self( name: $data['name'], title: $data['title'] ?? null, description: $data['description'] ?? null, arguments: $arguments, - icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'Prompt') : null, meta: isset($data['_meta']) ? $data['_meta'] : null ); } diff --git a/src/Schema/PromptArgument.php b/src/Schema/PromptArgument.php index 3ef45ca..38fe3dc 100644 --- a/src/Schema/PromptArgument.php +++ b/src/Schema/PromptArgument.php @@ -47,6 +47,13 @@ class PromptArgument implements \JsonSerializable throw new InvalidArgumentException('Invalid or missing "name" in PromptArgument data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in PromptArgument data.'); + } + if (isset($data['required']) && !\is_bool($data['required'])) { + throw new InvalidArgumentException('Invalid "required" in PromptArgument data.'); + } + return new self( name: $data['name'], description: $data['description'] ?? null, diff --git a/src/Schema/Request/CompletionCompleteRequest.php b/src/Schema/Request/CompletionCompleteRequest.php index 467bb97..7ad0332 100644 --- a/src/Schema/Request/CompletionCompleteRequest.php +++ b/src/Schema/Request/CompletionCompleteRequest.php @@ -45,8 +45,8 @@ final class CompletionCompleteRequest extends Request } $ref = match ($params['ref']['type'] ?? null) { - 'ref/prompt' => new PromptReference($params['ref']['name']), - 'ref/resource' => new ResourceReference($params['ref']['uri']), + 'ref/prompt' => new PromptReference(self::refString($params['ref'], 'name')), + 'ref/resource' => new ResourceReference(self::refString($params['ref'], 'uri')), default => throw new InvalidArgumentException('Invalid "ref" parameter for completion/complete.'), }; @@ -57,6 +57,18 @@ final class CompletionCompleteRequest extends Request return new self($ref, $params['argument']); } + /** + * @param array $ref + */ + private static function refString(array $ref, string $key): string + { + if (!isset($ref[$key]) || !\is_string($ref[$key])) { + throw new InvalidArgumentException(\sprintf('Missing or invalid "ref.%s" parameter for completion/complete.', $key)); + } + + return $ref[$key]; + } + /** * @return array{ * ref: PromptReference|ResourceReference, diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php index 3014405..12e0309 100644 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -13,9 +13,14 @@ namespace Mcp\Schema\Request; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\SamplingMessage; +use Mcp\Schema\Content\ToolResultContent; +use Mcp\Schema\Content\ToolUseContent; +use Mcp\Schema\Enum\Role; use Mcp\Schema\Enum\SamplingContext; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\ModelPreferences; +use Mcp\Schema\Tool; +use Mcp\Schema\ToolChoice; /** * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. @@ -23,6 +28,9 @@ use Mcp\Schema\ModelPreferences; * loop) and decide whether to approve it. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ final class CreateSamplingMessageRequest extends Request { @@ -37,10 +45,14 @@ final class CreateSamplingMessageRequest extends Request * @param ?SamplingContext $includeContext A request to include context from one or more MCP servers (including * the caller), to be attached to the prompt. The client MAY ignore this request. * Allowed values: "none", "thisServer", "allServers" + * Values other than "none" are soft-deprecated and SHOULD only be sent + * when the client advertises the sampling.context capability. * @param ?float $temperature The temperature to use for sampling. The client MAY ignore this request. * @param ?string[] $stopSequences A list of sequences to stop sampling at. The client MAY ignore this request. * @param ?array $metadata Optional metadata to pass through to the LLM provider. The format of * this metadata is provider-specific. + * @param ?Tool[] $tools tools that the model may use during generation + * @param ?ToolChoice $toolChoice controls how the model uses tools */ public function __construct( public readonly array $messages, @@ -51,12 +63,19 @@ final class CreateSamplingMessageRequest extends Request public readonly ?float $temperature = null, public readonly ?array $stopSequences = null, public readonly ?array $metadata = null, + public readonly ?array $tools = null, + public readonly ?ToolChoice $toolChoice = null, ) { foreach ($this->messages as $message) { if (!$message instanceof SamplingMessage) { throw new InvalidArgumentException('Messages must be instance of SamplingMessage.'); } } + foreach ($this->tools ?? [] as $tool) { + if (!$tool instanceof Tool) { + throw new InvalidArgumentException('Tools must be instances of Tool.'); + } + } } public static function getMethod(): string @@ -87,6 +106,9 @@ final class CreateSamplingMessageRequest extends Request $preferences = null; if (isset($params['preferences'])) { + if (!\is_array($params['preferences'])) { + throw new InvalidArgumentException('Invalid "preferences" parameter for sampling/createMessage.'); + } $preferences = ModelPreferences::fromArray($params['preferences']); } @@ -95,18 +117,130 @@ final class CreateSamplingMessageRequest extends Request $includeContext = SamplingContext::tryFrom($params['includeContext']); } + if (isset($params['systemPrompt']) && !\is_string($params['systemPrompt'])) { + throw new InvalidArgumentException('Invalid "systemPrompt" parameter for sampling/createMessage.'); + } + + if (isset($params['temperature']) && !\is_float($params['temperature']) && !\is_int($params['temperature'])) { + throw new InvalidArgumentException('Invalid "temperature" parameter for sampling/createMessage.'); + } + + if (isset($params['stopSequences'])) { + if (!\is_array($params['stopSequences'])) { + throw new InvalidArgumentException('Invalid "stopSequences" parameter for sampling/createMessage.'); + } + + foreach ($params['stopSequences'] as $stopSequence) { + if (!\is_string($stopSequence)) { + throw new InvalidArgumentException('Each entry in "stopSequences" must be a string for sampling/createMessage.'); + } + } + } + + if (isset($params['metadata']) && !\is_array($params['metadata'])) { + throw new InvalidArgumentException('Invalid "metadata" parameter for sampling/createMessage.'); + } + + $tools = null; + if (isset($params['tools'])) { + if (!\is_array($params['tools'])) { + throw new InvalidArgumentException('Invalid "tools" parameter for sampling/createMessage.'); + } + $tools = []; + foreach ($params['tools'] as $toolData) { + if ($toolData instanceof Tool) { + $tools[] = $toolData; + } elseif (\is_array($toolData)) { + $tools[] = Tool::fromArray($toolData); + } else { + throw new InvalidArgumentException('Invalid tool format in sampling/createMessage.'); + } + } + } + + $toolChoice = null; + if (isset($params['toolChoice'])) { + if ($params['toolChoice'] instanceof ToolChoice) { + $toolChoice = $params['toolChoice']; + } elseif (\is_array($params['toolChoice'])) { + $toolChoice = ToolChoice::fromArray($params['toolChoice']); + } else { + throw new InvalidArgumentException('Invalid "toolChoice" parameter for sampling/createMessage.'); + } + } + return new self( $messages, $params['maxTokens'], $preferences, $params['systemPrompt'] ?? null, $includeContext, - $params['temperature'] ?? null, + isset($params['temperature']) ? (float) $params['temperature'] : null, $params['stopSequences'] ?? null, $params['metadata'] ?? null, + $tools, + $toolChoice, ); } + /** + * Assert the spec's tool-flow rules over the whole message list. + * + * These are deliberately kept out of the hydration path: a violation is an + * "invalid params" condition the peer must be told about, not a parse failure + * that would leave the request unanswered. Call it from whatever boundary can + * report it — the request handler when receiving, the gateway when sending. + * + * @throws InvalidArgumentException on the first violation found + */ + public function validateToolFlow(): void + { + $pendingToolUseIds = []; + + foreach ($this->messages as $message) { + $blocks = $message->getContentBlocks(); + + $toolResults = array_filter($blocks, static fn ($block): bool => $block instanceof ToolResultContent); + $toolUses = array_filter($blocks, static fn ($block): bool => $block instanceof ToolUseContent); + + if ($toolResults && \count($toolResults) !== \count($blocks)) { + throw new InvalidArgumentException('Tool results mixed with other content.'); + } + + if (Role::User === $message->role && $toolUses) { + throw new InvalidArgumentException('ToolUseContent is only valid in assistant sampling messages.'); + } + + if (Role::Assistant === $message->role && $toolResults) { + throw new InvalidArgumentException('ToolResultContent is only valid in user sampling messages.'); + } + + if ($pendingToolUseIds && !$toolResults) { + throw new InvalidArgumentException('Tool result missing in request.'); + } + + foreach ($toolResults as $toolResult) { + $matched = array_search($toolResult->toolUseId, $pendingToolUseIds, true); + if (false === $matched) { + throw new InvalidArgumentException(\sprintf('Tool result "%s" does not answer a preceding tool use.', $toolResult->toolUseId)); + } + unset($pendingToolUseIds[$matched]); + } + + if ($pendingToolUseIds) { + throw new InvalidArgumentException('Tool result missing in request.'); + } + + foreach ($toolUses as $toolUse) { + $pendingToolUseIds[] = $toolUse->id; + } + } + + if ($pendingToolUseIds) { + throw new InvalidArgumentException('Tool result missing in request.'); + } + } + /** * @return array{ * messages: SamplingMessage[], @@ -116,7 +250,9 @@ final class CreateSamplingMessageRequest extends Request * includeContext?: string, * temperature?: float, * stopSequences?: string[], - * metadata?: array + * metadata?: array, + * tools?: Tool[], + * toolChoice?: ToolChoice, * } */ protected function getParams(): array @@ -150,6 +286,14 @@ final class CreateSamplingMessageRequest extends Request $params['metadata'] = $this->metadata; } + if (null !== $this->tools) { + $params['tools'] = $this->tools; + } + + if (null !== $this->toolChoice) { + $params['toolChoice'] = $this->toolChoice; + } + return $params; } } diff --git a/src/Schema/Request/DiscoverRequest.php b/src/Schema/Request/DiscoverRequest.php new file mode 100644 index 0000000..24852bc --- /dev/null +++ b/src/Schema/Request/DiscoverRequest.php @@ -0,0 +1,43 @@ + + */ +final class DiscoverRequest extends Request +{ + public static function getMethod(): string + { + return 'server/discover'; + } + + protected static function fromParams(?array $params): static + { + return new self(); + } + + protected function getParams(): ?array + { + return null; + } +} diff --git a/src/Schema/Request/ElicitRequest.php b/src/Schema/Request/ElicitRequest.php index ab56e10..15a74ee 100644 --- a/src/Schema/Request/ElicitRequest.php +++ b/src/Schema/Request/ElicitRequest.php @@ -13,26 +13,56 @@ namespace Mcp\Schema\Request; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Elicitation\ElicitationSchema; +use Mcp\Schema\Enum\ElicitationMode; use Mcp\Schema\JsonRpc\Request; /** * A request from the server to elicit additional information from the user. * - * The client will present the message and requested schema to the user, allowing them - * to provide the requested information, decline, or cancel the operation. + * Comes in two modes. In `form` mode the client presents the message alongside + * the requested schema and returns the values the user filled in. In `url` mode + * it sends the user to a URL to complete the interaction out of band, and the + * result carries only the user's action — no content. * * @author Johannes Wachter */ final class ElicitRequest extends Request { /** - * @param string $message A human-readable message describing what information is needed - * @param ElicitationSchema $requestedSchema The schema defining the fields to elicit from the user + * @param string $message A human-readable message describing what information is needed + * @param ?ElicitationSchema $requestedSchema The schema defining the fields to elicit; required in form mode + * @param ElicitationMode $mode how the client should collect the information + * @param ?string $url the URL to send the user to; required in url mode */ public function __construct( public readonly string $message, - public readonly ElicitationSchema $requestedSchema, + public readonly ?ElicitationSchema $requestedSchema = null, + public readonly ElicitationMode $mode = ElicitationMode::Form, + public readonly ?string $url = null, ) { + if (ElicitationMode::Form === $mode && null === $requestedSchema) { + throw new InvalidArgumentException('Form elicitation requires a "requestedSchema".'); + } + + if (ElicitationMode::Url === $mode && (null === $url || '' === $url)) { + throw new InvalidArgumentException('URL elicitation requires a non-empty "url".'); + } + } + + /** + * Elicit values from the user through a form built from $requestedSchema. + */ + public static function forForm(string $message, ElicitationSchema $requestedSchema): self + { + return new self($message, $requestedSchema); + } + + /** + * Send the user to $url to complete the interaction out of band. + */ + public static function forUrl(string $message, string $url): self + { + return new self($message, null, ElicitationMode::Url, $url); } public static function getMethod(): string @@ -46,24 +76,48 @@ final class ElicitRequest extends Request throw new InvalidArgumentException('Missing or invalid "message" parameter for elicitation/create.'); } + // Default `mode` is form - url elicitation is opt-in. + $mode = ElicitationMode::Form; + if (\array_key_exists('mode', $params)) { + if (!\is_string($params['mode']) || null === $mode = ElicitationMode::tryFrom($params['mode'])) { + throw new InvalidArgumentException('Invalid "mode" parameter for elicitation/create.'); + } + } + + if (ElicitationMode::Url === $mode) { + if (!isset($params['url']) || !\is_string($params['url'])) { + throw new InvalidArgumentException('Missing or invalid "url" parameter for url-mode elicitation/create.'); + } + + return new self($params['message'], null, $mode, $params['url']); + } + if (!isset($params['requestedSchema']) || !\is_array($params['requestedSchema'])) { throw new InvalidArgumentException('Missing or invalid "requestedSchema" parameter for elicitation/create.'); } - return new self( - $params['message'], - ElicitationSchema::fromArray($params['requestedSchema']), - ); + return new self($params['message'], ElicitationSchema::fromArray($params['requestedSchema']), $mode); } /** * @return array{ * message: string, - * requestedSchema: ElicitationSchema, + * mode?: string, + * requestedSchema?: ElicitationSchema, + * url?: string, * } */ protected function getParams(): array { + if (ElicitationMode::Url === $this->mode) { + return [ + 'message' => $this->message, + 'mode' => $this->mode->value, + 'url' => $this->url, + ]; + } + + // We don't need to send the mode if it's the default (form). return [ 'message' => $this->message, 'requestedSchema' => $this->requestedSchema, diff --git a/src/Schema/Request/InitializeRequest.php b/src/Schema/Request/InitializeRequest.php index 04db317..f7b7460 100644 --- a/src/Schema/Request/InitializeRequest.php +++ b/src/Schema/Request/InitializeRequest.php @@ -42,17 +42,17 @@ final class InitializeRequest extends Request protected static function fromParams(?array $params): static { - if (!isset($params['protocolVersion'])) { - throw new InvalidArgumentException('protocolVersion is required'); + if (!isset($params['protocolVersion']) || !\is_string($params['protocolVersion'])) { + throw new InvalidArgumentException('Missing or invalid "protocolVersion" parameter for initialize.'); } - if (!isset($params['capabilities'])) { - throw new InvalidArgumentException('capabilities is required'); + if (!isset($params['capabilities']) || !\is_array($params['capabilities'])) { + throw new InvalidArgumentException('Missing or invalid "capabilities" parameter for initialize.'); } $capabilities = ClientCapabilities::fromArray($params['capabilities']); - if (!isset($params['clientInfo'])) { - throw new InvalidArgumentException('clientInfo is required'); + if (!isset($params['clientInfo']) || !\is_array($params['clientInfo'])) { + throw new InvalidArgumentException('Missing or invalid "clientInfo" parameter for initialize.'); } $clientInfo = Implementation::fromArray($params['clientInfo']); diff --git a/src/Schema/Request/ListPromptsRequest.php b/src/Schema/Request/ListPromptsRequest.php index 8c62799..3f1cff0 100644 --- a/src/Schema/Request/ListPromptsRequest.php +++ b/src/Schema/Request/ListPromptsRequest.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Request; /** @@ -37,6 +38,10 @@ final class ListPromptsRequest extends Request protected static function fromParams(?array $params): static { + if (isset($params['cursor']) && !\is_string($params['cursor'])) { + throw new InvalidArgumentException('Invalid "cursor" parameter for prompts/list.'); + } + return new self($params['cursor'] ?? null); } diff --git a/src/Schema/Request/ListResourceTemplatesRequest.php b/src/Schema/Request/ListResourceTemplatesRequest.php index 4ce4dfd..1822c57 100644 --- a/src/Schema/Request/ListResourceTemplatesRequest.php +++ b/src/Schema/Request/ListResourceTemplatesRequest.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Request; /** @@ -37,6 +38,10 @@ final class ListResourceTemplatesRequest extends Request protected static function fromParams(?array $params): static { + if (isset($params['cursor']) && !\is_string($params['cursor'])) { + throw new InvalidArgumentException('Invalid "cursor" parameter for resources/templates/list.'); + } + return new self($params['cursor'] ?? null); } diff --git a/src/Schema/Request/ListResourcesRequest.php b/src/Schema/Request/ListResourcesRequest.php index 30c0041..a8fe772 100644 --- a/src/Schema/Request/ListResourcesRequest.php +++ b/src/Schema/Request/ListResourcesRequest.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Request; /** @@ -37,6 +38,10 @@ final class ListResourcesRequest extends Request protected static function fromParams(?array $params): static { + if (isset($params['cursor']) && !\is_string($params['cursor'])) { + throw new InvalidArgumentException('Invalid "cursor" parameter for resources/list.'); + } + return new self($params['cursor'] ?? null); } diff --git a/src/Schema/Request/ListRootsRequest.php b/src/Schema/Request/ListRootsRequest.php index 35c8a00..ea5987f 100644 --- a/src/Schema/Request/ListRootsRequest.php +++ b/src/Schema/Request/ListRootsRequest.php @@ -23,6 +23,10 @@ use Mcp\Schema\JsonRpc\Request; * structure or access specific locations that the client has permission to read from. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource + * URIs or server configuration instead. */ final class ListRootsRequest extends Request { diff --git a/src/Schema/Request/ListToolsRequest.php b/src/Schema/Request/ListToolsRequest.php index 00af286..83cf887 100644 --- a/src/Schema/Request/ListToolsRequest.php +++ b/src/Schema/Request/ListToolsRequest.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Request; /** @@ -37,6 +38,10 @@ final class ListToolsRequest extends Request protected static function fromParams(?array $params): static { + if (isset($params['cursor']) && !\is_string($params['cursor'])) { + throw new InvalidArgumentException('Invalid "cursor" parameter for tools/list.'); + } + return new self($params['cursor'] ?? null); } diff --git a/src/Schema/Request/SetLogLevelRequest.php b/src/Schema/Request/SetLogLevelRequest.php index eb83e1c..f781ca5 100644 --- a/src/Schema/Request/SetLogLevelRequest.php +++ b/src/Schema/Request/SetLogLevelRequest.php @@ -19,6 +19,9 @@ use Mcp\Schema\JsonRpc\Request; * A request from the client to the server, to enable or adjust logging. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ final class SetLogLevelRequest extends Request { @@ -43,7 +46,11 @@ final class SetLogLevelRequest extends Request throw new InvalidArgumentException('Missing or invalid "level" parameter for "logging/setLevel".'); } - return new self(LoggingLevel::from($params['level'])); + if (null === $level = LoggingLevel::tryFrom($params['level'])) { + throw new InvalidArgumentException(\sprintf('Invalid "level" parameter "%s" for "logging/setLevel".', $params['level'])); + } + + return new self($level); } /** diff --git a/src/Schema/ResourceDefinition.php b/src/Schema/ResourceDefinition.php index a399966..69c44d5 100644 --- a/src/Schema/ResourceDefinition.php +++ b/src/Schema/ResourceDefinition.php @@ -35,11 +35,6 @@ use Mcp\Exception\InvalidArgumentException; */ class ResourceDefinition implements \JsonSerializable { - /** - * Resource name pattern regex - must contain only alphanumeric characters, underscores, and hyphens. - */ - private const RESOURCE_NAME_PATTERN = '/^[a-zA-Z0-9_-]+$/'; - /** * URI pattern regex - requires a valid scheme followed by colon and optional path (RFC 3986). * Example patterns: file://path, db://table, urn:isbn:123, config:key, etc. @@ -68,9 +63,6 @@ class ResourceDefinition implements \JsonSerializable public readonly ?array $icons = null, public readonly ?array $meta = null, ) { - if (!preg_match(self::RESOURCE_NAME_PATTERN, $name)) { - throw new InvalidArgumentException(\sprintf('Invalid resource name "%s": must contain only alphanumeric characters, underscores, and hyphens.', $name)); - } if (!preg_match(self::URI_PATTERN, $uri)) { throw new InvalidArgumentException(\sprintf('Invalid resource URI: "%s" must be a valid URI with a scheme and optional path.', $uri)); } @@ -88,9 +80,18 @@ class ResourceDefinition implements \JsonSerializable throw new InvalidArgumentException('Invalid or missing "name" in ResourceDefinition data.'); } - if (!empty($data['_meta']) && !\is_array($data['_meta'])) { + if (isset($data['_meta']) && !\is_array($data['_meta'])) { throw new InvalidArgumentException('Invalid "_meta" in ResourceDefinition data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in ResourceDefinition data.'); + } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" in ResourceDefinition data.'); + } + if (isset($data['size']) && !\is_int($data['size'])) { + throw new InvalidArgumentException('Invalid "size" in ResourceDefinition data; expected an integer.'); + } return new self( uri: $data['uri'], @@ -98,9 +99,9 @@ class ResourceDefinition implements \JsonSerializable title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, description: $data['description'] ?? null, mimeType: $data['mimeType'] ?? null, - annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, - size: isset($data['size']) ? (int) $data['size'] : null, - icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + annotations: Annotations::tryFromArray($data['annotations'] ?? null, 'ResourceDefinition'), + size: $data['size'] ?? null, + icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'ResourceDefinition') : null, meta: isset($data['_meta']) ? $data['_meta'] : null ); } diff --git a/src/Schema/ResourceTemplate.php b/src/Schema/ResourceTemplate.php index 46f173c..4603397 100644 --- a/src/Schema/ResourceTemplate.php +++ b/src/Schema/ResourceTemplate.php @@ -32,11 +32,6 @@ use Mcp\Exception\InvalidArgumentException; */ class ResourceTemplate implements \JsonSerializable { - /** - * Resource name pattern regex - must contain only alphanumeric characters, underscores, and hyphens. - */ - private const RESOURCE_NAME_PATTERN = '/^[a-zA-Z0-9_-]+$/'; - /** * URI Template pattern regex - requires a valid scheme followed by colon and path with at least one placeholder (RFC 3986). * Example patterns: file://{path}/contents.txt, db://{table}/{id}, config:{key}, etc. @@ -61,9 +56,6 @@ class ResourceTemplate implements \JsonSerializable public readonly ?Annotations $annotations = null, public readonly ?array $meta = null, ) { - if (!preg_match(self::RESOURCE_NAME_PATTERN, $name)) { - throw new InvalidArgumentException(\sprintf('Invalid resource name "%s": must contain only alphanumeric characters, underscores, and hyphens.', $name)); - } if (!preg_match(self::URI_TEMPLATE_PATTERN, $uriTemplate)) { throw new InvalidArgumentException(\sprintf('Invalid URI template : "%s" must be a valid URI template with at least one placeholder.', $uriTemplate)); } @@ -81,9 +73,15 @@ class ResourceTemplate implements \JsonSerializable throw new InvalidArgumentException('Invalid or missing "name" in ResourceTemplate data.'); } - if (!empty($data['_meta']) && !\is_array($data['_meta'])) { + if (isset($data['_meta']) && !\is_array($data['_meta'])) { throw new InvalidArgumentException('Invalid "_meta" in ResourceTemplate data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in ResourceTemplate data.'); + } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" in ResourceTemplate data.'); + } return new self( uriTemplate: $data['uriTemplate'], @@ -91,7 +89,7 @@ class ResourceTemplate implements \JsonSerializable title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, description: $data['description'] ?? null, mimeType: $data['mimeType'] ?? null, - annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, + annotations: Annotations::tryFromArray($data['annotations'] ?? null, 'ResourceTemplate'), meta: isset($data['_meta']) ? $data['_meta'] : null ); } diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index bfbc9fa..b986bec 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -16,6 +16,7 @@ use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\Content; use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; +use Mcp\Schema\Content\ResourceLink; use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\ResultInterface; @@ -40,13 +41,15 @@ class CallToolResult implements ResultInterface * * @param Content[] $content The content of the tool result * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). - * @param mixed[] $structuredContent JSON content for `structuredContent` + * @param mixed $structuredContent Structured result of the call. Any JSON value — object, array, + * string, number, boolean or null — conforming to the tool's + * outputSchema when one is declared. * @param array|null $meta Optional metadata */ public function __construct( public readonly array $content, public readonly bool $isError = false, - public readonly ?array $structuredContent = null, + public readonly mixed $structuredContent = null, public readonly ?array $meta = null, ) { foreach ($this->content as $item) { @@ -83,7 +86,7 @@ class CallToolResult implements ResultInterface * content: array, * isError?: bool, * _meta?: array, - * structuredContent?: array + * structuredContent?: mixed * } $data */ public static function fromArray(array $data): self @@ -95,15 +98,28 @@ class CallToolResult implements ResultInterface $contents = []; foreach ($data['content'] as $item) { - $contents[] = match ($item['type'] ?? null) { + $type = \is_array($item) ? $item['type'] ?? null : null; + if (!\is_string($type)) { + throw new InvalidArgumentException('Missing or invalid content "type" in CallToolResult data.'); + } + + $contents[] = match ($type) { 'text' => TextContent::fromArray($item), 'image' => ImageContent::fromArray($item), 'audio' => AudioContent::fromArray($item), 'resource' => EmbeddedResource::fromArray($item), - default => throw new InvalidArgumentException(\sprintf('Invalid content type in CallToolResult data: "%s".', $item['type'] ?? null)), + 'resource_link' => ResourceLink::fromArray($item), + default => throw new InvalidArgumentException(\sprintf('Invalid content type in CallToolResult data: "%s".', $type)), }; } + if (isset($data['isError']) && !\is_bool($data['isError'])) { + throw new InvalidArgumentException('Invalid "isError" in CallToolResult data.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in CallToolResult data.'); + } + return new self( $contents, $data['isError'] ?? false, @@ -116,7 +132,7 @@ class CallToolResult implements ResultInterface * @return array{ * content: array, * isError: bool, - * structuredContent?: array, + * structuredContent?: mixed, * _meta?: array, * } */ @@ -127,7 +143,7 @@ class CallToolResult implements ResultInterface 'isError' => $this->isError, ]; - if ($this->structuredContent) { + if (null !== $this->structuredContent) { $result['structuredContent'] = $this->structuredContent; } diff --git a/src/Schema/Result/CompletionCompleteResult.php b/src/Schema/Result/CompletionCompleteResult.php index d7fa4c4..43c9fed 100644 --- a/src/Schema/Result/CompletionCompleteResult.php +++ b/src/Schema/Result/CompletionCompleteResult.php @@ -67,6 +67,18 @@ class CompletionCompleteResult implements ResultInterface public static function fromArray(array $data): self { $completion = $data['completion'] ?? []; + if (!\is_array($completion)) { + throw new InvalidArgumentException('Invalid "completion" in CompletionCompleteResult data.'); + } + if (isset($completion['values']) && !\is_array($completion['values'])) { + throw new InvalidArgumentException('Invalid "completion.values" in CompletionCompleteResult data.'); + } + if (isset($completion['total']) && !\is_int($completion['total'])) { + throw new InvalidArgumentException('Invalid "completion.total" in CompletionCompleteResult data.'); + } + if (isset($completion['hasMore']) && !\is_bool($completion['hasMore'])) { + throw new InvalidArgumentException('Invalid "completion.hasMore" in CompletionCompleteResult data.'); + } return new self( $completion['values'] ?? [], diff --git a/src/Schema/Result/CreateSamplingMessageResult.php b/src/Schema/Result/CreateSamplingMessageResult.php index 986d629..a325ed9 100644 --- a/src/Schema/Result/CreateSamplingMessageResult.php +++ b/src/Schema/Result/CreateSamplingMessageResult.php @@ -15,6 +15,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Content\ToolUseContent; use Mcp\Schema\Enum\Role; use Mcp\Schema\JsonRpc\ResultInterface; @@ -24,21 +25,62 @@ use Mcp\Schema\JsonRpc\ResultInterface; * the server to see it. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ class CreateSamplingMessageResult implements ResultInterface { /** - * @param Role $role the role of the message - * @param TextContent|ImageContent|AudioContent $content the content of the message - * @param string $model the name of the model that generated the message - * @param string|null $stopReason the reason why sampling stopped, if known + * @var TextContent|ImageContent|AudioContent|ToolUseContent|list + */ + public readonly TextContent|ImageContent|AudioContent|ToolUseContent|array $content; + + /** + * @param Role $role the role of the message + * @param TextContent|ImageContent|AudioContent|ToolUseContent|array $content The content of the message. Keys are discarded, the property always holds a list. + * @param string $model the name of the model that generated the message + * @param ?string $stopReason The reason why sampling stopped, if known. The spec defines "endTurn", + * "stopSequence", "maxTokens" and "toolUse", but leaves the set open for + * provider-specific values, so this stays an unconstrained string. + * @param ?array $meta optional message metadata */ public function __construct( public readonly Role $role, - public readonly TextContent|ImageContent|AudioContent $content, + TextContent|ImageContent|AudioContent|ToolUseContent|array $content, public readonly string $model, public readonly ?string $stopReason = null, + public readonly ?array $meta = null, ) { + if (Role::Assistant !== $role) { + throw new InvalidArgumentException('CreateSamplingMessageResult role must be "assistant".'); + } + + if (\is_array($content)) { + if ([] === $content) { + throw new InvalidArgumentException('CreateSamplingMessageResult content must not be empty.'); + } + + foreach ($content as $item) { + if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent) { + throw new InvalidArgumentException('CreateSamplingMessageResult contains an unsupported content block.'); + } + } + + // array_filter() and friends preserve keys, and a keyed array serializes + // as a JSON object rather than the array the schema requires. + $content = array_values($content); + } + + $this->content = $content; + } + + /** + * @return list + */ + public function getContentBlocks(): array + { + return \is_array($this->content) ? $this->content : [$this->content]; } /** @@ -50,7 +92,7 @@ class CreateSamplingMessageResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "role" in CreateSamplingMessageResult data.'); } - if (!isset($data['content']) || !\is_array($data['content'])) { + if (!isset($data['content']) || !\is_array($data['content']) || [] === $data['content']) { throw new InvalidArgumentException('Missing or invalid "content" in CreateSamplingMessageResult data.'); } @@ -58,19 +100,37 @@ class CreateSamplingMessageResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "model" in CreateSamplingMessageResult data.'); } - $role = Role::from($data['role']); + if (null === $role = Role::tryFrom($data['role'])) { + throw new InvalidArgumentException(\sprintf('Invalid "role" value "%s" in CreateSamplingMessageResult data.', $data['role'])); + } + $contentPayload = $data['content']; - $content = self::hydrateContent($contentPayload); + $isSingleContent = isset($contentPayload['type']); + $contentItems = $isSingleContent ? [$contentPayload] : $contentPayload; + $content = []; + foreach ($contentItems as $item) { + if (!\is_array($item)) { + throw new InvalidArgumentException('Invalid content block in CreateSamplingMessageResult data.'); + } + $content[] = self::hydrateContent($item); + } + $stopReason = isset($data['stopReason']) && \is_string($data['stopReason']) ? $data['stopReason'] : null; - return new self($role, $content, $data['model'], $stopReason); + return new self( + $role, + $isSingleContent ? $content[0] : $content, + $data['model'], + $stopReason, + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); } /** * @param array $contentData */ - private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent + private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent|ToolUseContent { $type = $contentData['type'] ?? null; @@ -82,6 +142,7 @@ class CreateSamplingMessageResult implements ResultInterface 'text' => TextContent::fromArray($contentData), 'image' => ImageContent::fromArray($contentData), 'audio' => AudioContent::fromArray($contentData), + 'tool_use' => ToolUseContent::fromArray($contentData), default => throw new InvalidArgumentException(\sprintf('Unsupported sampling content type "%s".', $type)), }; } @@ -89,9 +150,10 @@ class CreateSamplingMessageResult implements ResultInterface /** * @return array{ * role: string, - * content: TextContent|ImageContent|AudioContent, + * content: TextContent|ImageContent|AudioContent|ToolUseContent|list, * model: string, * stopReason?: string, + * _meta?: array, * } */ public function jsonSerialize(): array @@ -106,6 +168,10 @@ class CreateSamplingMessageResult implements ResultInterface $result['stopReason'] = $this->stopReason; } + if (null !== $this->meta) { + $result['_meta'] = $this->meta; + } + return $result; } } diff --git a/src/Schema/Result/DiscoverResult.php b/src/Schema/Result/DiscoverResult.php new file mode 100644 index 0000000..22222e9 --- /dev/null +++ b/src/Schema/Result/DiscoverResult.php @@ -0,0 +1,70 @@ + + */ +class DiscoverResult implements ResultInterface +{ + /** + * @param list $supportedVersions every revision this server can answer + */ + public function __construct( + public readonly array $supportedVersions, + public readonly ServerCapabilities $capabilities, + public readonly ?string $instructions = null, + ) { + if ([] === $this->supportedVersions) { + throw new InvalidArgumentException('A DiscoverResult must advertise at least one supported version.'); + } + } + + /** + * `resultType`, caching hints and serverInfo are wire vocabulary, stamped + * by {@see \Mcp\Server\Wire\Rev2026Codec} rather than modelled here. + * + * @return array{ + * supportedVersions: list, + * capabilities: ServerCapabilities, + * instructions?: string, + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'supportedVersions' => array_values(array_map( + static fn (ProtocolVersion $version): string => $version->value, + $this->supportedVersions, + )), + 'capabilities' => $this->capabilities, + ]; + + if (null !== $this->instructions) { + $data['instructions'] = $this->instructions; + } + + return $data; + } +} diff --git a/src/Schema/Result/ElicitResult.php b/src/Schema/Result/ElicitResult.php index d667374..533d28f 100644 --- a/src/Schema/Result/ElicitResult.php +++ b/src/Schema/Result/ElicitResult.php @@ -13,13 +13,15 @@ namespace Mcp\Schema\Result; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ElicitationMode; use Mcp\Schema\JsonRpc\ResultInterface; /** * The client's response to an elicitation/create request from the server. * * Contains the user's action (accept, decline, or cancel) and the content - * they provided when accepting. + * they provided when accepting. Only form elicitation collects content: a url-mode + * interaction happens out of band, so an accepted one carries the action alone. * * @author Johannes Wachter */ @@ -36,19 +38,30 @@ final class ElicitResult implements ResultInterface } /** + * The result carries no discriminator of its own, so the mode of the request it + * answers has to be passed in: it decides whether an accepted response is + * expected to carry content. + * * @param array{action: string, content?: array} $data */ - public static function fromArray(array $data): self + public static function fromArray(array $data, ElicitationMode $mode = ElicitationMode::Form): self { if (!isset($data['action']) || !\is_string($data['action'])) { throw new InvalidArgumentException('Missing or invalid "action" in ElicitResult data.'); } - $action = ElicitAction::from($data['action']); + if (null === $action = ElicitAction::tryFrom($data['action'])) { + throw new InvalidArgumentException(\sprintf('Invalid "action" value "%s" in ElicitResult data.', $data['action'])); + } + $content = isset($data['content']) && \is_array($data['content']) ? $data['content'] : null; - if (ElicitAction::Accept === $action && null === $content) { - throw new InvalidArgumentException('Content must be provided when action is "accept".'); + if (ElicitationMode::Form === $mode) { + if (ElicitAction::Accept === $action && null === $content) { + throw new InvalidArgumentException('Content must be provided when action is "accept".'); + } + } elseif (isset($data['content'])) { + throw new InvalidArgumentException('Content must not be provided for a url-mode elicitation result.'); } return new self($action, $content); diff --git a/src/Schema/Result/GetPromptResult.php b/src/Schema/Result/GetPromptResult.php index 40277f8..0083a83 100644 --- a/src/Schema/Result/GetPromptResult.php +++ b/src/Schema/Result/GetPromptResult.php @@ -51,8 +51,16 @@ class GetPromptResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "messages" array in GetPromptResult data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in GetPromptResult data.'); + } + $messages = []; foreach ($data['messages'] as $message) { + if (!\is_array($message)) { + throw new InvalidArgumentException('Each entry in "messages" of GetPromptResult data must be an array.'); + } + $messages[] = PromptMessage::fromArray($message); } diff --git a/src/Schema/Result/InitializeResult.php b/src/Schema/Result/InitializeResult.php index 5c184d6..e80c28c 100644 --- a/src/Schema/Result/InitializeResult.php +++ b/src/Schema/Result/InitializeResult.php @@ -64,6 +64,13 @@ class InitializeResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "serverInfo".'); } + if (isset($data['instructions']) && !\is_string($data['instructions'])) { + throw new InvalidArgumentException('Invalid "instructions" in InitializeResult data.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in InitializeResult data.'); + } + return new self( ServerCapabilities::fromArray($data['capabilities']), Implementation::fromArray($data['serverInfo']), diff --git a/src/Schema/Result/InputRequiredResult.php b/src/Schema/Result/InputRequiredResult.php new file mode 100644 index 0000000..a0fca5e --- /dev/null +++ b/src/Schema/Result/InputRequiredResult.php @@ -0,0 +1,86 @@ + + */ +class InputRequiredResult implements ResultInterface +{ + public const RESULT_TYPE = 'input_required'; + + /** + * @param array $inputRequests server-assigned keys, unique within this request + * @param string|null $requestState opaque server context the client echoes back + */ + public function __construct( + public readonly array $inputRequests = [], + public readonly ?string $requestState = null, + ) { + // Neither member would tell the client to retry with nothing new. + if ([] === $this->inputRequests && null === $this->requestState) { + throw new InvalidArgumentException('An InputRequiredResult must carry at least one of "inputRequests" or "requestState".'); + } + } + + /** + * @return array{ + * resultType: string, + * inputRequests?: array, + * requestState?: string, + * } + */ + public function jsonSerialize(): array + { + $data = ['resultType' => self::RESULT_TYPE]; + + if ([] !== $this->inputRequests) { + $requests = []; + foreach ($this->inputRequests as $key => $request) { + // Values are bare method/params pairs, not messages: the client + // keys answers by the map key. getParams() is protected, so the + // envelope is built with a throwaway id and then discarded. + $envelope = $request->withId(0)->jsonSerialize(); + + $params = $envelope['params'] ?? null; + + $requests[$key] = [ + 'method' => $request::getMethod(), + // An empty PHP array would encode as `[]`, not `{}`. + 'params' => [] === $params || null === $params ? new \stdClass() : $params, + ]; + } + $data['inputRequests'] = $requests; + } + + if (null !== $this->requestState) { + $data['requestState'] = $this->requestState; + } + + return $data; + } +} diff --git a/src/Schema/Result/ListPromptsResult.php b/src/Schema/Result/ListPromptsResult.php index 7b1b382..fd164c4 100644 --- a/src/Schema/Result/ListPromptsResult.php +++ b/src/Schema/Result/ListPromptsResult.php @@ -49,8 +49,21 @@ class ListPromptsResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "prompts" array in ListPromptsResult data.'); } + if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { + throw new InvalidArgumentException('Invalid "nextCursor" in ListPromptsResult data.'); + } + return new self( - array_map(static fn (array $prompt) => Prompt::fromArray($prompt), $data['prompts']), + array_map( + static function (mixed $entry): Prompt { + if (!\is_array($entry)) { + throw new InvalidArgumentException('Each entry in "prompts" of ListPromptsResult data must be an array.'); + } + + return Prompt::fromArray($entry); + }, + $data['prompts'], + ), $data['nextCursor'] ?? null ); } diff --git a/src/Schema/Result/ListResourceTemplatesResult.php b/src/Schema/Result/ListResourceTemplatesResult.php index 1b8ddb2..e0b7114 100644 --- a/src/Schema/Result/ListResourceTemplatesResult.php +++ b/src/Schema/Result/ListResourceTemplatesResult.php @@ -49,8 +49,21 @@ class ListResourceTemplatesResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "resourceTemplates" array in ListResourceTemplatesResult data.'); } + if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { + throw new InvalidArgumentException('Invalid "nextCursor" in ListResourceTemplatesResult data.'); + } + return new self( - array_map(static fn (array $resourceTemplate) => ResourceTemplate::fromArray($resourceTemplate), $data['resourceTemplates']), + array_map( + static function (mixed $entry): ResourceTemplate { + if (!\is_array($entry)) { + throw new InvalidArgumentException('Each entry in "resourceTemplates" of ListResourceTemplatesResult data must be an array.'); + } + + return ResourceTemplate::fromArray($entry); + }, + $data['resourceTemplates'], + ), $data['nextCursor'] ?? null ); } diff --git a/src/Schema/Result/ListResourcesResult.php b/src/Schema/Result/ListResourcesResult.php index 0f338ca..63066b0 100644 --- a/src/Schema/Result/ListResourcesResult.php +++ b/src/Schema/Result/ListResourcesResult.php @@ -49,8 +49,21 @@ class ListResourcesResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "resources" array in ListResourcesResult data.'); } + if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { + throw new InvalidArgumentException('Invalid "nextCursor" in ListResourcesResult data.'); + } + return new self( - array_map(static fn (array $resource) => ResourceDefinition::fromArray($resource), $data['resources']), + array_map( + static function (mixed $entry): ResourceDefinition { + if (!\is_array($entry)) { + throw new InvalidArgumentException('Each entry in "resources" of ListResourcesResult data must be an array.'); + } + + return ResourceDefinition::fromArray($entry); + }, + $data['resources'], + ), $data['nextCursor'] ?? null ); } diff --git a/src/Schema/Result/ListRootsResult.php b/src/Schema/Result/ListRootsResult.php index 3abe2e0..613b53f 100644 --- a/src/Schema/Result/ListRootsResult.php +++ b/src/Schema/Result/ListRootsResult.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Result; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Root; @@ -20,6 +21,10 @@ use Mcp\Schema\Root; * or file that the server can operate on. * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource + * URIs or server configuration instead. */ class ListRootsResult implements ResultInterface { @@ -33,6 +38,32 @@ class ListRootsResult implements ResultInterface ) { } + /** + * @param array{ + * roots: array, + * _meta?: ?array + * } $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['roots']) || !\is_array($data['roots'])) { + throw new InvalidArgumentException('Missing or invalid "roots" in ListRootsResult data.'); + } + + $roots = []; + foreach ($data['roots'] as $root) { + if (!\is_array($root)) { + throw new InvalidArgumentException('Invalid root in ListRootsResult data, expected an array.'); + } + + $roots[] = Root::fromArray($root); + } + + $meta = isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null; + + return new self($roots, $meta); + } + /** * @return array{ * roots: Root[], diff --git a/src/Schema/Result/ListToolsResult.php b/src/Schema/Result/ListToolsResult.php index 7b08a0d..c6ba6c8 100644 --- a/src/Schema/Result/ListToolsResult.php +++ b/src/Schema/Result/ListToolsResult.php @@ -49,8 +49,21 @@ class ListToolsResult implements ResultInterface throw new InvalidArgumentException('Missing or invalid "tools" array in ListToolsResult data.'); } + if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { + throw new InvalidArgumentException('Invalid "nextCursor" in ListToolsResult data.'); + } + return new self( - array_map(static fn (array $tool) => Tool::fromArray($tool), $data['tools']), + array_map( + static function (mixed $entry): Tool { + if (!\is_array($entry)) { + throw new InvalidArgumentException('Each entry in "tools" of ListToolsResult data must be an array.'); + } + + return Tool::fromArray($entry); + }, + $data['tools'], + ), $data['nextCursor'] ?? null ); } diff --git a/src/Schema/Result/ReadResourceResult.php b/src/Schema/Result/ReadResourceResult.php index 7fd8000..7c6d0c5 100644 --- a/src/Schema/Result/ReadResourceResult.php +++ b/src/Schema/Result/ReadResourceResult.php @@ -15,6 +15,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\BlobResourceContents; use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Content\TextResourceContents; +use Mcp\Schema\Enum\CacheScope; use Mcp\Schema\JsonRpc\ResultInterface; /** @@ -30,16 +31,28 @@ class ReadResourceResult implements ResultInterface /** * Create a new ReadResourceResult. * - * @param ResourceContents[] $contents The contents of the resource + * @param ResourceContents[] $contents The contents of the resource + * @param ?int $ttlMs How long a client may consider this fresh, in milliseconds. Null + * leaves it to the server's configured {@see \Mcp\Server\Wire\CachePolicy}; + * set it when one resource's freshness differs from the rest. + * @param ?CacheScope $cacheScope Who may cache it. Null defers to the policy. `Private` for anything + * shaped by who asked. */ public function __construct( public readonly array $contents, + public readonly ?int $ttlMs = null, + public readonly ?CacheScope $cacheScope = null, ) { + if (null !== $this->ttlMs && $this->ttlMs < 0) { + throw new InvalidArgumentException(\sprintf('A resource "ttlMs" must be zero or more, got %d.', $this->ttlMs)); + } } /** * @param array{ * contents: array, + * ttlMs?: int, + * cacheScope?: string, * } $data */ public static function fromArray(array $data): self @@ -59,18 +72,32 @@ class ReadResourceResult implements ResultInterface } } - return new self($contents); + $scope = isset($data['cacheScope']) && \is_string($data['cacheScope']) ? CacheScope::tryFrom($data['cacheScope']) : null; + + return new self($contents, isset($data['ttlMs']) && \is_int($data['ttlMs']) ? $data['ttlMs'] : null, $scope); } /** * @return array{ * contents: array, + * ttlMs?: int, + * cacheScope?: string, * } */ public function jsonSerialize(): array { - return [ - 'contents' => $this->contents, - ]; + $data = ['contents' => $this->contents]; + + // Only what this result actually decided; the wire codec fills the rest + // from policy, and an absent member is the signal for it to do so. + if (null !== $this->ttlMs) { + $data['ttlMs'] = $this->ttlMs; + } + + if (null !== $this->cacheScope) { + $data['cacheScope'] = $this->cacheScope->value; + } + + return $data; } } diff --git a/src/Schema/Root.php b/src/Schema/Root.php index 5f6862d..97a2e3a 100644 --- a/src/Schema/Root.php +++ b/src/Schema/Root.php @@ -22,6 +22,10 @@ use Mcp\Exception\InvalidArgumentException; * } * * @author Kyrian Obikwelu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource + * URIs or server configuration instead. */ class Root implements \JsonSerializable { @@ -54,6 +58,10 @@ class Root implements \JsonSerializable throw new InvalidArgumentException('Invalid or missing "uri" in Root data.'); } + if (isset($data['name']) && !\is_string($data['name'])) { + throw new InvalidArgumentException('Invalid "name" in Root data.'); + } + return new self($data['uri'], $data['name'] ?? null); } diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index 0e47c61..33cc1ee 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -189,7 +189,12 @@ class ServerCapabilities implements \JsonSerializable } if ($this->extensions) { - $data['extensions'] = (object) $this->extensions; + // Each entry is a settings *object*; an extension with no settings + // declares `{}`, and an empty PHP array would serialize as `[]`. + $data['extensions'] = (object) array_map( + static fn (mixed $settings): mixed => \is_array($settings) ? (object) $settings : $settings, + $this->extensions, + ); } return $data; diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 1817804..1e939f6 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -12,6 +12,7 @@ namespace Mcp\Schema; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Wire\McpHeader; /** * Definition for a tool the client can call. @@ -21,14 +22,14 @@ use Mcp\Exception\InvalidArgumentException; * * @phpstan-type ToolInputSchema array{ * type: 'object', - * properties: array, + * properties: array|\stdClass, * required: string[]|null * } * @phpstan-type ToolOutputSchema array{ - * type: 'object', - * properties?: array, + * type?: string, + * properties?: array|\stdClass, * required?: string[]|null, - * additionalProperties?: bool|array, + * additionalProperties?: bool|array|\stdClass, * description?: string * } * @phpstan-type ToolData array{ @@ -46,6 +47,53 @@ use Mcp\Exception\InvalidArgumentException; */ class Tool implements \JsonSerializable { + /** + * JSON Schema keywords whose value is a single sub-schema. + */ + private const SUB_SCHEMA_KEYWORDS = [ + 'additionalItems', + 'additionalProperties', + 'contains', + 'else', + 'if', + 'not', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties', + ]; + + /** + * JSON Schema keywords whose value maps names to sub-schemas. + */ + private const SUB_SCHEMA_MAP_KEYWORDS = [ + '$defs', + 'definitions', + 'dependentSchemas', + 'patternProperties', + 'properties', + ]; + + /** + * JSON Schema keywords whose value is a list of sub-schemas. + */ + private const SUB_SCHEMA_LIST_KEYWORDS = [ + 'allOf', + 'anyOf', + 'oneOf', + 'prefixItems', + ]; + + /** + * @var ToolInputSchema + */ + public readonly array $inputSchema; + + /** + * @var ToolOutputSchema|null + */ + public readonly ?array $outputSchema; + /** * @param string $name the name of the tool * @param ?string $title Optional human-readable title for display in UI @@ -56,21 +104,36 @@ class Tool implements \JsonSerializable * @param ?ToolAnnotations $annotations optional additional tool information * @param ?Icon[] $icons optional icons representing the tool * @param ?array $meta Optional metadata - * @param ToolOutputSchema|null $outputSchema optional JSON Schema object (as a PHP array) defining the expected output structure + * @param ToolOutputSchema|null $outputSchema Optional JSON Schema (as a PHP array) describing the tool's + * structuredContent. Unlike $inputSchema its root is unconstrained — + * it may describe an array, a primitive, or a composition. */ public function __construct( public readonly string $name, public readonly ?string $title, - public readonly array $inputSchema, + array $inputSchema, public readonly ?string $description, public readonly ?ToolAnnotations $annotations, public readonly ?array $icons = null, public readonly ?array $meta = null, - public readonly ?array $outputSchema = null, + ?array $outputSchema = null, ) { if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) { throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".'); } + + // Always normalize here so every construction path emits `{}` for empty + // sub-schemas — not only SchemaGenerator / fromArray. + $this->inputSchema = self::normalizeSchema($inputSchema); + $this->outputSchema = null !== $outputSchema ? self::normalizeSchema($outputSchema) : null; + + // An out-of-bounds `x-mcp-header` reachable through `properties` makes + // the whole tool definition invalid, so it is refused where the tool + // is defined rather than discovered when a header comparison + // mysteriously fails. + if (null !== $reason = McpHeader::checkAnnotations($this->inputSchema)) { + throw new InvalidArgumentException(\sprintf('Tool "%s" has an invalid "x-mcp-header" annotation: %s', $this->name, $reason)); + } } /** @@ -87,23 +150,19 @@ class Tool implements \JsonSerializable if (!isset($data['inputSchema']['type']) || 'object' !== $data['inputSchema']['type']) { throw new InvalidArgumentException('Tool inputSchema must be of type "object".'); } - $inputSchema = self::normalizeSchemaProperties($data['inputSchema']); $outputSchema = null; if (isset($data['outputSchema']) && \is_array($data['outputSchema'])) { - if (!isset($data['outputSchema']['type']) || 'object' !== $data['outputSchema']['type']) { - throw new InvalidArgumentException('Tool outputSchema must be of type "object".'); - } - $outputSchema = self::normalizeSchemaProperties($data['outputSchema']); + $outputSchema = $data['outputSchema']; } return new self( name: $data['name'], title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, - inputSchema: $inputSchema, + inputSchema: $data['inputSchema'], description: isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, annotations: isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, - icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'Tool') : null, meta: isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, outputSchema: $outputSchema, ); @@ -118,7 +177,7 @@ class Tool implements \JsonSerializable * annotations?: ToolAnnotations, * icons?: Icon[], * _meta?: array, - * outputSchema?: ToolOutputSchema + * outputSchema?: ToolOutputSchema|\stdClass * } */ public function jsonSerialize(): array @@ -141,25 +200,92 @@ class Tool implements \JsonSerializable $data['_meta'] = $this->meta; } if (null !== $this->outputSchema) { - $data['outputSchema'] = $this->outputSchema; + $data['outputSchema'] = [] === $this->outputSchema ? new \stdClass() : $this->outputSchema; } return $data; } /** - * Normalize schema properties: convert an empty properties array to stdClass. + * Normalize a JSON Schema so that empty sub-schemas JSON-encode as `{}` rather than `[]`. + * + * Once JSON is decoded into associative arrays, PHP cannot tell the empty object `{}` + * from the empty array `[]` — both are `[]`. Re-encoding then produces `[]`, which is + * invalid wherever a schema is expected (`properties`, `items`, `additionalProperties`, + * …), and strict clients reject it. Every empty sub-schema is therefore replaced with a + * `\stdClass` before serialization. + * + * The walk is recursive and covers the schema keywords of draft-07 through 2020-12, so + * nested object parameters, `$defs`, combinators, and `outputSchema` are all covered — + * not only the top-level `properties` map. * * @param array $schema * * @return array */ - private static function normalizeSchemaProperties(array $schema): array + private static function normalizeSchema(array $schema): array { - if (isset($schema['properties']) && \is_array($schema['properties']) && empty($schema['properties'])) { - $schema['properties'] = new \stdClass(); + foreach (self::SUB_SCHEMA_KEYWORDS as $keyword) { + if (isset($schema[$keyword]) && \is_array($schema[$keyword])) { + $schema[$keyword] = self::normalizeSubSchema($schema[$keyword]); + } + } + + foreach (self::SUB_SCHEMA_MAP_KEYWORDS as $keyword) { + if (!isset($schema[$keyword]) || !\is_array($schema[$keyword])) { + continue; + } + + if ([] === $schema[$keyword]) { + $schema[$keyword] = new \stdClass(); + continue; + } + + foreach ($schema[$keyword] as $name => $subSchema) { + if (\is_array($subSchema)) { + $schema[$keyword][$name] = self::normalizeSubSchema($subSchema); + } + } + } + + foreach (self::SUB_SCHEMA_LIST_KEYWORDS as $keyword) { + if (!isset($schema[$keyword]) || !\is_array($schema[$keyword])) { + continue; + } + + // An empty list stays a list — `allOf: []` is already valid JSON. + foreach ($schema[$keyword] as $index => $subSchema) { + if (\is_array($subSchema)) { + $schema[$keyword][$index] = self::normalizeSubSchema($subSchema); + } + } + } + + if (isset($schema['items']) && \is_array($schema['items'])) { + // `items` is a single sub-schema, or a list of them in draft-07 tuple form. + // An empty array is read as the empty schema `{}` — what an `items: {}` from + // SchemaGenerator decodes to — rather than as an empty tuple. + if ([] !== $schema['items'] && array_is_list($schema['items'])) { + foreach ($schema['items'] as $index => $itemSchema) { + if (\is_array($itemSchema)) { + $schema['items'][$index] = self::normalizeSubSchema($itemSchema); + } + } + } else { + $schema['items'] = self::normalizeSubSchema($schema['items']); + } } return $schema; } + + /** + * @param array $schema + * + * @return array|\stdClass + */ + private static function normalizeSubSchema(array $schema): array|\stdClass + { + return [] === $schema ? new \stdClass() : self::normalizeSchema($schema); + } } diff --git a/src/Schema/ToolAnnotations.php b/src/Schema/ToolAnnotations.php index 783d959..c4f2774 100644 --- a/src/Schema/ToolAnnotations.php +++ b/src/Schema/ToolAnnotations.php @@ -11,6 +11,8 @@ namespace Mcp\Schema; +use Mcp\Exception\InvalidArgumentException; + /** * Additional properties describing a Tool to clients. * NOTE: all properties in ToolAnnotations are hints. @@ -48,6 +50,16 @@ class ToolAnnotations implements \JsonSerializable */ public static function fromArray(array $data): self { + if (isset($data['title']) && !\is_string($data['title'])) { + throw new InvalidArgumentException('Invalid "title" in ToolAnnotations data.'); + } + + foreach (['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as $hint) { + if (isset($data[$hint]) && !\is_bool($data[$hint])) { + throw new InvalidArgumentException(\sprintf('Invalid "%s" in ToolAnnotations data; expected a boolean.', $hint)); + } + } + return new self( $data['title'] ?? null, $data['readOnlyHint'] ?? null, diff --git a/src/Schema/ToolChoice.php b/src/Schema/ToolChoice.php new file mode 100644 index 0000000..ad00323 --- /dev/null +++ b/src/Schema/ToolChoice.php @@ -0,0 +1,54 @@ + $this->mode->value]; + } +} diff --git a/src/Schema/Wire/McpHeader.php b/src/Schema/Wire/McpHeader.php new file mode 100644 index 0000000..e73907b --- /dev/null +++ b/src/Schema/Wire/McpHeader.php @@ -0,0 +1,217 @@ + + */ +final class McpHeader +{ + public const METHOD = 'Mcp-Method'; + public const NAME = 'Mcp-Name'; + public const PARAM_PREFIX = 'Mcp-Param-'; + public const PROTOCOL_VERSION = 'MCP-Protocol-Version'; + + /** Wrapper marking a header value as Base64 of its UTF-8 representation. */ + private const BASE64_PREFIX = '=?base64?'; + private const BASE64_SUFFIX = '?='; + + /** + * The subject of a request, per method. Anything unlisted is exempt. + * + * @param array|null $params + */ + public static function nameFor(string $method, ?array $params): ?string + { + $value = match ($method) { + 'tools/call', 'prompts/get' => $params['name'] ?? null, + 'resources/read' => $params['uri'] ?? null, + 'tasks/get', 'tasks/update', 'tasks/cancel' => $params['taskId'] ?? null, + default => null, + }; + + return \is_string($value) ? $value : null; + } + + /** + * Renders a mirrored argument as a header value, wrapping it when it is not + * header-safe. + * + * Booleans travel as `true`/`false` and integers as decimal digits, both of + * which are always safe. A string is wrapped when it carries a control + * character, anything outside US-ASCII, or leading or trailing whitespace — + * the last because a receiver is entitled to trim the value (RFC 9110 + * §5.5), which would otherwise silently change it. + * + * Returns null for a value that cannot be mirrored at all. + */ + public static function encode(mixed $value): ?string + { + $rendered = match (true) { + \is_bool($value) => $value ? 'true' : 'false', + \is_int($value) => (string) $value, + \is_string($value) => $value, + default => null, + }; + + if (null === $rendered) { + return null; + } + + return self::isSafe($rendered) ? $rendered : self::wrap($rendered); + } + + /** + * Unwraps a `=?base64?…?=` value, or returns a plain value unchanged. + * + * Strict: PHP's decoder accepts mispadded input and returns plausible + * bytes, which would turn a corrupted header into a silent mismatch. + * Null when the wrapper is present but its contents are not valid Base64. + */ + public static function decode(string $value): ?string + { + if (!str_starts_with($value, self::BASE64_PREFIX) || !str_ends_with($value, self::BASE64_SUFFIX)) { + return $value; + } + + $encoded = substr($value, \strlen(self::BASE64_PREFIX), -\strlen(self::BASE64_SUFFIX)); + + $decoded = base64_decode($encoded, true); + + if (false === $decoded || base64_encode($decoded) !== $encoded) { + return null; + } + + return $decoded; + } + + /** + * Validates every `x-mcp-header` annotation reachable through `properties` + * in an input schema (SEP-2243). + * + * The value becomes an HTTP field name, so it has to be one; it has to be + * unique case-insensitively, or two arguments would fight over one header; + * and it may only sit on a primitive that is not `number`, because a float + * has no single decimal spelling for a receiver to compare against. + * + * @param array $schema + * + * @return string|null the reason it is invalid, or null when every annotation is well-formed + */ + public static function checkAnnotations(array $schema): ?string + { + $seen = []; + + foreach (self::annotations($schema) as [$name, $type, $path]) { + if ('' === $name) { + return \sprintf('the annotation at "%s" is empty', $path); + } + + // RFC 9110 tchar; excludes CR, LF and every other control character. + if (1 !== preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) { + return \sprintf('"%s" is not a valid HTTP field name', $name); + } + + $folded = strtolower($name); + if (isset($seen[$folded])) { + return \sprintf('"%s" is declared twice, at "%s" and "%s"', $name, $seen[$folded], $path); + } + $seen[$folded] = $path; + + if ('number' === $type) { + return \sprintf('"%s" is on a "number" property ("%s"), which cannot be mirrored', $name, $path); + } + + if (null !== $type && !\in_array($type, ['string', 'integer', 'boolean'], true)) { + return \sprintf('"%s" is on a "%s" property ("%s"); only string, integer and boolean can be mirrored', $name, $type, $path); + } + } + + return null; + } + + /** + * Every annotation reachable through `properties` alone, as name, declared + * type and dotted path. + * + * @param array $schema + * + * @return list + */ + public static function annotations(array $schema, string $prefix = ''): array + { + $properties = $schema['properties'] ?? null; + + if (!\is_array($properties)) { + return []; + } + + $found = []; + + foreach ($properties as $property => $definition) { + if (!\is_array($definition)) { + continue; + } + + $path = '' === $prefix ? (string) $property : $prefix.'.'.$property; + $annotation = $definition['x-mcp-header'] ?? null; + + if (null !== $annotation) { + if (!\is_string($annotation)) { + $found[] = ['', null, $path]; + } else { + $found[] = [$annotation, \is_string($definition['type'] ?? null) ? $definition['type'] : null, $path]; + } + } + + $found = [...$found, ...self::annotations($definition, $path)]; + } + + return $found; + } + + private static function wrap(string $value): string + { + return self::BASE64_PREFIX.base64_encode($value).self::BASE64_SUFFIX; + } + + /** + * Printable US-ASCII with no leading or trailing whitespace. Interior + * spaces are fine; a tab is not, since it is a control character that + * field parsers are allowed to fold. + * + * A literal that already has the wrapper's shape is not safe either: sent + * unchanged, {@see self::decode()} would unwrap it as if it were Base64 + * and hand back something other than the literal. + */ + private static function isSafe(string $value): bool + { + if ($value !== trim($value)) { + return false; + } + + if (str_starts_with($value, self::BASE64_PREFIX) && str_ends_with($value, self::BASE64_SUFFIX)) { + return false; + } + + return 1 === preg_match('/^[\x20-\x7E]*$/', $value); + } +} diff --git a/src/Server.php b/src/Server.php index 8657610..a19e859 100644 --- a/src/Server.php +++ b/src/Server.php @@ -13,6 +13,8 @@ namespace Mcp; use Mcp\Server\Builder; use Mcp\Server\Protocol; +use Mcp\Server\Stateless\StatelessProtocol; +use Mcp\Server\Transport\StatelessAwareTransportInterface; use Mcp\Server\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -23,9 +25,14 @@ use Psr\Log\NullLogger; */ final class Server { + /** + * @param StatelessProtocol|null $statelessProtocol the modern-era (SEP-2575) dispatcher, absent on a + * server that serves the handshake era alone + */ public function __construct( private readonly Protocol $protocol, private readonly LoggerInterface $logger = new NullLogger(), + private readonly ?StatelessProtocol $statelessProtocol = null, ) { } @@ -47,6 +54,13 @@ final class Server $this->protocol->connect($transport); + // The eras share the transport, not the dispatcher: a transport that + // can tell them apart takes both and picks per request. One that + // cannot — stdio — carries the handshake era alone. + if (null !== $this->statelessProtocol && $transport instanceof StatelessAwareTransportInterface) { + $transport->connectStateless($this->statelessProtocol); + } + $this->logger->info('Running server...'); try { diff --git a/src/Server/Builder.php b/src/Server/Builder.php index c53fa62..1a5dd73 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -32,7 +32,8 @@ use Mcp\Exception\LogicException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; -use Mcp\Schema\Extension\ServerExtensionInterface; +use Mcp\Schema\Extension\AbstractExtension; +use Mcp\Schema\Extension\ExtensionInterface; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; use Mcp\Schema\Prompt; @@ -55,6 +56,14 @@ use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionManager; use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Session\SessionStoreInterface; +use Mcp\Server\Stateless\RequestStateCodec; +use Mcp\Server\Stateless\StandardHeaderValidator; +use Mcp\Server\Stateless\StatelessProtocol; +use Mcp\Server\Subscription\InMemoryNotificationBus; +use Mcp\Server\Subscription\NotificationBusInterface; +use Mcp\Server\Subscription\Psr16NotificationBus; +use Mcp\Server\Subscription\PublishingEventDispatcher; +use Mcp\Server\Wire\CachePolicy; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -65,6 +74,17 @@ use Symfony\Component\Finder\Finder; /** * @phpstan-import-type Handler from ElementReference * + * @phpstan-type AssembledParts array{ + * logger: LoggerInterface, + * eventDispatcher: ?EventDispatcherInterface, + * configuration: Configuration, + * messageFactory: MessageFactory, + * sessionManager: SessionManagerInterface, + * registry: RegistryInterface, + * requestHandlers: list>, + * notificationHandlers: list, + * } + * * @author Kyrian Obikwelu */ final class Builder @@ -103,6 +123,22 @@ final class Builder private ?ProtocolVersion $protocolVersion = null; + private ?CachePolicy $cachePolicy = null; + + private ?NotificationBusInterface $notificationBus = null; + + private float $subscriptionLifetime = 30.0; + + /** @var array RPC method to the extension identifier defining it */ + private array $extensionMethods = []; + + /** @var list|class-string<\Mcp\Schema\JsonRpc\Notification>> */ + private array $extensionMessages = []; + + private ?string $requestStateKey = null; + + private int $requestStateTtl = 600; + /** * @var array> */ @@ -223,10 +259,29 @@ final class Builder private bool $lazyLoading = true; + private bool $headerValidation = true; + + /** @var list|null null defaults to every modern revision, [] serves none */ + private ?array $modernVersions = null; + + private bool $inputRequiredShim = true; + + private int $inputRequiredRounds = InputRequiredShim::DEFAULT_MAX_ROUNDS; + + private int $inputRequiredTimeout = InputRequiredShim::DEFAULT_ROUND_TIMEOUT; + + /** + * @see self::assemble() for why this is memoized + * + * @var AssembledParts|null + */ + private ?array $parts = null; + /** * Sets the server's identity. Required. * * @param ?Icon[] $icons + * @param ?string $title Display name for UI and end-user contexts. Falls back to $name when absent. */ public function setServerInfo( string $name, @@ -234,8 +289,89 @@ final class Builder ?string $description = null, ?array $icons = null, ?string $websiteUrl = null, + ?string $title = null, ): self { - $this->serverInfo = new Implementation(trim($name), trim($version), $description, $icons, $websiteUrl); + $this->serverInfo = new Implementation(trim($name), trim($version), $description, $icons, $websiteUrl, $title); + + return $this; + } + + /** + * Sets the bus carrying server-initiated notifications to open + * `subscriptions/listen` streams (SEP-2575). + * + * Without one, a listen stream acknowledges and then carries nothing: there + * is no safe default, because the right implementation depends on whether + * the publisher and the stream share a process. + * {@see InMemoryNotificationBus} is correct for stdio and persistent + * runtimes; under PHP-FPM, where they are different workers, use + * {@see Psr16NotificationBus} or an implementation over your own broker. + * + * Registry changes are published automatically when an event dispatcher is + * configured; anything else — `notifications/resources/updated` above all — + * is published by the application calling + * {@see NotificationBusInterface::publish()}. + */ + public function setNotificationBus(NotificationBusInterface $bus): self + { + $this->notificationBus = $bus; + + return $this; + } + + /** + * Sets how long a `subscriptions/listen` stream is held open before the + * server closes it gracefully. + * + * The real ceiling is the runtime's: under PHP-FPM a stream cannot outlive + * `max_execution_time`, and a value above it buys a killed worker instead + * of a longer subscription. Pass `0` for "until the client or the runtime + * ends it", which is what a persistent runtime wants. + */ + public function setSubscriptionLifetime(float $seconds): self + { + $this->subscriptionLifetime = max(0.0, $seconds); + + return $this; + } + + /** + * Sets how long, and to whom, this server's answers may be cached (SEP-2549). + * + * The modern lifecycle must put `ttlMs` and `cacheScope` on every cacheable + * result; without a policy it says "private, immediately stale", which is + * conformant and forfeits the point. Build one with + * {@see CachePolicy::default()} and narrow it per method: + * + * ```php + * $builder->setCachePolicy( + * CachePolicy::default(60_000) + * ->withMethod('tools/list', 3_600_000, CacheScope::Public), + * ); + * ``` + */ + public function setCachePolicy(CachePolicy $policy): self + { + $this->cachePolicy = $policy; + + return $this; + } + + /** + * Sets the key signing the `requestState` carried across the rounds of a + * multi round-trip request (SEP-2322). Without one, every echoed state is + * refused. + * + * The same key must reach every instance that might serve the retry, so a + * per-process random value only works for a single-process deployment. + * + * @param string $key at least 32 bytes + * @param int $ttl how long a minted state stays valid, in seconds + */ + public function setRequestState(string $key, int $ttl = 600): self + { + $this->requestStateKey = $key; + $this->requestStateTtl = $ttl; return $this; } @@ -278,18 +414,46 @@ final class Builder * Enable one or more MCP protocol extensions, announced to clients under * `capabilities.extensions` during the initialize handshake. * - * @throws LogicException if the same extension is enabled more than once + * An extension also contributes the message classes its methods decode + * into and the handlers serving them, if any — see {@see AbstractExtension} + * for extensions that only announce a capability. + * + * @throws InvalidArgumentException if the identifier is not a valid `_meta` prefix + * @throws LogicException if the same extension is enabled more than once, or + * two enabled extensions define the same RPC method */ - public function enableExtension(ServerExtensionInterface ...$extensions): self + public function enableExtension(ExtensionInterface ...$extensions): self { foreach ($extensions as $extension) { - $id = $extension->getId(); + $id = (string) $extension->getId(); if (isset($this->extensions[$id])) { throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id)); } $this->extensions[$id] = $extension->getCapabilities(); + + // Without this the method cannot be decoded at all, so nothing + // downstream ever sees it. + foreach ($extension->getMessages() as $message) { + $method = $message::getMethod(); + + // The message factory resolves a method to whichever class was + // registered first, so a second owner here would silently lose + // the dispatch race while still being named in error messages. + if (isset($this->extensionMethods[$method]) && $this->extensionMethods[$method] !== $id) { + throw new LogicException(\sprintf('Method "%s" is already claimed by extension "%s", so extension "%s" cannot also define it.', $method, $this->extensionMethods[$method], $id)); + } + + $this->extensionMessages[] = $message; + // Recorded even though the handler answers it, so a server with + // the extension *off* can say so instead of "no such method". + $this->extensionMethods[$method] = $id; + } + + foreach ($extension->getRequestHandlers() as $handler) { + $this->requestHandlers[] = $handler; + } } return $this; @@ -358,7 +522,8 @@ final class Builder * * Lazy (the default) defers loading to the first registry read so a persistent runtime does not * freeze the registry to a source not yet ready at build time. Disable to load eagerly at build. - * A registry supplied via setRegistry() is always loaded eagerly. + * A registry supplied via setRegistry() is always loaded eagerly; its own constructor loader, + * if it has one, still runs on the first read. */ public function setLazyLoading(bool $lazyLoading = true): self { @@ -367,6 +532,21 @@ final class Builder return $this; } + /** + * Controls whether {@see self::buildStateless()} checks the standard MCP + * request headers (SEP-2243) against the JSON-RPC body. On by default. + * + * Disable for a `StatelessProtocol` served by a transport with no header + * layer — the validator would otherwise reject every request for + * carrying none of the standard headers. + */ + public function setHeaderValidator(bool $headerValidation = true): self + { + $this->headerValidation = $headerValidation; + + return $this; + } + /** * Provides a PSR-3 logger instance. Defaults to NullLogger. */ @@ -655,13 +835,183 @@ final class Builder return $this; } + /** + * Stop serving multi round-trip handlers to handshake-era clients. + * + * A handler that returns an {@see \Mcp\Schema\Result\InputRequiredResult} + * is written for the modern era, where the client answers the embedded + * requests and retries the call. On a handshake-era connection the SDK + * fulfils it instead, by sending those requests over that connection's own + * channel and re-entering the handler with the answers — so one handler + * serves both eras. See {@see InputRequiredShim} for what re-entry costs. + * + * Turn it off to have such a handler fail on a handshake-era connection + * rather than be fulfilled behind your back. + */ + public function withoutInputRequiredShim(): self + { + $this->inputRequiredShim = false; + + return $this; + } + + /** + * Bounds on the shim's loop: how many times a handler may be re-entered for + * one request, and how long one answer is waited for. + * + * The wait holds the originating request open, so on a process-per-request + * runtime it holds a worker too. Size it against your pool, not against a + * user's patience. + */ + public function setInputRequiredLimits(int $maxRounds, int $roundTimeout): self + { + if ($maxRounds < 1) { + throw new InvalidArgumentException('maxRounds must be at least 1.'); + } + + if ($roundTimeout < 1) { + throw new InvalidArgumentException('roundTimeout must be at least 1 second.'); + } + + $this->inputRequiredRounds = $maxRounds; + $this->inputRequiredTimeout = $roundTimeout; + + return $this; + } + + private function requestStateCodec(): ?RequestStateCodec + { + return null !== $this->requestStateKey + ? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl) + : null; + } + + /** + * Serve only the handshake era, refusing modern-era traffic. + * + * The default is to serve both from whatever the server is run on, because + * an endpoint that turns a client away for speaking the newer revision is + * almost never what anyone wants. Call this when it is: a deployment that + * has to stay on the handshake wire, or one whose tools call back into the + * client and would fail the modern half anyway. + */ + public function withoutModernEra(): self + { + $this->modernVersions = []; + + return $this; + } + + /** + * Revisions the modern-era leg answers for. Defaults to every modern + * revision this SDK knows. + * + * @param list $versions + * + * @throws InvalidArgumentException if a version is not one {@see Wire\InboundClassifier} routes to this leg + */ + public function setModernVersions(array $versions): self + { + foreach ($versions as $version) { + if (!$version->isModern()) { + throw new InvalidArgumentException(\sprintf('"%s" is a handshake-era revision; a request claiming it never reaches the modern leg to be served.', $version->value)); + } + } + + $this->modernVersions = $versions; + + return $this; + } + /** * Builds the fully configured Server instance. + * + * The result carries a dispatcher for each era. Which one answers is a + * per-request decision the transport makes, so one server object — and one + * endpoint — serves handshake-era and modern-era clients alike. */ public function build(): Server + { + $parts = $this->assemble(); + + $protocol = new Protocol( + requestHandlers: $parts['requestHandlers'], + notificationHandlers: $parts['notificationHandlers'], + messageFactory: $parts['messageFactory'], + sessionManager: $parts['sessionManager'], + logger: $parts['logger'], + eventDispatcher: $parts['eventDispatcher'], + inputRequiredShim: $this->inputRequiredShim + ? new InputRequiredShim($this->inputRequiredRounds, $this->inputRequiredTimeout, $parts['logger']) + : null, + requestStateCodec: $this->requestStateCodec(), + ); + + $modernVersions = $this->modernVersions ?? ProtocolVersion::modernVersions(); + + return new Server( + $protocol, + $parts['logger'], + [] === $modernVersions ? null : $this->buildStateless($modernVersions), + ); + } + + /** + * Builds a dispatcher for the modern (SEP-2575) lifecycle on its own. + * + * Tools, prompts, resources and their handlers are era-independent, so one + * builder configuration drives either lifecycle. {@see self::build()} wires + * both together; this is the modern era by itself, for an endpoint that + * serves nothing else. + * + * @param list $supportedVersions revisions this dispatcher will answer for + */ + public function buildStateless(array $supportedVersions = [ProtocolVersion::V2026_07_28]): StatelessProtocol + { + $parts = $this->assemble(); + + return new StatelessProtocol( + requestHandlers: $parts['requestHandlers'], + messageFactory: $parts['messageFactory'], + configuration: $parts['configuration'], + supportedVersions: $supportedVersions, + logger: $parts['logger'], + subscriptionLifetime: $this->subscriptionLifetime, + headerValidator: $this->headerValidation ? new StandardHeaderValidator($parts['registry']) : null, + requestStateCodec: $this->requestStateCodec(), + cachePolicy: $this->cachePolicy, + notificationBus: $this->notificationBus, + extensionMethods: $this->extensionMethods, + ); + } + + /** + * Resolves the builder's configuration into the parts both lifecycles need. + * + * Memoized: the two eras share one registry, one session manager and one + * set of handler instances, so they answer for the same server rather than + * for two that merely started from the same configuration. + * + * @return AssembledParts + */ + private function assemble(): array + { + return $this->parts ??= $this->resolve(); + } + + /** + * @return AssembledParts + */ + private function resolve(): array { $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); + + // A configured bus needs the registry's change events, and PSR-14 hands + // the SDK a dispatcher it cannot register listeners on — so it wraps. + $eventDispatcher = null !== $this->notificationBus + ? new PublishingEventDispatcher($this->notificationBus, $this->eventDispatcher) + : $this->eventDispatcher; $subscriptionManager = $this->subscriptionManager ?? new SessionSubscriptionManager($logger); $sessionManager = $this->sessionManager ?? new SessionManager( $this->sessionStore ?? new InMemorySessionStore(), @@ -696,20 +1046,25 @@ final class Builder if ($this->hasCustomRegistry) { // Builder can't inject the loader into an already-constructed instance, so load it eagerly. + // Via loadFrom(), which suppresses the change events the load would otherwise dispatch. $registry = $this->registry; - $chainLoader->load($registry); + if ($registry instanceof Registry) { + $registry->loadFrom($chainLoader); + } else { + $chainLoader->load($registry); + } $eagerlyLoaded = true; } else { - $registry = new Registry($this->eventDispatcher, $logger, loader: $chainLoader); + $registry = new Registry($eventDispatcher, $logger, loader: $chainLoader); if (!$this->lazyLoading) { $registry->load(); } $eagerlyLoaded = !$this->lazyLoading; } - $messageFactory = MessageFactory::make(); + $messageFactory = MessageFactory::make(additional: $this->extensionMessages); - $capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded); + $capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded, $eventDispatcher); // Extensions enabled via enableExtension() are folded into caller-supplied // capabilities too, so setCapabilities() does not silently drop them. @@ -717,13 +1072,20 @@ final class Builder $capabilities = $capabilities->withExtensions($this->extensions); } + if (null !== $this->protocolVersion && $this->protocolVersion->isModern()) { + $logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, negotiating the handshake revisions instead.', [ + 'configured' => $this->protocolVersion->value, + 'negotiable' => array_map(static fn (ProtocolVersion $v): string => $v->value, ProtocolVersion::handshakeVersions()), + ]); + } + $serverInfo = $this->serverInfo ?? new Implementation(); $configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); $referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container); $requestHandlers = array_merge($this->requestHandlers, [ new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger), - new Handler\Request\CompletionCompleteHandler($registry, $container), + new Handler\Request\CompletionCompleteHandler($registry, $container, $logger), new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), new Handler\Request\InitializeHandler($configuration), new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit), @@ -741,16 +1103,16 @@ final class Builder new Handler\Notification\InitializedHandler(), ]); - $protocol = new Protocol( - requestHandlers: $requestHandlers, - notificationHandlers: $notificationHandlers, - messageFactory: $messageFactory, - sessionManager: $sessionManager, - logger: $logger, - eventDispatcher: $this->eventDispatcher, - ); - - return new Server($protocol, $logger); + return [ + 'logger' => $logger, + 'eventDispatcher' => $eventDispatcher, + 'registry' => $registry, + 'configuration' => $configuration, + 'messageFactory' => $messageFactory, + 'sessionManager' => $sessionManager, + 'requestHandlers' => $requestHandlers, + 'notificationHandlers' => $notificationHandlers, + ]; } /** @@ -758,9 +1120,11 @@ final class Builder * the load, so they are advertised from the configured sources instead — opaque sources (custom * loaders, discovery) advertise all kinds, and over-advertising is harmless per MCP semantics. */ - private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded): ServerCapabilities + private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded, ?EventDispatcherInterface $eventDispatcher): ServerCapabilities { - $listChanged = $this->eventDispatcher instanceof EventDispatcherInterface; + // Without a dispatcher the registry announces nothing, so there is no + // list-changed notification to advertise. + $listChanged = $eventDispatcher instanceof EventDispatcherInterface; if ($eagerlyLoaded) { $hasResources = $registry->hasResources() || $registry->hasResourceTemplates(); diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 4686071..a60e539 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -23,6 +23,7 @@ use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\Role; use Mcp\Schema\Enum\SamplingContext; +use Mcp\Schema\Extension\ExtensionIdentifier; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\JsonRpc\Request; @@ -32,8 +33,12 @@ use Mcp\Schema\Notification\LoggingMessageNotification; use Mcp\Schema\Notification\ProgressNotification; use Mcp\Schema\Request\CreateSamplingMessageRequest; use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Request\ListRootsRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; use Mcp\Schema\Result\ElicitResult; +use Mcp\Schema\Result\ListRootsResult; +use Mcp\Schema\Tool; +use Mcp\Schema\ToolChoice; use Mcp\Server\Session\SessionInterface; /** @@ -52,9 +57,9 @@ use Mcp\Server\Session\SessionInterface; * $client->notify(new ProgressNotification("Starting analysis...")); * * // Request LLM sampling from client - * $response = $client->request(new SamplingRequest($text)); + * $result = $client->sample($text); * - * return $response->content->text; + * return $result->content->text; * } * ``` * @@ -65,6 +70,8 @@ use Mcp\Server\Session\SessionInterface; * includeContext?: SamplingContext, * stopSequences?: string[], * metadata?: array, + * tools?: Tool[], + * toolChoice?: ToolChoice, * } * * @author Kyrian Obikwelu @@ -93,9 +100,14 @@ class ClientGateway /** * Convenience method to send a logging notification to the client. + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ public function log(LoggingLevel $level, mixed $data, ?string $logger = null): void { + trigger_deprecation('mcp/sdk', '0.8', 'MCP logging is deprecated since protocol revision 2026-07-28 (SEP-2577); log to stderr or use OpenTelemetry instead.'); + $this->notify(new LoggingMessageNotification($level, $data, $logger)); } @@ -122,13 +134,21 @@ class ClientGateway * @param int $maxTokens Maximum tokens to generate * @param int $timeout The timeout in seconds * @param SampleOptions $options Additional sampling options (temperature, etc.) + * Context values other than `none` require the client's + * sampling.context capability; tools and toolChoice require + * the client's sampling.tools capability. * * @return CreateSamplingMessageResult The sampling response * * @throws ClientException if the client request results in an error message + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Integrate with an LLM provider's API directly instead. */ public function sample(array|Content|string $message, int $maxTokens = 1000, int $timeout = 120, array $options = []): CreateSamplingMessageResult { + trigger_deprecation('mcp/sdk', '0.8', 'MCP sampling is deprecated since protocol revision 2026-07-28 (SEP-2577); integrate with an LLM provider\'s API directly instead.'); + $preferences = $options['preferences'] ?? null; if (null !== $preferences && !$preferences instanceof ModelPreferences) { throw new InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.'); @@ -150,8 +170,13 @@ class ClientGateway temperature: $options['temperature'] ?? null, stopSequences: $options['stopSequences'] ?? null, metadata: $options['metadata'] ?? null, + tools: $options['tools'] ?? null, + toolChoice: $options['toolChoice'] ?? null, ); + // Fail here rather than letting the client reject the request with -32602. + $request->validateToolFlow(); + $response = $this->request($request, $timeout); if ($response instanceof Error) { @@ -162,22 +187,83 @@ class ClientGateway } /** - * Convenience method for elicitation requests. + * Convenience method for form-mode elicitation requests. * * Requests additional information from the user via the client. The user can * accept (providing the requested data), decline, or cancel the request. * + * Serves every revision, by two different mechanics. Where the client can be + * asked while the request is open, it is: an `elicitation/create` goes out and + * this call returns its answer. From 2026-07-28 on there are no + * server-initiated requests, so the ask ends the request instead and the call + * returns once the client re-sends it with the answer — see + * {@see Stateless\ElicitationReplay} for what that costs, namely + * that the handler is entered once per ask. + * * @param string $message A human-readable message describing what information is needed * @param ElicitationSchema $requestedSchema The schema defining the fields to elicit from the user - * @param int $timeout The timeout in seconds + * @param int $timeout The timeout in seconds; unused where the answer arrives on a later request + * @param string|null $key Names this ask, so it keeps resolving to the same answer across the rounds + * a revision without server-initiated requests needs. Defaults to the ask's + * position in the handler, which only a handler asking in a different order + * every time needs to override. * * @return ElicitResult The elicitation response containing the user's action and any provided content * * @throws ClientException if the client request results in an error message */ - public function elicit(string $message, ElicitationSchema $requestedSchema, int $timeout = 120): ElicitResult + public function elicit(string $message, ElicitationSchema $requestedSchema, int $timeout = 120, ?string $key = null): ElicitResult { - $request = new ElicitRequest($message, $requestedSchema); + return $this->sendElicitation(ElicitRequest::forForm($message, $requestedSchema), $timeout, $key); + } + + /** + * Convenience method for url-mode elicitation requests. + * + * Sends the user to $url to complete the interaction out of band — an OAuth + * consent screen, a checkout, a form hosted elsewhere. The result carries only + * the user's action; unlike form mode there is no content to read back, so + * whatever the user did there has to be picked up through the URL's own channel. + * + * Portable across revisions on the same terms as {@see self::elicit()}. + * + * @param string|null $key names this ask across the rounds of a multi round-trip call + * + * @throws ClientException if the client request results in an error message + * @throws InvalidArgumentException if the client did not declare url-mode elicitation + */ + public function elicitUrl(string $message, string $url, int $timeout = 120, ?string $key = null): ElicitResult + { + // URL mode only exists from 2025-11-25 on, and only for clients declaring it + if (!$this->supportsElicitationUrl()) { + throw new InvalidArgumentException('The client did not declare the "elicitation.url" capability, so it cannot be sent a url-mode elicitation.'); + } + + return $this->sendElicitation(ElicitRequest::forUrl($message, $url), $timeout, $key); + } + + /** + * Request the list of filesystem roots exposed by the client. + * + * Roots are the client's "workspace folders" — the directories or files the + * server is allowed to operate on. The client answers the roots/list request + * with a list of file:// URIs. + * + * @param int $timeout The timeout in seconds + * + * @return ListRootsResult The roots exposed by the client + * + * @throws ClientException if the client request results in an error message + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Pass directories or files through tool arguments, resource URIs or server + * configuration instead. + */ + public function listRoots(int $timeout = 120): ListRootsResult + { + trigger_deprecation('mcp/sdk', '0.8', 'MCP roots are deprecated since protocol revision 2026-07-28 (SEP-2577); pass directories through tool arguments or resource URIs instead.'); + + $request = new ListRootsRequest(); $response = $this->request($request, $timeout); @@ -185,7 +271,24 @@ class ClientGateway throw new ClientException($response); } - return ElicitResult::fromArray($response->result); + return ListRootsResult::fromArray($response->result); + } + + /** + * Check if the connected client supports roots. + * + * Roots allow servers to ask the client for the set of directories or files + * it is permitted to operate on. This method checks the client's advertised + * capabilities to determine if roots/list requests are supported. + * + * @return bool True if the client supports roots, false otherwise + */ + public function supportsRoots(): bool + { + $capabilities = (array) $this->session->get('client_capabilities', []); + + // MCP spec: capability presence indicates support (value is typically {} or []) + return \array_key_exists('roots', $capabilities); } /** @@ -199,32 +302,159 @@ class ClientGateway */ public function supportsElicitation(): bool { - $capabilities = $this->session->get('client_capabilities', []); + $capabilities = (array) $this->session->get('client_capabilities', []); // MCP spec: capability presence indicates support (value is typically {} or []) return \array_key_exists('elicitation', $capabilities); } + /** + * Check if the connected client supports sampling. + * + * Sampling lets a server borrow the client's model during tool execution. + * This method checks the client's advertised capabilities to determine if + * sampling/createMessage requests are supported. + * + * @return bool True if the client supports sampling, false otherwise + */ + public function supportsSampling(): bool + { + $capabilities = (array) $this->session->get('client_capabilities', []); + + // MCP spec: capability presence indicates support (value is typically {} or []) + return \array_key_exists('sampling', $capabilities); + } + + /** + * Check if the connected client supports tools during sampling. + * + * Per the spec a server MUST NOT put `tools` or `toolChoice` on a + * `sampling/createMessage` request unless the client advertised + * `sampling.tools`, so check this before passing either option to + * {@see self::sample()}. + * + * @return bool True if the client supports tool-enabled sampling, false otherwise + */ + public function supportsSamplingTools(): bool + { + return $this->hasSubCapability('sampling', 'tools'); + } + + /** + * Check if the connected client supports context inclusion during sampling. + * + * The `includeContext` values other than `none` are soft-deprecated and should + * only be sent when the client advertised `sampling.context`. + * + * @return bool True if the client supports sampling context, false otherwise + */ + public function supportsSamplingContext(): bool + { + return $this->hasSubCapability('sampling', 'context'); + } + + /** + * Check if the connected client supports url-mode elicitation. + * + * An `elicitation` capability naming no mode declares form mode — the only shape + * that existed before URL elicitation — so url mode has to be named explicitly. + * + * @return bool True if the client supports url-mode elicitation, false otherwise + */ + public function supportsElicitationUrl(): bool + { + return $this->hasSubCapability('elicitation', 'url'); + } + + /** + * Check if the connected client negotiated the given protocol extension. + * + * Extensions are advertised under `capabilities.extensions` keyed by their + * reverse-DNS id, e.g. `McpApps::EXTENSION_ID` — a server offering MCP Apps + * should check this before pointing a tool at a `ui://` resource, and fall back + * to a text-only result otherwise. + * + * @return bool True if the client advertised the extension, false otherwise + */ + public function supportsExtension(ExtensionIdentifier|string $id): bool + { + return $this->hasSubCapability('extensions', (string) $id); + } + + /** + * Sub-capabilities are declared by the presence of a (possibly empty) object, so + * only the key matters — not whatever it holds. The value arrives as an object on + * a live session and as an array once the session has round-tripped through JSON, + * hence both shapes. + */ + private function hasSubCapability(string $capability, string $name): bool + { + $capabilities = (array) $this->session->get('client_capabilities', []); + $declared = $capabilities[$capability] ?? null; + + if (\is_array($declared)) { + return \array_key_exists($name, $declared); + } + + return \is_object($declared) && property_exists($declared, $name); + } + + /** + * @throws ClientException if the client request results in an error message + */ + private function sendElicitation(ElicitRequest $request, int $timeout, ?string $key = null): ElicitResult + { + $response = $this->suspend($request, $timeout, $key); + + if ($response instanceof Error) { + throw new ClientException($response); + } + + return ElicitResult::fromArray($response->result, $request->mode); + } + /** * Send a request to the client and wait for a response (blocking). * * This suspends the Fiber and waits for the client to respond. The transport * handles polling the session for the response and resuming the Fiber when ready. * + * Public for {@see InputRequiredShim}, which sends the requests a handler + * embedded in an {@see \Mcp\Schema\Result\InputRequiredResult} and knows + * nothing about their kinds. Prefer the typed methods above. + * * @param Request $request The request to send * @param int $timeout Maximum time to wait for response (seconds) * * @return Response>|Error The client's response message * * @throws RuntimeException If Fiber support is not available + * + * @internal */ - private function request(Request $request, int $timeout = 120): Response|Error + public function request(Request $request, int $timeout = 120): Response|Error + { + return $this->suspend($request, $timeout); + } + + /** + * Hands the request to whatever is driving this fiber and waits for its answer. + * + * @param string|null $key the name an elicitation's answer is filed under when + * the revision serving this call answers by asking + * ({@see Stateless\ElicitationReplay}); + * ignored by every leg that has a live client to ask + * + * @return Response>|Error the peer's answer + */ + private function suspend(Request $request, int $timeout, ?string $key = null): Response|Error { $response = \Fiber::suspend([ 'type' => 'request', 'request' => $request, 'session_id' => $this->session->getId()->toRfc4122(), 'timeout' => $timeout, + 'input_key' => $key, ]); if (!$response instanceof Response && !$response instanceof Error) { diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index e78ce1b..72f3c21 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -14,6 +14,7 @@ namespace Mcp\Server\Handler\Request; use Mcp\Capability\Discovery\SchemaValidator; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; +use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\TextContent; @@ -22,12 +23,17 @@ use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\InputRequiredResult; +use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** - * @implements RequestHandlerInterface + * A tools/call answers with the tool's output or, under MRTR, with a request + * for the input it still needs. + * + * @implements RequestHandlerInterface * * @author Christopher Hertel * @author Tobias Nyholm @@ -51,7 +57,7 @@ final class CallToolHandler implements RequestHandlerInterface } /** - * @return Response|Error + * @return Response|Error */ public function handle(Request $request, SessionInterface $session): Response|Error { @@ -67,7 +73,9 @@ final class CallToolHandler implements RequestHandlerInterface } catch (ToolNotFoundException $e) { $this->logger->error('Tool not found', ['name' => $toolName, 'exception' => $e]); - return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); + // -32601 answers an unknown *method*; tools/call exists, it is the + // name in its params that does not. + return Error::forInvalidParams($e->getMessage(), $request->getId()); } $inputSchema = $reference->tool->inputSchema; @@ -92,13 +100,44 @@ final class CallToolHandler implements RequestHandlerInterface $arguments['_session'] = $session; $arguments['_request'] = $request; + $context = new RequestContext($session, $request); + try { $result = $this->referenceHandler->handle($reference, $arguments); + // An ask is a result in its own right, not tool output. + if ($result instanceof InputRequiredResult) { + return new Response($request->getId(), $result); + } + + $protocolVersion = $context->getProtocolVersion(); + $structuredContent = null; if (!$result instanceof CallToolResult) { - $structuredContent = $reference->extractStructuredContent($result); + $structuredContent = $reference->extractStructuredContent($result, $protocolVersion); + + if (null === $structuredContent && null !== $reference->tool->outputSchema) { + $this->logger->warning('Tool declares an "outputSchema" but returned a value that cannot be sent as "structuredContent"; the value is only carried in "content".', [ + 'name' => $toolName, + 'result_type' => get_debug_type($result), + ]); + } + $result = new CallToolResult($reference->formatResult($result), structuredContent: $structuredContent); + } elseif ($protocolVersion->requiresObjectStructuredContent() + && null !== $result->structuredContent + && [] !== $result->structuredContent + && !self::isJsonObject($result->structuredContent) + ) { + // A tool building its own `CallToolResult` bypasses the extraction + // rules on purpose, so the value is sent as it was set — but before + // SEP-2106 only a JSON object is valid here, whether the value is a + // list or a scalar, and clients may reject it. + $this->logger->warning('Tool returned a "CallToolResult" whose "structuredContent" is not a JSON object, which the negotiated protocol revision requires; sending it unchanged.', [ + 'name' => $toolName, + 'protocol_version' => $protocolVersion->value, + 'structured_content_type' => get_debug_type($result->structuredContent), + ]); } $this->logger->debug('Tool executed successfully', [ @@ -108,8 +147,12 @@ final class CallToolHandler implements RequestHandlerInterface ]); return new Response($request->getId(), $result); + } catch (MissingRequiredClientCapabilityException $e) { + // Not a tool failure — the request was unservable, and the client + // needs to retry declaring the capability. Rendered as -32021. + throw $e; } catch (ToolCallException $e) { - $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ + $this->logger->debug(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ 'tool' => $toolName, 'arguments' => $arguments, 'exception' => $e, @@ -127,4 +170,13 @@ final class CallToolHandler implements RequestHandlerInterface return Error::forInternalError('Error while executing tool', $request->getId()); } } + + /** + * Whether a `structuredContent` value encodes as a JSON object — the only shape + * revisions predating SEP-2106 accept. + */ + private static function isJsonObject(mixed $value): bool + { + return \is_array($value) && !array_is_list($value); + } } diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index b3c4d04..704116d 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -25,6 +25,8 @@ use Mcp\Schema\ResourceReference; use Mcp\Schema\Result\CompletionCompleteResult; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * Handles completion/complete requests. @@ -38,6 +40,7 @@ final class CompletionCompleteHandler implements RequestHandlerInterface public function __construct( private readonly RegistryInterface $registry, private readonly ?ContainerInterface $container = null, + private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -85,8 +88,14 @@ final class CompletionCompleteHandler implements RequestHandlerInterface return new Response($request->getId(), new CompletionCompleteResult($paged, $total, $hasMore)); } catch (PromptNotFoundException|ResourceNotFoundException $e) { - return Error::forResourceNotFound($e->getMessage(), $request->getId()); + // The reference names something the server does not have, which is + // a bad parameter rather than a missing resource. + $this->logger->warning(\sprintf('Completion requested for unknown reference: %s', $e->getMessage()), ['exception' => $e]); + + return Error::forInvalidParams($e->getMessage(), $request->getId()); } catch (\Throwable $e) { + $this->logger->error(\sprintf('Error while handling completion request: %s', $e->getMessage()), ['exception' => $e]); + return Error::forInternalError('Error while handling completion request', $request->getId()); } } diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index 745b9b8..71d6f8c 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -13,6 +13,7 @@ namespace Mcp\Server\Handler\Request; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; +use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Exception\PromptGetException; use Mcp\Exception\PromptNotFoundException; use Mcp\Schema\JsonRpc\Error; @@ -20,12 +21,13 @@ use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; +use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** - * @implements RequestHandlerInterface + * @implements RequestHandlerInterface * * @author Tobias Nyholm */ @@ -44,7 +46,7 @@ final class GetPromptHandler implements RequestHandlerInterface } /** - * @return Response|Error + * @return Response|Error */ public function handle(Request $request, SessionInterface $session): Response|Error { @@ -61,9 +63,18 @@ final class GetPromptHandler implements RequestHandlerInterface $result = $this->referenceHandler->handle($reference, $arguments); + // An ask is a result in its own right, not prompt content. + if ($result instanceof InputRequiredResult) { + return new Response($request->getId(), $result); + } + $formatted = $reference->formatResult($result); return new Response($request->getId(), new GetPromptResult($formatted)); + } catch (MissingRequiredClientCapabilityException $e) { + // Not a handler failure — the request was unservable, and the client + // needs to retry declaring the capability. Rendered as -32021. + throw $e; } catch (PromptGetException $e) { $this->logger->error(\sprintf('Error while handling prompt "%s": "%s".', $promptName, $e->getMessage()), ['exception' => $e]); @@ -71,7 +82,9 @@ final class GetPromptHandler implements RequestHandlerInterface } catch (PromptNotFoundException $e) { $this->logger->error('Prompt not found', ['prompt_name' => $promptName, 'exception' => $e]); - return Error::forResourceNotFound($e->getMessage(), $request->getId()); + // An unknown prompt name is a bad parameter, not a missing + // resource: -32002 was never the code for this. + return Error::forInvalidParams($e->getMessage(), $request->getId()); } catch (\Throwable $e) { $this->logger->error(\sprintf('Unexpected error while handling prompt "%s": "%s".', $promptName, $e->getMessage()), ['exception' => $e]); diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index f7c7eac..4461ae4 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Handler\Request; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -47,6 +48,9 @@ final class InitializeHandler implements RequestHandlerInterface $session->set('client_info', $request->clientInfo->jsonSerialize()); $session->set('client_capabilities', $request->capabilities->jsonSerialize()); + $negotiated = $this->negotiate($request->protocolVersion); + $session->set('protocol_version', $negotiated->value); + return new Response( $request->getId(), new InitializeResult( @@ -54,8 +58,49 @@ final class InitializeHandler implements RequestHandlerInterface $this->configuration->serverInfo ?? new Implementation(), $this->configuration?->instructions, null, - $this->configuration?->protocolVersion, + $negotiated, ), ); } + + /** + * Picks the protocol version to answer an `initialize` handshake with. + * + * If the client asked for a version this server supports, the spec requires + * responding with that exact version. Otherwise the server counter-offers + * the newest version it does support and leaves it to the client to decide + * whether it can continue on that revision or must disconnect. + */ + private function negotiate(string $requested): ProtocolVersion + { + $supported = $this->supportedVersions(); + $version = ProtocolVersion::tryFrom($requested); + + if (null !== $version && \in_array($version, $supported, true)) { + return $version; + } + + return $supported[\count($supported) - 1]; + } + + /** + * Versions this server is willing to negotiate over `initialize`. + * + * A version configured on the server pins the handshake to exactly that + * revision. Modern revisions are never offered here: they have no + * `initialize` at all, so a client that reached this handler cannot speak + * one, and answering with it would leave the connection unusable. + * + * @return non-empty-list + */ + private function supportedVersions(): array + { + $configured = $this->configuration?->protocolVersion; + + if (null !== $configured && !$configured->isModern()) { + return [$configured]; + } + + return ProtocolVersion::handshakeVersions(); + } } diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index a9551ef..60755f8 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -14,19 +14,22 @@ namespace Mcp\Server\Handler\Request; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\RegistryInterface; +use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ResourceReadException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; +use Mcp\Schema\Result\InputRequiredResult; use Mcp\Schema\Result\ReadResourceResult; +use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** - * @implements RequestHandlerInterface + * @implements RequestHandlerInterface * * @author Tobias Nyholm */ @@ -45,7 +48,7 @@ final class ReadResourceHandler implements RequestHandlerInterface } /** - * @return Response|Error + * @return Response|Error */ public function handle(Request $request, SessionInterface $session): Response|Error { @@ -69,13 +72,30 @@ final class ReadResourceHandler implements RequestHandlerInterface $arguments = array_merge($arguments, $variables); $result = $this->referenceHandler->handle($reference, $arguments); + + // An ask is a result in its own right, not resource contents; + // and a handler that built the whole result keeps what it + // decided, caching hints included. + if ($result instanceof InputRequiredResult || $result instanceof ReadResourceResult) { + return new Response($request->getId(), $result); + } + $formatted = $reference->formatResult($result, $uri, $reference->resourceTemplate->mimeType); } else { $result = $this->referenceHandler->handle($reference, $arguments); + + if ($result instanceof InputRequiredResult || $result instanceof ReadResourceResult) { + return new Response($request->getId(), $result); + } + $formatted = $reference->formatResult($result, $uri, $reference->resource->mimeType); } return new Response($request->getId(), new ReadResourceResult($formatted)); + } catch (MissingRequiredClientCapabilityException $e) { + // Not a handler failure — the request was unservable, and the client + // needs to retry declaring the capability. Rendered as -32021. + throw $e; } catch (ResourceReadException $e) { $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage()), ['exception' => $e]); @@ -83,7 +103,12 @@ final class ReadResourceHandler implements RequestHandlerInterface } catch (ResourceNotFoundException $e) { $this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]); - return Error::forResourceNotFound($e->getMessage(), $request->getId()); + // SEP-2164 retired -32002 in favour of the JSON-RPC code that + // already meant this. Older peers still expect the old one, so the + // revision answering the request decides. + return (new RequestContext($session, $request))->getProtocolVersion()->usesInvalidParamsForResourceNotFound() + ? Error::forInvalidParams($e->getMessage(), $request->getId(), ['uri' => $uri]) + : Error::forResourceNotFound($e->getMessage(), $request->getId()); } catch (\Throwable $e) { $this->logger->error(\sprintf('Unexpected error while reading resource "%s": "%s".', $uri, $e->getMessage()), ['exception' => $e]); diff --git a/src/Server/Handler/Request/RequestHandlerInterface.php b/src/Server/Handler/Request/RequestHandlerInterface.php index d81c079..9a9c013 100644 --- a/src/Server/Handler/Request/RequestHandlerInterface.php +++ b/src/Server/Handler/Request/RequestHandlerInterface.php @@ -17,7 +17,11 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Server\Session\SessionInterface; /** - * @template TResult + * Covariant in TResult: a handler only ever produces its result, so one + * declaring a concrete result type satisfies a collection of handlers typed by + * the interface every result implements. + * + * @template-covariant TResult * * @author Kyrian Obikwelu */ diff --git a/src/Server/Handler/Request/ResourceSubscribeHandler.php b/src/Server/Handler/Request/ResourceSubscribeHandler.php index b5421f8..1e8147b 100644 --- a/src/Server/Handler/Request/ResourceSubscribeHandler.php +++ b/src/Server/Handler/Request/ResourceSubscribeHandler.php @@ -18,6 +18,7 @@ use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ResourceSubscribeRequest; use Mcp\Schema\Result\EmptyResult; +use Mcp\Server\RequestContext; use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; @@ -57,7 +58,9 @@ final class ResourceSubscribeHandler implements RequestHandlerInterface } catch (ResourceNotFoundException $e) { $this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]); - return Error::forResourceNotFound($e->getMessage(), $request->getId()); + return (new RequestContext($session, $request))->getProtocolVersion()->usesInvalidParamsForResourceNotFound() + ? Error::forInvalidParams($e->getMessage(), $request->getId(), ['uri' => $uri]) + : Error::forResourceNotFound($e->getMessage(), $request->getId()); } $this->logger->debug('Subscribing to resource', ['uri' => $uri]); diff --git a/src/Server/Handler/Request/ResourceUnsubscribeHandler.php b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php index 50ab8bc..522c512 100644 --- a/src/Server/Handler/Request/ResourceUnsubscribeHandler.php +++ b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php @@ -18,6 +18,7 @@ use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ResourceUnsubscribeRequest; use Mcp\Schema\Result\EmptyResult; +use Mcp\Server\RequestContext; use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; @@ -57,7 +58,9 @@ final class ResourceUnsubscribeHandler implements RequestHandlerInterface } catch (ResourceNotFoundException $e) { $this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]); - return Error::forResourceNotFound($e->getMessage(), $request->getId()); + return (new RequestContext($session, $request))->getProtocolVersion()->usesInvalidParamsForResourceNotFound() + ? Error::forInvalidParams($e->getMessage(), $request->getId(), ['uri' => $uri]) + : Error::forResourceNotFound($e->getMessage(), $request->getId()); } $this->logger->debug('Unsubscribing from resource', ['uri' => $uri]); diff --git a/src/Server/Handler/Request/SetLogLevelHandler.php b/src/Server/Handler/Request/SetLogLevelHandler.php index 5563898..a04d22b 100644 --- a/src/Server/Handler/Request/SetLogLevelHandler.php +++ b/src/Server/Handler/Request/SetLogLevelHandler.php @@ -27,6 +27,9 @@ use Mcp\Server\Session\SessionInterface; * @implements RequestHandlerInterface * * @author Adam Jamiu + * + * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. + * Log to stderr (stdio) or use OpenTelemetry instead. */ final class SetLogLevelHandler implements RequestHandlerInterface { diff --git a/src/Server/InputRequiredShim.php b/src/Server/InputRequiredShim.php new file mode 100644 index 0000000..9dff786 --- /dev/null +++ b/src/Server/InputRequiredShim.php @@ -0,0 +1,228 @@ + + */ +final class InputRequiredShim +{ + /** + * Re-entries per originating request. Deliberately below the modern + * client driver's allowance: this loop holds a live request open. + */ + public const DEFAULT_MAX_ROUNDS = 8; + + /** Seconds to wait for one answer. Legs are human-paced, so the protocol's 120s default is wrong here. */ + public const DEFAULT_ROUND_TIMEOUT = 600; + + public function __construct( + private readonly int $maxRounds = self::DEFAULT_MAX_ROUNDS, + private readonly int $roundTimeout = self::DEFAULT_ROUND_TIMEOUT, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Runs a handler to a result the client can be given. + * + * Returns whatever the handler returned when it asks for nothing, which is + * every call that is not multi round-trip. + * + * @param Response|Error $result what the handler returned on its first entry + * @param RequestHandlerInterface> $handler the handler that produced it + * + * @return Response|Error + */ + public function fulfill( + Response|Error $result, + RequestHandlerInterface $handler, + Request $request, + SessionInterface $session, + ?RequestStateCodec $codec, + ): Response|Error { + $round = 0; + + while (($ask = self::askOf($result)) instanceof InputRequiredResult) { + if (++$round > $this->maxRounds) { + $this->logger->warning('A handler kept asking for input past the round limit; the call was failed instead.', [ + 'method' => $request::getMethod(), + 'rounds' => $this->maxRounds, + ]); + + return Error::forInternalError( + \sprintf('The server asked for input more than %d times without reaching a result.', $this->maxRounds), + $request->getId(), + ); + } + + if (null !== $refusal = $this->refuseUndeclared($ask, $request, $session)) { + return $refusal; + } + + try { + $session->set(InputContext::class, new InputContext( + $this->collect($ask, $session), + self::payloadOf($ask, $codec), + )); + } catch (RequestStateException $e) { + $this->logger->error('A handler minted a requestState this server cannot verify.', ['exception' => $e]); + + return Error::forInternalError('The server could not carry its own state across a round of input.', $request->getId()); + } + + $result = $handler->handle($request, $session); + } + + return $result; + } + + /** + * Sends each embedded request and keeps the answer under the key it was + * asked under. + * + * Answers are stored as the raw result arrays {@see InputContext} parses, + * so this needs to know nothing about the kinds it is carrying — which is + * also why an extension's future kind rides through unchanged. + * + * @return array + */ + private function collect(InputRequiredResult $ask, SessionInterface $session): array + { + $gateway = new ClientGateway($session); + $responses = []; + + foreach ($ask->inputRequests as $key => $embedded) { + $answer = $gateway->request($embedded, $this->roundTimeout); + + if ($answer instanceof Error) { + // Not fatal to the call: a client that refuses one ask has + // answered it, and the handler decides what that means. + $this->logger->info('The client failed an input request; the handler is re-entered without it.', [ + 'key' => $key, + 'error' => $answer->message, + ]); + + continue; + } + + $responses[$key] = $answer->result; + } + + return $responses; + } + + /** + * Refuses an ask the client never said it could answer, the way the modern + * era does — rather than sending a request that can only come back as an + * error. + */ + private function refuseUndeclared(InputRequiredResult $ask, Request $request, SessionInterface $session): ?Error + { + $declared = ClientCapabilities::fromArray((array) $session->get('client_capabilities', [])); + $missing = InputRequestCapabilities::missing($ask, $declared); + + if (null === $missing) { + return null; + } + + $this->logger->warning('A handler asked for input the client did not declare it could provide; the ask was replaced with -32021.', [ + 'method' => $request::getMethod(), + 'required' => $missing->jsonSerialize(), + ]); + + return Error::forMissingRequiredClientCapability( + 'The server needs input this client did not declare it can provide.', + $missing, + $request->getId(), + ); + } + + /** + * The ask a handler returned, if it returned one. + * + * @param Response|Error $result + */ + private static function askOf(Response|Error $result): ?InputRequiredResult + { + return $result instanceof Response && $result->result instanceof InputRequiredResult + ? $result->result + : null; + } + + /** + * The state the handler sealed last round, verified. + * + * Verified rather than trusted even though it never left this process: the + * handler reads it back through the same accessor either era, so it has to + * have been through the same check. + * + * @return array + * + * @throws RequestStateException when a state is present but does not verify + */ + private static function payloadOf(InputRequiredResult $ask, ?RequestStateCodec $codec): array + { + if (null === $ask->requestState) { + return []; + } + + if (null === $codec) { + throw new RequestStateException('mac'); + } + + return $codec->verify($ask->requestState); + } +} diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index d9af4e4..f460c3c 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -27,6 +27,8 @@ use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Session\SessionManagerInterface; +use Mcp\Server\Stateless\InputContext; +use Mcp\Server\Stateless\RequestStateCodec; use Mcp\Server\Transport\TransportInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -61,6 +63,12 @@ class Protocol public const SESSION_LOGGING_LEVEL = '_mcp.logging_level'; + /** + * Deliberately generic: unexpected throwables carry internal details such as file paths, class + * names and argument types, which must not be handed to the peer. The full exception is logged. + */ + private const INTERNAL_ERROR_MESSAGE = 'Internal server error.'; + /** * @param array>> $requestHandlers * @param array $notificationHandlers @@ -72,6 +80,8 @@ class Protocol private readonly SessionManagerInterface $sessionManager, private readonly LoggerInterface $logger = new NullLogger(), private readonly ?EventDispatcherInterface $eventDispatcher = null, + private readonly ?InputRequiredShim $inputRequiredShim = null, + private readonly ?RequestStateCodec $requestStateCodec = null, ) { } @@ -107,6 +117,66 @@ class Protocol * @param TransportInterface $transport */ public function processInput(TransportInterface $transport, string $input, ?Uuid $sessionId): void + { + // Last line of defense: a malformed message must never escape as a PHP error and take the + // server process down. + try { + $this->doProcessInput($transport, $input, $sessionId); + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Uncaught exception while processing input: %s', $e->getMessage()), ['exception' => $e]); + + // Only a request may be answered. Replying to a notification would violate JSON-RPC, + // and the failure has already been logged. + if (null === $id = self::findResponseId($input)) { + return; + } + + try { + $this->sendResponse($transport, Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), null); + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Failed to send internal error response: %s', $e->getMessage()), ['exception' => $e]); + } + } + } + + /** + * Determines the id an unprocessable input has to be answered under. + * + * Returns null when the input carries no request at all, in which case it consists of + * notifications only and JSON-RPC forbids answering it. A batch resolves to the empty id + * because its failure cannot be attributed to one of its requests. + */ + private static function findResponseId(string $input): string|int|null + { + try { + $data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + + if (!\is_array($data)) { + return null; + } + + if (!array_is_list($data)) { + $id = $data['id'] ?? null; + + return \is_string($id) || \is_int($id) ? $id : null; + } + + foreach ($data as $message) { + if (\is_array($message) && isset($message['id'])) { + return ''; + } + } + + return null; + } + + /** + * @param TransportInterface $transport + */ + private function doProcessInput(TransportInterface $transport, string $input, ?Uuid $sessionId): void { $this->logger->info('Received message to process.', ['message' => $input]); @@ -128,14 +198,25 @@ class Protocol } foreach ($messages as $message) { - if ($message instanceof InvalidInputMessageException) { - $this->handleInvalidMessage($transport, $message, $session); - } elseif ($message instanceof Request) { - $this->handleRequest($transport, $message, $session); - } elseif ($message instanceof Response || $message instanceof Error) { - $this->handleResponse($message, $session); - } elseif ($message instanceof Notification) { - $this->handleNotification($message, $session); + // Guarded per message so one faulty message cannot suppress the rest of a batch. + try { + if ($message instanceof InvalidInputMessageException) { + $this->handleInvalidMessage($transport, $message, $session); + } elseif ($message instanceof Request) { + $this->handleRequest($transport, $message, $session); + } elseif ($message instanceof Response || $message instanceof Error) { + $this->handleResponse($message, $session); + } elseif ($message instanceof Notification) { + $this->handleNotification($message, $session); + } + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Uncaught exception while handling message: %s', $e->getMessage()), ['exception' => $e]); + + // Only a request may be answered; a notification or a response must not produce one. + if ($message instanceof Request) { + $error = Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $message->getId()); + $this->sendResponse($transport, $error, $session); + } } } @@ -151,7 +232,7 @@ class Protocol { $this->logger->warning('Failed to create message.', ['exception' => $exception]); - $error = Error::forInvalidRequest($exception->getMessage()); + $error = Error::forInvalidRequest($exception->getMessage(), $exception->getRequestId()); $this->sendResponse($transport, $error, $session); } @@ -180,6 +261,15 @@ class Protocol $session->set(self::SESSION_ACTIVE_REQUEST_META, $request->getMeta()); + // A request starts with nothing behind it: the shim fills this in as it + // collects answers, and clearing it here is what keeps one request's + // round from being read as another's. + $session->set(InputContext::class, null); + + if (null !== $this->requestStateCodec) { + $session->set(RequestStateCodec::class, $this->requestStateCodec); + } + $event = $this->dispatchEvent(new RequestEvent($request, $session)); $request = $event->getRequest(); @@ -193,8 +283,17 @@ class Protocol $handlerFound = true; try { + $shim = $this->inputRequiredShim; + $codec = $this->requestStateCodec; + + // One fiber for the whole exchange: with the shim, the handler + // re-enters inside it each round rather than needing a new one. /** @var McpFiber $fiber */ - $fiber = new \Fiber(static fn () => $handler->handle($request, $session)); + $fiber = new \Fiber(static function () use ($handler, $request, $session, $shim, $codec): Response|Error { + $result = $handler->handle($request, $session); + + return $shim?->fulfill($result, $handler, $request, $session, $codec) ?? $result; + }); $result = $fiber->start(); @@ -204,9 +303,11 @@ class Protocol $notification = $result['notification']; $this->sendNotification($notification, $session); } elseif ('request' === $result['type']) { - $request = $result['request']; + // Keep $request untouched: it is the inbound request the catch + // blocks below answer under, not this outbound one. + $outboundRequest = $result['request']; $timeout = $result['timeout'] ?? 120; - $this->sendRequest($request, $timeout, $session); + $this->sendRequest($outboundRequest, $timeout, $session); } } @@ -236,7 +337,7 @@ class Protocol } catch (\Throwable $e) { $this->logger->error(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); - $error = Error::forInternalError($e->getMessage(), $request->getId()); + $error = Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $request->getId()); $errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, $e)); $error = $errorEvent->getError(); @@ -264,6 +365,12 @@ class Protocol $messageId = $response->getId(); + if (null === $messageId) { + $this->logger->warning('Received an id-less error response from client; cannot correlate it to a pending request.', ['response' => $response->jsonSerialize()]); + + return; + } + $session->set(self::SESSION_RESPONSES.".{$messageId}", $response->jsonSerialize()); $session->forget(self::SESSION_ACTIVE_REQUEST_META); diff --git a/src/Server/RequestContext.php b/src/Server/RequestContext.php index 158057c..f322a39 100644 --- a/src/Server/RequestContext.php +++ b/src/Server/RequestContext.php @@ -12,8 +12,14 @@ namespace Mcp\Server; use Mcp\Capability\Logger\ClientLogger; +use Mcp\Exception\LogicException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Request; use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Stateless\InputContext; +use Mcp\Server\Stateless\RequestMeta; +use Mcp\Server\Stateless\RequestStateCodec; /** * Context related to a single request. This includes information about the session and @@ -25,6 +31,14 @@ use Mcp\Server\Session\SessionInterface; */ final class RequestContext { + /** + * `_meta` key carrying the protocol revision of a single request, introduced + * with the modern era that replaced the `initialize` handshake. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning + */ + private const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; + private ?ClientGateway $clientGateway = null; private ?ClientLogger $clientLogger = null; @@ -44,6 +58,26 @@ final class RequestContext return $this->session; } + /** + * The protocol revision this request is served under. + * + * Modern revisions declare it per request in `_meta`, handshake ones negotiate + * it once and keep it on the session. Neither is guaranteed to be present — a + * transport may skip `initialize` entirely — so this falls back to the newest + * handshake revision, whose rules hold for every revision below it too. + */ + public function getProtocolVersion(): ProtocolVersion + { + $requested = $this->request->getMeta()[self::PROTOCOL_VERSION_META_KEY] + ?? $this->session->get('protocol_version'); + + if (!\is_string($requested)) { + return ProtocolVersion::latestHandshake(); + } + + return ProtocolVersion::tryFrom($requested) ?? ProtocolVersion::latestHandshake(); + } + public function getClientGateway(): ClientGateway { if (null == $this->clientGateway) { @@ -53,6 +87,65 @@ final class RequestContext return $this->clientGateway; } + /** + * What a multi round-trip retry carried back, or null on a first call — + * which is the signal to return an + * {@see \Mcp\Schema\Result\InputRequiredResult} instead of an answer. + */ + public function getInputContext(): ?InputContext + { + $context = $this->session->get(InputContext::class); + + return $context instanceof InputContext ? $context : null; + } + + /** + * What the client declared on this request. A server MUST NOT ask for + * input the client cannot supply. Null in the handshake era, where + * capabilities are connection state. + */ + public function getClientCapabilities(): ?ClientCapabilities + { + $meta = $this->session->get(RequestMeta::class); + + return $meta instanceof RequestMeta ? $meta->clientCapabilities : null; + } + + /** + * The W3C trace context this request carried, if any. + * + * Values are passed through exactly as they arrived — validating or + * regenerating them is the tracing library's job, not the protocol's. The + * SDK echoes them onto the notifications a handler emits so a span stays + * joined across the stream. + * + * @return array keyed by `traceparent`, `tracestate`, `baggage` + */ + public function getTraceContext(): array + { + $meta = $this->session->get(RequestMeta::class); + + return $meta instanceof RequestMeta ? $meta->traceContext : []; + } + + /** + * Seals handler context into the string an + * {@see \Mcp\Schema\Result\InputRequiredResult} carries to the client. + * Signed, not encrypted: nothing secret belongs in the payload. + * + * @param array $payload + */ + public function mintRequestState(array $payload): string + { + $codec = $this->session->get(RequestStateCodec::class); + + if (!$codec instanceof RequestStateCodec) { + throw new LogicException('No requestState signing key is configured; call Builder::setRequestState() before returning state from a handler.'); + } + + return $codec->mint($payload); + } + public function getClientLogger(): ClientLogger { if (null === $this->clientLogger) { diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php index aa99aba..8d08bae 100644 --- a/src/Server/Session/FileSessionStore.php +++ b/src/Server/Session/FileSessionStore.php @@ -11,8 +11,11 @@ namespace Mcp\Server\Session; +use Mcp\Exception\RuntimeException; use Mcp\Server\NativeClock; use Psr\Clock\ClockInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\Component\Uid\Uuid; /** @@ -25,13 +28,17 @@ class FileSessionStore implements SessionStoreInterface private readonly string $directory, private readonly int $ttl = 3600, private readonly ClockInterface $clock = new NativeClock(), + private readonly LoggerInterface $logger = new NullLogger(), ) { - if (!is_dir($this->directory)) { - @mkdir($this->directory, 0775, true); + if (!is_dir($this->directory) && !@mkdir($this->directory, 0775, true) && !is_dir($this->directory)) { + $this->logger->warning('Failed to create session directory.', [ + 'directory' => $this->directory, + 'error' => error_get_last()['message'] ?? 'unknown', + ]); } if (!is_dir($this->directory) || !is_writable($this->directory)) { - throw new \RuntimeException(\sprintf('Session directory "%s" is not writable.', $this->directory)); + throw new RuntimeException(\sprintf('Session directory "%s" is not writable.', $this->directory)); } } @@ -58,13 +65,23 @@ class FileSessionStore implements SessionStoreInterface $mtime = @filemtime($path) ?: 0; if (($this->clock->now()->getTimestamp() - $mtime) > $this->ttl) { - @unlink($path); + if (!@unlink($path) && is_file($path)) { + $this->logger->warning('Failed to delete expired session file.', [ + 'path' => $path, + 'error' => error_get_last()['message'] ?? 'unknown', + ]); + } return false; } $data = @file_get_contents($path); if (false === $data) { + $this->logger->warning('Failed to read session file.', [ + 'path' => $path, + 'error' => error_get_last()['message'] ?? 'unknown', + ]); + return false; } @@ -77,6 +94,11 @@ class FileSessionStore implements SessionStoreInterface $tmp = $path.'.tmp'; if (false === @file_put_contents($tmp, $data, \LOCK_EX)) { + $this->logger->warning('Failed to write session file.', [ + 'path' => $tmp, + 'error' => error_get_last()['message'] ?? 'unknown', + ]); + return false; } @@ -84,6 +106,10 @@ class FileSessionStore implements SessionStoreInterface if (!@rename($tmp, $path)) { // Fallback if rename fails cross-device if (false === @copy($tmp, $path)) { + $this->logger->warning('Failed to move session file into place.', [ + 'path' => $path, + 'error' => error_get_last()['message'] ?? 'unknown', + ]); @unlink($tmp); return false; @@ -100,8 +126,11 @@ class FileSessionStore implements SessionStoreInterface { $path = $this->pathFor($id); - if (is_file($path)) { - @unlink($path); + if (is_file($path) && !@unlink($path) && is_file($path)) { + $this->logger->warning('Failed to delete session file.', [ + 'path' => $path, + 'error' => error_get_last()['message'] ?? 'unknown', + ]); } return true; @@ -118,6 +147,11 @@ class FileSessionStore implements SessionStoreInterface $dir = @opendir($this->directory); if (false === $dir) { + $this->logger->warning('Failed to open session directory for garbage collection.', [ + 'directory' => $this->directory, + 'error' => error_get_last()['message'] ?? 'unknown', + ]); + return $deleted; } @@ -127,6 +161,11 @@ class FileSessionStore implements SessionStoreInterface continue; } + // Only delete files this store owns: sessions are named by their RFC 4122 UUID + if (!Uuid::isValid($entry)) { + continue; + } + $path = $this->directory.\DIRECTORY_SEPARATOR.$entry; if (!is_file($path)) { continue; @@ -134,12 +173,16 @@ class FileSessionStore implements SessionStoreInterface $mtime = @filemtime($path) ?: 0; if (($now - $mtime) > $this->ttl) { - @unlink($path); - try { - $deleted[] = Uuid::fromString($entry); - } catch (\Throwable) { - // ignore non-UUID file names + if (!@unlink($path) && is_file($path)) { + $this->logger->warning('Failed to delete expired session file.', [ + 'path' => $path, + 'error' => error_get_last()['message'] ?? 'unknown', + ]); + + continue; } + + $deleted[] = Uuid::fromString($entry); } } diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php index 6eee72d..4db11f7 100644 --- a/src/Server/Session/Session.php +++ b/src/Server/Session/Session.php @@ -110,15 +110,12 @@ class Session implements SessionInterface while (\count($segments) > 1) { $segment = array_shift($segments); if (!isset($data[$segment]) || !\is_array($data[$segment])) { - $data[$segment] = []; + return; } $data = &$data[$segment]; } - $lastKey = array_shift($segments); - if (isset($data[$lastKey])) { - unset($data[$lastKey]); - } + unset($data[array_shift($segments)]); } public function clear(): void diff --git a/src/Server/Stateless/ElicitationReplay.php b/src/Server/Stateless/ElicitationReplay.php new file mode 100644 index 0000000..fe93627 --- /dev/null +++ b/src/Server/Stateless/ElicitationReplay.php @@ -0,0 +1,169 @@ + + */ +final class ElicitationReplay +{ + /** + * The `requestState` member carrying answers from earlier rounds. Reserved, + * like every `_mcp.` key: a handler's own payload travels beside it. + */ + public const CARRIED_ANSWERS = '_mcp.answers'; + + /** @var array> raw answers, keyed as they were asked */ + private array $answers = []; + + /** @var array the verified state this round arrived with */ + private array $payload; + + /** @var array keys whose answer this run could not read */ + private array $rejected = []; + + private int $asked = 0; + + public function __construct(?InputContext $input, private readonly ?RequestStateCodec $codec = null) + { + $this->payload = $input?->requestState() ?? []; + + $carried = $this->payload[self::CARRIED_ANSWERS] ?? []; + + if (\is_array($carried)) { + $this->answers = array_filter($carried, \is_array(...)); + } + + // Lifted out, so what is sealed again is what this run could still use + // rather than whatever the last round happened to carry. + unset($this->payload[self::CARRIED_ANSWERS]); + + // What this round answered wins over what an earlier one did: a key is + // only re-asked because its old answer was not usable. + foreach ($input?->all() ?? [] as $key => $answer) { + if (\is_array($answer)) { + $this->answers[$key] = $answer; + } + } + } + + /** + * The key an ask is filed under: the one the handler named, or its position + * among this run's asks. + * + * Positional keys hold across rounds only because the handler reaches its + * asks in the same order every time — which it does whenever it is + * re-enterable at all. A handler whose asks depend on a coin flip should + * name them. + */ + public function key(?string $key): string + { + ++$this->asked; + + return $key ?? 'elicitation_'.$this->asked; + } + + /** + * The answer to `$key`, or null when there is none to give the handler. + * + * Null covers an answer that does not parse as well as one that never + * arrived: the specification says a server SHOULD ask again for what it + * still needs, and a malformed answer left the server still needing it. + * + * @return array|null + */ + public function answer(string $key, ElicitationMode $mode = ElicitationMode::Form): ?array + { + $answer = $this->answers[$key] ?? null; + + if (null === $answer) { + return null; + } + + try { + ElicitResult::fromArray($answer, $mode); + } catch (\Throwable) { + // Noted rather than dropped: `answer()` only reads, and what makes + // this one unreadable is the mode it was read under. Marking it + // keeps it out of the state, since it is about to be asked again. + $this->rejected[$key] = true; + + return null; + } + + unset($this->rejected[$key]); + + return $answer; + } + + /** + * The result that ends this request by asking, carrying everything already + * answered so the next round does not have to ask for it again. + * + * @throws LogicException when there is state to carry and no key to sign it with + */ + public function ask(string $key, ElicitRequest $request): InputRequiredResult + { + return new InputRequiredResult([$key => $request], $this->state()); + } + + private function state(): ?string + { + $payload = $this->payload; + $answers = array_diff_key($this->answers, $this->rejected); + + if ([] !== $answers) { + $payload[self::CARRIED_ANSWERS] = $answers; + } + + if ([] === $payload) { + return null; + } + + if (null === $this->codec) { + throw new LogicException('Carrying an answer to the next round of a multi round-trip call needs a signing key; call Builder::setRequestState() to configure one.'); + } + + return $this->codec->mint($payload); + } +} diff --git a/src/Server/Stateless/InputContext.php b/src/Server/Stateless/InputContext.php new file mode 100644 index 0000000..d91197b --- /dev/null +++ b/src/Server/Stateless/InputContext.php @@ -0,0 +1,125 @@ + + */ +final class InputContext +{ + /** + * @param array $responses client answers, keyed as the inputRequests were + * @param array $requestState the verified payload the server sealed last round + */ + public function __construct( + private readonly array $responses = [], + private readonly array $requestState = [], + ) { + } + + /** + * The client's answer for `$key`, or null when it did not provide one. + */ + public function response(string $key): mixed + { + return $this->responses[$key] ?? null; + } + + /** + * The client's answer to an `elicitation/create` ask, typed. + * + * Null when the key is absent or the answer does not parse — which the + * caller should read as "not answered" and ask again, rather than as an + * error: the specification says a server SHOULD re-ask for information it + * still needs instead of failing the request. + * + * @param ElicitationMode $mode the mode of the ask this answers; url-mode + * answers carry no content, so an accepted one + * is complete without it + */ + public function elicitResult(string $key, ElicitationMode $mode = ElicitationMode::Form): ?ElicitResult + { + return $this->parse($key, static fn (array $data): ElicitResult => ElicitResult::fromArray($data, $mode)); + } + + /** + * The client's answer to a `sampling/createMessage` ask, typed. + */ + public function samplingResult(string $key): ?CreateSamplingMessageResult + { + return $this->parse($key, CreateSamplingMessageResult::fromArray(...)); + } + + /** + * The client's answer to a `roots/list` ask, typed. + */ + public function rootsResult(string $key): ?ListRootsResult + { + return $this->parse($key, ListRootsResult::fromArray(...)); + } + + /** + * @template T of ResultInterface + * + * @param callable(array): T $factory + * + * @return T|null + */ + private function parse(string $key, callable $factory): ?ResultInterface + { + $response = $this->responses[$key] ?? null; + + if (!\is_array($response)) { + return null; + } + + try { + return $factory($response); + } catch (\Throwable) { + // A malformed answer is one the client did not really give. + return null; + } + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->responses); + } + + /** + * @return array + */ + public function all(): array + { + return $this->responses; + } + + /** + * @return array + */ + public function requestState(): array + { + return $this->requestState; + } +} diff --git a/src/Server/Stateless/InputRequestCapabilities.php b/src/Server/Stateless/InputRequestCapabilities.php new file mode 100644 index 0000000..1f4287f --- /dev/null +++ b/src/Server/Stateless/InputRequestCapabilities.php @@ -0,0 +1,96 @@ + + */ +final class InputRequestCapabilities +{ + /** + * The capabilities $result needs that $declared does not offer, or null + * when the client can answer everything it is being asked. + */ + public static function missing(InputRequiredResult $result, ClientCapabilities $declared): ?ClientCapabilities + { + $roots = false; + $sampling = false; + $samplingTools = false; + $samplingContext = false; + $elicitationForm = false; + $elicitationUrl = false; + + foreach ($result->inputRequests as $request) { + if ($request instanceof ListRootsRequest) { + $roots = $roots || true !== $declared->roots; + + continue; + } + + if ($request instanceof CreateSamplingMessageRequest) { + $sampling = $sampling || true !== $declared->sampling; + + if (null !== $request->tools || null !== $request->toolChoice) { + $samplingTools = $samplingTools || true !== $declared->samplingTools; + } + + // `none` is the default and needs nothing; the other two are + // deprecated values that only a declaring client understands. + if (null !== $request->includeContext && SamplingContext::NONE !== $request->includeContext) { + $samplingContext = $samplingContext || true !== $declared->samplingContext; + } + + continue; + } + + if ($request instanceof ElicitRequest) { + if (ElicitationMode::Url === $request->mode) { + $elicitationUrl = $elicitationUrl || true !== $declared->elicitationUrl; + } else { + $elicitationForm = $elicitationForm || true !== $declared->elicitationForm; + } + } + } + + if (!$roots && !$sampling && !$samplingTools && !$samplingContext && !$elicitationForm && !$elicitationUrl) { + return null; + } + + return new ClientCapabilities( + roots: $roots, + sampling: $sampling ?: null, + elicitation: ($elicitationForm || $elicitationUrl) ?: null, + samplingContext: $samplingContext ?: null, + samplingTools: $samplingTools ?: null, + elicitationForm: $elicitationForm ?: null, + elicitationUrl: $elicitationUrl ?: null, + ); + } +} diff --git a/src/Server/Stateless/NotificationFilter.php b/src/Server/Stateless/NotificationFilter.php new file mode 100644 index 0000000..4649126 --- /dev/null +++ b/src/Server/Stateless/NotificationFilter.php @@ -0,0 +1,115 @@ + + */ +final class NotificationFilter +{ + /** + * @param list $resourceSubscriptions resource URIs to report updates for + */ + public function __construct( + public readonly bool $toolsListChanged = false, + public readonly bool $promptsListChanged = false, + public readonly bool $resourcesListChanged = false, + public readonly array $resourceSubscriptions = [], + ) { + } + + /** + * @param array|null $notifications the request's `params.notifications` member + */ + public static function fromParams(?array $notifications): self + { + $notifications ??= []; + + $uris = $notifications['resourceSubscriptions'] ?? []; + + return new self( + true === ($notifications['toolsListChanged'] ?? false), + true === ($notifications['promptsListChanged'] ?? false), + true === ($notifications['resourcesListChanged'] ?? false), + \is_array($uris) ? array_values(array_filter($uris, \is_string(...))) : [], + ); + } + + /** + * Narrows the filter to what this server can deliver, so the acknowledgment + * reflects the subset it agreed to honour rather than promising silence. + */ + public function intersect(ServerCapabilities $capabilities): self + { + return new self( + $this->toolsListChanged && true === $capabilities->toolsListChanged, + $this->promptsListChanged && true === $capabilities->promptsListChanged, + $this->resourcesListChanged && true === $capabilities->resourcesListChanged, + true === $capabilities->resourcesSubscribe ? $this->resourceSubscriptions : [], + ); + } + + /** + * Whether this filter admits $notification onto the stream. + * + * An allow-list decision, so an unrecognized notification is declined: the + * server MUST NOT send a type the client did not ask for, and "did not ask + * for" includes types it has never heard of. + */ + public function carries(Notification $notification): bool + { + return match (true) { + $notification instanceof ToolListChangedNotification => $this->toolsListChanged, + $notification instanceof PromptListChangedNotification => $this->promptsListChanged, + $notification instanceof ResourceListChangedNotification => $this->resourcesListChanged, + $notification instanceof ResourceUpdatedNotification => \in_array($notification->uri, $this->resourceSubscriptions, true), + default => false, + }; + } + + /** + * Agreed types only; declined ones are omitted rather than sent as `false`. + * + * @return array + */ + public function toAcknowledgedArray(): array + { + $data = []; + + if ($this->toolsListChanged) { + $data['toolsListChanged'] = true; + } + if ($this->promptsListChanged) { + $data['promptsListChanged'] = true; + } + if ($this->resourcesListChanged) { + $data['resourcesListChanged'] = true; + } + if ([] !== $this->resourceSubscriptions) { + $data['resourceSubscriptions'] = $this->resourceSubscriptions; + } + + return $data; + } +} diff --git a/src/Server/Stateless/RequestMeta.php b/src/Server/Stateless/RequestMeta.php new file mode 100644 index 0000000..3744d92 --- /dev/null +++ b/src/Server/Stateless/RequestMeta.php @@ -0,0 +1,133 @@ + + */ +final class RequestMeta +{ + public const PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion'; + public const CLIENT_INFO = 'io.modelcontextprotocol/clientInfo'; + public const CLIENT_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities'; + public const LOG_LEVEL = 'io.modelcontextprotocol/logLevel'; + public const SERVER_INFO = 'io.modelcontextprotocol/serverInfo'; + public const SUBSCRIPTION_ID = 'io.modelcontextprotocol/subscriptionId'; + + /** + * OpenTelemetry trace context, exempt from the prefix rule by name so it + * matches what the wider ecosystem already puts on the wire. + * + * @see https://www.w3.org/TR/trace-context/ + */ + public const TRACE_KEYS = ['traceparent', 'tracestate', 'baggage']; + + /** + * @param array $traceContext W3C trace context carried through from the request + */ + public function __construct( + public readonly string $protocolVersion, + public readonly ClientCapabilities $clientCapabilities, + public readonly ?Implementation $clientInfo = null, + public readonly ?LoggingLevel $logLevel = null, + public readonly array $traceContext = [], + ) { + } + + /** + * @param array|null $params the request's `params` member, if any + * @param array $headers request headers, case-insensitively matched; a + * transport without a header layer (stdio) passes none + * + * @throws MissingRequestMetaException when a structurally required member is absent or malformed + */ + public static function fromParams(?array $params, array $headers = []): self + { + $meta = $params['_meta'] ?? null; + + if (!\is_array($meta)) { + throw new MissingRequestMetaException('Request is missing the required "params._meta" member.'); + } + + $version = $meta[self::PROTOCOL_VERSION] ?? null; + if (!\is_string($version) || '' === $version) { + throw new MissingRequestMetaException(\sprintf('Request "_meta" is missing the required "%s" member.', self::PROTOCOL_VERSION)); + } + + // An empty object is valid — a client with no optional capabilities. + $capabilities = $meta[self::CLIENT_CAPABILITIES] ?? null; + if (!\is_array($capabilities) && !$capabilities instanceof \stdClass) { + throw new MissingRequestMetaException(\sprintf('Request "_meta" is missing the required "%s" member.', self::CLIENT_CAPABILITIES)); + } + + $clientInfo = $meta[self::CLIENT_INFO] ?? null; + + return new self( + $version, + ClientCapabilities::fromArray((array) $capabilities), + \is_array($clientInfo) ? Implementation::fromArray($clientInfo) : null, + self::parseLogLevel($meta[self::LOG_LEVEL] ?? null), + self::parseTraceContext($meta, $headers), + ); + } + + /** + * An unparseable level means "none requested": failing the caller's actual + * work over a diagnostic preference would be worse. + */ + private static function parseLogLevel(mixed $level): ?LoggingLevel + { + return \is_string($level) ? LoggingLevel::tryFrom($level) : null; + } + + /** + * Carried through opaquely: the values are the tracing ecosystem's to + * interpret, and a malformed one is not this server's to reject. + * + * `_meta` wins over the native W3C header of the same name when both are + * present — it is the more specific of the two, scoped to this one call + * rather than the whole HTTP request, and a caller that put it there did + * so deliberately. + * + * @param array $meta + * @param array $headers + * + * @return array + */ + private static function parseTraceContext(array $meta, array $headers): array + { + $context = []; + + foreach (self::TRACE_KEYS as $key) { + if (\is_string($meta[$key] ?? null)) { + $context[$key] = $meta[$key]; + } elseif (null !== $header = InboundClassifier::header($headers, $key)) { + $context[$key] = $header; + } + } + + return $context; + } +} diff --git a/src/Server/Stateless/RequestStateCodec.php b/src/Server/Stateless/RequestStateCodec.php new file mode 100644 index 0000000..9dcf477 --- /dev/null +++ b/src/Server/Stateless/RequestStateCodec.php @@ -0,0 +1,125 @@ + + */ +final class RequestStateCodec +{ + /** Below this the MAC — the only thing making the blob trustworthy — is forgeable. */ + public const MINIMUM_KEY_BYTES = 32; + + private const ALGORITHM = 'sha256'; + + public function __construct( + private readonly string $key, + private readonly int $ttlSeconds = 600, + ) { + if (\strlen($this->key) < self::MINIMUM_KEY_BYTES) { + throw new InvalidArgumentException(\sprintf('The requestState signing key must be at least %d bytes, got %d.', self::MINIMUM_KEY_BYTES, \strlen($this->key))); + } + + if ($this->ttlSeconds < 1) { + throw new InvalidArgumentException(\sprintf('The requestState TTL must be at least one second, got %d.', $this->ttlSeconds)); + } + } + + /** + * @param array $payload server context to carry to the retry — never secrets + */ + public function mint(array $payload, ?int $now = null): string + { + $body = self::encode(json_encode([ + 'exp' => ($now ?? time()) + $this->ttlSeconds, + 'data' => $payload, + ], \JSON_THROW_ON_ERROR)); + + return $body.'.'.self::encode($this->sign($body)); + } + + /** + * @return array the payload that was sealed + * + * @throws RequestStateException when the value is malformed, unsigned by this key, or expired + */ + public function verify(string $state, ?int $now = null): array + { + $parts = explode('.', $state); + + if (2 !== \count($parts)) { + throw new RequestStateException('malformed'); + } + + [$body, $mac] = $parts; + + $expected = $this->sign($body); + $actual = self::decode($mac); + + // Constant-time: a short-circuiting compare is a byte-at-a-time oracle. + if (null === $actual || !hash_equals($expected, $actual)) { + throw new RequestStateException('mac'); + } + + $decoded = self::decode($body); + + if (null === $decoded) { + throw new RequestStateException('malformed'); + } + + try { + /** @var array $claims */ + $claims = json_decode($decoded, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + throw new RequestStateException('malformed'); + } + + if (!\is_array($claims) || !\is_int($claims['exp'] ?? null) || !\is_array($claims['data'] ?? null)) { + throw new RequestStateException('malformed'); + } + + // Bounds replay; does not make the state single-use. + if ($claims['exp'] < ($now ?? time())) { + throw new RequestStateException('expired'); + } + + return $claims['data']; + } + + private function sign(string $body): string + { + return hash_hmac(self::ALGORITHM, $body, $this->key, true); + } + + private static function encode(string $raw): string + { + return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + private static function decode(string $encoded): ?string + { + $decoded = base64_decode(strtr($encoded, '-_', '+/'), true); + + return false === $decoded ? null : $decoded; + } +} diff --git a/src/Server/Stateless/StandardHeaderValidator.php b/src/Server/Stateless/StandardHeaderValidator.php new file mode 100644 index 0000000..0d54873 --- /dev/null +++ b/src/Server/Stateless/StandardHeaderValidator.php @@ -0,0 +1,299 @@ + + */ +final class StandardHeaderValidator +{ + public const METHOD_HEADER = McpHeader::METHOD; + public const NAME_HEADER = McpHeader::NAME; + public const PARAM_HEADER_PREFIX = McpHeader::PARAM_PREFIX; + + public function __construct( + private readonly ?RegistryInterface $registry = null, + ) { + } + + /** + * @param array|null $params + * @param array $headers + * + * @return string|null the reason to reject, or null when the request is consistent + */ + public function validate(string $method, ?array $params, array $headers): ?string + { + return $this->checkMethod($method, $headers) + ?? $this->checkName($method, $params, $headers) + ?? $this->checkParams($method, $params, $headers); + } + + /** + * @param array $headers + */ + private function checkMethod(string $method, array $headers): ?string + { + $declared = $this->header($headers, self::METHOD_HEADER); + + if (null === $declared) { + return \sprintf('Missing required %s header (body method is "%s").', self::METHOD_HEADER, $method); + } + + if ($declared !== $method) { + return \sprintf('%s header "%s" does not match the body method "%s".', self::METHOD_HEADER, $declared, $method); + } + + return null; + } + + /** + * When the body carries a name the header must repeat it; when it does not, + * the server must not demand one. + * + * @param array|null $params + * @param array $headers + */ + private function checkName(string $method, ?array $params, array $headers): ?string + { + $expected = self::nameFor($method, $params); + $declared = $this->header($headers, self::NAME_HEADER); + + if (null === $expected) { + return null; + } + + if (null === $declared) { + return \sprintf('Missing required %s header (body carries "%s").', self::NAME_HEADER, $expected); + } + + // Tool and prompt names are only SHOULD-constrained to header-safe + // characters and a resource URI is not constrained at all, so the + // client wraps anything unsafe — decode before comparing or every + // conformant client carrying a non-ASCII subject is refused. + $decoded = self::decode($declared); + + if (null === $decoded) { + return \sprintf('%s header is not a well-formed Base64 wrapper.', self::NAME_HEADER); + } + + if ($decoded !== $expected) { + return \sprintf('%s header "%s" does not match the body value "%s".', self::NAME_HEADER, $decoded, $expected); + } + + return null; + } + + /** + * The subject of a request, per method. Anything unlisted is exempt. + * + * @param array|null $params + */ + public static function nameFor(string $method, ?array $params): ?string + { + return McpHeader::nameFor($method, $params); + } + + /** + * Only headers the tool itself declares are checked: an unrecognized + * `Mcp-Param-*` belongs to somebody else in the chain, and intermediaries + * are meant to forward what they do not understand. + * + * @param array|null $params + * @param array $headers + */ + private function checkParams(string $method, ?array $params, array $headers): ?string + { + if ('tools/call' !== $method || null === $this->registry) { + return null; + } + + $toolName = $params['name'] ?? null; + if (!\is_string($toolName)) { + return null; + } + + try { + $tool = $this->registry->getTool($toolName)->tool; + } catch (ToolNotFoundException) { + // The handler reports this properly; a header complaint would not. + return null; + } + + $arguments = \is_array($params['arguments'] ?? null) ? $params['arguments'] : []; + + foreach (self::mirroredProperties($tool->inputSchema) as $name => $path) { + $error = $this->checkParam( + self::PARAM_HEADER_PREFIX.$name, + $headers, + self::valueAt($arguments, $path), + ); + + if (null !== $error) { + return $error; + } + } + + return null; + } + + /** + * Every `x-mcp-header` annotation in $schema, as header name to the property + * path it mirrors. + * + * Only statically reachable properties count: the chain from the root must + * be `properties` keys the whole way. A chain through `items`, a + * composition keyword, `if`/`then`/`else` or a `$ref` is not extractable + * without evaluating the instance, so the specification puts an annotation + * there out of bounds — and this walk simply never reaches one. + * + * @param array $schema + * @param list $path + * + * @return array> + */ + public static function mirroredProperties(array $schema, array $path = []): array + { + $properties = $schema['properties'] ?? null; + + if (!\is_array($properties)) { + return []; + } + + $found = []; + + foreach ($properties as $property => $definition) { + if (!\is_array($definition)) { + continue; + } + + $here = [...$path, (string) $property]; + + if (\is_string($definition['x-mcp-header'] ?? null)) { + $found[$definition['x-mcp-header']] = $here; + } + + $found = [...$found, ...self::mirroredProperties($definition, $here)]; + } + + return $found; + } + + /** + * Reads the instance value at an exact property path, or null when the path + * is not present — which the specification reads as "no header expected". + * + * @param array $arguments + * @param list $path + */ + private static function valueAt(array $arguments, array $path): mixed + { + $node = $arguments; + + foreach ($path as $segment) { + if (!\is_array($node) || !\array_key_exists($segment, $node)) { + return null; + } + + $node = $node[$segment]; + } + + return $node; + } + + /** + * @param array $headers + */ + private function checkParam(string $headerName, array $headers, mixed $argument): ?string + { + $declared = $this->header($headers, $headerName); + + // An omitted argument means an omitted header. + if (null === $argument) { + return null; + } + + if (null === $declared) { + return \sprintf('Missing required %s header (body carries the mirrored argument).', $headerName); + } + + $decoded = self::decode($declared); + + if (null === $decoded) { + return \sprintf('%s header is not a well-formed Base64 wrapper.', $headerName); + } + + // Numbers travel as decimal strings, booleans as "true"/"false". + $expected = match (true) { + \is_bool($argument) => $argument ? 'true' : 'false', + \is_scalar($argument) => (string) $argument, + default => null, + }; + + // A non-scalar cannot be mirrored at all, so the annotation on it is + // the tool definition's problem rather than this request's. + if (null === $expected) { + return null; + } + + // Numerically for numbers, so "42.0" and "42" agree — the spec asks for + // this, and a client's JSON writer is free to pick either. Gated on the + // argument's actual type (not is_numeric, which a numeric-looking + // string like "042" would also satisfy) and a decimal header (not + // "4e1"), so a string argument keeps its exact-match comparison. + if ((\is_int($argument) || \is_float($argument)) && 1 === preg_match('/^-?\d+(?:\.\d+)?$/', $decoded)) { + return (float) $decoded === (float) $argument + ? null + : \sprintf('%s header "%s" does not match the body argument "%s".', $headerName, $decoded, $expected); + } + + if ($decoded !== $expected) { + return \sprintf('%s header "%s" does not match the body argument "%s".', $headerName, $decoded, $expected); + } + + return null; + } + + /** + * Unwraps a `=?base64?…?=` value, or returns a plain value unchanged. + */ + public static function decode(string $value): ?string + { + return McpHeader::decode($value); + } + + /** + * Case-insensitive name, whitespace-trimmed value (RFC 9110 §5.5). + * + * @param array $headers + */ + private function header(array $headers, string $name): ?string + { + foreach ($headers as $key => $value) { + if (0 === strcasecmp($key, $name)) { + return trim($value); + } + } + + return null; + } +} diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php new file mode 100644 index 0000000..a179a21 --- /dev/null +++ b/src/Server/Stateless/StatelessProtocol.php @@ -0,0 +1,874 @@ + + */ +final class StatelessProtocol +{ + private readonly WireCodecInterface $codec; + + /** + * Methods the modern era deleted. Answered as unknown methods, which is + * what they are to a modern server. + * + * A deny-list rather than an allow-list on purpose: extensions add methods + * this class has never heard of, so an unlisted method has to reach + * dispatch. Every removal named in the 2026-07-28 changelog belongs here — + * the handlers behind them stay registered for the handshake era, which is + * why the era guard, and not the registration, is what turns them off. + */ + public const REMOVED_METHODS = [ + 'initialize', + 'notifications/initialized', + 'ping', + 'logging/setLevel', + // Replaced by the `resourceSubscriptions` filter of subscriptions/listen. + 'resources/subscribe', + 'resources/unsubscribe', + 'notifications/roots/list_changed', + ]; + + public const DISCOVER_METHOD = 'server/discover'; + public const LISTEN_METHOD = 'subscriptions/listen'; + public const ACKNOWLEDGED_NOTIFICATION = 'notifications/subscriptions/acknowledged'; + + /** + * What a client is told when a handler fails in a way nothing anticipated. + * + * Deliberately generic rather than {@see \Throwable::getMessage()}: the + * real message is logged, not returned, so an internal detail (a + * connection string, a file path, another library's error text) never + * reaches the client that triggered it. + */ + private const INTERNAL_ERROR_MESSAGE = 'Internal server error.'; + + /** + * @param iterable> $requestHandlers + * @param list $supportedVersions + * @param array $extensionMethods RPC method to the extension identifier defining it + */ + public function __construct( + private readonly iterable $requestHandlers, + private readonly MessageFactory $messageFactory, + private readonly Configuration $configuration, + private readonly array $supportedVersions = [ProtocolVersion::V2026_07_28], + private readonly LoggerInterface $logger = new NullLogger(), + private readonly float $subscriptionLifetime = 30.0, + ?WireCodecInterface $codec = null, + private readonly ?StandardHeaderValidator $headerValidator = null, + private readonly ?RequestStateCodec $requestStateCodec = null, + ?CachePolicy $cachePolicy = null, + private readonly ?NotificationBusInterface $notificationBus = null, + private readonly array $extensionMethods = [], + ) { + $this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy); + + if (null === $this->headerValidator) { + // Not fatal: a transport without a header layer — stdio — has + // nothing to validate. But on HTTP the headers are REQUIRED for + // compliance, so an absent validator there is a silently + // non-conformant server and worth saying out loud once. + $this->logger->warning('No StandardHeaderValidator configured; the SEP-2243 request headers will not be enforced. This is correct only for a transport without a header layer.'); + } + } + + /** + * The modern revisions this dispatcher answers for. + * + * @return list + */ + public function supportedVersions(): array + { + return $this->supportedVersions; + } + + /** + * Whether the transport carrying this dispatcher has a header layer whose + * required members must be present. + * + * The validator's presence is the signal: it is what a header-bearing + * transport installs, and stdio carries its metadata inline instead + * (see the stdio binding's "Request Metadata"). + */ + private function requiresTransportHeaders(): bool + { + return null !== $this->headerValidator; + } + + /** + * Answers one JSON-RPC request read from an HTTP request body. + * + * @param array $headers request headers, case-insensitively matched + */ + public function handle(string $body, array $headers = []): StatelessResult + { + try { + /** @var array|null $decoded */ + $decoded = json_decode($body, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + return StatelessResult::error(Error::forParseError($e->getMessage()), 400); + } + + if (!\is_array($decoded)) { + return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC message must be a JSON object.'), 400); + } + + // The id is read before anything is validated so that every error below + // can echo it when there is one to echo. A missing or malformed id is + // left as null rather than coerced to "" — Error::jsonSerialize() + // already knows to omit a null id instead of fabricating one, and + // "id": "" would falsely claim the sender issued a request with an + // empty-string id. + // + // An absent id and an unreadable one are different messages: the first + // is a notification, the second a malformed request. JSON-RPC 2.0 + // writes "no id" as an explicit null, so that counts as absent too. + $isNotification = !\array_key_exists('id', $decoded) || null === $decoded['id']; + $id = $decoded['id'] ?? null; + + if (!\is_string($id) && !\is_int($id)) { + $id = null; + } + + $method = $decoded['method'] ?? null; + if (!\is_string($method) || '' === $method) { + return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC message must carry a "method".', $id), 400); + } + + $params = \is_array($decoded['params'] ?? null) ? $decoded['params'] : null; + + // No id is a notification. It gets an acknowledgment, never a response + // — answering one with a JSON-RPC message would invent a correlation + // the client has no request to match it against. Checked before the + // `_meta` parse: notification params carry `NotificationMetaObject`, + // which has none of a request's required members. + if ($isNotification) { + return $this->acknowledge($method); + } + + if (null === $id) { + return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC request id must be a string or a number.'), 400); + } + + try { + $meta = RequestMeta::fromParams($params, $headers); + } catch (MissingRequestMetaException $e) { + return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400); + } + + if (null !== $versionError = $this->checkVersion($meta, $headers, $id)) { + return $versionError; + } + + // After the version check: a peer on the wrong revision has a more + // fundamental problem than headers that disagree with its body. + if (null !== $headerError = $this->headerValidator?->validate($method, $params, $headers)) { + return StatelessResult::error(Error::forHeaderMismatch($headerError, $id), 400); + } + + if (self::DISCOVER_METHOD === $method || self::LISTEN_METHOD === $method) { + if (self::DISCOVER_METHOD === $method) { + return $this->encode($method, $id, $this->discover()); + } + + return $this->listen($params, $id); + } + + if (\in_array($method, self::REMOVED_METHODS, true)) { + return StatelessResult::error( + Error::forMethodNotFound(\sprintf('Method "%s" does not exist in protocol version %s.', $method, $meta->protocolVersion), $id), + 404, + ); + } + + return $this->dispatch($method, $decoded, $meta, $id, self::acceptsEventStream($headers)); + } + + /** + * Answers a notification. + * + * This revision's core defines no client-to-server notification over HTTP — + * `notifications/cancelled` is stdio-only, since closing the response + * stream is the cancellation signal here — so anything arriving is either + * an extension's or a client still speaking an older revision. Accepting + * the former and refusing the latter both come out as a status with no + * body; what must not happen is a JSON-RPC response. + */ + private function acknowledge(string $method): StatelessResult + { + if (\in_array($method, self::REMOVED_METHODS, true)) { + $this->logger->debug('Refused a notification this revision removed.', ['method' => $method]); + + return StatelessResult::empty(400); + } + + $this->logger->debug('Accepted a notification with no handler to run.', ['method' => $method]); + + return StatelessResult::empty(202); + } + + /** + * Header and `_meta` must agree before the version can be judged supported: + * when they disagree the server cannot know which the client meant, so a + * mismatch outranks an unsupported version. + * + * @param array $headers + */ + private function checkVersion(RequestMeta $meta, array $headers, string|int|null $id): ?StatelessResult + { + $headerVersion = $this->header($headers, 'MCP-Protocol-Version'); + + // REQUIRED on every POST. The 2025-03-26 fallback for a header-less + // request exists only for servers choosing to serve pre-2025-06-18 + // clients, which a modern-only endpoint is not. + if (null === $headerVersion && $this->requiresTransportHeaders()) { + return StatelessResult::error( + Error::forHeaderMismatch( + \sprintf('Missing required MCP-Protocol-Version header (_meta declares "%s").', $meta->protocolVersion), + $id, + ), + 400, + ); + } + + // The same check the HTTP entry runs before routing, so the edge and + // this dispatcher cannot disagree about what a request claims. + if (null !== $mismatch = InboundClassifier::crossCheckVersion($headerVersion, $meta->protocolVersion)) { + return StatelessResult::error(Error::forHeaderMismatch($mismatch, $id), 400); + } + + $version = ProtocolVersion::tryFrom($meta->protocolVersion); + + if (null === $version || !\in_array($version, $this->supportedVersions, true)) { + return StatelessResult::error( + Error::forUnsupportedProtocolVersion($meta->protocolVersion, $this->supportedVersions, $id), + 400, + ); + } + + return null; + } + + /** + * Opens a `subscriptions/listen` stream. The subscription id is the + * JSON-RPC id of this request, so there is none to mint. + * + * @param array|null $params + */ + private function listen(?array $params, string|int $id): StatelessResult + { + $notifications = \is_array($params['notifications'] ?? null) ? $params['notifications'] : null; + $agreed = NotificationFilter::fromParams($notifications)->intersect($this->configuration->capabilities); + + $lifetime = $this->subscriptionLifetime; + $bus = $this->notificationBus; + $codec = $this->codec; + + return StatelessResult::stream(static function () use ($agreed, $id, $lifetime, $bus, $codec): \Generator { + // MUST be the first message carrying this subscription's id, and + // MUST precede any notification on it. + yield [ + 'jsonrpc' => '2.0', + 'method' => self::ACKNOWLEDGED_NOTIFICATION, + 'params' => [ + '_meta' => [RequestMeta::SUBSCRIPTION_ID => $id], + 'notifications' => (object) $agreed->toAcknowledgedArray(), + ], + ]; + + // From now, not from the beginning: a subscriber wants what happens + // next, not a replay of the server's history. + $cursor = $bus?->cursor() ?? 0; + + // The tick is not optional: PHP spots a dropped peer by writing, + // and a sleeping loop would pin an FPM worker for the full lifetime. + $deadline = 0.0 >= $lifetime ? \INF : microtime(true) + $lifetime; + + while (microtime(true) < $deadline) { + if (null !== $bus) { + [$notifications, $cursor] = $bus->since($cursor); + + foreach ($notifications as $notification) { + if (!$agreed->carries($notification)) { + continue; + } + + yield self::tagWithSubscription($notification, $id); + } + } + + yield null; + + if (connection_aborted()) { + return; + } + + usleep(250_000); + } + + // Graceful closure (SHOULD), so the client can tell this from a + // dropped transport. + yield [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => $codec->encodeResult(self::LISTEN_METHOD, [ + 'resultType' => 'complete', + '_meta' => [RequestMeta::SUBSCRIPTION_ID => $id], + ], false), + ]; + }); + } + + /** + * Every message on a listen stream carries the id of the subscription it + * belongs to, which is how a client demultiplexes them on stdio — where + * they all share one channel. + * + * @return array + */ + private static function tagWithSubscription(Notification $notification, string|int $id): array + { + /** @var array $frame */ + $frame = $notification->jsonSerialize(); + + $params = \is_array($frame['params'] ?? null) ? $frame['params'] : []; + $meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; + + $meta[RequestMeta::SUBSCRIPTION_ID] = $id; + $params['_meta'] = $meta; + $frame['params'] = $params; + + return $frame; + } + + private function discover(): DiscoverResult + { + return new DiscoverResult( + $this->supportedVersions, + $this->configuration->capabilities, + $this->configuration->instructions, + ); + } + + /** + * @param array $decoded + */ + private function dispatch(string $method, array $decoded, RequestMeta $meta, string|int|null $id, bool $wantsStream = false): StatelessResult + { + try { + $messages = $this->messageFactory->create(json_encode($decoded, \JSON_THROW_ON_ERROR)); + } catch (\Throwable $e) { + $this->logger->warning('Rejected an unparseable modern-era request.', ['method' => $method, 'exception' => $e]); + + return StatelessResult::error($this->unknownMethod($method, $id), 404); + } + + $request = $messages[0] ?? null; + + // The factory hands back an exception object rather than throwing one, + // distinguishing a genuinely unknown method (-32601, the client should + // stop asking) from a known method the message could not otherwise be + // parsed into (-32600, the request itself is malformed). + if ($request instanceof InvalidInputMessageException) { + $unknownMethod = \sprintf('Unknown method "%s".', $method) === $request->getMessage(); + + return StatelessResult::error( + $unknownMethod + ? $this->unknownMethod($method, $id) + : Error::forInvalidRequest($request->getMessage(), $id), + $unknownMethod ? 404 : 400, + ); + } + + if (!$request instanceof Request) { + // Reachable only for a well-formed Response/Error object posted to + // this endpoint — decodable by the factory, but not a request this + // server can answer. + return StatelessResult::error(Error::forInvalidRequest(\sprintf('"%s" is not a request this server can answer.', $method), $id), 400); + } + + // Request::fromArray() already rejected a missing/invalid id (as an + // InvalidInputMessageException, handled above), so this request's id + // is always present here — reusing it narrows $id for the rest of this + // method instead of trusting the raw, still-nullable value from above. + $id = $request->getId(); + + $session = new Session(new InMemorySessionStore()); + $session->set(RequestMeta::class, $meta); + + // Under the same keys the handshake era writes, so everything reading + // connection state — ClientGateway's capability probes above all — sees + // this request's declaration instead of an empty session. + $session->set('client_capabilities', $meta->clientCapabilities->jsonSerialize()); + $session->set('protocol_version', $meta->protocolVersion); + + try { + $input = $this->liftInputContext($decoded['params'] ?? null); + } catch (RequestStateException $e) { + // Invalid params, not an authorization failure: the client only + // echoes what it was given, and the reason stays out of the answer. + $this->logger->warning('Rejected a requestState that failed verification.', ['method' => $method, 'reason' => $e->getMessage()]); + + return StatelessResult::error(Error::forInvalidParams('The supplied requestState failed verification.', $id), 400); + } + + if (null !== $input) { + $session->set(InputContext::class, $input); + } + + if (null !== $this->requestStateCodec) { + $session->set(RequestStateCodec::class, $this->requestStateCodec); + } + + // What ClientGateway::progress() reads to find the progress token, and + // the handshake era sets under the same key. + $session->set(Protocol::SESSION_ACTIVE_REQUEST_META, $request->getMeta()); + + foreach ($this->requestHandlers as $handler) { + if (!$handler->supports($request)) { + continue; + } + + $run = $this->run($handler, $request, $session, $meta); + + try { + // Runs the handler up to its first notification, or to the end + // if it emits none. Deciding here and not earlier is what keeps + // the status codes honest: a request that turns out to need + // -32021 has said nothing yet, so it can still be answered + // with 400 rather than an error frame under a 200. + $run->rewind(); + } catch (\Throwable $e) { + return $this->toErrorResult($method, $id, $e); + } + + if ($run->valid() && $wantsStream) { + return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $meta, $method, $id, null === $input)); + } + + try { + // Stepped rather than foreach()ed: rewind() already advanced it, + // and a generator will not be traversed a second time. + while ($run->valid()) { + $this->logger->debug('Dropped a notification: the client did not accept a response stream.', [ + 'method' => $method, + 'notification' => $run->current()::getMethod(), + ]); + + $run->next(); + } + + $result = $run->getReturn(); + } catch (\Throwable $e) { + return $this->toErrorResult($method, $id, $e); + } + + if ($result instanceof Error) { + return StatelessResult::error($result, 400); + } + + if (null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) { + return $capabilityError; + } + + return $this->encode($method, $id, $result->result, null === $input); + } + + return StatelessResult::error($this->unknownMethod($method, $id), 404); + } + + /** + * A method with no handler, said as precisely as the server can. + * + * An extension's method is still `-32601` when the extension is off — the + * server genuinely does not implement it — but naming the extension turns + * an opaque refusal into something the caller can act on. + */ + private function unknownMethod(string $method, string|int $id): Error + { + $extension = $this->extensionMethods[$method] ?? null; + + if (null !== $extension) { + return Error::forMethodNotFound( + \sprintf('Method "%s" belongs to the "%s" extension, which this server does not serve.', $method, $extension), + $id, + ); + } + + return Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id); + } + + /** + * Runs a handler, yielding the notifications it emits as it emits them and + * returning its result. + * + * The fiber is what makes a handler's `$gateway->progress(...)` look + * synchronous while the caller decides where the notification goes. Server + * -to-client *requests* are never forwarded: this revision carries what it + * needs in the result (MRTR), and putting a request on a response stream is + * something the transport binding forbids outright. + * + * An elicitation is answered here rather than refused. Already answered, it + * resumes the fiber and the handler runs on; not yet, and the ask becomes + * the result — abandoning the fiber, since this request has nothing left to + * say and the client will re-send it. Abandoning unwinds it, so a handler's + * `finally` still runs; what does not run is everything after the ask. + * + * That is what lets one handler serve both eras through + * {@see \Mcp\Server\ClientGateway::elicit()}; see {@see ElicitationReplay} + * for what it costs. + * + * @param RequestHandlerInterface $handler + * + * @return \Generator|Error> + */ + private function run(RequestHandlerInterface $handler, Request $request, Session $session, RequestMeta $meta): \Generator + { + $fiber = new \Fiber(static fn (): mixed => $handler->handle($request, $session)); + $input = $session->get(InputContext::class); + $replay = new ElicitationReplay($input instanceof InputContext ? $input : null, $this->requestStateCodec); + + $suspended = $fiber->start(); + + while (!$fiber->isTerminated()) { + if (null !== $elicitation = self::readElicitation($suspended)) { + [$named, $elicit] = $elicitation; + $key = $replay->key($named); + + if (null === $answer = $replay->answer($key, $elicit->mode)) { + try { + return new Response($request->getId(), $replay->ask($key, $elicit)); + } catch (LogicException $e) { + $this->logger->error('A handler asked for input across rounds on a server with no requestState signing key.', ['exception' => $e]); + + return Error::forInternalError('The server could not carry its own state across a round of input.', $request->getId()); + } + } + + $suspended = $fiber->resume(new Response($request->getId(), $answer)); + + continue; + } + + $notification = $this->readNotification($suspended, $meta); + + if (null !== $notification) { + yield $notification; + } + + $suspended = $fiber->resume(null); + } + + /** @var Response|Error $return */ + $return = $fiber->getReturn(); + + return $return; + } + + /** + * Reads one fiber suspension, or null when it carries nothing to send. + * + * @param mixed $suspended the payload {@see \Mcp\Server\ClientGateway} suspended with + */ + private function readNotification(mixed $suspended, RequestMeta $meta): ?Notification + { + if (!\is_array($suspended) || 'notification' !== ($suspended['type'] ?? null)) { + if (\is_array($suspended) && 'request' === ($suspended['type'] ?? null)) { + // Elicitation never reaches here — it is answered in run(). What + // is left are the kinds this revision removed outright, and no + // multi round-trip shape brings them back. + throw new LogicException('This protocol revision has no server-initiated requests: sampling and roots were removed with it, so take what you need through tool arguments, resource URIs or server configuration instead. Elicitation is the one ask that survived, as a multi round-trip request.'); + } + + return null; + } + + $notification = $suspended['notification'] ?? null; + + if (!$notification instanceof Notification) { + return null; + } + + // The client opts into logs per request; with no level named the server + // MUST NOT send any, which is why an absent level drops rather than + // defaults. + if ($notification instanceof LoggingMessageNotification) { + if (null === $meta->logLevel || !$notification->level->isAtLeast($meta->logLevel)) { + return null; + } + } + + return $notification; + } + + /** + * One fiber suspension read as an elicitation, or null when it is not one. + * + * @param mixed $suspended the payload {@see \Mcp\Server\ClientGateway} suspended with + * + * @return array{0: string|null, 1: ElicitRequest}|null the name the handler gave the ask, and the ask + */ + private static function readElicitation(mixed $suspended): ?array + { + if (!\is_array($suspended) || 'request' !== ($suspended['type'] ?? null)) { + return null; + } + + $request = $suspended['request'] ?? null; + + if (!$request instanceof ElicitRequest) { + return null; + } + + $key = $suspended['input_key'] ?? null; + + return [\is_string($key) ? $key : null, $request]; + } + + /** + * The frames of a request-scoped response stream: the notifications the + * handler emits, then the response that ends it. + * + * @param \Generator|Error> $run + * + * @return \Generator + */ + private function streamFrames(\Generator $run, RequestMeta $meta, string $method, string|int $id, bool $cacheable): \Generator + { + try { + while ($run->valid()) { + yield self::withTraceContext($run->current()->jsonSerialize(), $meta->traceContext); + + $run->next(); + } + + $result = $run->getReturn(); + } catch (\Throwable $e) { + // Headers left long ago, so the status is already 200 and the only + // way left to report this is a frame. + yield $this->toErrorResult($method, $id, $e)->message?->jsonSerialize(); + + return; + } + + if (!$result instanceof Error && null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) { + yield $capabilityError->message?->jsonSerialize(); + + return; + } + + yield $result instanceof Error + ? $result->jsonSerialize() + : ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $result->result->jsonSerialize(), $cacheable)]; + } + + /** + * Refuses to send an ask the client cannot answer. + * + * The handler's mistake rather than the client's, but the client is the one + * that has to hear about it, and `-32021` is precisely the code for + * "processing this needs a capability you did not declare" — so it is + * reported as that, and logged as the server-side bug it is. + */ + private function checkInputRequests(ResultInterface $result, RequestMeta $meta, string $method, string|int $id): ?StatelessResult + { + if (!$result instanceof InputRequiredResult) { + return null; + } + + $missing = InputRequestCapabilities::missing($result, $meta->clientCapabilities); + + if (null === $missing) { + return null; + } + + $this->logger->warning('A handler asked for input the client did not declare it could provide; the ask was replaced with -32021.', [ + 'method' => $method, + 'required' => $missing->jsonSerialize(), + ]); + + return StatelessResult::error( + Error::forMissingRequiredClientCapability( + 'The server needs input this client did not declare it can provide.', + $missing, + $id, + ), + 400, + ); + } + + /** + * Puts the request's trace context back onto a notification it caused, so a + * collector can join the two without the handler carrying it by hand. + * + * @param array $frame + * @param array $traceContext + * + * @return array + */ + private static function withTraceContext(array $frame, array $traceContext): array + { + if ([] === $traceContext) { + return $frame; + } + + $params = \is_array($frame['params'] ?? null) ? $frame['params'] : []; + $frameMeta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; + + // Anything the notification set itself wins: it knows its own span. + $params['_meta'] = [...$traceContext, ...$frameMeta]; + $frame['params'] = $params; + + return $frame; + } + + /** + * The one place a handler's exception becomes an answer, so the streaming + * and non-streaming paths cannot disagree about which code it earns. + */ + private function toErrorResult(string $method, string|int $id, \Throwable $e): StatelessResult + { + if ($e instanceof MissingRequiredClientCapabilityException) { + return StatelessResult::error( + Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $id), + 400, + ); + } + + if ($e instanceof \InvalidArgumentException) { + return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400); + } + + if ($e instanceof LogicException) { + // Guidance for the tool author, not a detail leaked from their + // code or a dependency's — safe to echo back verbatim. + return StatelessResult::error(Error::forInternalError($e->getMessage(), $id), 500); + } + + $this->logger->error('Uncaught exception handling a modern-era request.', ['method' => $method, 'exception' => $e]); + + return StatelessResult::error(Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), 500); + } + + /** + * Reads the multi round-trip material off a retry, verifying the state + * before any of it reaches a handler. Neither member means a first call, + * which is what a handler tests to decide whether it still needs to ask. + * + * @param array|null $params + * + * @throws RequestStateException when a state is present but does not verify + */ + private function liftInputContext(?array $params): ?InputContext + { + $responses = \is_array($params['inputResponses'] ?? null) ? $params['inputResponses'] : null; + $state = \is_string($params['requestState'] ?? null) ? $params['requestState'] : null; + + if (null === $responses && null === $state) { + return null; + } + + // Answers are result objects; dropping non-objects leaves the handler + // to ask again rather than read a malformed retry as satisfied. + if (null !== $responses) { + $responses = array_filter($responses, static fn (mixed $response): bool => \is_array($response)); + } + + $payload = []; + + if (null !== $state) { + // A server with no codec never minted a state, so this one cannot + // have come from here. + if (null === $this->requestStateCodec) { + throw new RequestStateException('mac'); + } + + $payload = $this->requestStateCodec->verify($state); + } + + return new InputContext($responses ?? [], $payload); + } + + /** + * Runs a result through the wire codec. Passed as-is rather than via a + * json round trip, which would turn a nested `{}` into `[]`. + */ + private function encode(string $method, string|int $id, ResultInterface $result, bool $cacheable = true): StatelessResult + { + return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize(), $cacheable)); + } + + /** + * Whether the client will read a response stream. + * + * Clients MUST offer both content types, so this is normally true; a client + * that does not gets its notifications dropped rather than a stream it + * cannot parse. + * + * @param array $headers + */ + private static function acceptsEventStream(array $headers): bool + { + foreach ($headers as $key => $value) { + if (0 === strcasecmp($key, 'Accept')) { + return str_contains(strtolower($value), 'text/event-stream'); + } + } + + return false; + } + + /** + * @param array $headers + */ + private function header(array $headers, string $name): ?string + { + return InboundClassifier::header($headers, $name); + } +} diff --git a/src/Server/Stateless/StatelessResult.php b/src/Server/Stateless/StatelessResult.php new file mode 100644 index 0000000..9e2b630 --- /dev/null +++ b/src/Server/Stateless/StatelessResult.php @@ -0,0 +1,113 @@ + + */ +final class StatelessResult +{ + /** + * @param (\Closure(): \Generator)|null $frames set instead of $message when the answer is a stream + * @param array|null $body a result body already through the wire codec + */ + private function __construct( + public readonly ?MessageInterface $message, + public readonly int $httpStatus, + public readonly ?\Closure $frames = null, + private readonly ?array $body = null, + private readonly string|int|null $id = null, + private readonly bool $bodyless = false, + ) { + } + + /** + * A successful answer whose body the wire codec has already stamped — a + * plain array, since no result class models the fields it added. + * + * @param array $body + */ + public static function ok(string|int $id, array $body): self + { + return new self(null, 200, null, $body, $id); + } + + public static function error(Error $error, int $httpStatus): self + { + return new self($error, $httpStatus); + } + + /** + * A status with no body — what a notification gets, since it has no id to + * correlate a JSON-RPC message against. + */ + public static function empty(int $httpStatus): self + { + return new self(null, $httpStatus, bodyless: true); + } + + /** + * A long-lived answer delivered as frames — today only + * `subscriptions/listen`. Deferred, since the frames are produced over the + * life of the connection. + * + * @param \Closure(): \Generator $frames + */ + public static function stream(\Closure $frames): self + { + return new self(null, 200, $frames); + } + + public function isStream(): bool + { + return null !== $this->frames; + } + + public function isEmpty(): bool + { + return $this->bodyless; + } + + public function isError(): bool + { + return $this->message instanceof Error; + } + + public function toJson(): string + { + if (null !== $this->body) { + return json_encode([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => $this->id, + 'result' => $this->body, + ], \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES); + } + + if ($this->bodyless) { + throw new \LogicException('This result carries no body; send its status alone.'); + } + + if (null === $this->message) { + throw new \LogicException('A streaming or empty result has no single JSON body; check isStream()/isEmpty() first.'); + } + + return json_encode($this->message, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES); + } +} diff --git a/src/Server/Subscription/InMemoryNotificationBus.php b/src/Server/Subscription/InMemoryNotificationBus.php new file mode 100644 index 0000000..af93cef --- /dev/null +++ b/src/Server/Subscription/InMemoryNotificationBus.php @@ -0,0 +1,74 @@ + + */ +final class InMemoryNotificationBus implements NotificationBusInterface +{ + /** @var array */ + private array $entries = []; + + private int $next = 0; + + /** + * @param int $backlog how many notifications to keep before dropping the oldest + */ + public function __construct( + private readonly int $backlog = 256, + ) { + if ($this->backlog < 1) { + throw new InvalidArgumentException(\sprintf('The notification backlog must be at least one entry, got %d.', $this->backlog)); + } + } + + public function publish(Notification $notification): void + { + $this->entries[$this->next] = $notification; + ++$this->next; + + // A stream that went away must not make this grow forever. + if (\count($this->entries) > $this->backlog) { + $this->entries = \array_slice($this->entries, -$this->backlog, preserve_keys: true); + } + } + + public function cursor(): int + { + return $this->next; + } + + public function since(int $cursor): array + { + $found = []; + + foreach ($this->entries as $sequence => $notification) { + if ($sequence >= $cursor) { + $found[] = $notification; + } + } + + return [$found, $this->next]; + } +} diff --git a/src/Server/Subscription/NotificationBusInterface.php b/src/Server/Subscription/NotificationBusInterface.php new file mode 100644 index 0000000..8569366 --- /dev/null +++ b/src/Server/Subscription/NotificationBusInterface.php @@ -0,0 +1,53 @@ + + */ +interface NotificationBusInterface +{ + /** + * Publishes a notification to every stream currently reading forward. + */ + public function publish(Notification $notification): void; + + /** + * The cursor a stream opening now should start from. + * + * Deliberately "now" and not "the beginning": a client that subscribes + * wants what happens next, not a replay of everything the server has done. + */ + public function cursor(): int; + + /** + * Notifications published after $cursor, and the cursor to read from next. + * + * @return array{list, int} + */ + public function since(int $cursor): array; +} diff --git a/src/Server/Subscription/Psr16NotificationBus.php b/src/Server/Subscription/Psr16NotificationBus.php new file mode 100644 index 0000000..2f9b63a --- /dev/null +++ b/src/Server/Subscription/Psr16NotificationBus.php @@ -0,0 +1,113 @@ + + */ +final class Psr16NotificationBus implements NotificationBusInterface +{ + private const CURSOR_KEY = 'cursor'; + + private readonly MessageFactory $messageFactory; + + /** + * @param string $prefix namespace for this bus's keys, so one cache can carry several + * @param int $ttl how long an entry stays readable, in seconds + * @param int $backlog how many entries a reader will look back over + */ + public function __construct( + private readonly CacheInterface $cache, + private readonly string $prefix = 'mcp.notifications.', + private readonly int $ttl = 120, + private readonly int $backlog = 256, + private readonly LoggerInterface $logger = new NullLogger(), + ?MessageFactory $messageFactory = null, + ) { + if ($this->backlog < 1) { + throw new InvalidArgumentException(\sprintf('The notification backlog must be at least one entry, got %d.', $this->backlog)); + } + + $this->messageFactory = $messageFactory ?? MessageFactory::make(); + } + + public function publish(Notification $notification): void + { + $sequence = $this->cursor(); + + $this->cache->set($this->key((string) $sequence), json_encode($notification, \JSON_THROW_ON_ERROR), $this->ttl); + $this->cache->set($this->key(self::CURSOR_KEY), $sequence + 1, $this->ttl); + } + + public function cursor(): int + { + $cursor = $this->cache->get($this->key(self::CURSOR_KEY), 0); + + return \is_int($cursor) ? $cursor : 0; + } + + public function since(int $cursor): array + { + $head = $this->cursor(); + + // A reader that fell far behind reads what is still there, not + // everything it missed. + $from = max($cursor, $head - $this->backlog); + + $found = []; + + for ($sequence = $from; $sequence < $head; ++$sequence) { + $raw = $this->cache->get($this->key((string) $sequence)); + + if (!\is_string($raw)) { + // Expired, or lost to a concurrent publisher taking the same + // number. Neither is worth failing the stream over. + continue; + } + + try { + foreach ($this->messageFactory->create($raw) as $message) { + if ($message instanceof Notification) { + $found[] = $message; + } + } + } catch (\Throwable $e) { + $this->logger->warning('Dropped an unreadable notification from the bus.', ['sequence' => $sequence, 'exception' => $e]); + } + } + + return [$found, $head]; + } + + private function key(string $suffix): string + { + return $this->prefix.$suffix; + } +} diff --git a/src/Server/Subscription/PublishingEventDispatcher.php b/src/Server/Subscription/PublishingEventDispatcher.php new file mode 100644 index 0000000..a1c4e68 --- /dev/null +++ b/src/Server/Subscription/PublishingEventDispatcher.php @@ -0,0 +1,53 @@ + + */ +final class PublishingEventDispatcher implements EventDispatcherInterface +{ + /** @var array */ + private readonly array $listeners; + + public function __construct( + NotificationBusInterface $bus, + private readonly ?EventDispatcherInterface $inner = null, + ) { + $this->listeners = (new RegistryChangePublisher($bus))->listeners(); + } + + public function dispatch(object $event): object + { + $listener = $this->listeners[$event::class] ?? null; + + if (null !== $listener) { + $listener($event); + } + + return $this->inner?->dispatch($event) ?? $event; + } +} diff --git a/src/Server/Subscription/RegistryChangePublisher.php b/src/Server/Subscription/RegistryChangePublisher.php new file mode 100644 index 0000000..f54a356 --- /dev/null +++ b/src/Server/Subscription/RegistryChangePublisher.php @@ -0,0 +1,81 @@ + + */ +final class RegistryChangePublisher +{ + public function __construct( + private readonly NotificationBusInterface $bus, + ) { + } + + public function onToolListChanged(ToolListChangedEvent $event): void + { + $this->bus->publish(new ToolListChangedNotification()); + } + + public function onPromptListChanged(PromptListChangedEvent $event): void + { + $this->bus->publish(new PromptListChangedNotification()); + } + + public function onResourceListChanged(ResourceListChangedEvent $event): void + { + $this->bus->publish(new ResourceListChangedNotification()); + } + + public function onResourceTemplateListChanged(ResourceTemplateListChangedEvent $event): void + { + $this->bus->publish(new ResourceListChangedNotification()); + } + + /** + * Every event this publisher handles, keyed by event class. + * + * Shaped for a dispatcher that wants a map; a framework's own subscriber + * conventions can read it too rather than restating the list. + * + * @return array + */ + public function listeners(): array + { + return [ + ToolListChangedEvent::class => $this->onToolListChanged(...), + PromptListChangedEvent::class => $this->onPromptListChanged(...), + ResourceListChangedEvent::class => $this->onResourceListChanged(...), + ResourceTemplateListChangedEvent::class => $this->onResourceTemplateListChanged(...), + ]; + } +} diff --git a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php index 6d60e1a..e4ec015 100644 --- a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php @@ -35,6 +35,9 @@ use Psr\Http\Server\RequestHandlerInterface; * the `initialize` round-trip does not carry the header, and legacy clients * that omit it are tolerated. * + * Validation is against a fixed set, not against the version a particular session + * negotiated: PSR-15 middleware runs before the transport resolves the session. + * * @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header * * @author Volodymyr Panivko @@ -47,8 +50,11 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface /** @var list */ private readonly array $supportedVersions; + /** @var list */ + private readonly array $supported; + /** - * @param list|null $supportedVersions Versions the server accepts. Defaults to all values of {@see ProtocolVersion}. + * @param list|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()}. {@see StreamableHttpTransport::handshakeMiddleware()} always uses that default: it runs only on traffic already classified as handshake-era, so this middleware never needs to know about modern revisions there. * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) */ @@ -57,8 +63,9 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, ) { - $versions = $supportedVersions ?? ProtocolVersion::cases(); - $this->supportedVersions = array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $versions)); + $versions = $supportedVersions ?? ProtocolVersion::handshakeVersions(); + $this->supported = array_values($versions); + $this->supportedVersions = array_map(static fn (ProtocolVersion $v): string => $v->value, $this->supported); $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); } @@ -70,29 +77,20 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface // Spec backwards-compat: when the header is absent, the server SHOULD assume // protocol version 2025-03-26 — the release in which Streamable HTTP and the // header itself were introduced. This is deliberately lower than the SDK's - // own default (V2025_06_18) so clients predating the header convention still - // get a deterministic protocol version applied. Servers that whitelist only - // newer versions in $supportedVersions will reject such requests with 400. - $version = '' === $headerValue ? ProtocolVersion::V2025_03_26->value : $headerValue; + // own default so clients predating the header convention still get a + // deterministic protocol version applied. Servers that whitelist only newer + // versions in $supportedVersions will reject such requests with 400. + $version = '' === $headerValue ? ProtocolVersion::DEFAULT_HEADER_VERSION->value : $headerValue; if (\in_array($version, $this->supportedVersions, true)) { return $handler->handle($request); } - $message = '' === $headerValue - ? \sprintf( - 'Missing %s header; backwards-compat default %s is not accepted. Supported versions: %s.', - StreamableHttpTransport::PROTOCOL_VERSION_HEADER, - $version, - implode(', ', $this->supportedVersions), - ) - : \sprintf( - 'Unsupported %s header value: %s. Supported versions: %s.', - StreamableHttpTransport::PROTOCOL_VERSION_HEADER, - $headerValue, - implode(', ', $this->supportedVersions), - ); + // The client is expected to pick a mutually supported version from the + // error payload and retry, so the supported set travels as structured + // data rather than only inside the message. + $error = Error::forUnsupportedProtocolVersion($version, $this->supported); - return JsonRpcErrorResponse::create($this->responseFactory, $this->streamFactory, 400, Error::forInvalidParams($message)); + return JsonRpcErrorResponse::create($this->responseFactory, $this->streamFactory, 400, $error); } } diff --git a/src/Server/Transport/Http/OAuth/OidcDiscovery.php b/src/Server/Transport/Http/OAuth/OidcDiscovery.php index 2f24685..a0c0cd6 100644 --- a/src/Server/Transport/Http/OAuth/OidcDiscovery.php +++ b/src/Server/Transport/Http/OAuth/OidcDiscovery.php @@ -146,8 +146,10 @@ class OidcDiscovery implements OidcDiscoveryInterface */ private function fetchMetadata(string $issuer): array { - $issuer = rtrim($issuer, '/'); - $parsed = parse_url($issuer); + // The trailing slash is dropped to build discovery URLs (RFC 8414 §3.1), + // but the issuer is matched verbatim (RFC 8414 §3.3, OIDC Discovery §4.3). + $discoveryIssuer = rtrim($issuer, '/'); + $parsed = parse_url($discoveryIssuer); if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) { throw new RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer)); @@ -170,7 +172,7 @@ class OidcDiscovery implements OidcDiscoveryInterface // 2. OIDC path insertion $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'.$path; // 3. OIDC path appending - $discoveryUrls[] = $issuer.'/.well-known/openid-configuration'; + $discoveryUrls[] = $discoveryIssuer.'/.well-known/openid-configuration'; } else { // For issuer URLs without path components $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'; diff --git a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php index 0b0cf7f..1d0d2a1 100644 --- a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php +++ b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadataHandler.php @@ -27,7 +27,7 @@ use Psr\Http\Server\RequestHandlerInterface; * - 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). diff --git a/src/Server/Transport/Http/StatelessResponder.php b/src/Server/Transport/Http/StatelessResponder.php new file mode 100644 index 0000000..f752fb8 --- /dev/null +++ b/src/Server/Transport/Http/StatelessResponder.php @@ -0,0 +1,99 @@ + + */ +final class StatelessResponder +{ + public function __construct( + private readonly ResponseFactoryInterface $responseFactory, + private readonly StreamFactoryInterface $streamFactory, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function respond(StatelessResult $result): ResponseInterface + { + if ($result->isStream()) { + \assert(null !== $result->frames); + + return $this->sse($result->frames); + } + + if ($result->isEmpty()) { + return $this->responseFactory->createResponse($result->httpStatus); + } + + return $this->json($result->toJson(), $result->httpStatus); + } + + public function error(Error $error, int $httpStatus): ResponseInterface + { + return $this->json(json_encode($error, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES), $httpStatus); + } + + public function json(string $payload, int $status): ResponseInterface + { + return $this->responseFactory->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($payload)); + } + + /** + * @param \Closure(): \Generator $frames + */ + private function sse(\Closure $frames): ResponseInterface + { + $logger = $this->logger; + + $callback = static function () use ($frames, $logger): void { + try { + foreach ($frames() as $frame) { + // A null frame is a keep-alive tick: an SSE comment the + // client ignores, and the write PHP needs to spot a drop. + echo null === $frame + ? ": keep-alive\n\n" + : 'data: '.json_encode($frame, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)."\n\n"; + flush(); + } + } catch (\Throwable $e) { + // Headers are long sent, so this cannot become an error + // response; the client sees a close without the closure frame. + $logger->error('Subscription stream ended with an error.', ['exception' => $e]); + } + }; + + return $this->responseFactory->createResponse(200) + ->withHeader('Content-Type', 'text/event-stream') + ->withHeader('Cache-Control', 'no-cache') + ->withHeader('Connection', 'keep-alive') + ->withHeader('X-Accel-Buffering', 'no') + ->withBody(new CallbackStream($callback, $this->logger)); + } +} diff --git a/src/Server/Transport/ReadsBoundedBody.php b/src/Server/Transport/ReadsBoundedBody.php new file mode 100644 index 0000000..00f47b9 --- /dev/null +++ b/src/Server/Transport/ReadsBoundedBody.php @@ -0,0 +1,61 @@ + + */ +trait ReadsBoundedBody +{ + /** + * Returns the body contents, or `null` when the payload exceeds $maxBytes. + * + * A stream advertising its size is rejected up front; otherwise the read is + * incremental and stops at the cap, so an unbounded stream cannot exhaust + * memory. + */ + private function readBoundedBody(StreamInterface $body, int $maxBytes): ?string + { + if ($body->isSeekable()) { + $body->rewind(); + } + + $size = $body->getSize(); + if (null !== $size && $size > $maxBytes) { + return null; + } + + $contents = ''; + while (!$body->eof()) { + $chunk = $body->read(8192); + if ('' === $chunk) { + break; + } + + $contents .= $chunk; + if (\strlen($contents) > $maxBytes) { + return null; + } + } + + return $contents; + } +} diff --git a/src/Server/Transport/StatelessAwareTransportInterface.php b/src/Server/Transport/StatelessAwareTransportInterface.php new file mode 100644 index 0000000..52815b3 --- /dev/null +++ b/src/Server/Transport/StatelessAwareTransportInterface.php @@ -0,0 +1,29 @@ + + */ +interface StatelessAwareTransportInterface +{ + public function connectStateless(StatelessProtocol $protocol): void; +} diff --git a/src/Server/Transport/StatelessHttpTransport.php b/src/Server/Transport/StatelessHttpTransport.php new file mode 100644 index 0000000..c55caed --- /dev/null +++ b/src/Server/Transport/StatelessHttpTransport.php @@ -0,0 +1,136 @@ + + */ +final class StatelessHttpTransport +{ + use ReadsBoundedBody; + + /** + * Upper bound on the request body read for a POST, guarding against memory + * exhaustion from an oversized (or unbounded chunked) payload. + */ + public const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024; + + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + private StatelessResponder $responder; + + /** @var list */ + private array $middleware; + + /** + * @param iterable|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables all middleware + */ + public function __construct( + private readonly StatelessProtocol $protocol, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + private readonly LoggerInterface $logger = new NullLogger(), + private readonly int $maxBodyBytes = self::DEFAULT_MAX_BODY_BYTES, + ?iterable $middleware = null, + ) { + $this->middleware = null === $middleware + ? self::defaultMiddleware() + : array_values([...$middleware]); + + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + $this->responder = new StatelessResponder($this->responseFactory, $this->streamFactory, $this->logger); + } + + /** + * Browser-facing protections, era-independent. The protocol-version + * middleware is absent: here the version travels in `_meta`, so a + * header-only check would judge half the story. + * + * @return list + */ + public static function defaultMiddleware(): array + { + return [ + new CorsMiddleware(), + new DnsRebindingProtectionMiddleware(), + ]; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $handler = new MiddlewareRequestHandler( + $this->middleware, + \Closure::fromCallable([$this, 'dispatch']), + ); + + return $handler->handle($request); + } + + private function dispatch(ServerRequestInterface $request): ResponseInterface + { + if ('OPTIONS' === $request->getMethod()) { + return $this->responseFactory->createResponse(204); + } + + // No GET stream and no DELETE teardown: there is no session to address. + if ('POST' !== $request->getMethod()) { + return $this->json( + json_encode(Error::forInvalidRequest(\sprintf('The modern lifecycle accepts POST only, got %s.', $request->getMethod())), \JSON_THROW_ON_ERROR), + 405, + ); + } + + $payload = $this->readBoundedBody($request->getBody(), $this->maxBodyBytes); + + if (null === $payload) { + $this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]); + + return $this->json( + json_encode(Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), \JSON_THROW_ON_ERROR), + 413, + ); + } + + $headers = []; + foreach ($request->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + + return $this->responder->respond($this->protocol->handle($payload, $headers)); + } + + private function json(string $payload, int $status): ResponseInterface + { + return $this->responder->json($payload, $status); + } +} diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 4da7f2a..565f7da 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Transport; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Error; use Mcp\Server\Transport\Stdio\RunnerControl; use Mcp\Server\Transport\Stdio\RunnerControlInterface; @@ -24,17 +25,34 @@ use Psr\Log\LoggerInterface; */ class StdioTransport extends BaseTransport { + /** + * Default cap on the bytes read for a single input line. + */ + public const DEFAULT_MAX_LINE_BYTES = 4 * 1024 * 1024; + + /** Whether the current over-length line is still being drained and discarded. */ + private bool $discardingLine = false; + /** * @param resource $input * @param resource $output + * @param int $maxLineBytes Maximum bytes read for a single input line. fgets() with no length reads until a + * newline or EOF, so a peer that never sends a newline would buffer the whole stream + * into one allocation and exhaust memory; a line exceeding this cap is discarded + * instead. */ public function __construct( private $input = \STDIN, private $output = \STDOUT, ?LoggerInterface $logger = null, private readonly RunnerControlInterface $runnerControl = new RunnerControl(), + private readonly int $maxLineBytes = self::DEFAULT_MAX_LINE_BYTES, ) { parent::__construct($logger); + + if ($maxLineBytes < 1) { + throw new InvalidArgumentException(\sprintf('The maximum line size must be a positive number of bytes, got %d.', $maxLineBytes)); + } } public function send(string $data, array $context): void @@ -68,13 +86,36 @@ class StdioTransport extends BaseTransport protected function processInput(): void { - $line = fgets($this->input); + $line = fgets($this->input, $this->maxLineBytes); if (false === $line) { usleep(50000); // 50ms return; } + $lineComplete = str_ends_with($line, "\n"); + + // A previous over-length line is still being drained: keep discarding + // one bounded chunk per tick until its terminating newline is reached, + // so the run loop stays responsive instead of blocking on a drain loop. + if ($this->discardingLine) { + $this->discardingLine = !$lineComplete; + + return; + } + + // fgets() reads at most maxLineBytes - 1 bytes; a full read with no + // trailing newline means the line exceeds the cap. Discard it rather + // than buffering it, and keep discarding the remainder on later ticks. + if (!$lineComplete && \strlen($line) >= $this->maxLineBytes - 1) { + $this->discardingLine = true; + $this->logger->warning('StdioTransport discarded an input line exceeding the maximum length.', [ + 'max_line_bytes' => $this->maxLineBytes, + ]); + + return; + } + $trimmedLine = trim($line); if (!empty($trimmedLine)) { $this->handleMessage($trimmedLine, $this->sessionId); diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index cce2bd5..c01503e 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -13,11 +13,17 @@ namespace Mcp\Server\Transport; use Http\Discovery\Psr17FactoryDiscovery; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; +use Mcp\Server\NativeClock; +use Mcp\Server\Stateless\StatelessProtocol; use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; use Mcp\Server\Transport\Http\MiddlewareRequestHandler; +use Mcp\Server\Transport\Http\StatelessResponder; +use Mcp\Server\Wire\InboundClassifier; +use Psr\Clock\ClockInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -28,12 +34,27 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Uid\Uuid; /** + * Carries MCP over HTTP, in either protocol era. + * + * Every request is classified once, before anything else looks at it, and + * routed to the lifecycle it belongs to: a per-request envelope claiming a + * modern revision goes to {@see StatelessProtocol}, everything else — the + * `initialize` handshake, its session's later requests, its `DELETE` teardown — + * goes to the session machinery below. One endpoint, both eras, nothing for the + * client to pick. + * + * A server run without a modern-era dispatcher (see + * {@see \Mcp\Server\Builder::withoutModernEra()}) serves the handshake era + * alone and refuses modern claims, naming the revisions it does serve. + * * @extends BaseTransport * * @author Kyrian Obikwelu */ -class StreamableHttpTransport extends BaseTransport +class StreamableHttpTransport extends BaseTransport implements StatelessAwareTransportInterface { + use ReadsBoundedBody; + public const SESSION_HEADER = 'Mcp-Session-Id'; public const PROTOCOL_VERSION_HEADER = 'Mcp-Protocol-Version'; @@ -45,23 +66,28 @@ class StreamableHttpTransport extends BaseTransport private ResponseFactoryInterface $responseFactory; private StreamFactoryInterface $streamFactory; + private StatelessResponder $responder; + private InboundClassifier $classifier; + + private ?StatelessProtocol $stateless = null; private ?string $immediateResponse = null; private ?int $immediateStatusCode = null; - /** @var list */ - private array $middleware; + /** @var list|null null until {@see self::listen()} resolves the defaults */ + private ?array $middleware; /** * @param iterable|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables all middleware */ public function __construct( - private ServerRequestInterface $request, + private readonly ServerRequestInterface $request, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, ?LoggerInterface $logger = null, ?iterable $middleware = null, private readonly int $maxBodyBytes = self::DEFAULT_MAX_BODY_BYTES, + private readonly ClockInterface $clock = new NativeClock(), ) { parent::__construct($logger); @@ -71,20 +97,42 @@ class StreamableHttpTransport extends BaseTransport $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + $this->responder = new StatelessResponder($this->responseFactory, $this->streamFactory, $this->logger); + $this->classifier = new InboundClassifier(); if (null === $middleware) { - $this->middleware = self::defaultMiddleware(); + // Left unresolved: the default stack's version middleware has to + // know which revisions this endpoint serves, and the modern + // dispatcher arrives after the constructor. + $this->middleware = null; } else { $this->middleware = self::normalizeMiddleware($middleware); if ([] === $this->middleware) { $this->logger->warning('Streamable HTTP transport started with an empty middleware list. Default security protections (CORS, DNS rebinding, protocol version validation) are disabled. Pass null (or omit the argument) to use the secure defaults, or include them via [...StreamableHttpTransport::defaultMiddleware(), $yourMiddleware].'); } + + // Custom middleware runs before the request's era is classified, so a + // ProtocolVersionMiddleware here rejects every modern-era request by + // default — it only accepts the handshake versions it was built for. + foreach ($this->middleware as $entry) { + if ($entry instanceof ProtocolVersionMiddleware) { + $this->logger->warning('A custom middleware list includes ProtocolVersionMiddleware. It runs before the modern (2026-07-28) era is classified and rejects that era\'s requests by default, since it only recognises handshake revisions. Remove it from the custom list — the transport already applies it to handshake-era traffic on its own via self::handshakeMiddleware().'); + + break; + } + } } } /** * Secure default middleware stack applied when no `$middleware` is provided to the constructor. * + * These run at the edge, before the request's era is known, because what + * they enforce — origin policy, DNS rebinding — is true of both eras. The + * `MCP-Protocol-Version` header rule is not: it belongs to the handshake + * era, so {@see self::handshakeMiddleware()} carries it instead and the + * modern leg answers for its own revisions. + * * @return list */ public static function defaultMiddleware(): array @@ -92,10 +140,26 @@ class StreamableHttpTransport extends BaseTransport return [ new CorsMiddleware(), new DnsRebindingProtectionMiddleware(), + ]; + } + + /** + * Middleware applied only to requests classified as handshake-era traffic. + * + * @return list + */ + public static function handshakeMiddleware(): array + { + return [ new ProtocolVersionMiddleware(), ]; } + public function connectStateless(StatelessProtocol $protocol): void + { + $this->stateless = $protocol; + } + public function send(string $data, array $context): void { $this->immediateResponse = $data; @@ -105,7 +169,7 @@ class StreamableHttpTransport extends BaseTransport public function listen(): ResponseInterface { $handler = new MiddlewareRequestHandler( - $this->middleware, + $this->middleware ??= self::defaultMiddleware(), \Closure::fromCallable([$this, 'handleRequest']), ); @@ -117,23 +181,24 @@ class StreamableHttpTransport extends BaseTransport return $this->responseFactory->createResponse(204); } - protected function handlePostRequest(): ResponseInterface + /** + * @param string $body the request body, already read and bounded by {@see self::handleRequest()} + */ + protected function handlePostRequest(string $body): ResponseInterface { - $body = $this->readBody($this->request->getBody()); - if (null === $body) { - $this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]); - - return $this->createErrorResponse(Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), 413); - } - $this->handleMessage($body, $this->sessionId); - if (null !== $this->immediateResponse) { - $response = $this->responseFactory->createResponse($this->immediateStatusCode ?? 200) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($this->immediateResponse)); + // Consume the immediate response exactly once, so a transport instance + // reused for a later POST does not replay it. + $immediateResponse = $this->immediateResponse; + $immediateStatusCode = $this->immediateStatusCode; + $this->immediateResponse = null; + $this->immediateStatusCode = null; - return $response; + if (null !== $immediateResponse) { + return $this->responseFactory->createResponse($immediateStatusCode ?? 200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($immediateResponse)); } if (null !== $this->sessionFiber) { @@ -211,7 +276,7 @@ class StreamableHttpTransport extends BaseTransport break; } - if (time() - $timestamp >= $timeout) { + if ($this->clock->now()->getTimestamp() - $timestamp >= $timeout) { $error = Error::forInternalError('Request timed out', $requestId); $yielded = $this->sessionFiber->resume($error); $this->handleFiberYield($yielded, $this->sessionId); @@ -293,33 +358,10 @@ class StreamableHttpTransport extends BaseTransport /** * Reads the request body, bounded by {@see self::$maxBodyBytes}. - * - * Returns the body contents, or `null` when the payload exceeds the cap. When - * the stream advertises a size we reject up-front; otherwise (e.g. chunked - * transfer with unknown size) we read incrementally and stop at the cap so an - * unbounded stream cannot exhaust memory. */ private function readBody(StreamInterface $body): ?string { - $size = $body->getSize(); - if (null !== $size && $size > $this->maxBodyBytes) { - return null; - } - - $contents = ''; - while (!$body->eof()) { - $chunk = $body->read(8192); - if ('' === $chunk) { - break; - } - - $contents .= $chunk; - if (\strlen($contents) > $this->maxBodyBytes) { - return null; - } - } - - return $contents; + return $this->readBoundedBody($body, $this->maxBodyBytes); } /** @@ -342,7 +384,47 @@ class StreamableHttpTransport extends BaseTransport private function handleRequest(ServerRequestInterface $request): ResponseInterface { - $this->request = $request; + if ('OPTIONS' === $request->getMethod()) { + return $this->handleOptionsRequest(); + } + + // Read once, here: the era decision needs the body, and so does + // whichever leg it routes to. A PSR-7 stream over `php://input` cannot + // be read twice. + $body = null; + if ('POST' === $request->getMethod()) { + $body = $this->readBody($request->getBody()); + + if (null === $body) { + $this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]); + + return $this->createErrorResponse(Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), 413); + } + } + + $classification = $this->classifier->classify($request->getMethod(), $body, self::headers($request)); + + if ($classification->isRejected()) { + \assert(null !== $classification->error); + + return $this->responder->error($classification->error, $classification->httpStatus); + } + + if ($classification->modern) { + return $this->handleModernRequest($request, $body ?? '', $classification->claimedVersion ?? ''); + } + + // The version-header rule only reaches the traffic it is about. Running + // it at the edge would let it answer a modern claim with the handshake + // era's revision list, ahead of the leg that knows better. + return (new MiddlewareRequestHandler( + self::handshakeMiddleware(), + fn (ServerRequestInterface $handshake): ResponseInterface => $this->handleHandshakeRequest($handshake, $body), + ))->handle($request); + } + + private function handleHandshakeRequest(ServerRequestInterface $request, ?string $body): ResponseInterface + { $sessionIdHeaders = $request->getHeader(self::SESSION_HEADER); if (\count($sessionIdHeaders) > 1) { return $this->createErrorResponse(Error::forInvalidRequest(self::SESSION_HEADER.' header must not be repeated.'), 400); @@ -358,10 +440,38 @@ class StreamableHttpTransport extends BaseTransport } return match ($request->getMethod()) { - 'OPTIONS' => $this->handleOptionsRequest(), - 'POST' => $this->handlePostRequest(), + 'POST' => $this->handlePostRequest($body ?? ''), 'DELETE' => $this->handleDeleteRequest(), default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405), }; } + + /** + * Answers a request that claimed the modern era's per-request envelope. + */ + private function handleModernRequest(ServerRequestInterface $request, string $body, string $claimedVersion): ResponseInterface + { + if (null === $this->stateless) { + return $this->responder->error( + Error::forUnsupportedProtocolVersion($claimedVersion, ProtocolVersion::handshakeVersions()), + 400, + ); + } + + return $this->responder->respond($this->stateless->handle($body, self::headers($request))); + } + + /** + * @return array + */ + private static function headers(ServerRequestInterface $request): array + { + $headers = []; + + foreach ($request->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + + return $headers; + } } diff --git a/src/Server/Wire/CachePolicy.php b/src/Server/Wire/CachePolicy.php new file mode 100644 index 0000000..e22fb21 --- /dev/null +++ b/src/Server/Wire/CachePolicy.php @@ -0,0 +1,87 @@ + + */ +final class CachePolicy +{ + /** + * @param array $perMethod + */ + private function __construct( + private readonly int $ttlMs, + private readonly CacheScope $scope, + private readonly array $perMethod = [], + ) { + } + + /** + * The conservative default: nothing is fresh, nothing is shared. + */ + public static function none(): self + { + return new self(0, CacheScope::Private); + } + + /** + * @param int $ttlMs how long an answer stays fresh, in milliseconds + */ + public static function default(int $ttlMs, CacheScope $scope = CacheScope::Private): self + { + if ($ttlMs < 0) { + throw new InvalidArgumentException(\sprintf('A cache TTL must be zero or more milliseconds, got %d.', $ttlMs)); + } + + return new self($ttlMs, $scope); + } + + /** + * Overrides the policy for one method. + * + * Only the methods the specification makes cacheable are worth naming; any + * other is accepted and simply never consulted. + */ + public function withMethod(string $method, int $ttlMs, CacheScope $scope = CacheScope::Private): self + { + if ($ttlMs < 0) { + throw new InvalidArgumentException(\sprintf('A cache TTL must be zero or more milliseconds, got %d.', $ttlMs)); + } + + return new self($this->ttlMs, $this->scope, [...$this->perMethod, $method => [$ttlMs, $scope]]); + } + + public function ttlFor(string $method): int + { + return $this->perMethod[$method][0] ?? $this->ttlMs; + } + + public function scopeFor(string $method): CacheScope + { + return $this->perMethod[$method][1] ?? $this->scope; + } +} diff --git a/src/Server/Wire/EraClassification.php b/src/Server/Wire/EraClassification.php new file mode 100644 index 0000000..20c0aae --- /dev/null +++ b/src/Server/Wire/EraClassification.php @@ -0,0 +1,61 @@ + + */ +final class EraClassification +{ + private function __construct( + public readonly bool $modern, + public readonly ?string $claimedVersion, + public readonly ?Error $error, + public readonly int $httpStatus, + ) { + } + + /** + * The handshake era: everything that makes no per-request envelope claim. + */ + public static function legacy(?string $claimedVersion = null): self + { + return new self(false, $claimedVersion, null, 200); + } + + /** + * The modern era, claiming $version — which may be one this server does not + * serve. Routing and support are separate questions, and the dispatcher + * owns the second one so its answer can name what it does support. + */ + public static function modern(string $version): self + { + return new self(true, $version, null, 200); + } + + public static function reject(Error $error, int $httpStatus): self + { + return new self(false, null, $error, $httpStatus); + } + + public function isRejected(): bool + { + return null !== $this->error; + } +} diff --git a/src/Server/Wire/InboundClassifier.php b/src/Server/Wire/InboundClassifier.php new file mode 100644 index 0000000..826284d --- /dev/null +++ b/src/Server/Wire/InboundClassifier.php @@ -0,0 +1,222 @@ + + */ +final class InboundClassifier +{ + public const PROTOCOL_VERSION_HEADER = 'MCP-Protocol-Version'; + + /** + * @param string $httpMethod the request's HTTP method + * @param string|null $body the request body, already read + * @param array $headers request headers, case-insensitively matched + */ + public function classify(string $httpMethod, ?string $body, array $headers = []): EraClassification + { + if ('POST' !== strtoupper($httpMethod)) { + return EraClassification::legacy(); + } + + if (null === $body || '' === trim($body)) { + return EraClassification::legacy(); + } + + try { + $decoded = json_decode($body, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + // Unreadable to both eras. Routed to the handshake leg so the one + // parse error the client sees is the one that leg already writes. + return EraClassification::legacy(); + } + + if (!\is_array($decoded)) { + return EraClassification::legacy(); + } + + $headerVersion = self::header($headers, self::PROTOCOL_VERSION_HEADER); + + if (array_is_list($decoded)) { + return $this->classifyBatch($decoded, $headerVersion); + } + + /* @var array $decoded */ + return $this->classifyMessage($decoded, $headerVersion); + } + + /** + * The header-against-body check both eras' entries share. + * + * Kept here rather than in the dispatcher so the edge and the leg it routes + * to cannot disagree about what a request claims. + * + * @return string|null the disagreement, or null when the two agree + */ + public static function crossCheckVersion(?string $headerVersion, string $claimedVersion): ?string + { + if (null === $headerVersion || $headerVersion === $claimedVersion) { + return null; + } + + return \sprintf('MCP-Protocol-Version header "%s" contradicts the "%s" declared in _meta.', $headerVersion, $claimedVersion); + } + + /** + * Case-insensitive header lookup, since PSR-7 preserves the sender's casing. + * + * @param array $headers + */ + public static function header(array $headers, string $name): ?string + { + foreach ($headers as $key => $value) { + if (0 === strcasecmp($key, $name)) { + return '' === $value ? null : $value; + } + } + + return null; + } + + /** + * @param list $messages + */ + private function classifyBatch(array $messages, ?string $headerVersion): EraClassification + { + foreach ($messages as $message) { + if (!\is_array($message) || array_is_list($message)) { + continue; + } + + /** @var array $message */ + $classification = $this->classifyMessage($message, $headerVersion); + + if ($classification->isRejected()) { + return $classification; + } + + if ($classification->modern) { + return EraClassification::reject( + Error::forInvalidRequest(\sprintf('Protocol revision %s removed JSON-RPC batching; send one message per request.', $classification->claimedVersion)), + 400, + ); + } + } + + return EraClassification::legacy(); + } + + /** + * @param array $message + */ + private function classifyMessage(array $message, ?string $headerVersion): EraClassification + { + $id = $message['id'] ?? null; + $isNotification = !\array_key_exists('id', $message) || null === $id; + + if (!\is_string($id) && !\is_int($id)) { + $id = null; + } + + $params = \is_array($message['params'] ?? null) ? $message['params'] : null; + $meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : null; + $claim = $meta[RequestMeta::PROTOCOL_VERSION] ?? null; + + if (null !== $claim) { + if (!\is_string($claim) || '' === $claim) { + return EraClassification::reject( + Error::forInvalidParams(\sprintf('Request "_meta" member "%s" must be a non-empty string.', RequestMeta::PROTOCOL_VERSION), $id), + 400, + ); + } + + if (null !== $mismatch = self::crossCheckVersion($headerVersion, $claim)) { + return EraClassification::reject(Error::forHeaderMismatch($mismatch, $id), 400); + } + + return self::eraOf($claim); + } + + if (!self::namesModern($headerVersion)) { + return EraClassification::legacy(); + } + + // The header names a revision that has no handshake, so the body has to + // carry the envelope. A notification is the exception: it has no claim + // to carry under this revision, so there the header is all there is. + if ($isNotification) { + return EraClassification::modern($headerVersion); + } + + return EraClassification::reject( + Error::forInvalidParams(\sprintf('Protocol revision %s requires the "%s" member in "params._meta".', $headerVersion, RequestMeta::PROTOCOL_VERSION), $id), + 400, + ); + } + + /** + * The era a claimed revision belongs to. + * + * An unknown revision counts as modern: it cannot be negotiated through a + * handshake, and the modern leg is the one that can name what it does serve. + */ + private static function eraOf(string $version): EraClassification + { + $known = ProtocolVersion::tryFrom($version); + + if (null !== $known && !$known->isModern()) { + return EraClassification::legacy($version); + } + + return EraClassification::modern($version); + } + + /** + * Only a *known* modern revision counts here. An unrecognised header with + * nothing in the body to back it up is not evidence of an era — it is a + * version this endpoint does not serve, and the handshake leg's version + * middleware is what says so, naming everything the endpoint does serve. + */ + private static function namesModern(?string $version): bool + { + return null !== $version && true === ProtocolVersion::tryFrom($version)?->isModern(); + } +} diff --git a/src/Server/Wire/Rev2025Codec.php b/src/Server/Wire/Rev2025Codec.php new file mode 100644 index 0000000..0e1472d --- /dev/null +++ b/src/Server/Wire/Rev2025Codec.php @@ -0,0 +1,28 @@ + + */ +final class Rev2025Codec implements WireCodecInterface +{ + public function encodeResult(string $method, array $result, bool $cacheable = true): array + { + return $result; + } +} diff --git a/src/Server/Wire/Rev2026Codec.php b/src/Server/Wire/Rev2026Codec.php new file mode 100644 index 0000000..d3b21ef --- /dev/null +++ b/src/Server/Wire/Rev2026Codec.php @@ -0,0 +1,146 @@ + + */ +final class Rev2026Codec implements WireCodecInterface +{ + /** Methods that can come back asking for input (MRTR). */ + public const EXTENDED_RESULT_TYPE_METHODS = [ + 'tools/call', + 'prompts/get', + 'resources/read', + ]; + + /** Methods whose results a client may cache, and which therefore MUST carry hints. */ + public const CACHEABLE_METHODS = [ + 'server/discover', + 'tools/list', + 'prompts/list', + 'resources/list', + 'resources/templates/list', + 'resources/read', + ]; + + private readonly CachePolicy $cachePolicy; + + public function __construct( + private readonly ?Implementation $serverInfo = null, + ?CachePolicy $cachePolicy = null, + ) { + $this->cachePolicy = $cachePolicy ?? CachePolicy::none(); + } + + public function encodeResult(string $method, array $result, bool $cacheable = true): array + { + $stamped = $this->stampResultType($method, $result); + + return $this->stampServerInfo( + $cacheable ? $this->fillCacheHints($method, $stamped) : $stamped, + ); + } + + /** + * A result naming its own type keeps it, but only where the method's + * vocabulary allows more than `complete`. + * + * @param array $result + * + * @return array + */ + private function stampResultType(string $method, array $result): array + { + $provided = $result['resultType'] ?? null; + + if (null === $provided) { + return [...$result, 'resultType' => ResultType::Complete->value]; + } + + if (ResultType::Complete->value === $provided || \in_array($method, self::EXTENDED_RESULT_TYPE_METHODS, true)) { + return $result; + } + + return [...$result, 'resultType' => ResultType::Complete->value]; + } + + /** + * Fills `ttlMs`/`cacheScope`, most-specific author first: a value the + * result itself carries, then the configured {@see CachePolicy}, whose own + * default is "private, do not cache". + * + * @param array $result + * + * @return array + */ + private function fillCacheHints(string $method, array $result): array + { + if (ResultType::Complete->value !== ($result['resultType'] ?? null)) { + return $result; + } + + if (!\in_array($method, self::CACHEABLE_METHODS, true)) { + return $result; + } + + $ttl = $result['ttlMs'] ?? null; + $scope = $result['cacheScope'] ?? null; + + // Invalid authored values fall through to the next author down. + if (!\is_int($ttl) || $ttl < 0) { + $ttl = $this->cachePolicy->ttlFor($method); + } + + if (!\is_string($scope) || null === CacheScope::tryFrom($scope)) { + $scope = $this->cachePolicy->scopeFor($method)->value; + } + + return [...$result, 'ttlMs' => $ttl, 'cacheScope' => $scope]; + } + + /** + * Servers SHOULD identify themselves on every response; an identity the + * result already carries wins. + * + * @param array $result + * + * @return array + */ + private function stampServerInfo(array $result): array + { + if (null === $this->serverInfo) { + return $result; + } + + $meta = \is_array($result['_meta'] ?? null) ? $result['_meta'] : []; + + if (isset($meta[RequestMeta::SERVER_INFO])) { + return $result; + } + + $meta[RequestMeta::SERVER_INFO] = $this->serverInfo; + + return [...$result, '_meta' => $meta]; + } +} diff --git a/src/Server/Wire/WireCodecInterface.php b/src/Server/Wire/WireCodecInterface.php new file mode 100644 index 0000000..e183c4b --- /dev/null +++ b/src/Server/Wire/WireCodecInterface.php @@ -0,0 +1,41 @@ + + */ +interface WireCodecInterface +{ + /** + * Stamps an already-serialized result with whatever this era requires. + * + * @param string $method the request method the result answers + * @param array $result the neutral result body + * @param bool $cacheable whether this answer may carry caching hints at all — false for one + * produced by a multi round-trip retry, whose inputs are not part of + * any cache key + * + * @return array + */ + public function encodeResult(string $method, array $result, bool $cacheable = true): array; +} diff --git a/tests/Conformance/Elements.php b/tests/Conformance/Elements.php index 6d65fd2..d7eac2b 100644 --- a/tests/Conformance/Elements.php +++ b/tests/Conformance/Elements.php @@ -34,6 +34,54 @@ final class Elements public const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; public const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; + /** + * The focal `inputSchema` the `json-schema-2020-12` scenario expects a + * server to advertise verbatim. + * + * It is handed to `addTool()` raw rather than generated from a signature, + * because the point is that the SDK passes an author-supplied 2020-12 + * schema through `tools/list` untouched: `$schema`/`$defs`/ + * `additionalProperties` (SEP-1613) and the composition, conditional and + * `$anchor` keywords (SEP-2106) must all survive. + * + * Mirrors `JSON_SCHEMA_2020_12_FIXTURE` in the conformance suite; keep the + * two in sync. + * + * @return array + */ + public static function jsonSchema2020_12Fixture(): array + { + return [ + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'type' => 'object', + '$defs' => [ + 'address' => [ + '$anchor' => 'addressDef', + 'type' => 'object', + 'properties' => [ + 'street' => ['type' => 'string'], + 'city' => ['type' => 'string'], + ], + ], + ], + 'properties' => [ + 'name' => ['type' => 'string'], + 'address' => ['$ref' => '#/$defs/address'], + 'contactMethod' => ['type' => 'string', 'enum' => ['phone', 'email']], + 'phone' => ['type' => 'string'], + 'email' => ['type' => 'string'], + ], + 'allOf' => [['anyOf' => [['required' => ['phone']], ['required' => ['email']]]]], + 'if' => [ + 'properties' => ['contactMethod' => ['const' => 'phone']], + 'required' => ['contactMethod'], + ], + 'then' => ['required' => ['phone']], + 'else' => ['required' => ['email']], + 'additionalProperties' => false, + ]; + } + public function toolMultipleTypes(): CallToolResult { return new CallToolResult([ @@ -146,7 +194,7 @@ final class Elements public function resourceTemplate(string $id): TextResourceContents { return new TextResourceContents( - uri: 'test://template/{id}/data', + uri: \sprintf('test://template/%s/data', $id), mimeType: 'application/json', text: json_encode([ 'id' => $id, diff --git a/tests/Conformance/Fixtures/docker-compose.yml b/tests/Conformance/Fixtures/docker-compose.yml index 62e2e8b..1d368f5 100644 --- a/tests/Conformance/Fixtures/docker-compose.yml +++ b/tests/Conformance/Fixtures/docker-compose.yml @@ -2,7 +2,8 @@ services: nginx: image: nginx:1.26-alpine ports: - - "8000:80" + # Overridable so the fixture can coexist with anything already on 8000. + - "${CONFORMANCE_PORT:-8000}:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ../../..:/app:ro diff --git a/tests/Conformance/Fixtures/nginx.conf b/tests/Conformance/Fixtures/nginx.conf index 9159c46..b481038 100644 --- a/tests/Conformance/Fixtures/nginx.conf +++ b/tests/Conformance/Fixtures/nginx.conf @@ -3,6 +3,10 @@ server { server_name localhost; root /app; + # One entry, both protocol eras: the fixture is built through + # Builder::build(), which carries a dispatcher for each, and the transport + # routes every request to the one it belongs to. Both conformance suites + # run against this same location. location / { try_files $uri /tests/Conformance/server.php$is_args$args; } diff --git a/tests/Conformance/MrtrElements.php b/tests/Conformance/MrtrElements.php new file mode 100644 index 0000000..8d5cadd --- /dev/null +++ b/tests/Conformance/MrtrElements.php @@ -0,0 +1,212 @@ +getInputContext(); + + if (null === $input || !$input->has(self::ELICIT_KEY)) { + return new InputRequiredResult([ + self::ELICIT_KEY => new ElicitRequest( + 'What is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return new CallToolResult([new TextContent(\sprintf('Hello, %s!', self::nameFrom($input->elicitResult(self::ELICIT_KEY))))]); + } + + public static function sampling(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('capital')) { + return new InputRequiredResult([ + 'capital' => new CreateSamplingMessageRequest( + [new SamplingMessage(Role::User, new TextContent('What is the capital of France?'))], + maxTokens: 100, + ), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return new CallToolResult([new TextContent('Sampling complete.')]); + } + + public static function listRoots(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('roots')) { + return new InputRequiredResult(['roots' => new ListRootsRequest()], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return new CallToolResult([new TextContent('Roots received.')]); + } + + /** Asks for everything in one result, so the client retries once. */ + public static function multipleInputs(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('user_name')) { + return new InputRequiredResult([ + 'user_name' => new ElicitRequest( + 'What is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ), + 'greeting' => new CreateSamplingMessageRequest( + [new SamplingMessage(Role::User, new TextContent('Generate a greeting'))], + maxTokens: 50, + ), + 'client_roots' => new ListRootsRequest(), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return new CallToolResult([new TextContent('All inputs received.')]); + } + + /** + * Reads the round out of the state, not the answers: a client retrying + * round two sends only round two's answer, so counting answers would read + * that as a fresh start and loop forever. + */ + public static function multiRound(RequestContext $context): CallToolResult|InputRequiredResult + { + $round = $context->getInputContext()?->requestState()['round'] ?? 0; + + if ($round < 1) { + return new InputRequiredResult([ + 'step_one' => new ElicitRequest( + 'Step one: what is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ), + ], requestState: $context->mintRequestState(['round' => 1])); + } + + if ($round < 2) { + return new InputRequiredResult([ + 'step_two' => new ElicitRequest( + 'Step two: what is your favourite colour?', + new ElicitationSchema(['color' => new StringSchemaDefinition('Colour')], ['color']), + ), + ], requestState: $context->mintRequestState(['round' => 2])); + } + + return new CallToolResult([new TextContent('All steps complete.')]); + } + + /** Reaches the second round only when the echoed state verified. */ + public static function tamperedState(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('confirm')) { + return new InputRequiredResult([ + 'confirm' => new ElicitRequest( + 'Confirm?', + new ElicitationSchema(['ok' => new StringSchemaDefinition('Ok')], ['ok']), + ), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-confirmation'])); + } + + return new CallToolResult([new TextContent('State verified.')]); + } + + /** + * Assembles the ask from the declared capabilities: a server MUST NOT + * request input the client never said it could supply. + */ + public static function capabilities(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null !== $input && [] !== $input->all()) { + return new CallToolResult([new TextContent('Input received.')]); + } + + $capabilities = $context->getClientCapabilities(); + $requests = []; + + if (true === $capabilities?->elicitation) { + $requests['user_name'] = new ElicitRequest( + 'What is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ); + } + + if (true === $capabilities?->sampling) { + $requests['greeting'] = new CreateSamplingMessageRequest( + [new SamplingMessage(Role::User, new TextContent('Generate a greeting'))], + maxTokens: 50, + ); + } + + // Nothing the client can service; asking would never come back. + if ([] === $requests) { + return new CallToolResult([new TextContent('No supported input capabilities.')]); + } + + return new InputRequiredResult($requests, requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + /** + * @return array>|InputRequiredResult + */ + public static function prompt(RequestContext $context): array|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has(self::ELICIT_KEY)) { + return new InputRequiredResult([ + self::ELICIT_KEY => new ElicitRequest( + 'What is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return [['role' => 'user', 'content' => \sprintf('Hello, %s!', self::nameFrom($input->elicitResult(self::ELICIT_KEY)))]]; + } + + /** A declined or cancelled answer has no content, so the greeting falls back. */ + private static function nameFrom(?ElicitResult $result): string + { + $name = $result?->content['name'] ?? null; + + return \is_string($name) ? $name : 'friend'; + } +} diff --git a/tests/Conformance/client.php b/tests/Conformance/client.php index 74ac8e9..9832f47 100644 --- a/tests/Conformance/client.php +++ b/tests/Conformance/client.php @@ -16,6 +16,7 @@ use Mcp\Client\Handler\Request\RequestHandlerInterface; use Mcp\Client\Transport\HttpTransport; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ElicitRequest; @@ -30,35 +31,53 @@ if (!$url || !$scenario) { exit(1); } +// The runner names the revision it is testing; without honouring it the client +// would open every scenario with `initialize` and never reach the modern wire. +$version = ProtocolVersion::tryFrom(getenv('MCP_CONFORMANCE_PROTOCOL_VERSION') ?: '') + ?? ProtocolVersion::V2025_11_25; + +// Scenario-specific data (tool arguments, credentials) the runner passes in. +$context = json_decode(getenv('MCP_CONFORMANCE_CONTEXT') ?: '[]', true); +$context = is_array($context) ? $context : []; + @mkdir(__DIR__.'/logs', 0777, true); $logger = new FileLogger(__DIR__.'/logs/client-conformance.log', true); -$logger->info(sprintf('Starting client conformance test: scenario=%s, url=%s', $scenario, $url)); +$logger->info(sprintf('Starting client conformance test: scenario=%s, url=%s, version=%s', $scenario, $url, $version->value)); $builder = Client::builder() ->setClientInfo('mcp-conformance-test-client', '1.0.0') + ->setProtocolVersion($version) ->setInitTimeout(30) ->setRequestTimeout(60) ->setLogger($logger); -if ('elicitation-sep1034-client-defaults' === $scenario) { +/** + * Accepts every elicitation with an empty payload. + * + * Enough for the scenarios here, which check that the client asked and echoed + * correctly rather than what a user would have typed. + */ +$acceptElicitation = new class($logger) implements RequestHandlerInterface { + public function __construct(private readonly Psr\Log\LoggerInterface $logger) + { + } + + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + $this->logger->info('Received elicitation request, accepting with empty content'); + + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, [])); + } +}; + +if (in_array($scenario, ['elicitation-sep1034-client-defaults', 'sep-2322-client-request-state'], true)) { $builder->setCapabilities(new ClientCapabilities(elicitation: true)); - $builder->addRequestHandler(new class($logger) implements RequestHandlerInterface { - public function __construct(private readonly Psr\Log\LoggerInterface $logger) - { - } - - public function supports(Request $request): bool - { - return $request instanceof ElicitRequest; - } - - public function handle(Request $request): Response - { - $this->logger->info('Received elicitation request, accepting with empty content'); - - return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, [])); - } - }); + $builder->addRequestHandler($acceptElicitation); } $client = $builder->build(); @@ -76,9 +95,11 @@ try { break; case 'tools_call': - $toolName = $toolsResult->tools[0]->name ?? 'test-tool'; - $client->callTool($toolName, []); - $logger->info(sprintf('Called tool: %s', $toolName)); + // The scenario asserts both arguments arrive as numbers, so the + // call has to be made by name with real values rather than + // whatever tool happens to be listed first. + $client->callTool('add_numbers', ['a' => 2, 'b' => 3]); + $logger->info('Called tool: add_numbers'); break; case 'elicitation-sep1034-client-defaults': @@ -87,6 +108,74 @@ try { $logger->info(sprintf('Called tool: %s', $toolName)); break; + case 'json-schema-2020-12-preservation': + // Round-trips the focal tool's inputSchema back through the echo + // tool so the harness can diff what survived the client's parsing + // (SEP-1613 keywords, plus the SEP-2106 vocabulary). + $focal = null; + foreach ($toolsResult->tools as $tool) { + if ('json_schema_2020_12_tool' === $tool->name) { + $focal = $tool; + break; + } + } + + if (null === $focal) { + throw new RuntimeException('Mock server did not advertise json_schema_2020_12_tool.'); + } + + $client->callTool('json_schema_echo', ['schema' => $focal->inputSchema]); + $logger->info('Echoed the observed inputSchema back via json_schema_echo'); + break; + + case 'http-standard-headers': + // Exercises every method that carries an Mcp-Method or Mcp-Name + // header, including the ones whose subject needs Base64 wrapping. + foreach ($toolsResult->tools as $tool) { + $client->callTool($tool->name, []); + } + + $resources = $client->listResources(); + foreach ($resources->resources as $resource) { + $client->readResource($resource->uri); + } + + $prompts = $client->listPrompts(); + foreach ($prompts->prompts as $prompt) { + $client->getPrompt($prompt->name, []); + } + + $logger->info('Exercised every header-carrying method'); + break; + + case 'http-custom-headers': + // The runner supplies the exact argument values, each chosen to hit + // a different corner of the encoding rules. + foreach ($context['toolCalls'] ?? [] as $call) { + $client->callTool($call['name'], $call['arguments'] ?? []); + $logger->info(sprintf('Called tool: %s', $call['name'])); + } + break; + + case 'http-invalid-tool-headers': + // Only the tools that survived the listing are callable; calling + // any of the malformed ones is the failure this scenario looks for. + foreach ($toolsResult->tools as $tool) { + $client->callTool($tool->name, ['region' => 'us-west1']); + $logger->info(sprintf('Called tool: %s', $tool->name)); + } + break; + + case 'sep-2322-client-request-state': + // Each tool drives one rule: echo the state back, omit it when none + // was sent, keep an unrelated call clean, and treat a result with + // no resultType as complete. + foreach (['test_mrtr_echo_state', 'test_mrtr_unrelated', 'test_mrtr_no_state', 'test_mrtr_no_result_type'] as $tool) { + $client->callTool($tool, []); + $logger->info(sprintf('Called tool: %s', $tool)); + } + break; + default: $logger->warning(sprintf('Unknown scenario: %s', $scenario)); break; diff --git a/tests/Conformance/conformance-baseline.yml b/tests/Conformance/conformance-baseline-2025-11-25.yml similarity index 98% rename from tests/Conformance/conformance-baseline.yml rename to tests/Conformance/conformance-baseline-2025-11-25.yml index efda80a..c57855e 100644 --- a/tests/Conformance/conformance-baseline.yml +++ b/tests/Conformance/conformance-baseline-2025-11-25.yml @@ -1,5 +1,3 @@ -server: [] - client: - elicitation-sep1034-client-defaults - sse-retry @@ -24,4 +22,3 @@ client: - auth/client-credentials-jwt - auth/client-credentials-basic - auth/cross-app-access-complete-flow - diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml new file mode 100644 index 0000000..1d19102 --- /dev/null +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -0,0 +1,26 @@ +client: + - http-invalid-tool-headers:sep-2243-invalid-tool-tools-list-gate + - auth/metadata-default + - auth/metadata-var1 + - auth/metadata-var2 + - auth/metadata-var3 + - auth/metadata-issuer-mismatch + - auth/basic-cimd + - auth/pre-registration + - auth/scope-from-www-authenticate + - auth/scope-from-scopes-supported + - auth/scope-omitted-when-undefined + - auth/scope-step-up + - auth/scope-retry-limit + - auth/token-endpoint-auth-basic + - auth/token-endpoint-auth-post + - auth/token-endpoint-auth-none + - auth/offline-access-scope + - auth/offline-access-not-supported + - auth/authorization-server-migration:sep-2352-reregister-on-as-change + - auth/iss-supported + - auth/iss-not-advertised + - auth/iss-supported-missing + - auth/iss-wrong-issuer + - auth/iss-unexpected + - auth/iss-normalized diff --git a/tests/Conformance/score.php b/tests/Conformance/score.php index ddad90f..37ebcab 100644 --- a/tests/Conformance/score.php +++ b/tests/Conformance/score.php @@ -13,13 +13,15 @@ * Turns a conformance run's results into a shields.io endpoint badge JSON, so * the client/server conformance score can be rendered in the README. * - * php score.php + * php score.php [results-dir] * - * The conformance CLI (run with `--output-dir results`) writes one - * `checks.json` per scenario into the `results/` directory next to this file. - * A scenario counts as passing when none of its checks has a FAILURE status; - * the badge message is "/ (%)" and is written to - * `-conformance.json`. + * The conformance CLI writes one `checks.json` per scenario into + * `[results-dir]` (default `results`), relative to this file. Every check with + * a SUCCESS or FAILURE status counts towards the score; the badge message is + * "/ (%)" and is written to + * `-conformance-.json`, so each revision gets its own + * badge. The badge is labelled "checks" only: the README renders the badges in + * a table whose row and column headers already name the revision and suite. */ use Symfony\Component\Console\Command\Command; @@ -36,10 +38,13 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; ->setName('conformance-score') ->setDescription('Generates a shields.io endpoint badge from the conformance results') ->addArgument('suite', InputArgument::REQUIRED, 'Which conformance suite was run: "server" or "client"') + ->addArgument('spec-version', InputArgument::REQUIRED, 'The spec revision that was run, e.g. "2025-11-25"') + ->addArgument('results-dir', InputArgument::OPTIONAL, 'Directory the conformance CLI wrote results into, relative to this file', 'results') ->setCode(static function (InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $suite = $input->getArgument('suite'); + $specVersion = $input->getArgument('spec-version'); if (!in_array($suite, ['server', 'client'], true)) { $io->error(sprintf('Suite must be "server" or "client", got "%s".', $suite)); @@ -47,10 +52,10 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; return Command::INVALID; } - $resultsDir = __DIR__.'/results'; + $resultsDir = __DIR__.'/'.$input->getArgument('results-dir'); if (!is_dir($resultsDir)) { - $io->error(sprintf('Results directory "%s" does not exist; run the conformance suite with `--output-dir results` first.', $resultsDir)); + $io->error(sprintf('Results directory "%s" does not exist; run the conformance suite with `--output-dir` first.', $resultsDir)); return Command::FAILURE; } @@ -88,7 +93,7 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; $badge = [ 'schemaVersion' => 1, - 'label' => $suite.' conformance', + 'label' => 'checks', 'message' => $total > 0 ? sprintf('%d/%d (%d%%)', $passed, $total, $pct) : 'no data', 'color' => match (true) { 0 === $total => 'lightgrey', @@ -99,7 +104,7 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; }, ]; - $outputFile = __DIR__.'/'.$suite.'-conformance.json'; + $outputFile = __DIR__.'/'.$suite.'-conformance-'.$specVersion.'.json'; if (false === file_put_contents($outputFile, json_encode($badge, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)."\n")) { $io->error(sprintf('Could not write badge file "%s".', $outputFile)); @@ -112,7 +117,7 @@ require_once dirname(__DIR__, 2).'/vendor/autoload.php'; $io->listing($failures); } - $io->success(sprintf('%s: %s', $badge['label'], $badge['message'])); + $io->success(sprintf('%s %s: %s', $suite, $specVersion, $badge['message'])); return Command::SUCCESS; }) diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index 02c78c2..b233d49 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -9,22 +9,49 @@ * file that was distributed with this source code. */ +/* + * The conformance fixture, for both protocol eras, on one endpoint. + * + * A server built through Builder::build() carries a dispatcher for each era and + * StreamableHttpTransport routes every request to the one it belongs to, so + * there is nothing here that a revision has to be told about. The element set + * is the union of what both suites ask for: where they overlap they share the + * registration, so a difference in results points at the lifecycle rather than + * at drifted fixtures. + */ + ini_set('display_errors', '0'); require_once dirname(__DIR__, 2).'/vendor/autoload.php'; use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; +use Mcp\Capability\Registry; +use Mcp\Exception\MissingRequiredClientCapabilityException; +use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Elicitation\ElicitationSchema; +use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Enum\CacheScope; +use Mcp\Schema\Prompt; +use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\InputRequiredResult; +use Mcp\Schema\Tool; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Subscription\Psr16NotificationBus; +use Mcp\Server\Subscription\PublishingEventDispatcher; use Mcp\Server\Transport\StreamableHttpTransport; +use Mcp\Server\Wire\CachePolicy; use Mcp\Tests\Conformance\Elements; use Mcp\Tests\Conformance\FileLogger; +use Mcp\Tests\Conformance\MrtrElements; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; chdir(__DIR__); @@ -33,25 +60,133 @@ $logger = new FileLogger(__DIR__.'/logs/conformance.log', true); $psr17Factory = new Psr17Factory(); $request = $psr17Factory->createServerRequestFromGlobals(); -$transport = new StreamableHttpTransport($request, logger: $logger); +// Explicit rather than builder-built, so the diagnostic hooks below can mutate +// the live registry and the change reaches an open subscription. +// +// Filesystem-backed and not in-memory: under php-fpm the worker holding the +// listen stream open and the worker serving the tools/call that mutates the +// registry are different processes, so the only thing they share is storage. +$bus = new Psr16NotificationBus( + new Psr16Cache(new FilesystemAdapter('mcp-conformance-notifications', 120, __DIR__.'/sessions')), + logger: $logger, +); +$registry = new Registry(new PublishingEventDispatcher($bus), $logger); $server = Server::builder() ->setServerInfo('mcp-conformance-test-server', '1.0.0') - ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setLogger($logger) + ->setRegistry($registry) + // Only the handshake leg keeps one; the modern leg is sessionless either way. + ->setSession(new FileSessionStore(__DIR__.'/sessions')) // Tools ->addTool(static fn () => 'This is a simple text response for testing.', name: 'test_simple_text', description: 'Tests simple text content response') ->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), name: 'test_image_content', description: 'Tests image content response') ->addTool(static fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), name: 'test_audio_content', description: 'Tests audio content response') ->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), name: 'test_embedded_resource', description: 'Tests embedded resource content response') ->addTool([Elements::class, 'toolMultipleTypes'], name: 'test_multiple_content_types', description: 'Tests response with multiple content types') - ->addTool([Elements::class, 'toolWithLogging'], name: 'test_tool_with_logging', description: 'Tests tool that emits log messages') - ->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications') - ->addTool([Elements::class, 'toolWithSampling'], name: 'test_sampling', description: 'Tests server-initiated sampling') ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling') + ->addTool( + static fn () => 'ok', + name: 'json_schema_2020_12_tool', + description: 'Tool with JSON Schema 2020-12 features', + inputSchema: Elements::jsonSchema2020_12Fixture(), + ) + // Exercises the -32021 path. + ->addTool( + static function (): never { + throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'test_missing_capability requires the sampling capability.'); + }, + name: 'test_missing_capability', + description: 'Always reports a missing client capability, for testing -32021 handling', + ) + // The ask travels back inside the result, never as its own request. + ->addTool( + static fn (): InputRequiredResult => new InputRequiredResult( + [ + 'conformance_probe' => new ElicitRequest( + 'Please provide a value for the conformance probe.', + new ElicitationSchema(['value' => new StringSchemaDefinition('Value')], ['value']), + ), + ], + requestState: base64_encode(json_encode(['tool' => 'test_streaming_elicitation'], \JSON_THROW_ON_ERROR)), + ), + name: 'test_streaming_elicitation', + description: 'Returns an InputRequiredResult asking for elicitation input', + ) + // Logs server-side only; no logLevel was requested, so nothing goes out. + ->addTool( + static function () use ($logger): string { + $logger->info('test_logging_tool executed'); + + return 'Logged.'; + }, + name: 'test_logging_tool', + description: 'Emits a server-side log message while returning normally', + ) + // Mirrors its arguments into Mcp-Param-* headers (SEP-2243). + ->addTool( + static fn (string $region = '', int $retries = 0): string => sprintf('region=%s retries=%d', $region, $retries), + name: 'test_custom_headers', + description: 'Tests custom header mirroring via x-mcp-header', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'], + ], + 'required' => ['region'], + ], + ) + ->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications') + // Handshake-era elements. The scenarios that drive them are 2025-11-25 + // ones — server-initiated requests and session-scoped logging both went + // away in 2026-07-28 — but they are registered once, like everything else. + ->addTool([Elements::class, 'toolWithLogging'], name: 'test_tool_with_logging', description: 'Tests tool that emits log messages') + ->addTool([Elements::class, 'toolWithSampling'], name: 'test_sampling', description: 'Tests server-initiated sampling') ->addTool([Elements::class, 'toolWithElicitation'], name: 'test_elicitation', description: 'Tests server-initiated elicitation') ->addTool([Elements::class, 'toolWithElicitationDefaults'], name: 'test_elicitation_sep1034_defaults', description: 'Tests elicitation with default values') ->addTool([Elements::class, 'toolWithElicitationEnums'], name: 'test_elicitation_sep1330_enums', description: 'Tests elicitation with enum schemas') + // Diagnostic hooks the subscription scenarios call to make the lists change + // while a listen stream is open. + ->addTool( + static function () use ($registry): string { + $registry->registerTool( + new Tool( + name: 'test_ephemeral_tool_'.bin2hex(random_bytes(4)), + title: null, + inputSchema: ['type' => 'object', 'properties' => new stdClass(), 'required' => null], + description: 'Registered to trigger a list change', + annotations: null, + ), + static fn (): string => 'ephemeral', + ); + + return 'Tool list mutated.'; + }, + name: 'test_trigger_tool_change', + description: 'Registers a tool so the tool list changes', + ) + ->addTool( + static function () use ($registry): string { + $registry->registerPrompt( + new Prompt('test_ephemeral_prompt_'.bin2hex(random_bytes(4)), null, 'Registered to trigger a list change'), + static fn (): array => [['role' => 'user', 'content' => 'ephemeral']], + ); + + return 'Prompt list mutated.'; + }, + name: 'test_trigger_prompt_change', + description: 'Registers a prompt so the prompt list changes', + ) + // Multi round-trip request tools (SEP-2322). + ->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_elicitation', description: 'MRTR: asks for a name via elicitation') + ->addTool([MrtrElements::class, 'sampling'], name: 'test_input_required_result_sampling', description: 'MRTR: asks for a sampling completion') + ->addTool([MrtrElements::class, 'listRoots'], name: 'test_input_required_result_list_roots', description: 'MRTR: asks for the client roots') + ->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_request_state', description: 'MRTR: exercises requestState round-tripping') + ->addTool([MrtrElements::class, 'multipleInputs'], name: 'test_input_required_result_multiple_inputs', description: 'MRTR: asks for two inputs at once') + ->addTool([MrtrElements::class, 'multiRound'], name: 'test_input_required_result_multi_round', description: 'MRTR: asks across two sequential rounds') + ->addTool([MrtrElements::class, 'capabilities'], name: 'test_input_required_result_capabilities', description: 'MRTR: asks only for capabilities the client declared') + ->addTool([MrtrElements::class, 'tamperedState'], name: 'test_input_required_result_tampered_state', description: 'MRTR: completes only when the echoed state verifies') // Resources ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') @@ -62,8 +197,24 @@ $server = Server::builder() ->addPrompt([Elements::class, 'promptWithArguments'], name: 'test_prompt_with_arguments', description: 'A prompt with required arguments') ->addPrompt([Elements::class, 'promptWithEmbeddedResource'], name: 'test_prompt_with_embedded_resource', description: 'A prompt that includes an embedded resource') ->addPrompt([Elements::class, 'promptWithImage'], name: 'test_prompt_with_image', description: 'A prompt that includes image content') + ->addPrompt([MrtrElements::class, 'prompt'], name: 'test_input_required_result_prompt', description: 'MRTR: a prompt that asks for input first') + // Fixed so a retry landing on another process still verifies. + ->setRequestState(str_repeat('conformance-fixture-key-', 2)) + // So a listen stream carries the registry's changes rather than only + // acknowledging; see $bus above for why it is filesystem-backed. + ->setNotificationBus($bus) + // Short, so a listen stream cannot tie up an fpm worker for the length of + // a whole run. + ->setSubscriptionLifetime(5.0) + // Lists are the same for everyone here; a read is not. + ->setCachePolicy( + CachePolicy::default(60_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('prompts/list', 3_600_000, CacheScope::Public) + ->withMethod('resources/list', 3_600_000, CacheScope::Public) + ->withMethod('resources/templates/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), + ) ->build(); -$response = $server->run($transport); - -(new SapiEmitter())->emit($response); +(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request, logger: $logger))); diff --git a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php index 985b01b..353230a 100644 --- a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php +++ b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php @@ -17,6 +17,22 @@ final class HttpDiscoveryUserProfileTest extends HttpInspectorSnapshotTestCase { return [ ...parent::provideMethods(), + 'Lookup User Tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'lookup_user', + 'toolArgs' => ['userId' => '"101"'], + ], + 'testName' => 'lookup_user', + ], + 'Lookup User Tool (Not Found)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'lookup_user', + 'toolArgs' => ['userId' => '"999"'], + ], + 'testName' => 'lookup_user_not_found', + ], 'Send Welcome Tool' => [ 'method' => 'tools/call', 'options' => [ diff --git a/tests/Inspector/Http/HttpMcpAppsTest.php b/tests/Inspector/Http/HttpMcpAppsTest.php new file mode 100644 index 0000000..76866f6 --- /dev/null +++ b/tests/Inspector/Http/HttpMcpAppsTest.php @@ -0,0 +1,50 @@ + [ + 'method' => 'resources/read', + 'options' => ['uri' => 'ui://weather-app'], + 'testName' => 'weather_app', + ], + 'Call the linked tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'get_weather', + 'toolArgs' => ['city' => 'london'], + ], + 'testName' => 'get_weather', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/server/mcp-apps/server.php'; + } +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json new file mode 100644 index 0000000..1399cd2 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json @@ -0,0 +1,16 @@ +{ + "content": [ + { + "type": "text", + "text": "Found user Alice (admin)." + }, + { + "name": "user_profile", + "uri": "user://101/profile", + "description": "Full profile for Alice.", + "mimeType": "application/json", + "type": "resource_link" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json new file mode 100644 index 0000000..211521c --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "User ID 999 not found." + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json index 205ad0c..08b8198 100644 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json @@ -23,6 +23,22 @@ ] } }, + { + "name": "lookup_user", + "description": "Looks up a user and returns a reference to their profile resource.\n\nRather than embedding the full profile (as `resources/read` on\n`user://{userId}/profile` would), this returns a `resource_link` block\npointing at that resource template so the caller can fetch it\nseparately if needed. This mirrors how a tool like a search returning\nmany hits would reference each matching resource by URI instead of\ninlining every one of them.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "the ID of the user to look up" + } + }, + "required": [ + "userId" + ] + } + }, { "name": "send_welcome", "description": "Sends a welcome message to a user.\n\n(This is a placeholder - in a real app, it might queue an email).", diff --git a/tests/Inspector/Http/snapshots/HttpMcpAppsTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-prompts_list.json new file mode 100644 index 0000000..7292222 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-prompts_list.json @@ -0,0 +1,3 @@ +{ + "prompts": [] +} diff --git a/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_list.json b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_list.json new file mode 100644 index 0000000..2ff24ba --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_list.json @@ -0,0 +1,13 @@ +{ + "resources": [ + { + "name": "weather-app", + "uri": "ui://weather-app", + "description": "Interactive weather dashboard", + "mimeType": "text/html;profile=mcp-app", + "_meta": { + "ui": {} + } + } + ] +} diff --git a/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_read-weather_app.json b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_read-weather_app.json new file mode 100644 index 0000000..84e1751 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_read-weather_app.json @@ -0,0 +1,22 @@ +{ + "contents": [ + { + "uri": "ui://weather-app", + "mimeType": "text/html;profile=mcp-app", + "_meta": { + "ui": { + "csp": { + "connectDomains": [ + "https://api.weather.example.com" + ] + }, + "permissions": { + "geolocation": {} + }, + "prefersBorder": true + } + }, + "text": "\n\n\n \n \n Weather Dashboard\n \n\n\n
\n \n \n
\n
\n
\n
\n
—
\n
—
\n
\n
🌤️
\n
\n
—
\n
\n Humidity —\n
\n
\n
\n \n\n\n" + } + ] +} diff --git a/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_templates_list.json new file mode 100644 index 0000000..e867d9d --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-resources_templates_list.json @@ -0,0 +1,3 @@ +{ + "resourceTemplates": [] +} diff --git a/tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_call-get_weather.json b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_call-get_weather.json new file mode 100644 index 0000000..8d42069 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_call-get_weather.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "Weather in london: 15°C, Cloudy, Humidity: 78%" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_list.json new file mode 100644 index 0000000..dcb064a --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-tools_list.json @@ -0,0 +1,28 @@ +{ + "tools": [ + { + "name": "get_weather", + "description": "Get current weather for a city", + "inputSchema": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": [ + "city" + ] + }, + "_meta": { + "ui": { + "resourceUri": "ui://weather-app", + "visibility": [ + "model", + "app" + ] + } + } + } + ] +} diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php index 35d08e1..ddfbacb 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -18,7 +18,7 @@ use Symfony\Component\Process\Process; abstract class InspectorSnapshotTestCase extends TestCase { - private const INSPECTOR_VERSION = '0.22.0'; + private const INSPECTOR_VERSION = '2.2.0'; /** @param array $options */ #[DataProvider('provideMethods')] diff --git a/tests/Integration/CompletionTest.php b/tests/Integration/CompletionTest.php new file mode 100644 index 0000000..0da021a --- /dev/null +++ b/tests/Integration/CompletionTest.php @@ -0,0 +1,57 @@ +connect('completion'); + + $result = $client->complete(new PromptReference('book_seat'), ['name' => 'seat', 'value' => '12']); + + $this->assertSame(['12A', '12B'], $result->values); + } + + #[TestDox('an empty value offers every completion the provider knows')] + public function testEmptyValueOffersEveryCompletion(): void + { + $client = $this->connect('completion'); + + $result = $client->complete(new PromptReference('book_seat'), ['name' => 'seat', 'value' => '']); + + $this->assertSame(['12A', '12B', '14C'], $result->values); + } + + #[TestDox('an argument the prompt does not declare completes to nothing')] + public function testUnknownArgumentCompletesToNothing(): void + { + $client = $this->connect('completion'); + + $result = $client->complete(new PromptReference('book_seat'), ['name' => 'unknown', 'value' => '1']); + + $this->assertSame([], $result->values); + } +} diff --git a/tests/Integration/DualEraElicitationTest.php b/tests/Integration/DualEraElicitationTest.php new file mode 100644 index 0000000..b2a443d --- /dev/null +++ b/tests/Integration/DualEraElicitationTest.php @@ -0,0 +1,105 @@ + true, + 'party_size' => 4, + 'date' => '2026-09-01', + 'dietary' => 'vegan', + 'rating' => '5', + 'comments' => 'Excellent', + ]; + + protected static function server(): string + { + return __DIR__.'/../../examples/server/elicitation/server.php'; + } + + protected static function portBase(): int + { + return 9500; + } + + #[DataProvider('provideEras')] + #[TestDox('a confirmation is collected on $_dataName')] + public function testConfirmAction(ProtocolVersion $era): void + { + $client = $this->connect($era, elicitation: true); + + $result = $client->callTool('confirm_action', ['actionDescription' => 'delete the staging database']); + + $this->assertStringContainsString('Action confirmed', self::text($result)); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a multi-field form is collected on $_dataName')] + public function testBookRestaurant(ProtocolVersion $era): void + { + $client = $this->connect($era, elicitation: true); + + $result = $client->callTool('book_restaurant', ['restaurantName' => 'Osteria']); + + $this->assertStringContainsString('Reservation confirmed at Osteria for 4 guests', self::text($result)); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('feedback with an optional field is collected on $_dataName')] + public function testCollectFeedback(ProtocolVersion $era): void + { + $client = $this->connect($era, elicitation: true); + + $result = $client->callTool('collect_feedback', ['topic' => 'the new checkout flow']); + + $this->assertStringContainsString('Thank you for your feedback', self::text($result)); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a client that declares no elicitation is told what to do instead, on $_dataName')] + public function testWithoutTheCapability(ProtocolVersion $era): void + { + $client = $this->connect($era); + + // Both eras refuse an ask the client cannot answer. The handshake era + // finds out from the capability the client declared at initialize, the + // modern era from the envelope on this very request — and the example + // says the same thing either way. + try { + $answer = self::text($client->callTool('confirm_action', ['actionDescription' => 'anything'])); + $this->assertStringContainsString('does not support elicitation', $answer); + } catch (\Throwable $e) { + $this->assertStringContainsString('did not declare it can provide', $e->getMessage()); + } + + $client->disconnect(); + } +} diff --git a/tests/Integration/DualEraEndpointTest.php b/tests/Integration/DualEraEndpointTest.php new file mode 100644 index 0000000..754d8a7 --- /dev/null +++ b/tests/Integration/DualEraEndpointTest.php @@ -0,0 +1,115 @@ + 'Ada']; + + protected static function server(): string + { + return __DIR__.'/../../examples/server/stateless-lifecycle/server.php'; + } + + protected static function portBase(): int + { + return 9200; + } + + #[DataProvider('provideEras')] + #[TestDox('a client on $_dataName connects to the one endpoint')] + public function testConnects(ProtocolVersion $era): void + { + $client = $this->connect($era); + + $this->assertTrue($client->isConnected()); + $this->assertSame($era, $client->getProtocolVersion()); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a client on $_dataName sees the same tools')] + public function testListsTheSameTools(ProtocolVersion $era): void + { + $client = $this->connect($era); + + $names = array_map(static fn ($tool): string => $tool->name, $client->listTools()->tools); + sort($names); + + // One registry behind both legs, so the catalogue cannot drift. + $this->assertSame(['get_weather', 'greet', 'reindex'], $names); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a client on $_dataName gets the same answer from the same tool')] + public function testCallsTheSameTool(ProtocolVersion $era): void + { + $client = $this->connect($era); + + $this->assertSame( + 'It is 17°C and cloudy in Munich.', + self::text($client->callTool('get_weather', ['city' => 'Munich'])), + ); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a tool that has to ask the user completes on $_dataName')] + public function testAsksForInput(ProtocolVersion $era): void + { + // The one place the eras genuinely differ on the wire: the handshake era + // is asked mid-call over its session's stream, the modern era is handed + // the question as a result and retries. The example forks on exactly + // that; the caller here does not. + $client = $this->connect($era, elicitation: true); + + $this->assertSame('Hello, Ada!', self::text($client->callTool('greet', []))); + + $client->disconnect(); + } + + #[TestDox('both eras are served in turn without restarting anything')] + public function testBothErasAgainstOneRunningServer(): void + { + $handshake = $this->connect(ProtocolVersion::V2025_11_25); + $modern = $this->connect(ProtocolVersion::V2026_07_28); + + // Interleaved on purpose: the handshake client's session stays open + // while the modern one is served, and neither disturbs the other. + $first = self::text($handshake->callTool('get_weather', ['city' => 'Berlin'])); + $second = self::text($modern->callTool('get_weather', ['city' => 'Berlin'])); + $third = self::text($handshake->callTool('get_weather', ['city' => 'Berlin'])); + + $this->assertSame($first, $second); + $this->assertSame($first, $third); + + $handshake->disconnect(); + $modern->disconnect(); + } +} diff --git a/tests/Integration/DualEraExampleTestCase.php b/tests/Integration/DualEraExampleTestCase.php new file mode 100644 index 0000000..b7671b8 --- /dev/null +++ b/tests/Integration/DualEraExampleTestCase.php @@ -0,0 +1,144 @@ + + */ +abstract class DualEraExampleTestCase extends TestCase +{ + /** Answers whatever the server elicits, so a tool that asks can complete. */ + protected const ANSWERS = []; + + private ?Process $server = null; + private int $port; + + /** Absolute path to the example's server script. */ + abstract protected static function server(): string; + + /** Port range base, so concurrent test classes do not collide. */ + abstract protected static function portBase(): int; + + protected function setUp(): void + { + // PHP_CLI_SERVER_WORKERS does not reliably fork more than one worker on + // PHP 8.1 (see php/php-src#9400), which reproduces the very deadlock the + // extra workers exist to avoid: every request below hangs to the test + // timeout instead of running. Skip until either PHP 8.1 is dropped or + // this suite stops depending on `php -S` for multi-worker concurrency. + if (\PHP_VERSION_ID < 80200) { + $this->markTestSkipped('php -S does not reliably fork multiple workers on PHP 8.1 (PHP_CLI_SERVER_WORKERS); see php/php-src#9400.'); + } + + $this->port = static::portBase() + (getmypid() % 200); + + // More than one worker because the handshake era needs it: a tool that + // asks the client mid-call holds its SSE response open while the client + // POSTs the answer on a second connection. One worker deadlocks there — + // a property of `php -S`, not of the server. + $this->server = new Process( + ['php', '-S', \sprintf('127.0.0.1:%d', $this->port), static::server()], + env: ['PHP_CLI_SERVER_WORKERS' => '4'], + ); + $this->server->start(); + + $deadline = microtime(true) + 5; + while (microtime(true) < $deadline) { + if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) { + return; + } + + usleep(50_000); + } + + $this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput())); + } + + protected function tearDown(): void + { + $this->server?->stop(); + } + + /** + * @return iterable + */ + public static function provideEras(): iterable + { + yield 'the handshake era' => [ProtocolVersion::V2025_11_25]; + yield 'the modern era' => [ProtocolVersion::V2026_07_28]; + } + + protected static function text(CallToolResult $result): string + { + $first = $result->content[0] ?? null; + + self::assertInstanceOf(TextContent::class, $first); + + return $first->text; + } + + protected function connect(ProtocolVersion $era, bool $elicitation = false): Client + { + $builder = Client::builder() + ->setClientInfo('dual-era-integration-client', '1.0.0') + ->setProtocolVersion($era) + ->setRequestTimeout(10); + + if ($elicitation) { + $answers = static::ANSWERS; + + $builder->setCapabilities(new ClientCapabilities(elicitation: true)); + $builder->addRequestHandler(new class($answers) implements RequestHandlerInterface { + /** @param array $answers */ + public function __construct(private readonly array $answers) + { + } + + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, $this->answers)); + } + }); + } + + $client = $builder->build(); + $client->connect(new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port))); + + return $client; + } +} diff --git a/tests/Integration/ElicitationTest.php b/tests/Integration/ElicitationTest.php new file mode 100644 index 0000000..dc16847 --- /dev/null +++ b/tests/Integration/ElicitationTest.php @@ -0,0 +1,107 @@ +connect('elicitation', $this->clientAnswering( + new ElicitResult(ElicitAction::Accept, ['name' => 'Ada']), + )); + + $result = $client->callTool('ask_name'); + + $this->assertFalse($result->isError); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('accept:Ada', $result->content[0]->text); + } + + #[TestDox('a declined elicitation reaches the tool as a decline, not an error')] + public function testDeclinedElicitation(): void + { + $client = $this->connect('elicitation', $this->clientAnswering( + new ElicitResult(ElicitAction::Decline), + )); + + $result = $client->callTool('ask_name'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('decline:', $result->content[0]->text); + } + + #[TestDox('a client that does not advertise elicitation is not asked')] + public function testCapabilityIsVisibleToTheServer(): void + { + // The tool consults supportsElicitation(), which answers from the + // capabilities this client sent during the handshake. + $client = $this->connect('elicitation'); + + $result = $client->callTool('ask_name'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('unsupported', $result->content[0]->text); + } + + #[TestDox('a client advertising elicitation without a handler fails the tool call')] + public function testAdvertisedCapabilityWithoutHandler(): void + { + // The client answers "method not found", which the gateway raises inside + // the tool as a ClientException rather than leaving it waiting. + $client = $this->connect( + 'elicitation', + $this->clientBuilder()->setCapabilities(new ClientCapabilities(elicitation: true)), + ); + + $result = $client->callTool('ask_name'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('Client does not handle "elicitation/create" requests.', $result->content[0]->text); + } + + private function clientAnswering(ElicitResult $answer): ClientBuilder + { + $callback = new class($answer) implements ElicitationCallbackInterface { + public function __construct(private readonly ElicitResult $answer) + { + } + + public function __invoke(ElicitRequest $request): ElicitResult + { + return $this->answer; + } + }; + + return $this->clientBuilder() + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler(new ElicitationRequestHandler($callback)); + } +} diff --git a/tests/Integration/Fixture/Completion/BookingElements.php b/tests/Integration/Fixture/Completion/BookingElements.php new file mode 100644 index 0000000..2a64e5d --- /dev/null +++ b/tests/Integration/Fixture/Completion/BookingElements.php @@ -0,0 +1,38 @@ + + */ +final class BookingElements +{ + /** + * Confirms a seat booking. + * + * @param string $seat the seat to book + * + * @return array the prompt messages + */ + #[McpPrompt(name: 'book_seat')] + public function bookSeat( + #[CompletionProvider(provider: SeatCompletionProvider::class)] + string $seat, + ): array { + return [ + ['role' => 'user', 'content' => \sprintf('Book seat %s for me.', $seat)], + ]; + } +} diff --git a/tests/Integration/Fixture/Completion/SeatCompletionProvider.php b/tests/Integration/Fixture/Completion/SeatCompletionProvider.php new file mode 100644 index 0000000..a749fcf --- /dev/null +++ b/tests/Integration/Fixture/Completion/SeatCompletionProvider.php @@ -0,0 +1,42 @@ + + */ +final class SeatCompletionProvider implements ProviderInterface +{ + /** + * @param list $seats + */ + public function __construct( + private readonly array $seats, + ) { + } + + public function getCompletions(string $currentValue): array + { + return array_values(array_filter( + $this->seats, + static fn (string $seat): bool => str_starts_with($seat, $currentValue), + )); + } +} diff --git a/tests/Integration/Fixture/completion.php b/tests/Integration/Fixture/completion.php new file mode 100644 index 0000000..f33e951 --- /dev/null +++ b/tests/Integration/Fixture/completion.php @@ -0,0 +1,31 @@ +set(SeatCompletionProvider::class, new SeatCompletionProvider(['12A', '12B', '14C'])); + +Server::builder() + ->setServerInfo('integration-server', '1.0.0') + ->setContainer($container) + ->setDiscovery(__DIR__, ['Completion']) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/elicitation.php b/tests/Integration/Fixture/elicitation.php new file mode 100644 index 0000000..ad588a9 --- /dev/null +++ b/tests/Integration/Fixture/elicitation.php @@ -0,0 +1,49 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + if (!$gateway->supportsElicitation()) { + return 'unsupported'; + } + + try { + $result = $gateway->elicit('What is your name?', new ElicitationSchema([ + 'name' => new StringSchemaDefinition(title: 'Name'), + ])); + } catch (ClientException $e) { + return $e->getMessage(); + } + + return sprintf('%s:%s', $result->action->value, $result->content['name'] ?? ''); + }, + name: 'ask_name', + description: 'Asks the client for a name.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/handshake.php b/tests/Integration/Fixture/handshake.php new file mode 100644 index 0000000..776f451 --- /dev/null +++ b/tests/Integration/Fixture/handshake.php @@ -0,0 +1,32 @@ +setServerInfo('integration-server', '1.0.0') + ->setInstructions('Be brief.'); + +if (is_string($pinned = getenv('MCP_INTEGRATION_PROTOCOL_VERSION')) && '' !== $pinned) { + $builder->setProtocolVersion(ProtocolVersion::from($pinned)); +} + +$builder->build()->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/notification.php b/tests/Integration/Fixture/notification.php new file mode 100644 index 0000000..166e84c --- /dev/null +++ b/tests/Integration/Fixture/notification.php @@ -0,0 +1,39 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + $gateway->log(LoggingLevel::Info, 'starting work'); + $gateway->progress(0.5, 1.0, 'halfway'); + $gateway->progress(1.0, 1.0, 'done'); + + return 'finished'; + }, + name: 'work', + description: 'Reports progress and logs while working.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/request_timeout.php b/tests/Integration/Fixture/request_timeout.php new file mode 100644 index 0000000..fb3697c --- /dev/null +++ b/tests/Integration/Fixture/request_timeout.php @@ -0,0 +1,42 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (): string { + sleep(2); + + return 'late'; + }, + name: 'slow', + description: 'Answers well after the client stopped waiting.', + ) + ->addTool( + static fn (): string => 'quick', + name: 'fast', + description: 'Returns at once.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/retry.php b/tests/Integration/Fixture/retry.php new file mode 100644 index 0000000..9bd25ee --- /dev/null +++ b/tests/Integration/Fixture/retry.php @@ -0,0 +1,46 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static fn (): string => 'quick', + name: 'fast', + description: 'Returns at once.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/roots.php b/tests/Integration/Fixture/roots.php new file mode 100644 index 0000000..a4b6382 --- /dev/null +++ b/tests/Integration/Fixture/roots.php @@ -0,0 +1,43 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + if (!$gateway->supportsRoots()) { + return 'unsupported'; + } + + $described = []; + foreach ($gateway->listRoots()->roots as $root) { + $described[] = sprintf('%s (%s)', $root->uri, $root->name ?? '-'); + } + + return implode(', ', $described); + }, + name: 'inspect_roots', + description: 'Reports the workspace roots the client exposes.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/sampling.php b/tests/Integration/Fixture/sampling.php new file mode 100644 index 0000000..5ea62c8 --- /dev/null +++ b/tests/Integration/Fixture/sampling.php @@ -0,0 +1,58 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context, string $text): string { + try { + $result = $context->getClientGateway()->sample($text, maxTokens: 64); + } catch (ClientException $e) { + return $e->getMessage(); + } + + assert($result->content instanceof TextContent); + + return sprintf('%s said: %s', $result->model, $result->content->text); + }, + name: 'summarize', + description: 'Summarizes text by asking the client to sample.', + ) + ->addTool( + static function (ClientGateway $client, string $text): string { + try { + $result = $client->sample($text, maxTokens: 64); + } catch (ClientException $e) { + return $e->getMessage(); + } + + assert($result->content instanceof TextContent); + + return sprintf('%s said: %s', $result->model, $result->content->text); + }, + name: 'summarize_via_gateway', + description: 'Summarizes text through a directly injected gateway.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/sampling_tools.php b/tests/Integration/Fixture/sampling_tools.php new file mode 100644 index 0000000..94cd561 --- /dev/null +++ b/tests/Integration/Fixture/sampling_tools.php @@ -0,0 +1,78 @@ + 'object', 'properties' => ['city' => ['type' => 'string']], 'required' => ['city']], + 'Get current weather for a city', + null, +); + +Server::builder() + ->setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context, string $city) use ($weather): string { + $gateway = $context->getClientGateway(); + + // The spec forbids sending tools to a client that did not advertise + // sampling.tools, so the loop is only entered when it did. + if (!$gateway->supportsSamplingTools()) { + return 'client cannot use tools during sampling'; + } + + $messages = [new SamplingMessage(Role::User, new TextContent(sprintf('Weather in %s?', $city)))]; + + $answer = $gateway->sample($messages, maxTokens: 64, options: ['tools' => [$weather]]); + $messages[] = new SamplingMessage(Role::Assistant, $answer->content); + + $toolResults = []; + foreach ($answer->getContentBlocks() as $block) { + if ($block instanceof ToolUseContent) { + $toolResults[] = new ToolResultContent( + $block->id, + [new TextContent(sprintf('%s: 18 C', $block->input['city'] ?? 'unknown'))], + ); + } + } + + if ([] === $toolResults) { + return 'the model asked for no tools'; + } + + $messages[] = new SamplingMessage(Role::User, $toolResults); + + $final = $gateway->sample($messages, maxTokens: 64, options: ['tools' => [$weather]]); + assert($final->content instanceof TextContent); + + return sprintf('%s (%s)', $final->content->text, $final->stopReason); + }, + name: 'weather_report', + description: 'Reports weather by running a sampling tool loop.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php new file mode 100644 index 0000000..5d21320 --- /dev/null +++ b/tests/Integration/HandshakeTest.php @@ -0,0 +1,90 @@ +clientBuilder(); + if (null !== $clientVersion) { + $client->setProtocolVersion($clientVersion); + } + + $connected = $this->connect( + 'handshake', + $client, + null !== $serverVersion ? ['MCP_INTEGRATION_PROTOCOL_VERSION' => $serverVersion->value] : [], + ); + + $this->assertSame($expected, $connected->getProtocolVersion()); + } + + /** + * @return iterable + */ + public static function provideNegotiations(): iterable + { + $latest = ProtocolVersion::latestHandshake(); + + yield 'both unconfigured' => [null, null, $latest]; + + // Whichever end of the supported range it sits at. + foreach (ProtocolVersion::handshakeVersions() as $version) { + yield \sprintf('client asks for %s', $version->value) => [$version, null, $version]; + } + + // A pinned server answers with its pin, and the client continues on it. + yield 'server pins an older revision' => [ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_03_26, ProtocolVersion::V2025_03_26]; + yield 'server pins a newer revision' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_11_25]; + yield 'both pin the same revision' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18]; + + // A modern client does not negotiate at all: it skips the handshake and + // states its revision on every request, so what it was configured with + // is what it reports. This server never answers `server/discover`, so + // there is nothing to reconcile against either. + yield 'client configured modern' => [ProtocolVersion::V2026_07_28, null, ProtocolVersion::V2026_07_28]; + yield 'both configured modern' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28]; + + // The server end still falls back: a handshake-era client offered a + // revision, and `initialize` cannot answer with a modern one. + yield 'server configured modern' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2026_07_28, ProtocolVersion::V2025_06_18]; + } + + #[TestDox('the handshake carries the server identity to the client')] + public function testServerInfoIsExchanged(): void + { + $client = $this->connect('handshake'); + + $this->assertSame('integration-server', $client->getServerInfo()->name); + $this->assertSame('1.0.0', $client->getServerInfo()->version); + $this->assertSame('Be brief.', $client->getInstructions()); + $this->assertTrue($client->isConnected()); + } + + #[TestDox('the negotiated revision is unset before the handshake')] + public function testProtocolVersionIsNullBeforeConnecting(): void + { + $this->assertNull($this->clientBuilder()->build()->getProtocolVersion()); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..4ad33d4 --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,99 @@ + + */ +abstract class IntegrationTestCase extends TestCase +{ + /** + * Both sides answer immediately, so anything reaching this is a deadlock. + * Far below the SDK's two-minute default, to fail rather than hang. + */ + private const TIMEOUT = 5; + + private ?Client $client = null; + + protected function clientBuilder(): ClientBuilder + { + return Client::builder() + ->setClientInfo('integration-client', '1.0.0') + ->setInitTimeout(self::TIMEOUT) + ->setRequestTimeout(self::TIMEOUT); + } + + /** + * Spawn a fixture server and connect a client to it. + * + * The returned client has completed the handshake. + * + * @param string $fixture basename of a script in {@see Fixture} + * @param array $env added to the server process environment + */ + protected function connect(string $fixture, ?ClientBuilder $client = null, array $env = []): Client + { + $this->client = ($client ?? $this->clientBuilder())->build(); + + try { + $this->client->connect($this->transport($fixture, $env)); + } catch (ConnectionException $e) { + // The transport discards the child's stderr, so a fixture dying on + // startup arrives here as a bare timeout. + $this->fail(\sprintf('Could not connect to fixture server "%s": %s. Run `%s %s` to see why.', $fixture, $e->getMessage(), \PHP_BINARY, self::script($fixture))); + } + + return $this->client; + } + + /** + * A transport that will spawn a fixture server, without connecting it. + * + * Tests that assert on a failing connection need the transport by itself, + * since {@see self::connect()} turns that failure into a test failure. + * + * @param array $env added to the server process environment + */ + protected function transport(string $fixture, array $env = []): StdioTransport + { + return new StdioTransport( + command: \PHP_BINARY, + args: [self::script($fixture)], + // proc_open() replaces the environment rather than adding to it. + env: [] === $env ? null : array_merge(getenv(), $env), + ); + } + + private static function script(string $fixture): string + { + return __DIR__.'/Fixture/'.$fixture.'.php'; + } + + protected function tearDown(): void + { + $this->client?->disconnect(); + $this->client = null; + } +} diff --git a/tests/Integration/NotificationTest.php b/tests/Integration/NotificationTest.php new file mode 100644 index 0000000..0948718 --- /dev/null +++ b/tests/Integration/NotificationTest.php @@ -0,0 +1,75 @@ +connect('notification'); + + $updates = []; + $result = $client->callTool('work', [], static function (float $progress, ?float $total, ?string $message) use (&$updates): void { + $updates[] = [$progress, $total, $message]; + }); + + $this->assertSame([[0.5, 1.0, 'halfway'], [1.0, 1.0, 'done']], $updates); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('finished', $result->content[0]->text); + } + + #[TestDox('progress is skipped when the caller asked for none')] + public function testProgressIsSkippedWithoutAToken(): void + { + // Without an onProgress callback the request carries no progress token, + // so the gateway drops the notification instead of sending it. + $client = $this->connect('notification'); + + $result = $client->callTool('work'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('finished', $result->content[0]->text); + } + + #[TestDox('log notifications reach a registered logging handler')] + public function testLoggingReachesTheClient(): void + { + $logged = []; + $client = $this->connect( + 'notification', + $this->clientBuilder()->addNotificationHandler(new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) use (&$logged): void { + $logged[] = [$notification->level, $notification->data]; + }, + )), + ); + + $client->callTool('work'); + + $this->assertSame([[LoggingLevel::Info, 'starting work']], $logged); + } +} diff --git a/tests/Integration/RequestTimeoutTest.php b/tests/Integration/RequestTimeoutTest.php new file mode 100644 index 0000000..fa3ef4c --- /dev/null +++ b/tests/Integration/RequestTimeoutTest.php @@ -0,0 +1,43 @@ +connect('request_timeout', $this->clientBuilder()->setRequestTimeout(1)); + + try { + $client->callTool('slow'); + $this->fail('The slow tool should have outlived the request timeout.'); + } catch (RequestException) { + } + + // The server is still executing the abandoned call and cannot answer + // anything until it returns, so the next request has to wait it out. + sleep(2); + + // Without the pending request being cleared this answers 'late': the + // response the client gave up on, handed to the request after it. + $this->assertSame('quick', $client->callTool('fast')->content[0]->text ?? null); + } +} diff --git a/tests/Integration/RetryTest.php b/tests/Integration/RetryTest.php new file mode 100644 index 0000000..7fc974c --- /dev/null +++ b/tests/Integration/RetryTest.php @@ -0,0 +1,93 @@ +fail('Could not create the spawn counter file.'); + } + + $this->counter = $counter; + file_put_contents($this->counter, '0'); + } + + protected function tearDown(): void + { + parent::tearDown(); + + @unlink($this->counter); + } + + #[TestDox('a failed attempt is retried against a newly spawned server')] + public function testRetrySpawnsAnotherServer(): void + { + $client = $this->connect( + 'retry', + $this->clientBuilder()->setInitTimeout(2)->setMaxRetries(1), + $this->environment(failing: 1), + ); + + $this->assertSame(2, $this->spawns()); + $this->assertTrue($client->isConnected()); + $this->assertSame('quick', $client->callTool('fast')->content[0]->text ?? null); + } + + #[TestDox('no retries means the first failure is the last word')] + public function testRetriesCanBeDisabled(): void + { + $client = $this->clientBuilder()->setInitTimeout(2)->setMaxRetries(0)->build(); + + try { + $client->connect($this->transport('retry', $this->environment(failing: 1))); + $this->fail('Connecting should not have succeeded.'); + } catch (ConnectionException) { + $this->assertSame(1, $this->spawns()); + } + } + + /** + * @return array + */ + private function environment(int $failing): array + { + return [ + 'MCP_SPAWN_COUNTER' => $this->counter, + 'MCP_FAIL_SPAWNS' => (string) $failing, + ]; + } + + private function spawns(): int + { + return (int) file_get_contents($this->counter); + } +} diff --git a/tests/Integration/RootsTest.php b/tests/Integration/RootsTest.php new file mode 100644 index 0000000..6dfd065 --- /dev/null +++ b/tests/Integration/RootsTest.php @@ -0,0 +1,98 @@ +connect('roots', $this->clientExposing( + new Root('file:///workspace/app', 'App'), + new Root('file:///workspace/docs', 'Docs'), + )); + + $result = $client->callTool('inspect_roots'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('file:///workspace/app (App), file:///workspace/docs (Docs)', $result->content[0]->text); + } + + #[TestDox('an empty root list is a valid answer, not a failure')] + public function testEmptyRootList(): void + { + $client = $this->connect('roots', $this->clientExposing()); + + $result = $client->callTool('inspect_roots'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('', $result->content[0]->text); + } + + #[TestDox('a client that does not advertise roots is not asked')] + public function testCapabilityIsVisibleToTheServer(): void + { + $client = $this->connect('roots'); + + $result = $client->callTool('inspect_roots'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('unsupported', $result->content[0]->text); + } + + #[TestDox('the client can announce that its roots changed')] + public function testRootsListChangedNotification(): void + { + $client = $this->connect('roots', $this->clientExposing(new Root('file:///workspace'))); + + // A notification has no reply, so what this pins down is that sending + // one mid-session leaves the connection usable. + $client->sendRootsListChanged(); + + $this->assertTrue($client->isConnected()); + $this->assertInstanceOf(TextContent::class, $client->callTool('inspect_roots')->content[0]); + } + + private function clientExposing(Root ...$roots): ClientBuilder + { + $callback = new class(array_values($roots)) implements RootsCallbackInterface { + /** @param list $roots */ + public function __construct(private readonly array $roots) + { + } + + public function __invoke(ListRootsRequest $request): ListRootsResult + { + return new ListRootsResult($this->roots); + } + }; + + return $this->clientBuilder() + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->addRequestHandler(new ListRootsRequestHandler($callback)); + } +} diff --git a/tests/Integration/SamplingTest.php b/tests/Integration/SamplingTest.php new file mode 100644 index 0000000..0f45286 --- /dev/null +++ b/tests/Integration/SamplingTest.php @@ -0,0 +1,121 @@ +connect('sampling', $this->clientSampling()); + + $result = $client->callTool('summarize', ['text' => 'a long report']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('test-model said: a long report', $result->content[0]->text); + } + + #[TestDox('the prompt the tool passed arrives at the client')] + public function testPromptReachesTheClient(): void + { + // The client stays in this process, so what it was asked can be + // collected by reference even though the tool asking runs in another. + /** @var \ArrayObject $seen */ + $seen = new \ArrayObject(); + $client = $this->connect('sampling', $this->clientSampling($seen)); + + $client->callTool('summarize', ['text' => 'inspect me']); + + $this->assertCount(1, $seen); + $this->assertInstanceOf(TextContent::class, $seen[0]->messages[0]->content); + $this->assertSame('inspect me', $seen[0]->messages[0]->content->text); + $this->assertSame(64, $seen[0]->maxTokens); + } + + #[TestDox('a gateway parameter is injected, not published in the schema')] + public function testGatewayParameterIsInjectedNotPublished(): void + { + $client = $this->connect('sampling', $this->clientSampling()); + + $tool = null; + foreach ($client->listTools()->tools as $candidate) { + if ('summarize_via_gateway' === $candidate->name) { + $tool = $candidate; + } + } + + $this->assertNotNull($tool); + $this->assertArrayNotHasKey('client', $tool->inputSchema['properties']); + $this->assertArrayHasKey('text', $tool->inputSchema['properties']); + $this->assertSame(['text'], $tool->inputSchema['required']); + + $result = $client->callTool('summarize_via_gateway', ['text' => 'a long report']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('test-model said: a long report', $result->content[0]->text); + } + + #[TestDox('a client that cannot sample refuses instead of stalling the tool')] + public function testClientWithoutSamplingRefuses(): void + { + // The gateway has no supportsSampling() to consult, so the tool finds + // out by asking and the refusal surfaces as a ClientException. + $client = $this->connect('sampling'); + + $result = $client->callTool('summarize', ['text' => 'anything']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('Client does not handle "sampling/createMessage" requests.', $result->content[0]->text); + } + + /** + * @param \ArrayObject|null $seen collects what the server asked for + */ + private function clientSampling(?\ArrayObject $seen = null): ClientBuilder + { + $callback = new class($seen ?? new \ArrayObject()) implements SamplingCallbackInterface { + /** @param \ArrayObject $seen */ + public function __construct(private readonly \ArrayObject $seen) + { + } + + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + $this->seen[] = $request; + + $prompt = $request->messages[0]->content; + \assert($prompt instanceof TextContent); + + return new CreateSamplingMessageResult(Role::Assistant, new TextContent($prompt->text), 'test-model'); + } + }; + + return $this->clientBuilder() + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addRequestHandler(new SamplingRequestHandler($callback)); + } +} diff --git a/tests/Integration/SamplingToolsTest.php b/tests/Integration/SamplingToolsTest.php new file mode 100644 index 0000000..107f163 --- /dev/null +++ b/tests/Integration/SamplingToolsTest.php @@ -0,0 +1,135 @@ +connect('sampling_tools', $this->clientSamplingWithTools()); + + $result = $client->callTool('weather_report', ['city' => 'Paris']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('Paris: 18 C is the answer. (endTurn)', $result->content[0]->text); + } + + #[TestDox('the tools the server offered arrive at the client')] + public function testToolsReachTheClient(): void + { + /** @var \ArrayObject $seen */ + $seen = new \ArrayObject(); + $client = $this->connect('sampling_tools', $this->clientSamplingWithTools($seen)); + + $client->callTool('weather_report', ['city' => 'Paris']); + + $this->assertCount(2, $seen); + $this->assertSame('get_weather', $seen[0]->tools[0]->name); + + // Second turn carries the assistant's tool use and the server's tool result. + $this->assertCount(3, $seen[1]->messages); + $this->assertInstanceOf(ToolUseContent::class, $seen[1]->messages[1]->getContentBlocks()[0]); + $toolResult = $seen[1]->messages[2]->getContentBlocks()[0]; + $this->assertInstanceOf(ToolResultContent::class, $toolResult); + $this->assertSame('call-1', $toolResult->toolUseId); + } + + #[TestDox('a client that did not advertise sampling.tools is never sent tools')] + public function testClientWithoutSamplingToolsIsNotOfferedTools(): void + { + $client = $this->connect('sampling_tools', $this->clientBuilder() + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addRequestHandler(new SamplingRequestHandler($this->neverCalled()))); + + $result = $client->callTool('weather_report', ['city' => 'Paris']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('client cannot use tools during sampling', $result->content[0]->text); + } + + /** + * @param \ArrayObject|null $seen collects what the server asked for + */ + private function clientSamplingWithTools(?\ArrayObject $seen = null): ClientBuilder + { + $callback = new class($seen ?? new \ArrayObject()) implements SamplingCallbackInterface { + /** @param \ArrayObject $seen */ + public function __construct(private readonly \ArrayObject $seen) + { + } + + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + $this->seen[] = $request; + + $lastMessage = $request->messages[\count($request->messages) - 1]; + $answeredTools = array_filter( + $lastMessage->getContentBlocks(), + static fn ($block): bool => $block instanceof ToolResultContent, + ); + + // First turn: ask for the tool. Second: answer from its result. + if ([] === $answeredTools) { + return new CreateSamplingMessageResult( + Role::Assistant, + [new ToolUseContent('call-1', 'get_weather', ['city' => 'Paris'])], + 'test-model', + 'toolUse', + ); + } + + $toolResult = reset($answeredTools); + $text = $toolResult->content[0]; + \assert($text instanceof TextContent); + + return new CreateSamplingMessageResult( + Role::Assistant, + new TextContent(\sprintf('%s is the answer.', $text->text)), + 'test-model', + 'endTurn', + ); + } + }; + + return $this->clientBuilder() + ->setCapabilities(new ClientCapabilities(sampling: true, samplingTools: true)) + ->addRequestHandler(new SamplingRequestHandler($callback)); + } + + private function neverCalled(): SamplingCallbackInterface + { + return new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + throw new \LogicException('The server must not sample a client that cannot use tools.'); + } + }; + } +} diff --git a/tests/Integration/StatelessClientTest.php b/tests/Integration/StatelessClientTest.php new file mode 100644 index 0000000..fbd3d59 --- /dev/null +++ b/tests/Integration/StatelessClientTest.php @@ -0,0 +1,171 @@ +port = 8900 + (getmypid() % 300); + + $this->server = new Process(['php', '-S', \sprintf('127.0.0.1:%d', $this->port), self::SERVER]); + $this->server->start(); + + $deadline = microtime(true) + 5; + while (microtime(true) < $deadline) { + if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) { + return; + } + + usleep(50_000); + } + + $this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput())); + } + + protected function tearDown(): void + { + $this->server->stop(); + } + + #[TestDox('connects without a handshake and learns who it is talking to')] + public function testConnectWithoutHandshake(): void + { + $client = $this->connect(); + + $this->assertTrue($client->isConnected()); + $this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion()); + $this->assertSame('Stateless Lifecycle Demo', $client->getServerInfo()?->name); + + $client->disconnect(); + } + + #[TestDox('lists and calls a tool, which the server accepts on the first try')] + public function testToolCall(): void + { + $client = $this->connect(); + + $tools = $client->listTools(); + $this->assertContains('get_weather', array_map(static fn ($tool) => $tool->name, $tools->tools)); + + // A header the server disagreed with would come back as -32020, so a + // plain result is also the assertion that the mirroring was right. + $result = $client->callTool('get_weather', ['city' => 'Munich']); + + $this->assertStringContainsString('Munich', self::text($result)); + + $client->disconnect(); + } + + #[TestDox('completes a multi round-trip call by answering the server itself')] + public function testMultiRoundTripIsTransparentToTheCaller(): void + { + $client = $this->connect(elicitation: true); + + // One call from here; two on the wire. The server asks for a name, the + // client answers from its own handler and retries with the sealed + // `requestState`, and the caller only ever sees the finished result. + $result = $client->callTool('greet', []); + + $this->assertStringContainsString('Hello, Ada!', self::text($result)); + + $client->disconnect(); + } + + #[TestDox('a capability the client never declared is refused up front, and costs only that call')] + public function testUndeclaredCapabilityIsRefused(): void + { + // No elicitation capability, and the envelope says so on every request, + // so the server refuses rather than asking for something that could + // never be answered. + $client = $this->connect(); + + try { + $client->callTool('greet', []); + $this->fail('Expected the server to refuse the undeclared capability.'); + } catch (\Throwable $e) { + $this->assertStringContainsString('did not declare it can provide', $e->getMessage()); + } + + // The connection is stateless, so a failed call costs nothing. + $this->assertStringContainsString('Munich', self::text($client->callTool('get_weather', ['city' => 'Munich']))); + + $client->disconnect(); + } + + private static function text(CallToolResult $result): string + { + $first = $result->content[0] ?? null; + + self::assertInstanceOf(TextContent::class, $first); + + return $first->text; + } + + private function connect(bool $elicitation = false): Client + { + $builder = Client::builder() + ->setClientInfo('stateless-integration-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setRequestTimeout(10); + + if ($elicitation) { + $builder->setCapabilities(new ClientCapabilities(elicitation: true)); + $builder->addRequestHandler(new class implements RequestHandlerInterface { + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, ['name' => 'Ada'])); + } + }); + } + + $client = $builder->build(); + $client->connect(new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port))); + + return $client; + } +} diff --git a/tests/Integration/StatelessLifecycleTest.php b/tests/Integration/StatelessLifecycleTest.php new file mode 100644 index 0000000..1983d23 --- /dev/null +++ b/tests/Integration/StatelessLifecycleTest.php @@ -0,0 +1,297 @@ +port = 8600 + (getmypid() % 300); + + $this->server = new Process(['php', '-S', \sprintf('127.0.0.1:%d', $this->port), self::SERVER]); + $this->server->start(); + + $deadline = microtime(true) + 5; + while (microtime(true) < $deadline) { + if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) { + return; + } + + usleep(50_000); + } + + $this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput())); + } + + protected function tearDown(): void + { + $this->server->stop(); + } + + #[TestDox('server/discover reports the versions, capabilities, identity and caching hints')] + public function testDiscover(): void + { + $result = $this->call('server/discover', [])['result']; + + $this->assertSame([ProtocolVersion::V2026_07_28->value], $result['supportedVersions']); + $this->assertSame('complete', $result['resultType']); + $this->assertSame('Stateless Lifecycle Demo', $result['_meta']['io.modelcontextprotocol/serverInfo']['name']); + $this->assertSame(3_600_000, $result['ttlMs']); + $this->assertSame('public', $result['cacheScope']); + } + + #[TestDox('a tool call needs no handshake before it')] + public function testToolCallWithoutAHandshake(): void + { + $result = $this->call('tools/call', ['name' => 'get_weather', 'arguments' => ['city' => 'Munich']], name: 'get_weather')['result']; + + $this->assertStringContainsString('Munich', $result['content'][0]['text']); + $this->assertSame('complete', $result['resultType']); + } + + #[TestDox('initialize is gone, and says so')] + public function testInitializeIsRefused(): void + { + $answer = $this->call('initialize', []); + + $this->assertSame(-32601, $answer['error']['code']); + } + + #[TestDox('a request whose header contradicts its body is refused with -32020')] + public function testHeaderMismatchIsRefused(): void + { + $answer = $this->call('tools/call', ['name' => 'get_weather', 'arguments' => []], name: 'something_else'); + + $this->assertSame(-32020, $answer['error']['code']); + } + + #[TestDox('an unsupported version comes back with the set to retry from')] + public function testUnsupportedVersion(): void + { + $answer = $this->call('tools/list', [], version: '1900-01-01'); + + $this->assertSame(-32022, $answer['error']['code']); + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['error']['data']['supported']); + } + + #[TestDox('a multi round-trip tool asks, then completes on the retry')] + public function testMultiRoundTrip(): void + { + $asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; + + $this->assertSame('input_required', $asked['resultType']); + $this->assertSame('elicitation/create', $asked['inputRequests']['who']['method']); + $this->assertNotEmpty($asked['requestState']); + + // An interim result is not cacheable and carries no hints. + $this->assertArrayNotHasKey('ttlMs', $asked); + + $done = $this->call('tools/call', [ + 'name' => 'greet', + 'arguments' => [], + 'requestState' => $asked['requestState'], + 'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['name' => 'Ada']]], + ], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; + + $this->assertSame('Hello, Ada!', $done['content'][0]['text']); + $this->assertSame('complete', $done['resultType']); + } + + #[TestDox('a tampered requestState is refused')] + public function testTamperedRequestStateIsRefused(): void + { + $asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; + + [$body] = explode('.', $asked['requestState']); + + $answer = $this->call('tools/call', [ + 'name' => 'greet', + 'arguments' => [], + 'requestState' => $body.'.'.strtr(base64_encode('forged'), '+/', '-_'), + 'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['name' => 'Mallory']]], + ], name: 'greet', capabilities: ['elicitation' => new \stdClass()]); + + $this->assertSame(-32602, $answer['error']['code']); + } + + #[TestDox('asking a client that declared no elicitation is refused with -32021')] + public function testUndeclaredCapabilityIsRefused(): void + { + $answer = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet'); + + $this->assertSame(-32021, $answer['error']['code']); + $this->assertArrayHasKey('elicitation', $answer['error']['data']['requiredCapabilities']); + } + + #[TestDox('progress and log notifications arrive on the response stream, before the response')] + public function testResponseStreamCarriesNotifications(): void + { + $frames = $this->stream('tools/call', ['name' => 'reindex', 'arguments' => ['steps' => 2]], name: 'reindex', meta: [ + 'progressToken' => 'p1', + 'io.modelcontextprotocol/logLevel' => 'info', + ]); + + $methods = array_map(static fn (array $frame): string => $frame['method'] ?? 'response', $frames); + + $this->assertSame([ + 'notifications/message', + 'notifications/progress', + 'notifications/message', + 'notifications/progress', + 'response', + ], $methods); + + $this->assertSame('p1', $frames[1]['params']['progressToken']); + $this->assertSame('Reindexed 2 shards.', $frames[4]['result']['content'][0]['text']); + } + + #[TestDox('a request naming no log level receives no log messages')] + public function testLoggingIsSilentWithoutALevel(): void + { + $frames = $this->stream('tools/call', ['name' => 'reindex', 'arguments' => ['steps' => 2]], name: 'reindex', meta: [ + 'progressToken' => 'p1', + ]); + + $methods = array_map(static fn (array $frame): string => $frame['method'] ?? 'response', $frames); + + $this->assertNotContains('notifications/message', $methods); + $this->assertContains('notifications/progress', $methods); + } + + /** + * @param array $params + * @param array $capabilities + * + * @return array + */ + private function call(string $method, array $params, ?string $name = null, ?string $version = null, array $capabilities = []): array + { + $body = $this->body($method, $params, $version, $capabilities); + + $context = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $this->headers($method, $name, $version)), + 'content' => $body, + 'ignore_errors' => true, + 'timeout' => 10, + ]]); + + $raw = file_get_contents($this->url(), false, $context); + + $this->assertIsString($raw, 'no response from the example server'); + + return json_decode($raw, true, flags: \JSON_THROW_ON_ERROR); + } + + /** + * @param array $params + * @param array $meta + * + * @return list> + */ + private function stream(string $method, array $params, ?string $name = null, array $meta = []): array + { + $context = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $this->headers($method, $name, null)), + 'content' => $this->body($method, $params, null, [], $meta), + 'ignore_errors' => true, + 'timeout' => 10, + ]]); + + $handle = fopen($this->url(), 'r', false, $context); + $this->assertIsResource($handle); + + $frames = []; + while (false !== $line = fgets($handle)) { + $line = trim($line); + + // SSE comments are keep-alives and carry no event data. + if ('' === $line || str_starts_with($line, ':')) { + continue; + } + + if (str_starts_with($line, 'data: ')) { + $frames[] = json_decode(substr($line, 6), true, flags: \JSON_THROW_ON_ERROR); + } + } + + fclose($handle); + + return $frames; + } + + /** + * @param array $params + * @param array $capabilities + * @param array $meta + */ + private function body(string $method, array $params, ?string $version, array $capabilities = [], array $meta = []): string + { + $params['_meta'] = [ + 'io.modelcontextprotocol/protocolVersion' => $version ?? ProtocolVersion::V2026_07_28->value, + 'io.modelcontextprotocol/clientCapabilities' => (object) $capabilities, + 'io.modelcontextprotocol/clientInfo' => ['name' => 'integration-test', 'version' => '1.0.0'], + ...$meta, + ]; + + return json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => $method, + 'params' => $params, + ], \JSON_THROW_ON_ERROR); + } + + /** + * @return list + */ + private function headers(string $method, ?string $name, ?string $version): array + { + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json, text/event-stream', + 'MCP-Protocol-Version: '.($version ?? ProtocolVersion::V2026_07_28->value), + 'Mcp-Method: '.$method, + ]; + + if (null !== $name) { + $headers[] = 'Mcp-Name: '.$name; + } + + return $headers; + } + + private function url(): string + { + return \sprintf('http://127.0.0.1:%d/', $this->port); + } +} diff --git a/tests/Unit/Capability/Attribute/CompletionProviderTest.php b/tests/Unit/Capability/Attribute/CompletionProviderTest.php index 19a7875..4b5269d 100644 --- a/tests/Unit/Capability/Attribute/CompletionProviderTest.php +++ b/tests/Unit/Capability/Attribute/CompletionProviderTest.php @@ -26,6 +26,15 @@ class CompletionProviderTest extends TestCase $this->assertNull($attribute->enum); } + public function testCanBeConstructedWithAPositionalProviderClass(): void + { + $attribute = new CompletionProvider(CompletionProviderFixture::class); + + $this->assertSame(CompletionProviderFixture::class, $attribute->provider); + $this->assertNull($attribute->values); + $this->assertNull($attribute->enum); + } + public function testCanBeConstructedWithProviderInstance(): void { $instance = new CompletionProviderFixture(); diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php index b419726..628aaa6 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -15,6 +15,7 @@ use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Capability\Completion\ListCompletionProvider; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Registry\ToolReference; +use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\AlternativeFileNameToolHandler; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\DiscoverableToolHandler; @@ -38,7 +39,7 @@ class DiscoveryTest extends TestCase $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']); $tools = $discovery->getTools(); - $this->assertCount(5, $tools); + $this->assertCount(6, $tools); $this->assertArrayHasKey('greet_user', $tools); $this->assertEquals('greet_user', $tools['greet_user']->tool->name); @@ -63,7 +64,7 @@ class DiscoveryTest extends TestCase $this->assertArrayNotHasKey('static_tool_should_be_ignored', $tools); $resources = $discovery->getResources(); - $this->assertCount(3, $resources); + $this->assertCount(4, $resources); $this->assertArrayHasKey('app://info/version', $resources); $this->assertEquals('app_version', $resources['app://info/version']->resource->name); @@ -72,6 +73,14 @@ class DiscoveryTest extends TestCase $this->assertArrayHasKey('invokable://config/status', $resources); $this->assertEquals([InvocableResourceFixture::class, '__invoke'], $resources['invokable://config/status']->handler); + $this->assertArrayHasKey('ui://widget/clock', $resources); + $this->assertEquals(McpApps::MIME_TYPE, $resources['ui://widget/clock']->resource->mimeType); + $this->assertJsonStringEqualsJsonString('{"ui":{}}', json_encode($resources['ui://widget/clock']->resource->meta)); + $this->assertJsonStringEqualsJsonString( + '{"ui":{"resourceUri":"ui://widget/clock","visibility":["app"]}}', + json_encode($tools['show_clock']->tool->meta), + ); + $prompts = $discovery->getPrompts(); $this->assertCount(4, $prompts); @@ -85,7 +94,7 @@ class DiscoveryTest extends TestCase $this->assertEquals([InvocablePromptFixture::class, '__invoke'], $prompts['InvokableGreeterPrompt']->handler); $this->assertArrayHasKey('content_creator', $prompts); - $this->assertCount(3, $prompts['content_creator']->completionProviders); + $this->assertCount(4, $prompts['content_creator']->completionProviders); $templates = $discovery->getResourceTemplates(); $this->assertCount(4, $templates); @@ -156,7 +165,7 @@ class DiscoveryTest extends TestCase $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); $this->assertArrayHasKey('content_creator', $prompts = $discovery->getPrompts()); - $this->assertCount(3, $prompts['content_creator']->completionProviders); + $this->assertCount(4, $prompts['content_creator']->completionProviders); $typeProvider = $prompts['content_creator']->completionProviders['type']; $this->assertInstanceOf(ListCompletionProvider::class, $typeProvider); @@ -173,4 +182,14 @@ class DiscoveryTest extends TestCase $categoryProvider = $templates['content://{category}/{slug}']->completionProviders['category']; $this->assertInstanceOf(ListCompletionProvider::class, $categoryProvider); } + + public function testDiscoversPositionalCompletionProviderAsClassString(): void + { + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); + + $this->assertArrayHasKey('content_creator', $prompts = $discovery->getPrompts()); + + // Kept as a class-string so the container resolves it at the point of use. + $this->assertEquals(CompletionProviderFixture::class, $prompts['content_creator']->completionProviders['author']); + } } diff --git a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableUiHandler.php b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableUiHandler.php new file mode 100644 index 0000000..f5d03ed --- /dev/null +++ b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableUiHandler.php @@ -0,0 +1,53 @@ + new \stdClass()], + )] + public function widget(): TextResourceContents + { + return new TextResourceContents( + uri: 'ui://widget/clock', + mimeType: McpApps::MIME_TYPE, + text: '', + meta: ['ui' => new UiResourceContentMeta(csp: new UiResourceCsp(connectDomains: ['https://time.example.com']))], + ); + } + + #[McpTool( + name: 'show_clock', + meta: ['ui' => new UiToolMeta(resourceUri: 'ui://widget/clock', visibility: [ToolVisibility::App])], + )] + public function showClock(string $timezone): string + { + return $timezone; + } +} diff --git a/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php b/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php index 5ac67dd..9975f9a 100644 --- a/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php +++ b/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php @@ -14,13 +14,14 @@ namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures; use Mcp\Capability\Attribute\CompletionProvider; use Mcp\Capability\Attribute\McpPrompt; use Mcp\Capability\Attribute\McpResourceTemplate; +use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; use Mcp\Tests\Unit\Fixtures\Enum\PriorityEnum; use Mcp\Tests\Unit\Fixtures\Enum\StatusEnum; class EnhancedCompletionHandler { /** - * Create content with list and enum completion providers. + * Create content with list, enum and positional provider completion providers. */ #[McpPrompt(name: 'content_creator')] public function createContent( @@ -30,9 +31,11 @@ class EnhancedCompletionHandler string $status, #[CompletionProvider(enum: PriorityEnum::class)] string $priority, + #[CompletionProvider(CompletionProviderFixture::class)] + string $author, ): array { return [ - ['role' => 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority}"], + ['role' => 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority} for {$author}"], ]; } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index d695917..613369b 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -13,6 +13,8 @@ namespace Mcp\Tests\Unit\Capability\Discovery; use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; +use Mcp\Server\ClientGateway; +use Mcp\Server\RequestContext; use Mcp\Tests\Unit\Fixtures\Enum\BackedIntEnum; use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum; use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum; @@ -130,6 +132,30 @@ class SchemaGeneratorFixture // ===== PARAMETER-LEVEL SCHEMA SCENARIOS ===== + /** + * Parameter-level Schema with complete definition. + */ + public function parameterLevelCompleteDefinition( + #[Schema(definition: [ + 'type' => 'string', + 'description' => 'The region to query.', + 'x-mcp-header' => 'Region', + ])] + string $region = 'eu', + #[Schema(definition: ['type' => 'integer', 'minimum' => 1, 'maximum' => 10])] + int $limit = 3, + ): void { + } + + /** + * Parameter-level definition that reshapes the parameter's type. + */ + public function definitionReshapingTheParameter( + #[Schema(definition: ['type' => 'object', 'properties' => ['id' => ['type' => 'integer']]])] + string $payload = '{}', + ): void { + } + /** * Parameter-level Schema attributes only. */ @@ -226,6 +252,21 @@ class SchemaGeneratorFixture ): void { } + /** + * PHPStan/Psalm integer ranges should keep their base integer type. + * + * @param int<0, max> $offset Positive offset + * @param int $negative Negative value + * @param int<-5, 10> $bounded Bounded value + */ + public function integerRangeTypes( + #[Schema(minimum: 0)] + int $offset, + int $negative, + int $bounded, + ): void { + } + // ===== ENUM SCENARIOS ===== /** @@ -311,6 +352,27 @@ class SchemaGeneratorFixture // ===== VARIADIC SCENARIOS ===== + /** + * Variadic parameter with a complete definition. + */ + public function variadicCompleteDefinition( + #[Schema(definition: ['type' => 'array', 'description' => 'Tags to apply.', 'items' => ['type' => 'string']])] + string ...$tags, + ): void { + } + + /** + * Variadic parameter with both a docblock description and a complete definition; + * the definition's own description must win. + * + * @param string ...$tags Docblock description that must lose to the definition's own + */ + public function variadicCompleteDefinitionWithDocblockDescription( + #[Schema(definition: ['type' => 'array', 'description' => 'Tags to apply.', 'items' => ['type' => 'string']])] + string ...$tags, + ): void { + } + /** * Variadic parameter scenarios. * @@ -459,6 +521,13 @@ class SchemaGeneratorFixture { } + /** + * @param string $query The search query + */ + public function withInjectableParameters(string $query, ClientGateway $gateway, RequestContext $context, int $limit = 10): void + { + } + // ===== OUTPUT SCHEMA FIXTURES ===== #[McpTool( outputSchema: [ diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 0c223cb..32817f6 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -92,6 +92,58 @@ final class SchemaGeneratorTest extends TestCase ], $schema); } + public function testUsesCompleteSchemaDefinitionFromParameterLevelSchemaAttribute(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterLevelCompleteDefinition'); + $schema = $this->schemaGenerator->generate($method); + + $this->assertEquals([ + 'type' => 'string', + 'description' => 'The region to query.', + 'x-mcp-header' => 'Region', + 'default' => 'eu', + ], $schema['properties']['region']); + + $this->assertEquals([ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 10, + 'default' => 3, + ], $schema['properties']['limit']); + + $this->assertArrayNotHasKey('required', $schema); + } + + public function testUsesCompleteSchemaDefinitionFromVariadicParameter(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicCompleteDefinition'); + $schema = $this->schemaGenerator->generate($method); + + // The array type is forced: a variadic always arrives as one. + $this->assertEquals([ + 'type' => 'array', + 'description' => 'Tags to apply.', + 'items' => ['type' => 'string'], + ], $schema['properties']['tags']); + } + + public function testDefinitionDescriptionWinsOverDocblockOnVariadicParameter(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicCompleteDefinitionWithDocblockDescription'); + $schema = $this->schemaGenerator->generate($method); + + $this->assertSame('Tags to apply.', $schema['properties']['tags']['description']); + } + + public function testKeepsTheSignatureDefaultWhenADefinitionReshapesTheParameter(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'definitionReshapingTheParameter'); + $schema = $this->schemaGenerator->generate($method); + + $this->assertSame('{}', $schema['properties']['payload']['default']); + $this->assertSame('object', $schema['properties']['payload']['type']); + } + public function testGeneratesSchemaFromMethodLevelSchemaAttributeWithProperties(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelWithProperties'); @@ -190,6 +242,16 @@ final class SchemaGeneratorTest extends TestCase $this->assertEqualsCanonicalizing(['username', 'priority'], $schema['required']); } + public function testMapsPhpStanAndPsalmIntegerRangesToBaseIntegerType(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'integerRangeTypes'); + $schema = $this->schemaGenerator->generate($method); + + $this->assertEquals(['type' => 'integer', 'description' => 'Positive offset', 'minimum' => 0], $schema['properties']['offset']); + $this->assertEquals(['type' => 'integer', 'description' => 'Negative value'], $schema['properties']['negative']); + $this->assertEquals(['type' => 'integer', 'description' => 'Bounded value'], $schema['properties']['bounded']); + } + public function testGeneratesCorrectSchemaForEnumParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); @@ -351,6 +413,17 @@ final class SchemaGeneratorTest extends TestCase $this->assertEquals(['inferredParam'], $schema['required']); } + public function testExcludesInjectableParameterTypesFromSchema(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'withInjectableParameters'); + $schema = $this->schemaGenerator->generate($method); + $this->assertArrayNotHasKey('gateway', $schema['properties']); + $this->assertArrayNotHasKey('context', $schema['properties']); + $this->assertEquals(['type' => 'string', 'description' => 'The search query'], $schema['properties']['query']); + $this->assertEquals(['type' => 'integer', 'default' => 10], $schema['properties']['limit']); + $this->assertEquals(['query'], $schema['required']); + } + public static function methodsWithForbiddenParameter(): array { return [ diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php new file mode 100644 index 0000000..9a8a6cf --- /dev/null +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php @@ -0,0 +1,130 @@ +}> + */ + public static function unions(): iterable + { + yield 'not a union' => ['string', ['string']]; + yield 'simple union' => ['int|string', ['int', 'string']]; + yield 'three branches' => ['int|string|bool', ['int', 'string', 'bool']]; + yield 'array branch alongside a scalar' => ['string[]|int', ['string[]', 'int']]; + yield 'generic branch alongside a scalar' => ['array|int', ['array', 'int']]; + + // The `|` here parameterises the outer type; splitting it would turn + // one type into two that do not exist. + yield 'union inside a generic' => ['array', ['array']]; + yield 'union inside a shape' => ['array{a: int|string}', ['array{a: int|string}']]; + yield 'nested generics' => ['array>', ['array>']]; + yield 'shape beside a scalar' => ['array{a: int|string}|null', ['array{a: int|string}', 'null']]; + + yield 'whitespace is trimmed' => ['int | string', ['int', 'string']]; + yield 'empty branches are dropped' => ['int||string', ['int', 'string']]; + } + + /** + * @param list $expected + */ + #[DataProvider('unions')] + #[TestDox('a type string splits on its top-level union only')] + public function testSplitUnion(string $type, array $expected): void + { + $this->assertSame($expected, SchemaGenerator::splitUnion($type)); + } + + /** + * @return iterable}> + */ + public static function mappedTypes(): iterable + { + yield 'scalar union' => ['int|string', ['integer', 'string']]; + + // The regression: the `[]` check ran before the union split, so this + // came back as ['array'] and the `int` branch vanished. + yield 'array beside a scalar keeps both' => ['string[]|int', ['array', 'integer']]; + yield 'scalar before an array keeps both' => ['int|string[]', ['integer', 'array']]; + yield 'generic beside a scalar keeps both' => ['array|bool', ['array', 'boolean']]; + + yield 'a generic union is still one array' => ['array', ['array']]; + yield 'a shape is still one object' => ['array{a: int|string}', ['object']]; + } + + /** + * @param list $expected + */ + #[DataProvider('mappedTypes')] + #[TestDox('a union maps to every JSON Schema type its branches need')] + public function testMappedTypes(string $type, array $expected): void + { + $generator = new SchemaGenerator(new DocBlockParser()); + + $map = new \ReflectionMethod($generator, 'mapPhpTypeToJsonSchemaType'); + + $this->assertSame($expected, $map->invoke($generator, $type)); + } + + #[TestDox('a generated schema keeps both branches of an array-or-scalar union')] + public function testGeneratedUnionSchemaKeepsBothBranches(): void + { + $schema = (new SchemaGenerator(new DocBlockParser())) + ->generate(new \ReflectionMethod(UnionFixture::class, 'handle')); + + // `items` constrains the instance only when it *is* an array, so it + // coexists with the scalar branch instead of excluding it. + $this->assertSame(['array', 'integer'], $schema['properties']['mixedish']['type']); + // The array branch keeps its own element-type constraint rather than + // losing it to the union as a whole (`items` would otherwise be `{}`). + $this->assertSame(['type' => 'string'], $schema['properties']['mixedish']['items']); + + $this->assertSame(['integer', 'string'], $schema['properties']['scalars']['type']); + $this->assertSame('array', $schema['properties']['arrayOnly']['type']); + $this->assertSame(['array', 'null'], $schema['properties']['nullableArray']['type']); + + // Two array branches with different element types can't both be + // enforced without rejecting a value that is valid under one of + // them, so neither constraint is applied (falls back to the "matches + // anything" empty schema `ensureArrayItems()` defaults every array to). + $this->assertSame('array', $schema['properties']['disagreeingArrays']['type']); + $this->assertEquals(new \stdClass(), $schema['properties']['disagreeingArrays']['items']); + } + + #[TestDox('a value from either branch validates against the generated schema')] + public function testBothBranchesValidate(): void + { + $schema = (new SchemaGenerator(new DocBlockParser())) + ->generate(new \ReflectionMethod(UnionFixture::class, 'handle')); + + $validator = new SchemaValidator(); + $base = ['scalars' => 1, 'arrayOnly' => ['a'], 'nullableArray' => null, 'disagreeingArrays' => []]; + + $this->assertSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => ['a', 'b']], $schema)); + $this->assertSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => 42], $schema)); + + // A float is in neither branch, so it is still refused. + $this->assertNotSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => 1.5], $schema)); + + // Neither element type is enforced, so both branches' values pass. + $this->assertSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => 42, 'disagreeingArrays' => ['a', 'b']], $schema)); + $this->assertSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => 42, 'disagreeingArrays' => [1, 2]], $schema)); + } +} diff --git a/tests/Unit/Capability/Discovery/UnionFixture.php b/tests/Unit/Capability/Discovery/UnionFixture.php new file mode 100644 index 0000000..b6d400d --- /dev/null +++ b/tests/Unit/Capability/Discovery/UnionFixture.php @@ -0,0 +1,27 @@ +assertSame($message, $result[0]); } + public function testFormatPromptMessageWithResourceLinkContent(): void + { + $message = new PromptMessage(Role::User, new ResourceLink('file:///a.png', 'a.png')); + $result = (new PromptResultFormatter())->format($message); + $this->assertCount(1, $result); + $this->assertSame($message, $result[0]); + } + + public function testFormatRoleContentArrayWithResourceLinkContent(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => ['type' => 'resource_link', 'uri' => 'file:///a.png', 'name' => 'a.png'], + ], + ]); + $this->assertCount(1, $result); + $this->assertSame(Role::User, $result[0]->role); + $this->assertInstanceOf(ResourceLink::class, $result[0]->content); + $this->assertSame('file:///a.png', $result[0]->content->uri); + $this->assertSame('a.png', $result[0]->content->name); + } + + public function testFormatTypedResourceLinkContentPreservesOptionalFields(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'resource_link', + 'uri' => 'file:///a.png', + 'name' => 'a.png', + 'title' => 'A picture', + 'description' => 'The first picture', + 'mimeType' => 'image/png', + 'size' => 1024, + 'annotations' => ['audience' => ['user'], 'priority' => 0.5], + '_meta' => ['origin' => 'test'], + ], + ], + ]); + + $content = $result[0]->content; + $this->assertInstanceOf(ResourceLink::class, $content); + $this->assertSame('file:///a.png', $content->uri); + $this->assertSame('a.png', $content->name); + $this->assertSame('A picture', $content->title); + $this->assertSame('The first picture', $content->description); + $this->assertSame('image/png', $content->mimeType); + $this->assertSame(1024, $content->size); + $this->assertNotNull($content->annotations); + $this->assertSame([Role::User], $content->annotations->audience); + $this->assertSame(0.5, $content->annotations->priority); + $this->assertSame(['origin' => 'test'], $content->meta); + } + public function testFormatUserAssistantShorthand(): void { $result = (new PromptResultFormatter())->format([ @@ -46,4 +107,142 @@ class PromptResultFormatterTest extends TestCase $this->assertCount(1, $result); $this->assertSame(Role::User, $result[0]->role); } + + public function testFormatTypedTextContentPreservesAnnotations(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'text', + 'text' => 'Hello', + 'annotations' => ['audience' => ['user'], 'priority' => 0.5], + ], + ], + ]); + + $content = $result[0]->content; + $this->assertInstanceOf(TextContent::class, $content); + $this->assertSame('Hello', $content->text); + $this->assertNotNull($content->annotations); + $this->assertSame([Role::User], $content->annotations->audience); + $this->assertSame(0.5, $content->annotations->priority); + } + + public function testFormatTypedImageContentPreservesAnnotations(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'image', + 'data' => base64_encode('binary'), + 'mimeType' => 'image/png', + 'annotations' => ['audience' => ['assistant']], + ], + ], + ]); + + $content = $result[0]->content; + $this->assertInstanceOf(ImageContent::class, $content); + $this->assertSame('image/png', $content->mimeType); + $this->assertNotNull($content->annotations); + $this->assertSame([Role::Assistant], $content->annotations->audience); + } + + public function testFormatTypedAudioContentPreservesAnnotations(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'audio', + 'data' => base64_encode('binary'), + 'mimeType' => 'audio/mpeg', + 'annotations' => ['priority' => 1.0], + ], + ], + ]); + + $content = $result[0]->content; + $this->assertInstanceOf(AudioContent::class, $content); + $this->assertNotNull($content->annotations); + $this->assertSame(1.0, $content->annotations->priority); + } + + public function testFormatTypedResourceContentPreservesOptionalFields(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'resource', + 'resource' => [ + 'uri' => 'file://data.json', + 'mimeType' => 'application/json', + 'text' => '{"key": "value"}', + '_meta' => ['origin' => 'test'], + ], + 'annotations' => ['audience' => ['user']], + ], + ], + ]); + + $content = $result[0]->content; + $this->assertInstanceOf(EmbeddedResource::class, $content); + $this->assertSame('application/json', $content->resource->mimeType); + $this->assertSame(['origin' => 'test'], $content->resource->meta); + $this->assertNotNull($content->annotations); + $this->assertSame([Role::User], $content->annotations->audience); + } + + public function testFormatTypedTextResourceContentDefaultsMimeType(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'resource', + 'resource' => ['uri' => 'file://a.txt', 'text' => 'plain'], + ], + ], + ]); + + $this->assertSame('text/plain', $result[0]->content->resource->mimeType); + } + + public function testFormatTypedBlobResourceContentDefaultsMimeType(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'resource', + 'resource' => ['uri' => 'file://a.bin', 'blob' => base64_encode('binary')], + ], + ], + ]); + + $this->assertSame('application/octet-stream', $result[0]->content->resource->mimeType); + } + + public function testFormatTypedContentRejectsInvalidDataWithIndexContext(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Invalid 'text' content at index 0"); + + (new PromptResultFormatter())->format([ + ['role' => 'user', 'content' => ['type' => 'text']], + ]); + } + + public function testFormatTypedContentRejectsUnknownType(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Invalid content type 'video' at index 0."); + + (new PromptResultFormatter())->format([ + ['role' => 'user', 'content' => ['type' => 'video']], + ]); + } } diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php index dadca9f..2952c81 100644 --- a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -14,6 +14,7 @@ namespace Mcp\Tests\Unit\Capability\Registry; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\RegistryException; use Mcp\Server\ClientGateway; use Mcp\Server\Handler\ResourceHandlerInterface; use Mcp\Server\Handler\ToolHandlerInterface; @@ -108,6 +109,32 @@ final class ReferenceHandlerTest extends TestCase $this->assertInstanceOf(ClientGateway::class, $resourceHandler->receivedGateway); } + public function testHandleInjectsClientGatewayIntoReflectedHandler(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $handler = new class { + public ?ClientGateway $receivedGateway = null; + + public function search(string $query, ClientGateway $gateway): string + { + $this->receivedGateway = $gateway; + + return 'found: '.$query; + } + }; + + $result = (new ReferenceHandler())->handle(new ElementReference([$handler, 'search']), [ + '_session' => $session, + '_request' => new \stdClass(), + 'query' => 'foo', + ]); + + $this->assertSame('found: foo', $result); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + public function testHandleStillReflectsOrdinaryClosuresAndDoesNotInjectArgumentBag(): void { $session = $this->createMock(SessionInterface::class); @@ -141,4 +168,99 @@ final class ReferenceHandlerTest extends TestCase (new ReferenceHandler())->handle($reference, ['_session' => $session]); } + + public function testHandleCastsEachElementOfAnArrayArgumentForAVariadicParameter(): void + { + // SchemaGenerator advertises variadic parameters as a JSON "array" schema, + // so the array arrives here as a single named argument (not spread across + // multiple keys) and must be cast element-by-element to the variadic's type. + $closure = static fn (string $name, int ...$scores): string => \sprintf('%s:%d', $name, array_sum($scores)); + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $this->createMock(SessionInterface::class), + 'name' => 'total', + 'scores' => ['1', '2', '3'], + ]); + + $this->assertSame('total:6', $result); + } + + public function testHandleTreatsOmittedVariadicArgumentAsZeroElements(): void + { + $closure = static fn (string $name, int ...$scores): int => \count($scores); + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $this->createMock(SessionInterface::class), + 'name' => 'empty', + ]); + + $this->assertSame(0, $result); + } + + public function testHandleWrapsANonArrayVariadicArgumentAsASingleElement(): void + { + // Tool arguments follow the advertised "array" schema, but MCP prompt + // arguments are always Record per the protocol - a + // client sends a plain string for a variadic prompt parameter, and + // that must still work rather than being rejected. + $closure = static fn (string $name, string ...$topics): array => $topics; + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $this->createMock(SessionInterface::class), + 'name' => 'single', + 'topics' => 'php', + ]); + + $this->assertSame(['php'], $result); + } + + public function testHandleThrowsRegistryExceptionWhenVariadicArgumentIsAnObjectNotAList(): void + { + $closure = static fn (string $name, int ...$scores): int => \count($scores); + $reference = new ElementReference($closure); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Parameter `scores` must be a list of values, not an object.'); + + (new ReferenceHandler())->handle($reference, [ + '_session' => $this->createMock(SessionInterface::class), + 'name' => 'bad', + 'scores' => ['a' => 1, 'b' => 2], + ]); + } + + public function testHandleKeepsArgumentOrderWhenAnInjectableParameterPrecedesAVariadic(): void + { + // Injectable and regular parameters are assigned by position while variadic + // elements are appended, so the final order only holds because a variadic is + // always the last parameter. + $closure = static fn (ClientGateway $gateway, string $sep = ',', string ...$parts): string => implode($sep, $parts); + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $this->createMock(SessionInterface::class), + '_request' => new \stdClass(), + 'parts' => ['a', 'b'], + ]); + + $this->assertSame('a,b', $result); + } + + public function testHandleIncludesParameterNameAndIndexWhenAVariadicElementFailsToCast(): void + { + $closure = static fn (string $name, int ...$scores): int => \count($scores); + $reference = new ElementReference($closure); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Parameter `scores[1]`: Cannot cast value to integer. Expected integer representation.'); + + (new ReferenceHandler())->handle($reference, [ + '_session' => $this->createMock(SessionInterface::class), + 'name' => 'bad', + 'scores' => ['1', 'not-a-number', '3'], + ]); + } } diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index c8aa5a7..92782ab 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -19,15 +19,20 @@ use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; use Mcp\Capability\RegistryInterface; +use Mcp\Event\ToolListChangedEvent; use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; +use Mcp\Schema\Content\ResourceLink; +use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Prompt; use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; class RegistryTest extends TestCase @@ -494,46 +499,153 @@ class RegistryTest extends TestCase $this->assertEquals(['success' => true, 'message' => 'done'], $toolRef->extractStructuredContent(['success' => true, 'message' => 'done'])); } - public function testExtractStructuredContentReturnsArrayDirectlyForArrayOutputSchema(): void + /** + * @dataProvider provideHandshakeVersions + */ + public function testExtractStructuredContentDropsListResultsBeforeSep2106(?ProtocolVersion $version): void { - // Arrange + // Up to 2025-11-25 a PHP list serializes to something `structuredContent` + // does not allow — a JSON array — and `Tool::fromArray()` enforces the + // matching rule by rejecting any outputSchema whose type is not "object". $outputSchema = [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'foo' => [ - 'type' => 'string', - 'description' => 'A static value', - ], - ], - 'required' => ['foo'], + 'type' => 'object', + 'properties' => [ + 'foo' => ['type' => 'string'], ], + 'required' => ['foo'], ]; $tool = $this->createValidTool('list_static_data', $outputSchema); $toolReturnValue = [ ['foo' => 'bar'], ['foo' => 'bar'], - ['foo' => 'bar'], - ['foo' => 'bar'], ]; $this->registry->registerTool($tool, static fn () => $toolReturnValue); - // Act $toolRef = $this->registry->getTool('list_static_data'); - $structuredContent = $toolRef->extractStructuredContent($toolReturnValue); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, $version)); + } - // Assert - $this->assertNotNull($structuredContent); - $this->assertCount(4, $structuredContent); - $this->assertEquals([ + /** + * The revision is optional, and omitting it has to keep the strict rule: it is + * what every revision reachable through the `initialize` handshake requires. + * + * @return iterable + */ + public static function provideHandshakeVersions(): iterable + { + yield 'unspecified' => [null]; + + foreach (ProtocolVersion::handshakeVersions() as $version) { + yield $version->value => [$version]; + } + } + + public function testExtractStructuredContentKeepsListResultsFromSep2106On(): void + { + // SEP-2106 widened `structuredContent` to any JSON value conforming to + // `outputSchema`, and `outputSchema` to any JSON Schema 2020-12 — the spec's + // own example of a legal result is a list of records like this one. + $outputSchema = [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => ['foo' => ['type' => 'string']], + ], + ]; + + $tool = $this->createValidTool('list_static_data', $outputSchema); + $toolReturnValue = [ ['foo' => 'bar'], - ['foo' => 'bar'], - ['foo' => 'bar'], - ['foo' => 'bar'], - ], $structuredContent); + ['foo' => 'baz'], + ]; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('list_static_data'); + $this->assertSame($toolReturnValue, $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); + } + + public function testExtractStructuredContentDropsListOfScalarsBeforeSep2106(): void + { + $tool = $this->createValidTool('list_ids', null); + $toolReturnValue = ['101', '102', '103']; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('list_ids'); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); + $this->assertSame($toolReturnValue, $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); + } + + public function testExtractStructuredContentEncodesObjectResults(): void + { + $tool = $this->createValidTool('describe_thing', null); + $toolReturnValue = new \stdClass(); + $toolReturnValue->id = 1; + $toolReturnValue->label = 'thing'; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('describe_thing'); + $this->assertSame(['id' => 1, 'label' => 'thing'], $toolRef->extractStructuredContent($toolReturnValue)); + } + + public function testExtractStructuredContentAppliesTheListRuleToObjectResultsToo(): void + { + // `JsonSerializable` can hand back a list just as a raw array result can, + // and it is no more — and no less — valid for having come from an object. + $tool = $this->createValidTool('list_things', null); + $toolReturnValue = new class implements \JsonSerializable { + public function jsonSerialize(): array + { + return [['id' => 1], ['id' => 2]]; + } + }; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('list_things'); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); + $this->assertSame([['id' => 1], ['id' => 2]], $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); + } + + public function testExtractStructuredContentReturnsNullForObjectsSerializingToAScalar(): void + { + // SEP-2106 allows a scalar `structuredContent`, but `CallToolResult` types + // the field as `?array` and cannot carry one — so it is dropped in every + // revision until that type widens. + $tool = $this->createValidTool('count_things', null); + $toolReturnValue = new class implements \JsonSerializable { + public function jsonSerialize(): int + { + return 42; + } + }; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('count_things'); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); + } + + public function testExtractStructuredContentReturnsNullForArrayOfContentItems(): void + { + // Unlike the list rule, this one is revision-independent: the items are + // already carried in the result's `content`. + $tool = $this->createValidTool('lookup_thing', null); + $toolReturnValue = [ + new TextContent('Found it.'), + new ResourceLink(uri: 'thing://1', name: 'thing_1'), + ]; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('lookup_thing'); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); } public function testConfiguredLoaderIsNotRunUntilFirstRead(): void @@ -612,6 +724,118 @@ class RegistryTest extends TestCase $this->assertArrayHasKey('loaded', $registry->getTools()->references); } + public function testListChangedEventsAreSuppressedDuringTheDeferredLoad(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects($this->never())->method('dispatch'); + + $loader = new class($this->createValidTool('loaded'), $this->createValidResource('file:///loaded'), $this->createValidResourceTemplate('file:///loaded/{id}'), $this->createValidPrompt('loaded_prompt')) implements LoaderInterface { + public function __construct( + private readonly Tool $tool, + private readonly ResourceDefinition $resource, + private readonly ResourceTemplate $template, + private readonly Prompt $prompt, + ) { + } + + public function load(RegistryInterface $registry): void + { + $registry->registerTool($this->tool, 'handler'); + $registry->registerResource($this->resource, 'handler'); + $registry->registerResourceTemplate($this->template, 'handler'); + $registry->registerPrompt($this->prompt, 'handler'); + } + }; + + $registry = new Registry($eventDispatcher, $this->logger, loader: $loader); + + $this->assertTrue($registry->hasTools()); + $this->assertTrue($registry->hasPrompts()); + } + + public function testListChangedEventsAreSuppressedWhenTheLoaderIsSuppliedFromOutside(): void + { + // As quiet as the deferred load above: these are the initial contents, not a change. + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects($this->never())->method('dispatch'); + + $registry = new Registry($eventDispatcher, $this->logger); + $registry->loadFrom($this->toolLoader($this->createValidTool('loaded'))); + + $this->assertTrue($registry->hasTool('loaded')); + } + + public function testARuntimeRegistrationAfterAnExternalLoadIsStillDispatched(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ToolListChangedEvent::class)) + ->willReturnArgument(0); + + $registry = new Registry($eventDispatcher, $this->logger); + $registry->loadFrom($this->toolLoader($this->createValidTool('loaded'))); + + $registry->registerTool($this->createValidTool('runtime'), 'handler'); + } + + public function testAnExternalLoadDoesNotConsumeTheRegistrysOwnLoader(): void + { + $registry = new Registry($this->createMock(EventDispatcherInterface::class), $this->logger, loader: $this->toolLoader($this->createValidTool('own'))); + $registry->loadFrom($this->toolLoader($this->createValidTool('external'))); + + $this->assertTrue($registry->hasTool('external')); + $this->assertTrue($registry->hasTool('own')); + } + + public function testAnExternalLoadCanRunMoreThanOnce(): void + { + $registry = new Registry($this->createMock(EventDispatcherInterface::class), $this->logger); + $registry->loadFrom($this->toolLoader($this->createValidTool('first'))); + $registry->loadFrom($this->toolLoader($this->createValidTool('second'))); + + $this->assertTrue($registry->hasTool('first')); + $this->assertTrue($registry->hasTool('second')); + } + + public function testAFailedExternalLoadLeavesTheRegistryRetryable(): void + { + $registry = new Registry($this->createMock(EventDispatcherInterface::class), $this->logger); + $failing = new class implements LoaderInterface { + public function load(RegistryInterface $registry): void + { + // Reads during its own run, as discovery's identity check does. + $registry->hasTool('anything'); + + throw new \RuntimeException('data source not ready'); + } + }; + + foreach ([1, 2] as $attempt) { + try { + $registry->loadFrom($failing); + $this->fail('The loader was expected to fail.'); + } catch (\RuntimeException $e) { + $this->assertSame('data source not ready', $e->getMessage()); + } + } + } + + public function testListChangedEventsAreStillDispatchedForRuntimeRegistrations(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ToolListChangedEvent::class)) + ->willReturnArgument(0); + + $loader = $this->toolLoader($this->createValidTool('loaded')); + $registry = new Registry($eventDispatcher, $this->logger, loader: $loader); + $registry->load(); + + $registry->registerTool($this->createValidTool('runtime'), 'handler'); + } + public function testLoadRunsTheConfiguredLoaderEagerly(): void { $loader = $this->createMock(LoaderInterface::class); @@ -643,6 +867,46 @@ class RegistryTest extends TestCase }; } + public function testExtractStructuredContentKeepsAScalarFromSep2106On(): void + { + $tool = $this->createValidTool('test_tool', ['type' => 'string']); + $this->registry->registerTool($tool, static fn () => 'sunny'); + + $toolRef = $this->registry->getTool('test_tool'); + + $this->assertSame('sunny', $toolRef->extractStructuredContent('sunny', ProtocolVersion::V2026_07_28)); + $this->assertNull($toolRef->extractStructuredContent('sunny', ProtocolVersion::V2025_11_25)); + } + + public function testExtractStructuredContentDropsAScalarWithoutAnOutputSchema(): void + { + $tool = $this->createValidTool('test_tool', null); + $this->registry->registerTool($tool, static fn () => 'sunny'); + + $toolRef = $this->registry->getTool('test_tool'); + + // Without a declared schema the value is already carried in `content`; + // advertising it as structured too would duplicate it. + $this->assertNull($toolRef->extractStructuredContent('sunny', ProtocolVersion::V2026_07_28)); + } + + public function testExtractStructuredContentKeepsAJsonSerializableScalarFromSep2106On(): void + { + $tool = $this->createValidTool('test_tool', ['type' => 'number']); + $result = new class implements \JsonSerializable { + public function jsonSerialize(): float + { + return 22.5; + } + }; + $this->registry->registerTool($tool, static fn () => $result); + + $toolRef = $this->registry->getTool('test_tool'); + + $this->assertSame(22.5, $toolRef->extractStructuredContent($result, ProtocolVersion::V2026_07_28)); + $this->assertNull($toolRef->extractStructuredContent($result, ProtocolVersion::V2025_11_25)); + } + private function createValidTool(string $name, ?array $outputSchema = null): Tool { return new Tool( diff --git a/tests/Unit/Client/BuilderTest.php b/tests/Unit/Client/BuilderTest.php new file mode 100644 index 0000000..b462b9b --- /dev/null +++ b/tests/Unit/Client/BuilderTest.php @@ -0,0 +1,91 @@ +setClientInfo('my-client', '1.0.0', 'A test client', 'My Client') + ->build(); + + $clientInfo = $this->extractConfiguration($client)->clientInfo; + + $this->assertSame('my-client', $clientInfo->name); + $this->assertSame('1.0.0', $clientInfo->version); + $this->assertSame('A test client', $clientInfo->description); + $this->assertSame('My Client', $clientInfo->title); + } + + #[TestDox('setClientInfo() leaves the title absent when it is not given')] + public function testSetClientInfoTitleIsOptional(): void + { + $client = Client::builder() + ->setClientInfo('my-client', '1.0.0') + ->build(); + + $this->assertNull($this->extractConfiguration($client)->clientInfo->title); + } + + #[TestDox('enableExtension() announces the extension under capabilities.extensions')] + public function testEnableExtensionAdvertisesExtension(): void + { + $client = Client::builder() + ->enableExtension(new McpApps()) + ->build(); + + $capabilities = $this->extractConfiguration($client)->capabilities; + + $this->assertSame([McpApps::EXTENSION_ID => ['mimeTypes' => [McpApps::MIME_TYPE]]], $capabilities->extensions); + } + + #[TestDox('enableExtension() extensions are merged into capabilities set via setCapabilities()')] + public function testEnableExtensionMergesIntoCustomCapabilities(): void + { + $client = Client::builder() + ->setCapabilities(new ClientCapabilities(elicitation: true, extensions: ['io.example/other' => []])) + ->enableExtension(new McpApps()) + ->build(); + + $capabilities = $this->extractConfiguration($client)->capabilities; + + $this->assertTrue($capabilities->elicitation); + $this->assertSame(['io.example/other', McpApps::EXTENSION_ID], array_keys($capabilities->extensions ?? [])); + } + + #[TestDox('enableExtension() throws when the same extension is enabled twice')] + public function testEnableExtensionRejectsDuplicate(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(McpApps::EXTENSION_ID); + + Client::builder()->enableExtension(new McpApps(), new McpApps()); + } + + private function extractConfiguration(Client $client): Configuration + { + $config = (new \ReflectionClass($client))->getProperty('config')->getValue($client); + $this->assertInstanceOf(Configuration::class, $config); + + return $config; + } +} diff --git a/tests/Unit/Client/ClientTest.php b/tests/Unit/Client/ClientTest.php new file mode 100644 index 0000000..29d23ff --- /dev/null +++ b/tests/Unit/Client/ClientTest.php @@ -0,0 +1,79 @@ +createMock(TransportInterface::class); + $transport->method('send')->willReturnCallback(static function (string $data) use (&$sent): void { + $sent[] = $data; + }); + // Stand in for a completed initialize handshake, which the transport drives. + $transport->method('setState')->willReturnCallback(static function (ClientStateInterface $state): void { + $state->setInitialized(true); + }); + + $client = Client::builder() + ->setClientInfo('Roots Test', '1.0.0') + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->build(); + + $client->connect($transport); + $client->sendRootsListChanged(); + + $this->assertCount(1, $sent); + $decoded = json_decode($sent[0], true); + $this->assertSame('notifications/roots/list_changed', $decoded['method']); + } + + public function testSendRootsListChangedThrowsWhenCapabilityNotDeclared(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->never())->method('send'); + + $client = Client::builder() + ->setClientInfo('Roots Test', '1.0.0') + ->setCapabilities(new ClientCapabilities(roots: true)) + ->build(); + + $client->connect($transport); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('roots.listChanged'); + + $client->sendRootsListChanged(); + } + + public function testSendRootsListChangedThrowsWhenNotConnected(): void + { + $client = Client::builder() + ->setClientInfo('Roots Test', '1.0.0') + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->build(); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('Client is not connected.'); + + $client->sendRootsListChanged(); + } +} diff --git a/tests/Unit/Client/ConfigurationTest.php b/tests/Unit/Client/ConfigurationTest.php new file mode 100644 index 0000000..ae50a49 --- /dev/null +++ b/tests/Unit/Client/ConfigurationTest.php @@ -0,0 +1,92 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The initialization timeout must be a positive number of seconds, got %d.', $seconds)); + + $this->createConfiguration(initTimeout: $seconds); + } + + #[TestDox('a non-positive request timeout of $seconds seconds is rejected')] + #[DataProvider('provideNonPositiveTimeouts')] + public function testNonPositiveRequestTimeoutIsRejected(int $seconds): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The request timeout must be a positive number of seconds, got %d.', $seconds)); + + $this->createConfiguration(requestTimeout: $seconds); + } + + /** + * @return iterable + */ + public static function provideNonPositiveTimeouts(): iterable + { + yield 'zero' => [0]; + yield 'negative' => [-1]; + } + + #[TestDox('the builder rejects a non-positive initialization timeout')] + public function testBuilderRejectsNonPositiveInitTimeout(): void + { + $builder = Client::builder()->setInitTimeout(0); + + $this->expectException(InvalidArgumentException::class); + + $builder->build(); + } + + #[TestDox('the builder rejects a non-positive request timeout')] + public function testBuilderRejectsNonPositiveRequestTimeout(): void + { + $builder = Client::builder()->setRequestTimeout(-5); + + $this->expectException(InvalidArgumentException::class); + + $builder->build(); + } + + #[TestDox('positive timeouts are accepted')] + public function testPositiveTimeoutsAreAccepted(): void + { + $config = $this->createConfiguration(initTimeout: 1, requestTimeout: 1); + + $this->assertSame(1, $config->initTimeout); + $this->assertSame(1, $config->requestTimeout); + } + + private function createConfiguration(int $initTimeout = 30, int $requestTimeout = 120): Configuration + { + return new Configuration( + clientInfo: new Implementation('test-client', '1.0.0'), + capabilities: new ClientCapabilities(), + initTimeout: $initTimeout, + requestTimeout: $requestTimeout, + ); + } +} diff --git a/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php b/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php new file mode 100644 index 0000000..c101b16 --- /dev/null +++ b/tests/Unit/Client/Handler/Request/ListRootsRequestHandlerTest.php @@ -0,0 +1,105 @@ +createCallback(new ListRootsResult([]))); + + $this->assertTrue($handler->supports(new ListRootsRequest())); + } + + public function testDoesNotSupportOtherRequests(): void + { + $handler = new ListRootsRequestHandler($this->createCallback(new ListRootsResult([]))); + + $this->assertFalse($handler->supports(new PingRequest())); + } + + public function testHandleReturnsRootsFromCallback(): void + { + $result = new ListRootsResult([ + new Root('file:///home/user/project', 'project'), + new Root('file:///tmp'), + ]); + + $handler = new ListRootsRequestHandler($this->createCallback($result)); + + $request = (new ListRootsRequest())->withId('req-1'); + $response = $handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame('req-1', $response->getId()); + $this->assertSame($result, $response->result); + } + + public function testHandleReturnsErrorWhenCallbackThrows(): void + { + $handler = new ListRootsRequestHandler(new class implements RootsCallbackInterface { + public function __invoke(ListRootsRequest $request): ListRootsResult + { + throw new \RuntimeException('boom'); + } + }); + + $request = (new ListRootsRequest())->withId('req-2'); + $response = $handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame('req-2', $response->getId()); + $this->assertSame('Error while listing roots', $response->message); + } + + public function testHandleForwardsRootsExceptionMessage(): void + { + $handler = new ListRootsRequestHandler(new class implements RootsCallbackInterface { + public function __invoke(ListRootsRequest $request): ListRootsResult + { + throw new RootsException('permission denied'); + } + }); + + $request = (new ListRootsRequest())->withId('req-3'); + $response = $handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame('req-3', $response->getId()); + $this->assertSame('permission denied', $response->message); + } + + private function createCallback(ListRootsResult $result): RootsCallbackInterface + { + return new class($result) implements RootsCallbackInterface { + public function __construct(private readonly ListRootsResult $result) + { + } + + public function __invoke(ListRootsRequest $request): ListRootsResult + { + return $this->result; + } + }; + } +} diff --git a/tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php b/tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php new file mode 100644 index 0000000..923d669 --- /dev/null +++ b/tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php @@ -0,0 +1,114 @@ +callbackReturningText()); + + $request = $this->requestFor([ + new SamplingMessage(Role::User, new TextContent('Weather in Paris?')), + new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', ['city' => 'Paris'])), + new SamplingMessage(Role::User, new ToolResultContent('call-1', [new TextContent('18 C')])), + ]); + + $response = $handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($request->getId(), $response->id); + } + + /** + * The spec asks for -32602 on both of these, and the callback must not run. + * + * @return iterable + */ + public static function provideToolFlowViolations(): iterable + { + yield 'tool results mixed with other content' => [ + [ + new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])), + new SamplingMessage(Role::User, [ + new ToolResultContent('call-1', [new TextContent('18 C')]), + new TextContent('and also...'), + ]), + ], + 'Tool results mixed with other content.', + ]; + + yield 'tool result missing' => [ + [new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', []))], + 'Tool result missing in request.', + ]; + } + + /** + * @param SamplingMessage[] $messages + */ + #[DataProvider('provideToolFlowViolations')] + public function testHandleRejectsToolFlowViolationsWithInvalidParams(array $messages, string $expectedMessage): void + { + $callback = new class implements SamplingCallbackInterface { + public bool $invoked = false; + + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + $this->invoked = true; + + throw new \LogicException('The callback must not run for an invalid request.'); + } + }; + + $request = $this->requestFor($messages); + $response = (new SamplingRequestHandler($callback))->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame(Error::INVALID_PARAMS, $response->code); + $this->assertSame($expectedMessage, $response->message); + $this->assertSame($request->getId(), $response->id); + $this->assertFalse($callback->invoked); + } + + /** + * @param SamplingMessage[] $messages + */ + private function requestFor(array $messages): CreateSamplingMessageRequest + { + return (new CreateSamplingMessageRequest($messages, 150))->withId('sampling-1'); + } + + private function callbackReturningText(): SamplingCallbackInterface + { + return new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + return new CreateSamplingMessageResult(Role::Assistant, new TextContent('Paris is warm.'), 'test-model'); + } + }; + } +} diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php new file mode 100644 index 0000000..ee42125 --- /dev/null +++ b/tests/Unit/Client/ProtocolTest.php @@ -0,0 +1,437 @@ +value); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_06_18)); + + $protocol->initialize($config); + + $this->assertSame(ProtocolVersion::V2025_06_18->value, $transport->offeredVersion); + } + + #[TestDox('never sends "initialize" on a modern revision, which removed it')] + public function testModernRevisionSkipsTheHandshake(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $protocol->initialize($config); + + $this->assertNotContains('initialize', $transport->methods); + $this->assertNotContains('notifications/initialized', $transport->methods); + $this->assertSame(ProtocolVersion::V2026_07_28, $protocol->getState()->getProtocolVersion()); + $this->assertTrue($protocol->getState()->isInitialized()); + } + + #[TestDox('carries the revision, capabilities and client info on every modern request')] + public function testModernRequestsCarryTheEnvelope(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $protocol->initialize($config); + + $this->assertNotSame([], $transport->metas); + + foreach ($transport->metas as $meta) { + $this->assertSame(ProtocolVersion::V2026_07_28->value, $meta[RequestMeta::PROTOCOL_VERSION] ?? null); + $this->assertArrayHasKey(RequestMeta::CLIENT_CAPABILITIES, $meta); + $this->assertSame('client-app', $meta[RequestMeta::CLIENT_INFO]['name'] ?? null); + } + } + + #[TestDox('a server that refuses "server/discover" still leaves a usable connection')] + public function testDiscoveryFailureIsNotFatal(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value, refuseDiscovery: true); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $protocol->initialize($config); + + $this->assertTrue($protocol->getState()->isInitialized()); + } + + #[TestDox('refuses to continue when discovery shows the server has no modern revision')] + public function testDiscoveryWithoutAModernRevisionFails(): void + { + // Advertising only handshake revisions leaves nothing this connection + // can use: it has already skipped the handshake. + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('does not support any modern protocol revision'); + + $protocol->initialize($config); + } + + #[TestDox('accepts a counter-offer the SDK can speak and records it as negotiated')] + public function testAcceptsHandshakeCounterOffer(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2024_11_05->value); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_11_25)); + + $result = $protocol->initialize($config); + + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(ProtocolVersion::V2024_11_05, $protocol->getState()->getProtocolVersion()); + $this->assertTrue($protocol->getState()->isInitialized()); + } + + #[TestDox('fails the handshake when the server answers with a version the SDK cannot speak')] + #[DataProvider('provideUnusableCounterOffers')] + public function testRejectsUnusableCounterOffer(string $counterOffer): void + { + $transport = new RecordingTransport($counterOffer); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_11_25)); + + $result = $protocol->initialize($config); + + $this->assertInstanceOf(Error::class, $result); + $this->assertStringContainsString($counterOffer, $result->message); + $this->assertNull($protocol->getState()->getProtocolVersion()); + $this->assertFalse($protocol->getState()->isInitialized()); + } + + /** + * @return iterable + */ + public static function provideUnusableCounterOffers(): iterable + { + yield 'unknown revision' => ['2099-01-01']; + // The modern era has no `initialize`, so a server answering the handshake + // with one has produced a connection neither side can actually use. + yield 'modern revision' => [ProtocolVersion::V2026_07_28->value]; + } + + #[TestDox('logs and ignores an id-less error response instead of crashing on it')] + public function testIgnoresIdLessErrorResponse(): void + { + $protocol = new Protocol(logger: $logger = new CollectingLogger()); + + $protocol->processMessage(json_encode([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'error' => ['code' => -32700, 'message' => 'Parse error'], + ], \JSON_THROW_ON_ERROR)); + + $this->assertCount(1, $logger->warnings); + } + + #[TestDox('stores an error response under its id so the pending request can be correlated')] + public function testErrorResponseWithIdIsStoredForItsPendingRequest(): void + { + $protocol = new Protocol(); + + $protocol->processMessage('{"jsonrpc": "2.0", "id": 7, "error": {"code": -32601, "message": "Method not found"}}'); + + $response = $protocol->getState()->consumeResponse(7); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame(7, $response->getId()); + $this->assertSame(Error::METHOD_NOT_FOUND, $response->code); + } + + #[TestDox('reconnecting starts with a fresh tool catalog, not the previous server\'s verdicts')] + public function testReconnectResetsToolCatalog(): void + { + $protocol = new Protocol(); + $protocol->connect(new RecordingTransport(ProtocolVersion::V2025_11_25->value), $config = $this->createConfiguration(ProtocolVersion::V2025_11_25)); + + $protocol->getToolCatalog()->record([[ + 'name' => 'broken', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => ['data' => ['type' => 'object', 'x-mcp-header' => 'Data']], + ], + ]]); + + $this->assertTrue($protocol->getToolCatalog()->isRejected('broken')); + + $protocol->connect(new RecordingTransport(ProtocolVersion::V2025_11_25->value), $config); + + $this->assertFalse($protocol->getToolCatalog()->isRejected('broken'), 'the previous server\'s verdict must not survive a reconnect'); + } + + #[TestDox('an empty inputResponses map is retried as a JSON object, never an array')] + public function testEmptyInputResponsesEncodesAsJsonObject(): void + { + $transport = new InputRequiredRoundTripTransport(); + $protocol = new Protocol(); + $protocol->connect($transport, $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $result = $protocol->request(new PingRequest(), 5); + + $this->assertInstanceOf(Response::class, $result); + $this->assertStringContainsString('"inputResponses":{}', $transport->retryBody); + $this->assertStringNotContainsString('"inputResponses":[]', $transport->retryBody); + } + + private function createConfiguration(ProtocolVersion $protocolVersion): Configuration + { + return new Configuration( + clientInfo: new Implementation('client-app', '1.0.0'), + capabilities: new ClientCapabilities(), + protocolVersion: $protocolVersion, + ); + } +} + +/** + * Answers the first request with an empty `input_required` ask and the retry + * with success, capturing the retry's raw body so the test can inspect how + * `inputResponses` was actually encoded on the wire. + */ +final class InputRequiredRoundTripTransport implements TransportInterface +{ + public string $retryBody = ''; + + private int $calls = 0; + private ClientStateInterface $state; + + public function setState(ClientStateInterface $state): void + { + $this->state = $state; + } + + public function send(string $data): void + { + /** @var array{id: int} $message */ + $message = json_decode($data, true); + $id = $message['id']; + + if (0 === $this->calls++) { + $this->answer($id, ['resultType' => 'input_required', 'inputRequests' => []]); + + return; + } + + $this->retryBody = $data; + + $this->answer($id, ['resultType' => 'complete']); + } + + /** + * @param array $result + */ + private function answer(int $id, array $result): void + { + $this->state->storeResponse($id, [ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => $id, + 'result' => $result, + ]); + } + + public function connect(): void + { + } + + public function close(): void + { + } + + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + throw new LogicException('Not used in this test.'); + } + + public function onInitialize(callable $callback): void + { + } + + public function onMessage(callable $callback): void + { + } + + public function onError(callable $callback): void + { + } + + public function onClose(callable $callback): void + { + } +} + +/** + * Transport that answers inline, so a request resolves without a Fiber + * round-trip: `initialize` with a canned `protocolVersion`, and + * `server/discover` with a minimal modern-era answer. + */ +final class RecordingTransport implements TransportInterface +{ + public ?string $offeredVersion = null; + + /** @var list every method that reached the wire, in order */ + public array $methods = []; + + /** @var list> the `_meta` each request carried */ + public array $metas = []; + + private ClientStateInterface $state; + + public function __construct( + private readonly string $counterOffer, + private readonly bool $refuseDiscovery = false, + ) { + } + + public function send(string $data): void + { + /** @var array{id?: int|string, method?: string, params?: array} $message */ + $message = json_decode($data, true); + $method = $message['method'] ?? null; + + if (!\is_string($method)) { + return; + } + + $this->methods[] = $method; + $this->metas[] = $message['params']['_meta'] ?? []; + + if (!isset($message['id'])) { + return; + } + + if ('initialize' === $method) { + $this->offeredVersion = $message['params']['protocolVersion'] ?? null; + + $this->answer($message['id'], [ + 'protocolVersion' => $this->counterOffer, + 'capabilities' => [], + 'serverInfo' => ['name' => 'server', 'version' => '1.2.3'], + ]); + + return; + } + + if ('server/discover' !== $method) { + return; + } + + if ($this->refuseDiscovery) { + $this->state->storeResponse($message['id'], [ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => $message['id'], + 'error' => ['code' => -32601, 'message' => 'Method not found'], + ]); + + return; + } + + $this->answer($message['id'], [ + 'resultType' => 'complete', + 'supportedVersions' => [$this->counterOffer], + 'capabilities' => [], + 'serverInfo' => ['name' => 'server', 'version' => '1.2.3'], + ]); + } + + /** + * @param array $result + */ + private function answer(int|string $id, array $result): void + { + $this->state->storeResponse($id, [ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => $id, + 'result' => $result, + ]); + } + + public function setState(ClientStateInterface $state): void + { + $this->state = $state; + } + + public function connect(): void + { + } + + public function close(): void + { + } + + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + throw new LogicException('Not used in these tests.'); + } + + public function onInitialize(callable $callback): void + { + } + + public function onMessage(callable $callback): void + { + } + + public function onError(callable $callback): void + { + } + + public function onClose(callable $callback): void + { + } +} + +/** + * Logger that keeps the context of every warning, so a silent fallback can be + * told apart from one the caller was told about. + */ +final class CollectingLogger extends AbstractLogger +{ + /** @var list> */ + public array $warnings = []; + + /** + * @param string|\Stringable $message + * @param array $context + */ + public function log($level, $message, array $context = []): void + { + if (LogLevel::WARNING === $level) { + $this->warnings[] = $context; + } + } +} diff --git a/tests/Unit/Client/Stateless/HeaderFactoryTest.php b/tests/Unit/Client/Stateless/HeaderFactoryTest.php new file mode 100644 index 0000000..5f28431 --- /dev/null +++ b/tests/Unit/Client/Stateless/HeaderFactoryTest.php @@ -0,0 +1,163 @@ +headersFor(['method' => 'tools/list', 'params' => []]); + + $this->assertSame('2026-07-28', $headers['MCP-Protocol-Version']); + $this->assertSame('tools/list', $headers['Mcp-Method']); + $this->assertArrayNotHasKey('Mcp-Name', $headers); + } + + #[TestDox('names the subject of a request that addresses one')] + public function testNameHeader(): void + { + $payload = ['method' => 'tools/call', 'params' => ['name' => 'search', 'arguments' => []]]; + + $this->assertSame('search', $this->headersFor($payload)['Mcp-Name']); + $this->assertNull($this->validate($payload)); + } + + #[TestDox('wraps a subject that is not header-safe, and the server unwraps it')] + public function testUnsafeNameRoundTrips(): void + { + $payload = ['method' => 'resources/read', 'params' => ['uri' => 'file:///café.txt']]; + + $this->assertSame('=?base64?ZmlsZTovLy9jYWbDqS50eHQ=?=', $this->headersFor($payload)['Mcp-Name']); + $this->assertNull($this->validate($payload)); + } + + #[TestDox('mirrors annotated tool arguments the server can verify')] + public function testMirroredArguments(): void + { + $payload = [ + 'method' => 'tools/call', + 'params' => [ + 'name' => 'search', + 'arguments' => ['region' => ' padded ', 'priority' => 7], + ], + ]; + + $headers = $this->headersFor($payload); + + $this->assertSame('=?base64?IHBhZGRlZCA=?=', $headers['Mcp-Param-Region']); + $this->assertSame('7', $headers['Mcp-Param-Priority']); + $this->assertNull($this->validate($payload)); + } + + #[TestDox('a response carries the revision but nothing to mirror')] + public function testResponseCarriesOnlyTheVersion(): void + { + $headers = $this->headersFor(['id' => 1, 'result' => []]); + + $this->assertSame(['MCP-Protocol-Version' => '2026-07-28'], $headers); + } + + #[TestDox('mirrors trace context an application put in `_meta` onto its native headers')] + public function testTraceContextMirrorsOntoHeaders(): void + { + $payload = [ + 'method' => 'tools/list', + 'params' => [ + '_meta' => [ + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + 'tracestate' => 'acme=1', + ], + ], + ]; + + $headers = $this->headersFor($payload); + + $this->assertSame('00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', $headers['traceparent']); + $this->assertSame('acme=1', $headers['tracestate']); + } + + #[TestDox('a response with no method still mirrors its trace context')] + public function testTraceContextMirrorsOntoAResponse(): void + { + $payload = ['id' => 1, 'result' => [], 'params' => ['_meta' => ['traceparent' => 'tp-1']]]; + + $this->assertSame( + ['MCP-Protocol-Version' => '2026-07-28', 'traceparent' => 'tp-1'], + $this->headersFor($payload), + ); + } + + #[TestDox('an untraced request mirrors nothing')] + public function testNoTraceContextMirrorsNothing(): void + { + $headers = $this->headersFor(['method' => 'tools/list', 'params' => []]); + + $this->assertArrayNotHasKey('traceparent', $headers); + $this->assertArrayNotHasKey('tracestate', $headers); + } + + /** + * @param array $payload + * + * @return array + */ + private function headersFor(array $payload): array + { + return (new HeaderFactory($this->catalog()))->forMessage($payload, ProtocolVersion::V2026_07_28); + } + + /** + * @param array $payload + * + * @return string|null the server's reason to reject, or null when it agrees + */ + private function validate(array $payload): ?string + { + return (new StandardHeaderValidator())->validate( + $payload['method'], + $payload['params'] ?? null, + $this->headersFor($payload), + ); + } + + private function catalog(): ToolCatalog + { + $catalog = new ToolCatalog(); + $catalog->record([[ + 'name' => 'search', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'priority' => ['type' => 'integer', 'x-mcp-header' => 'Priority'], + ], + ], + ]]); + + return $catalog; + } +} diff --git a/tests/Unit/Client/Stateless/InputRequestResolverTest.php b/tests/Unit/Client/Stateless/InputRequestResolverTest.php new file mode 100644 index 0000000..536aa31 --- /dev/null +++ b/tests/Unit/Client/Stateless/InputRequestResolverTest.php @@ -0,0 +1,119 @@ +assertNull(InputRequestResolver::asked(['content' => []])); + $this->assertNull(InputRequestResolver::asked(['resultType' => 'complete'])); + } + + #[TestDox('an input_required result is an ask, even with an empty map')] + public function testInputRequiredIsAnAsk(): void + { + $this->assertSame([], InputRequestResolver::asked(['resultType' => 'input_required'])); + $this->assertSame( + ['confirm' => ['method' => 'elicitation/create']], + InputRequestResolver::asked([ + 'resultType' => 'input_required', + 'inputRequests' => ['confirm' => ['method' => 'elicitation/create']], + ]), + ); + } + + #[TestDox('answers each ask under the key the server asked it under')] + public function testAnswersAreKeyedByTheServersKey(): void + { + $responses = $this->resolver()->resolve([ + 'confirm' => self::elicitation('Confirm?'), + 'name' => self::elicitation('Your name?'), + ]); + + $this->assertSame(['confirm', 'name'], array_keys($responses)); + $this->assertSame('accept', $responses['confirm']['action']); + } + + #[TestDox('refuses an ask the client has no handler for')] + public function testUnhandledAskIsRefused(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Client does not handle "elicitation/create" requests.'); + + (new InputRequestResolver([]))->resolve(['confirm' => self::elicitation('Confirm?')]); + } + + #[TestDox('refuses an ask that is not a request a client can answer')] + public function testUnanswerableMethodIsRefused(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('which is not a request a client can answer'); + + $this->resolver()->resolve(['x' => ['method' => 'tools/call', 'params' => []]]); + } + + #[TestDox('refuses an ask with no method to answer')] + public function testMethodlessAskIsRefused(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('without a method to answer'); + + $this->resolver()->resolve(['x' => ['params' => []]]); + } + + /** + * @return array + */ + private static function elicitation(string $message): array + { + return [ + 'method' => 'elicitation/create', + 'params' => [ + 'message' => $message, + 'requestedSchema' => [ + 'type' => 'object', + 'properties' => ['confirmed' => ['type' => 'boolean']], + ], + ], + ]; + } + + private function resolver(): InputRequestResolver + { + return new InputRequestResolver([ + new class implements RequestHandlerInterface { + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, [])); + } + }, + ]); + } +} diff --git a/tests/Unit/Client/Stateless/ToolCatalogTest.php b/tests/Unit/Client/Stateless/ToolCatalogTest.php new file mode 100644 index 0000000..2b87837 --- /dev/null +++ b/tests/Unit/Client/Stateless/ToolCatalogTest.php @@ -0,0 +1,124 @@ +record([self::annotatedTool()]); + + $this->assertSame( + ['Region' => 'us-west1', 'Priority' => '7', 'Verbose' => 'false'], + $catalog->headersFor('search', ['region' => 'us-west1', 'priority' => 7, 'verbose' => false]), + ); + } + + #[TestDox('an argument that is absent or null contributes no header')] + public function testOmittedArgumentsAreNotMirrored(): void + { + $catalog = new ToolCatalog(); + $catalog->record([self::annotatedTool()]); + + // An omitted header is how "no value" is said; an empty one would + // assert that the argument was present and empty. + $this->assertSame( + ['Region' => 'us-west1'], + $catalog->headersFor('search', ['region' => 'us-west1', 'verbose' => null]), + ); + } + + #[TestDox('an unannotated argument is never mirrored')] + public function testUnannotatedArgumentsAreNotMirrored(): void + { + $catalog = new ToolCatalog(); + $catalog->record([self::annotatedTool()]); + + $this->assertSame([], $catalog->headersFor('search', ['query' => 'SELECT 1'])); + } + + #[TestDox('a tool the client never listed is not second-guessed')] + public function testUnknownToolIsNeitherMirroredNorRejected(): void + { + $catalog = new ToolCatalog(); + + $this->assertSame([], $catalog->headersFor('unlisted', ['region' => 'x'])); + $this->assertFalse($catalog->isRejected('unlisted')); + } + + #[TestDox('a malformed annotation drops that tool and leaves the rest usable')] + public function testMalformedToolIsDroppedAlone(): void + { + $catalog = new ToolCatalog(); + + $usable = $catalog->record([ + self::annotatedTool(), + [ + 'name' => 'broken', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => ['data' => ['type' => 'object', 'x-mcp-header' => 'Data']], + ], + ], + ]); + + $this->assertSame(['search'], array_column($usable, 'name')); + $this->assertTrue($catalog->isRejected('broken')); + $this->assertFalse($catalog->isRejected('search')); + $this->assertStringContainsString('only string, integer and boolean', (string) $catalog->reasonFor('broken')); + } + + #[TestDox('a later listing replaces what was known about a tool')] + public function testRelistingReplacesTheVerdict(): void + { + $catalog = new ToolCatalog(); + $catalog->record([[ + 'name' => 'search', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => ['region' => ['type' => 'object', 'x-mcp-header' => 'Region']], + ], + ]]); + + $this->assertTrue($catalog->isRejected('search')); + + $catalog->record([self::annotatedTool()]); + + $this->assertFalse($catalog->isRejected('search')); + $this->assertSame(['Region' => 'eu'], $catalog->headersFor('search', ['region' => 'eu'])); + } + + /** + * @return array + */ + private static function annotatedTool(): array + { + return [ + 'name' => 'search', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'priority' => ['type' => 'integer', 'x-mcp-header' => 'Priority'], + 'verbose' => ['type' => 'boolean', 'x-mcp-header' => 'Verbose'], + 'query' => ['type' => 'string'], + ], + ], + ]; + } +} diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php new file mode 100644 index 0000000..83a77e0 --- /dev/null +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -0,0 +1,228 @@ +factory = new Psr17Factory(); + } + + /** + * @return iterable + */ + public static function frameProvider(): iterable + { + yield 'LF line endings' => ["event: message\ndata: %s\n\n"]; + // sse-starlette (and therefore every MCP Python SDK server) defaults to CRLF. + yield 'CRLF line endings' => ["event: message\r\ndata: %s\r\n\r\n"]; + yield 'CR line endings' => ["event: message\rdata: %s\r\r"]; + yield 'with an id field' => ["id: 1\r\nevent: message\r\ndata: %s\r\n\r\n"]; + yield 'no trailing blank line' => ["event: message\ndata: %s\n"]; + yield 'preceded by a comment' => [": ping\n\nevent: message\ndata: %s\n\n"]; + } + + #[DataProvider('frameProvider')] + #[TestDox('initialization succeeds for an SSE response framed as: $_dataName')] + public function testInitializeParsesSseFraming(string $frame): void + { + $httpClient = new class($frame) implements ClientInterface { + public function __construct(private readonly string $frame) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $decoded = json_decode((string) $request->getBody(), true); + + if ('initialize' !== ($decoded['method'] ?? null)) { + return new Response(202); + } + + $payload = json_encode([ + 'jsonrpc' => '2.0', + 'id' => $decoded['id'], + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => ['tools' => ['listChanged' => false]], + 'serverInfo' => ['name' => 'test-server', 'version' => '1.0.0'], + ], + ]); + + return new Response(200, [ + 'Content-Type' => 'text/event-stream', + 'Mcp-Session-Id' => 'abc123', + ], \sprintf($this->frame, $payload)); + } + }; + + $client = Client::builder() + ->setClientInfo('test-client', '1.0.0') + ->setInitTimeout(1) + ->build(); + + $client->connect(new HttpTransport('http://localhost/mcp', [], $httpClient, $this->factory, $this->factory)); + + $this->assertTrue($client->isConnected()); + $this->assertSame('test-server', $client->getServerInfo()?->name); + } + + #[TestDox('the server-minted session ID is exposed while connected and cleared on close')] + public function testSessionIdIsExposedAndClearedOnClose(): void + { + $httpClient = new class implements ClientInterface { + public function sendRequest(RequestInterface $request): ResponseInterface + { + $decoded = json_decode((string) $request->getBody(), true); + + if ('initialize' !== ($decoded['method'] ?? null)) { + return new Response(202); + } + + $payload = json_encode([ + 'jsonrpc' => '2.0', + 'id' => $decoded['id'], + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => ['tools' => ['listChanged' => false]], + 'serverInfo' => ['name' => 'test-server', 'version' => '1.0.0'], + ], + ]); + + return new Response(200, [ + 'Content-Type' => 'application/json', + 'Mcp-Session-Id' => 'session-abc123', + ], $payload); + } + }; + + $transport = new HttpTransport('http://localhost/mcp', [], $httpClient, $this->factory, $this->factory); + + $this->assertNull($transport->getSessionId()); + + $client = Client::builder() + ->setClientInfo('test-client', '1.0.0') + ->setInitTimeout(1) + ->build(); + + $client->connect($transport); + + $this->assertSame('session-abc123', $transport->getSessionId()); + + $client->disconnect(); + + $this->assertNull($transport->getSessionId()); + } + + #[TestDox('SSE stream is aborted before the buffer can exceed the configured cap')] + public function testSseBufferIsBoundedByConfiguredCap(): void + { + $transport = $this->createTransport(maxSseBufferBytes: 64); + $state = new ClientState(); + $state->addPendingRequest(1, 30); + $transport->setState($state); + + // A server that streams data without ever sending the "\n\n" delimiter. + $this->setActiveStream($transport, $this->factory->createStream(str_repeat('a', 4096))); + + $this->invokeProcessSseStream($transport); + + $this->assertSame('', $this->readPrivate($transport, 'sseBuffer'), 'buffer must be cleared on abort'); + $this->assertNull($this->readPrivate($transport, 'activeStream'), 'stream must be released on abort'); + } + + #[TestDox('aborting the SSE stream fails the in-flight request immediately instead of waiting for its timeout')] + public function testAbortFailsPendingRequestFast(): void + { + $transport = $this->createTransport(maxSseBufferBytes: 64); + $state = new ClientState(); + $state->addPendingRequest(1, 30); + $transport->setState($state); + + $this->setActiveStream($transport, $this->factory->createStream(str_repeat('a', 4096))); + + $this->invokeProcessSseStream($transport); + + $response = $state->consumeResponse(1); + $this->assertInstanceOf(Error::class, $response); + $this->assertSame(Error::INTERNAL_ERROR, $response->code); + $this->assertSame(1, $response->id); + } + + #[TestDox('well-formed delimited events are parsed and dispatched')] + public function testWellFormedEventsStillParse(): void + { + $transport = $this->createTransport(); + $messages = []; + $transport->onMessage(static function (string $message) use (&$messages): void { + $messages[] = $message; + }); + + $this->setActiveStream($transport, $this->factory->createStream("data: hello\n\ndata: world\n\n")); + + $this->invokeProcessSseStream($transport); + + $this->assertSame(['hello', 'world'], $messages); + } + + #[TestDox('the buffer cap must be a positive number of bytes')] + public function testRejectsNonPositiveCap(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->createTransport(maxSseBufferBytes: 0); + } + + private function createTransport(int $maxSseBufferBytes = 8 * 1024 * 1024): HttpTransport + { + return new HttpTransport( + endpoint: 'https://example.test/mcp', + httpClient: $this->createMock(ClientInterface::class), + requestFactory: $this->factory, + streamFactory: $this->factory, + maxSseBufferBytes: $maxSseBufferBytes, + ); + } + + private function setActiveStream(HttpTransport $transport, StreamInterface $stream): void + { + (new \ReflectionProperty($transport, 'activeStream'))->setValue($transport, $stream); + } + + private function invokeProcessSseStream(HttpTransport $transport): void + { + (new \ReflectionMethod($transport, 'processSSEStream'))->invoke($transport); + } + + private function readPrivate(HttpTransport $transport, string $property): mixed + { + return (new \ReflectionProperty($transport, $property))->getValue($transport); + } +} diff --git a/tests/Unit/Client/Transport/StdioTransportTest.php b/tests/Unit/Client/Transport/StdioTransportTest.php new file mode 100644 index 0000000..fb31408 --- /dev/null +++ b/tests/Unit/Client/Transport/StdioTransportTest.php @@ -0,0 +1,107 @@ +addPendingRequest(1, 30); + $transport->setState($state); + + // A server that floods stdout without ever emitting a newline. + $this->setStdout($transport, $this->stream(str_repeat('a', 8192))); + + $this->invokeProcessInput($transport); + + $this->assertSame('', $this->readPrivate($transport, 'inputBuffer'), 'buffer must be cleared on abort'); + } + + #[TestDox('aborting the input fails the in-flight request immediately')] + public function testAbortFailsPendingRequestFast(): void + { + $transport = new StdioTransport(command: 'true', maxBufferSize: 64); + $state = new ClientState(); + $state->addPendingRequest(1, 30); + $transport->setState($state); + + $this->setStdout($transport, $this->stream(str_repeat('a', 8192))); + + $this->invokeProcessInput($transport); + + $response = $state->consumeResponse(1); + $this->assertInstanceOf(Error::class, $response); + $this->assertSame(Error::INTERNAL_ERROR, $response->code); + $this->assertSame(1, $response->id); + } + + #[TestDox('newline-delimited frames within the cap are parsed and dispatched')] + public function testWellFormedFramesStillParse(): void + { + $transport = new StdioTransport(command: 'true'); + $messages = []; + $transport->onMessage(static function (string $message) use (&$messages): void { + $messages[] = $message; + }); + + $this->setStdout($transport, $this->stream('{"a":1}'."\n".'{"b":2}'."\n")); + + $this->invokeProcessInput($transport); + + $this->assertSame(['{"a":1}', '{"b":2}'], $messages); + } + + #[TestDox('the buffer cap must be a positive number of bytes')] + public function testRejectsNonPositiveCap(): void + { + $this->expectException(InvalidArgumentException::class); + + new StdioTransport(command: 'true', maxBufferSize: 0); + } + + /** + * @return resource + */ + private function stream(string $contents) + { + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $contents); + rewind($stream); + + return $stream; + } + + private function setStdout(StdioTransport $transport, mixed $stream): void + { + (new \ReflectionProperty($transport, 'stdout'))->setValue($transport, $stream); + } + + private function invokeProcessInput(StdioTransport $transport): void + { + (new \ReflectionMethod($transport, 'processInput'))->invoke($transport); + } + + private function readPrivate(StdioTransport $transport, string $property): mixed + { + return (new \ReflectionProperty($transport, $property))->getValue($transport); + } +} diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php new file mode 100644 index 0000000..558de95 --- /dev/null +++ b/tests/Unit/ClientTest.php @@ -0,0 +1,283 @@ +assertInstanceOf(Builder::class, Client::builder()); + } + + #[TestDox('connect() succeeds on the first attempt without retrying')] + public function testConnectSucceedsWithoutRetrying(): void + { + $transport = new FakeTransport([FakeTransport::ACCEPT]); + + $client = Client::builder()->build(); + $client->connect($transport); + + $this->assertSame(1, $transport->connectCalls); + $this->assertSame(0, $transport->closeCalls); + $this->assertTrue($client->isConnected()); + } + + #[TestDox('connect() retries a failed attempt and succeeds on a later one')] + public function testConnectRetriesUntilItSucceeds(): void + { + $transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::REJECT, FakeTransport::ACCEPT]); + + $client = Client::builder()->setMaxRetries(3)->build(); + $client->connect($transport); + + $this->assertSame(3, $transport->connectCalls); + $this->assertTrue($client->isConnected()); + $this->assertSame('Test Server', $client->getServerInfo()?->name); + } + + #[TestDox('connect() closes the transport between two attempts')] + public function testConnectClosesTransportBetweenAttempts(): void + { + $transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::ACCEPT]); + + $client = Client::builder()->setMaxRetries(1)->build(); + $client->connect($transport); + + $this->assertSame(2, $transport->connectCalls); + $this->assertSame(1, $transport->closeCalls, 'the failed attempt must be cleaned up before the retry'); + } + + #[TestDox('connect() rethrows once the retries are exhausted')] + public function testConnectThrowsWhenRetriesAreExhausted(): void + { + $transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::REJECT, FakeTransport::REJECT]); + + $client = Client::builder()->setMaxRetries(2)->build(); + + try { + $client->connect($transport); + $this->fail(\sprintf('Expected a "%s" to be thrown.', ConnectionException::class)); + } catch (ConnectionException) { + // Expected. + } + + $this->assertSame(3, $transport->connectCalls, 'one initial attempt plus two retries'); + $this->assertSame(3, $transport->closeCalls, 'the last attempt must be cleaned up as well'); + $this->assertFalse($client->isConnected()); + } + + #[TestDox('setMaxRetries(0) disables retrying')] + public function testZeroRetriesAttemptsToConnectOnce(): void + { + $transport = new FakeTransport([FakeTransport::REJECT, FakeTransport::ACCEPT]); + + $client = Client::builder()->setMaxRetries(0)->build(); + + $this->expectException(ConnectionException::class); + + try { + $client->connect($transport); + } finally { + $this->assertSame(1, $transport->connectCalls); + } + } + + #[TestDox('a negative retry count is rejected')] + public function testNegativeRetryCountIsRejected(): void + { + $builder = Client::builder()->setMaxRetries(-1); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The maximum number of retries must be zero or greater, got -1.'); + + $builder->build(); + } + + #[TestDox('a connection breaking after the handshake result does not leave the client connected')] + public function testFailureAfterHandshakeResultDoesNotLeaveClientConnected(): void + { + $transport = new FakeTransport([FakeTransport::BREAK_AFTER_ACCEPT]); + + $client = Client::builder()->setMaxRetries(0)->build(); + + try { + $client->connect($transport); + $this->fail(\sprintf('Expected a "%s" to be thrown.', ConnectionException::class)); + } catch (ConnectionException) { + // Expected. + } + + $this->assertFalse($client->isConnected()); + } + + #[TestDox('a timed out attempt does not leave state behind that fails the retry')] + public function testTimedOutAttemptDoesNotPoisonTheRetry(): void + { + // The shortest allowed init timeout, so this waits out about a second. + $transport = new FakeTransport([FakeTransport::IGNORE, FakeTransport::ACCEPT]); + + $client = Client::builder()->setInitTimeout(1)->setMaxRetries(1)->build(); + $client->connect($transport); + + $this->assertSame(2, $transport->connectCalls); + $this->assertTrue($client->isConnected()); + } +} + +/** + * Transport whose connection attempts succeed or fail on command. + * + * Requests are answered from the polling loop rather than from send(), the way + * a stdio transport does, with each connect() call answering according to the + * next configured outcome. + * + * @phpstan-import-type McpFiber from TransportInterface + */ +final class FakeTransport extends BaseTransport +{ + /** The initialize request is answered with a result. */ + public const ACCEPT = 'accept'; + + /** The initialize request is answered with a JSON-RPC error. */ + public const REJECT = 'reject'; + + /** The initialize request is not answered at all and has to time out. */ + public const IGNORE = 'ignore'; + + /** The initialize request is answered, but the connection breaks right after. */ + public const BREAK_AFTER_ACCEPT = 'break_after_accept'; + + public int $connectCalls = 0; + public int $closeCalls = 0; + + private string $outcome = self::ACCEPT; + + /** @var list Answers not yet delivered to the client */ + private array $outbox = []; + + /** + * @param list $attempts How each successive connect() call behaves + */ + public function __construct(private array $attempts = [self::ACCEPT]) + { + parent::__construct(); + } + + public function connect(): void + { + ++$this->connectCalls; + $this->outcome = array_shift($this->attempts) ?? self::ACCEPT; + + $result = $this->runRequest(new \Fiber(fn () => $this->handleInitialize())); + + if ($result instanceof Error) { + throw new ConnectionException('Initialization failed: '.$result->message); + } + } + + public function send(string $data): void + { + if (self::IGNORE === $this->outcome) { + return; + } + + $message = json_decode($data, true, 512, \JSON_THROW_ON_ERROR); + + if (!isset($message['id'])) { + if (self::BREAK_AFTER_ACCEPT === $this->outcome) { + throw new ConnectionException('Connection lost'); + } + + return; // A notification, nothing to answer. + } + + $answer = self::REJECT === $this->outcome + ? ['error' => ['code' => Error::INTERNAL_ERROR, 'message' => 'Server unavailable']] + : ['result' => [ + 'protocolVersion' => ProtocolVersion::V2025_11_25->value, + 'capabilities' => [], + 'serverInfo' => ['name' => 'Test Server', 'version' => '1.0.0'], + ]]; + + $this->outbox[] = json_encode(['jsonrpc' => '2.0', 'id' => $message['id']] + $answer, \JSON_THROW_ON_ERROR); + } + + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + $fiber->start(); + + // Polls at the same 1ms interval as the real transports, so a request + // left unanswered runs into its timeout in real time. + for ($poll = 0; !$fiber->isTerminated(); ++$poll) { + if ($poll > 5000) { + throw new \LogicException('The fiber never terminated, no pending request became resolvable.'); + } + + foreach ($this->outbox as $answer) { + $this->handleMessage($answer); + } + $this->outbox = []; + + $this->resumeFiber($fiber); + + usleep(1000); + } + + return $fiber->getReturn(); + } + + public function close(): void + { + ++$this->closeCalls; + + $this->handleClose('Transport closed'); + } + + /** + * @param McpFiber $fiber + */ + private function resumeFiber(\Fiber $fiber): void + { + if (!$fiber->isSuspended() || null === $this->state) { + return; + } + + foreach ($this->state->getPendingRequests() as $pending) { + $response = $this->state->consumeResponse($pending['request_id']); + + if (null !== $response) { + $fiber->resume($response); + + return; + } + + if (time() - $pending['timestamp'] >= $pending['timeout']) { + $fiber->resume(Error::forInternalError('Request timed out', $pending['request_id'])); + + return; + } + } + } +} diff --git a/tests/Unit/Fixtures/ThrowingRequest.php b/tests/Unit/Fixtures/ThrowingRequest.php new file mode 100644 index 0000000..d5b4136 --- /dev/null +++ b/tests/Unit/Fixtures/ThrowingRequest.php @@ -0,0 +1,38 @@ + + */ +final class ThrowingRequest extends Request +{ + public static function getMethod(): string + { + return 'test/throwing'; + } + + protected static function fromParams(?array $params): static + { + throw new \TypeError('Internal detail that must not leak to the client.'); + } + + protected function getParams(): ?array + { + return null; + } +} diff --git a/tests/Unit/JsonRpc/MalformedInputTest.php b/tests/Unit/JsonRpc/MalformedInputTest.php new file mode 100644 index 0000000..cb8d1ad --- /dev/null +++ b/tests/Unit/JsonRpc/MalformedInputTest.php @@ -0,0 +1,83 @@ + + */ +final class MalformedInputTest extends TestCase +{ + /** + * @return iterable + */ + public static function provideMalformedPayloads(): iterable + { + yield 'method is an object' => ['{"jsonrpc":"2.0","id":1,"method":{"evil":true}}']; + yield 'method is an array' => ['{"jsonrpc":"2.0","id":1,"method":[1,2,3]}']; + + yield 'initialize with array protocolVersion' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":[1],"capabilities":{},"clientInfo":{"name":"x","version":"1"}}}']; + yield 'initialize with string capabilities' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":"x","clientInfo":{"name":"x","version":"1"}}}']; + yield 'initialize with string clientInfo' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":"x"}}']; + yield 'initialize with non-array icons entry' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"1","icons":["x"]}}}']; + + yield 'completion ref/prompt with array name' => ['{"jsonrpc":"2.0","id":1,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":[1]},"argument":{"name":"x","value":"y"}}}']; + yield 'completion ref/prompt without name' => ['{"jsonrpc":"2.0","id":1,"method":"completion/complete","params":{"ref":{"type":"ref/prompt"},"argument":{"name":"x","value":"y"}}}']; + yield 'completion ref/resource with object uri' => ['{"jsonrpc":"2.0","id":1,"method":"completion/complete","params":{"ref":{"type":"ref/resource","uri":{}},"argument":{"name":"x","value":"y"}}}']; + + yield 'setLevel with unknown enum value' => ['{"jsonrpc":"2.0","id":1,"method":"logging/setLevel","params":{"level":"not-a-real-level"}}']; + yield 'logging notification with unknown enum value' => ['{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"nope","data":"x"}}']; + + yield 'tools/list with array cursor' => ['{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"cursor":[1]}}']; + yield 'cancelled notification with array reason' => ['{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1,"reason":[1]}}']; + yield 'progress notification with array total' => ['{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t","progress":1,"total":[1]}}']; + + yield 'sampling with string preferences' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[],"maxTokens":1,"preferences":"x"}}']; + yield 'sampling with array systemPrompt' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[],"maxTokens":1,"systemPrompt":[1]}}']; + yield 'sampling with unknown role' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[{"role":"nope","content":{"type":"text","text":"x"}}],"maxTokens":1}}']; + yield 'sampling with array content type' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[{"role":"user","content":{"type":[1],"text":"x"}}],"maxTokens":1}}']; + yield 'sampling with non-string stopSequence' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[],"maxTokens":1,"stopSequences":[[]]}}']; + + yield 'elicitation with string required' => ['{"jsonrpc":"2.0","id":1,"method":"elicitation/create","params":{"message":"m","requestedSchema":{"type":"object","properties":{"a":{"type":"string","title":"T"}},"required":"a"}}}']; + yield 'elicitation with array in required' => ['{"jsonrpc":"2.0","id":1,"method":"elicitation/create","params":{"message":"m","requestedSchema":{"type":"object","properties":{"a":{"type":"string","title":"T"}},"required":[[]]}}}']; + } + + #[DataProvider('provideMalformedPayloads')] + #[TestDox('Malformed payload is reported as invalid input: $_dataName')] + public function testMalformedPayloadIsReportedAsInvalidInput(string $payload): void + { + $results = MessageFactory::make()->create($payload); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + } + + #[TestDox('A malformed message in a batch does not discard the valid ones')] + public function testMalformedMessageInBatchDoesNotDiscardValidMessages(): void + { + $payload = '[{"jsonrpc":"2.0","id":1,"method":"tools/list"},{"jsonrpc":"2.0","id":2,"method":{}}]'; + + $results = MessageFactory::make()->create($payload); + + $this->assertCount(2, $results); + $this->assertInstanceOf(\Mcp\Schema\Request\ListToolsRequest::class, $results[0]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); + } +} diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index 291b6c5..fe2c0d2 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -21,6 +21,7 @@ use Mcp\Schema\Notification\InitializedNotification; use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Request\PingRequest; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class MessageFactoryTest extends TestCase @@ -277,13 +278,15 @@ final class MessageFactoryTest extends TestCase public function testErrorMissingId(): void { + // Well-formed: an error response leaves the member out when the id + // could not be read off the request it answers. $json = '{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid"}}'; $results = $this->factory->create($json); $this->assertCount(1, $results); - $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); - $this->assertStringContainsString('id', $results[0]->getMessage()); + $this->assertInstanceOf(Error::class, $results[0]); + $this->assertNull($results[0]->getId()); } public function testErrorMissingCode(): void @@ -364,9 +367,21 @@ final class MessageFactoryTest extends TestCase $this->assertStringContainsString('id', $results[0]->getMessage()); } + public function testErrorWithNullId(): void + { + // JSON-RPC 2.0 spells the same thing as an explicit null. + $json = '{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(Error::class, $results[0]); + $this->assertNull($results[0]->getId()); + } + public function testErrorWithInvalidIdType(): void { - $json = '{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Invalid"}}'; + $json = '{"jsonrpc": "2.0", "id": {"not": "an id"}, "error": {"code": -32600, "message": "Invalid"}}'; $results = $this->factory->create($json); @@ -452,6 +467,41 @@ final class MessageFactoryTest extends TestCase $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); } + /** + * @return iterable + */ + public static function provideNonStringMethods(): iterable + { + yield 'object' => ['{"evil": true}']; + yield 'array' => ['[1, 2, 3]']; + yield 'int' => ['5']; + yield 'bool' => ['true']; + } + + #[DataProvider('provideNonStringMethods')] + public function testNonStringMethodIsRejected(string $method): void + { + $results = $this->factory->create(\sprintf('{"jsonrpc": "2.0", "id": 1, "method": %s}', $method)); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('"method" must be a string', $results[0]->getMessage()); + } + + public function testBatchWithNonStringMethodStillYieldsTheValidMessages(): void + { + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "method": {}, "id": 2} + ]'; + + $results = $this->factory->create($json); + + $this->assertCount(2, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); + } + public function testLeadingWhitespaceObjectIsParsedAsSingleMessage(): void { $json = " \n {\"jsonrpc\": \"2.0\", \"method\": \"ping\", \"id\": 1}"; diff --git a/tests/Unit/Schema/ClientCapabilitiesTest.php b/tests/Unit/Schema/ClientCapabilitiesTest.php new file mode 100644 index 0000000..c2176e9 --- /dev/null +++ b/tests/Unit/Schema/ClientCapabilitiesTest.php @@ -0,0 +1,180 @@ +assertArrayHasKey('roots', $data); + $this->assertSame([], $data['roots']); + } + + public function testSerializesRootsWithListChanged(): void + { + $capabilities = new ClientCapabilities(roots: true, rootsListChanged: true); + + $data = json_decode(json_encode($capabilities), true); + + $this->assertSame(['listChanged' => true], $data['roots']); + } + + public function testSerializesEmptyCapabilitiesAsObject(): void + { + $capabilities = new ClientCapabilities(); + + $this->assertSame('{}', json_encode($capabilities)); + } + + public function testFromArrayReadsRootsListChanged(): void + { + $capabilities = ClientCapabilities::fromArray(['roots' => ['listChanged' => true]]); + + $this->assertTrue($capabilities->roots); + $this->assertTrue($capabilities->rootsListChanged); + } + + public function testFromArrayRootsWithoutListChanged(): void + { + $capabilities = ClientCapabilities::fromArray(['roots' => []]); + + $this->assertTrue($capabilities->roots); + $this->assertNull($capabilities->rootsListChanged); + } + + public function testRoundTripPreservesRootsListChanged(): void + { + $capabilities = new ClientCapabilities(roots: true, rootsListChanged: true); + + $data = json_decode(json_encode($capabilities), true); + $restored = ClientCapabilities::fromArray($data); + + $this->assertTrue($restored->roots); + $this->assertTrue($restored->rootsListChanged); + } + + public function testRoundTripPreservesSamplingSubCapabilities(): void + { + $capabilities = new ClientCapabilities(sampling: true, samplingContext: true, samplingTools: true); + + $serialized = $capabilities->jsonSerialize(); + $this->assertObjectHasProperty('context', $serialized['sampling']); + $this->assertObjectHasProperty('tools', $serialized['sampling']); + + $restored = ClientCapabilities::fromArray(json_decode(json_encode($capabilities), true)); + + $this->assertTrue($restored->sampling); + $this->assertTrue($restored->samplingContext); + $this->assertTrue($restored->samplingTools); + } + + public function testPlainSamplingLeavesSubCapabilitiesOff(): void + { + $capabilities = new ClientCapabilities(sampling: true); + + $serialized = $capabilities->jsonSerialize(); + $this->assertObjectNotHasProperty('context', $serialized['sampling']); + $this->assertObjectNotHasProperty('tools', $serialized['sampling']); + + $restored = ClientCapabilities::fromArray(json_decode(json_encode($capabilities), true)); + + $this->assertTrue($restored->sampling); + $this->assertFalse($restored->samplingContext); + $this->assertFalse($restored->samplingTools); + } + + public function testSamplingSubCapabilitiesAreHydratedFromObject(): void + { + $sampling = new \stdClass(); + $sampling->context = new \stdClass(); + $sampling->tools = new \stdClass(); + + $capabilities = ClientCapabilities::fromArray(['sampling' => $sampling]); + + $this->assertTrue($capabilities->sampling); + $this->assertTrue($capabilities->samplingContext); + $this->assertTrue($capabilities->samplingTools); + } + + #[TestDox('reads the elicitation sub-capabilities')] + public function testReadsElicitationSubCapabilities(): void + { + $capabilities = ClientCapabilities::fromArray([ + 'elicitation' => ['form' => [], 'url' => []], + ]); + + $this->assertTrue($capabilities->elicitation); + $this->assertTrue($capabilities->elicitationForm); + $this->assertTrue($capabilities->elicitationUrl); + } + + #[TestDox('an elicitation capability naming no mode declares form')] + public function testElicitationWithoutModeImpliesForm(): void + { + $capabilities = ClientCapabilities::fromArray(['elicitation' => []]); + + $this->assertTrue($capabilities->elicitationForm); + $this->assertFalse($capabilities->elicitationUrl); + } + + #[TestDox('naming url alone does not declare form')] + public function testElicitationUrlAloneIsNotForm(): void + { + $capabilities = ClientCapabilities::fromArray(['elicitation' => ['url' => []]]); + + $this->assertFalse($capabilities->elicitationForm); + $this->assertTrue($capabilities->elicitationUrl); + } + + #[TestDox('an absent elicitation capability declares no mode either way')] + public function testAbsentElicitationLeavesModesNull(): void + { + $capabilities = ClientCapabilities::fromArray([]); + + $this->assertNull($capabilities->elicitation); + $this->assertNull($capabilities->elicitationForm); + $this->assertNull($capabilities->elicitationUrl); + } + + #[TestDox('round-trips the elicitation sub-capabilities')] + public function testRoundTripPreservesElicitationSubCapabilities(): void + { + $capabilities = new ClientCapabilities(elicitation: true, elicitationForm: true, elicitationUrl: true); + + $encoded = json_encode($capabilities) ?: ''; + $this->assertStringContainsString('"elicitation":{"form":{},"url":{}}', $encoded); + + $restored = ClientCapabilities::fromArray(json_decode($encoded, true)); + + $this->assertTrue($restored->elicitationForm); + $this->assertTrue($restored->elicitationUrl); + } + + #[TestDox('declaring only a mode still advertises elicitation')] + public function testElicitationModeImpliesParent(): void + { + $capabilities = new ClientCapabilities(elicitationUrl: true); + + $encoded = json_decode(json_encode($capabilities) ?: '', true); + + $this->assertArrayHasKey('elicitation', $encoded); + $this->assertArrayHasKey('url', $encoded['elicitation']); + } +} diff --git a/tests/Unit/Schema/Content/ImageContentTest.php b/tests/Unit/Schema/Content/ImageContentTest.php new file mode 100644 index 0000000..b167202 --- /dev/null +++ b/tests/Unit/Schema/Content/ImageContentTest.php @@ -0,0 +1,87 @@ +assertNull($content->annotations); + } + + public function testConstructorAcceptsAnnotations(): void + { + $annotations = new Annotations([Role::User], 0.5); + $content = new ImageContent(base64_encode('binary'), 'image/png', $annotations); + + $this->assertSame($annotations, $content->annotations); + } + + public function testFromArrayDeserializesAnnotations(): void + { + $content = ImageContent::fromArray([ + 'type' => 'image', + 'data' => base64_encode('binary'), + 'mimeType' => 'image/png', + 'annotations' => ['audience' => ['user'], 'priority' => 0.5], + ]); + + $this->assertNotNull($content->annotations); + $this->assertSame([Role::User], $content->annotations->audience); + $this->assertSame(0.5, $content->annotations->priority); + } + + public function testJsonSerializeOmitsNullAnnotations(): void + { + $content = new ImageContent(base64_encode('binary'), 'image/png'); + + $this->assertArrayNotHasKey('annotations', $content->jsonSerialize()); + } + + public function testJsonSerializeIncludesAnnotations(): void + { + $annotations = new Annotations([Role::User], 0.5); + $content = new ImageContent(base64_encode('binary'), 'image/png', $annotations); + + $data = $content->jsonSerialize(); + + $this->assertSame($annotations, $data['annotations']); + } + + public function testRoundTripWithAnnotations(): void + { + $original = new ImageContent(base64_encode('binary'), 'image/png', new Annotations([Role::User], 0.5)); + + $decoded = json_decode(json_encode($original), true); + $rehydrated = ImageContent::fromArray($decoded); + + $this->assertSame($original->data, $rehydrated->data); + $this->assertSame($original->mimeType, $rehydrated->mimeType); + $this->assertEquals($original->annotations, $rehydrated->annotations); + } + + public function testFromStringAcceptsAnnotations(): void + { + $annotations = new Annotations([Role::User]); + $content = ImageContent::fromString('binary', 'image/png', $annotations); + + $this->assertSame(base64_encode('binary'), $content->data); + $this->assertSame($annotations, $content->annotations); + } +} diff --git a/tests/Unit/Schema/Content/PromptMessageTest.php b/tests/Unit/Schema/Content/PromptMessageTest.php new file mode 100644 index 0000000..8808d41 --- /dev/null +++ b/tests/Unit/Schema/Content/PromptMessageTest.php @@ -0,0 +1,84 @@ + 'user', + 'content' => [ + 'type' => 'resource_link', + 'uri' => 'file:///project/src/main.rs', + 'name' => 'main.rs', + ], + ]); + + $this->assertSame(Role::User, $message->role); + $this->assertInstanceOf(ResourceLink::class, $message->content); + $this->assertSame('file:///project/src/main.rs', $message->content->uri); + $this->assertSame('main.rs', $message->content->name); + } + + public function testJsonSerializeIncludesResourceLinkContent(): void + { + $message = new PromptMessage(Role::Assistant, new ResourceLink('file:///a.png', 'a.png')); + + $this->assertSame([ + 'role' => 'assistant', + 'content' => [ + 'type' => 'resource_link', + 'uri' => 'file:///a.png', + 'name' => 'a.png', + ], + ], json_decode(json_encode($message), true)); + } + + public function testRoundTripWithResourceLink(): void + { + $original = new PromptMessage(Role::User, new ResourceLink('file:///a.png', 'a.png', mimeType: 'image/png')); + + $decoded = json_decode(json_encode($original), true); + $rehydrated = PromptMessage::fromArray($decoded); + + $this->assertSame(Role::User, $rehydrated->role); + $this->assertInstanceOf(ResourceLink::class, $rehydrated->content); + $this->assertSame('file:///a.png', $rehydrated->content->uri); + $this->assertSame('image/png', $rehydrated->content->mimeType); + } + + public function testFromArrayRejectsUnknownContentType(): void + { + $this->expectException(InvalidArgumentException::class); + + /* @phpstan-ignore argument.type */ + PromptMessage::fromArray([ + 'role' => 'user', + 'content' => ['type' => 'not-a-real-type'], + ]); + } + + public function testConstructorAcceptsResourceLinkContent(): void + { + $link = new ResourceLink('file:///a.png', 'a.png'); + $message = new PromptMessage(Role::User, $link); + + $this->assertSame($link, $message->content); + } +} diff --git a/tests/Unit/Schema/Content/ResourceLinkTest.php b/tests/Unit/Schema/Content/ResourceLinkTest.php new file mode 100644 index 0000000..ca06455 --- /dev/null +++ b/tests/Unit/Schema/Content/ResourceLinkTest.php @@ -0,0 +1,251 @@ +assertSame('resource_link', $link->type); + $this->assertSame(self::VALID_URI, $link->uri); + $this->assertSame('main.rs', $link->name); + $this->assertNull($link->title); + $this->assertNull($link->description); + $this->assertNull($link->mimeType); + $this->assertNull($link->annotations); + $this->assertNull($link->size); + $this->assertNull($link->icons); + $this->assertNull($link->meta); + } + + public function testConstructorWithAllFields(): void + { + $annotations = new Annotations([Role::User], 0.5); + $icons = [new Icon('https://example.com/icon.png')]; + + $link = new ResourceLink( + uri: self::VALID_URI, + name: 'main.rs', + title: 'Main Source File', + description: 'Primary application entry point', + mimeType: 'text/x-rust', + annotations: $annotations, + size: 1024, + icons: $icons, + meta: ['origin' => 'test'], + ); + + $this->assertSame(self::VALID_URI, $link->uri); + $this->assertSame('main.rs', $link->name); + $this->assertSame('Main Source File', $link->title); + $this->assertSame('Primary application entry point', $link->description); + $this->assertSame('text/x-rust', $link->mimeType); + $this->assertSame($annotations, $link->annotations); + $this->assertSame(1024, $link->size); + $this->assertSame($icons, $link->icons); + $this->assertSame(['origin' => 'test'], $link->meta); + } + + public function testJsonSerializeMinimal(): void + { + $link = new ResourceLink(self::VALID_URI, 'main.rs'); + + $this->assertSame([ + 'type' => 'resource_link', + 'uri' => self::VALID_URI, + 'name' => 'main.rs', + ], $link->jsonSerialize()); + } + + public function testJsonSerializeWithAllFields(): void + { + $annotations = new Annotations([Role::User], 0.5); + $icons = [new Icon('https://example.com/icon.png')]; + + $link = new ResourceLink( + uri: self::VALID_URI, + name: 'main.rs', + title: 'Main Source File', + description: 'Primary application entry point', + mimeType: 'text/x-rust', + annotations: $annotations, + size: 1024, + icons: $icons, + meta: ['origin' => 'test'], + ); + + $data = $link->jsonSerialize(); + + $this->assertSame('resource_link', $data['type']); + $this->assertSame(self::VALID_URI, $data['uri']); + $this->assertSame('main.rs', $data['name']); + $this->assertSame('Main Source File', $data['title']); + $this->assertSame('Primary application entry point', $data['description']); + $this->assertSame('text/x-rust', $data['mimeType']); + $this->assertSame($annotations, $data['annotations']); + $this->assertSame(1024, $data['size']); + $this->assertSame($icons, $data['icons']); + $this->assertSame(['origin' => 'test'], $data['_meta']); + } + + public function testOptionalFieldsOmittedWhenNull(): void + { + $link = new ResourceLink(self::VALID_URI, 'main.rs'); + $data = $link->jsonSerialize(); + + $this->assertArrayNotHasKey('title', $data); + $this->assertArrayNotHasKey('description', $data); + $this->assertArrayNotHasKey('mimeType', $data); + $this->assertArrayNotHasKey('annotations', $data); + $this->assertArrayNotHasKey('size', $data); + $this->assertArrayNotHasKey('icons', $data); + $this->assertArrayNotHasKey('_meta', $data); + } + + public function testFromArrayMinimal(): void + { + $link = ResourceLink::fromArray([ + 'type' => 'resource_link', + 'uri' => self::VALID_URI, + 'name' => 'main.rs', + ]); + + $this->assertSame(self::VALID_URI, $link->uri); + $this->assertSame('main.rs', $link->name); + $this->assertNull($link->title); + $this->assertNull($link->annotations); + $this->assertNull($link->icons); + $this->assertNull($link->meta); + } + + public function testFromArrayWithAllFields(): void + { + $link = ResourceLink::fromArray([ + 'type' => 'resource_link', + 'uri' => self::VALID_URI, + 'name' => 'main.rs', + 'title' => 'Main Source File', + 'description' => 'Primary application entry point', + 'mimeType' => 'text/x-rust', + 'annotations' => ['audience' => ['user'], 'priority' => 0.5], + 'size' => 1024, + 'icons' => [['src' => 'https://example.com/icon.png']], + '_meta' => ['origin' => 'test'], + ]); + + $this->assertSame('Main Source File', $link->title); + $this->assertSame('Primary application entry point', $link->description); + $this->assertSame('text/x-rust', $link->mimeType); + $this->assertInstanceOf(Annotations::class, $link->annotations); + $this->assertSame(1024, $link->size); + $this->assertCount(1, $link->icons); + $this->assertInstanceOf(Icon::class, $link->icons[0]); + $this->assertSame(['origin' => 'test'], $link->meta); + } + + public function testRoundTripThroughJsonSerializeAndFromArray(): void + { + $original = new ResourceLink( + uri: self::VALID_URI, + name: 'main.rs', + title: 'Main Source File', + description: 'Primary application entry point', + mimeType: 'text/x-rust', + annotations: new Annotations([Role::User], 0.5), + size: 1024, + icons: [new Icon('https://example.com/icon.png')], + meta: ['origin' => 'test'], + ); + + $decoded = json_decode(json_encode($original), true); + $rehydrated = ResourceLink::fromArray($decoded); + + $this->assertSame($original->uri, $rehydrated->uri); + $this->assertSame($original->name, $rehydrated->name); + $this->assertSame($original->title, $rehydrated->title); + $this->assertSame($original->description, $rehydrated->description); + $this->assertSame($original->mimeType, $rehydrated->mimeType); + $this->assertSame($original->size, $rehydrated->size); + $this->assertSame($original->meta, $rehydrated->meta); + $this->assertEquals($original->annotations, $rehydrated->annotations); + } + + public function testFromArrayRejectsWrongType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid type for ResourceLink.'); + + /* @phpstan-ignore argument.type */ + ResourceLink::fromArray([ + 'type' => 'resource', + 'uri' => self::VALID_URI, + 'name' => 'main.rs', + ]); + } + + #[DataProvider('provideInvalidData')] + public function testFromArrayRejectsInvalidData(array $input, string $expectedExceptionMessage): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + ResourceLink::fromArray($input); + } + + public static function provideInvalidData(): iterable + { + yield 'missing uri' => [ + ['type' => 'resource_link', 'name' => 'main.rs'], + 'Invalid or missing "uri" in ResourceLink data.', + ]; + yield 'missing name' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI], + 'Invalid or missing "name" in ResourceLink data.', + ]; + yield 'invalid _meta' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', '_meta' => 'not-an-array'], + 'Invalid "_meta" in ResourceLink data.', + ]; + yield 'invalid description' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'description' => ['not-a-string']], + 'Invalid "description" in ResourceLink data.', + ]; + yield 'invalid mimeType' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'mimeType' => ['not-a-string']], + 'Invalid "mimeType" in ResourceLink data.', + ]; + yield 'invalid size' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'size' => 'not-an-int'], + 'Invalid "size" in ResourceLink data; expected an integer.', + ]; + yield 'invalid annotations' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'annotations' => 'not-an-array'], + 'Invalid "annotations" in ResourceLink data; expected an array.', + ]; + yield 'invalid icons entry' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'icons' => ['not-an-array']], + 'Each entry in "icons" of ResourceLink data must be an array.', + ]; + } +} diff --git a/tests/Unit/Schema/Content/SamplingMessageTest.php b/tests/Unit/Schema/Content/SamplingMessageTest.php new file mode 100644 index 0000000..c464575 --- /dev/null +++ b/tests/Unit/Schema/Content/SamplingMessageTest.php @@ -0,0 +1,125 @@ + 'assistant', + '_meta' => ['provider' => 'test'], + 'content' => [ + ['type' => 'text', 'text' => 'I will check.'], + ['type' => 'tool_use', 'id' => 'call-1', 'name' => 'weather', 'input' => ['city' => 'Paris']], + ], + ]); + $user = SamplingMessage::fromArray([ + 'role' => 'user', + 'content' => [[ + 'type' => 'tool_result', + 'toolUseId' => 'call-1', + 'content' => [['type' => 'text', 'text' => '21 C']], + 'structuredContent' => ['temperature' => 21], + ]], + ]); + + $this->assertInstanceOf(ToolUseContent::class, $assistant->content[1]); + $this->assertSame(['provider' => 'test'], $assistant->meta); + $this->assertSame(['provider' => 'test'], $assistant->jsonSerialize()['_meta']); + $this->assertInstanceOf(ToolResultContent::class, $user->content[0]); + $this->assertSame(['temperature' => 21], $user->content[0]->structuredContent); + + $this->assertEquals($assistant, SamplingMessage::fromArray(json_decode(json_encode($assistant), true))); + $this->assertEquals($user, SamplingMessage::fromArray(json_decode(json_encode($user), true))); + } + + public function testSingleContentBlockKeepsItsShape(): void + { + $message = SamplingMessage::fromArray(['role' => 'user', 'content' => ['type' => 'text', 'text' => 'hi']]); + + $this->assertInstanceOf(TextContent::class, $message->content); + $this->assertSame('{"role":"user","content":{"type":"text","text":"hi"}}', json_encode($message)); + $this->assertCount(1, $message->getContentBlocks()); + } + + public function testSingleElementListKeepsItsShape(): void + { + $message = SamplingMessage::fromArray(['role' => 'user', 'content' => [['type' => 'text', 'text' => 'hi']]]); + + $this->assertIsArray($message->content); + $this->assertSame('{"role":"user","content":[{"type":"text","text":"hi"}]}', json_encode($message)); + $this->assertCount(1, $message->getContentBlocks()); + } + + /** + * The tool-flow rules span the whole message list, so a single message is never + * rejected for carrying the "wrong" block — see CreateSamplingMessageRequestTest. + */ + public function testToolBlocksAreAcceptedRegardlessOfRole(): void + { + $message = new SamplingMessage(Role::User, new ToolUseContent('call-1', 'weather', [])); + + $this->assertInstanceOf(ToolUseContent::class, $message->content); + } + + public function testFilteredContentStillSerializesAsAnArray(): void + { + $blocks = [new TextContent('thinking'), new ToolUseContent('call-1', 'weather', [])]; + + // array_filter() preserves keys, so this list starts at index 1. + $toolUses = array_filter($blocks, static fn ($block): bool => $block instanceof ToolUseContent); + $message = new SamplingMessage(Role::Assistant, $toolUses); + + $this->assertSame( + '{"role":"assistant","content":[{"type":"tool_use","id":"call-1","name":"weather","input":{}}]}', + json_encode($message), + ); + } + + public function testEmptyContentListIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new SamplingMessage(Role::User, []); + } + + public function testEmptyContentListIsRejectedWhenHydrating(): void + { + $this->expectException(InvalidArgumentException::class); + + SamplingMessage::fromArray(['role' => 'user', 'content' => []]); + } + + public function testUnknownContentTypeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + SamplingMessage::fromArray(['role' => 'user', 'content' => ['type' => 'nope']]); + } + + public function testUnknownRoleIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + /* @phpstan-ignore argument.type */ + SamplingMessage::fromArray(['role' => 'system', 'content' => ['type' => 'text', 'text' => 'hi']]); + } +} diff --git a/tests/Unit/Schema/Content/ToolResultContentTest.php b/tests/Unit/Schema/Content/ToolResultContentTest.php new file mode 100644 index 0000000..3c45f66 --- /dev/null +++ b/tests/Unit/Schema/Content/ToolResultContentTest.php @@ -0,0 +1,119 @@ + 'tool_result', + 'toolUseId' => 'call-1', + 'content' => [['type' => 'text', 'text' => '21 C']], + 'structuredContent' => ['temperature' => 21], + 'isError' => true, + '_meta' => ['provider' => 'test'], + ]); + + $this->assertSame('tool_result', $content->type); + $this->assertSame('call-1', $content->toolUseId); + $this->assertSame(['temperature' => 21], $content->structuredContent); + $this->assertTrue($content->isError); + $this->assertSame(['provider' => 'test'], $content->meta); + + $textContent = $content->content[0]; + $this->assertInstanceOf(TextContent::class, $textContent); + $this->assertSame('21 C', $textContent->text); + + $restored = ToolResultContent::fromArray(json_decode(json_encode($content), true)); + $this->assertEquals($content, $restored); + } + + public function testIsErrorIsOmittedWhenFalse(): void + { + $content = new ToolResultContent('call-1', [new TextContent('ok')]); + + $serialized = $content->jsonSerialize(); + + $this->assertArrayNotHasKey('isError', $serialized); + $this->assertArrayNotHasKey('structuredContent', $serialized); + $this->assertArrayNotHasKey('_meta', $serialized); + $this->assertFalse(ToolResultContent::fromArray(json_decode(json_encode($content), true))->isError); + } + + public function testAcceptsEveryCallToolResultContentBlock(): void + { + $content = ToolResultContent::fromArray([ + 'toolUseId' => 'call-1', + 'content' => [ + ['type' => 'text', 'text' => 'a'], + ['type' => 'image', 'data' => base64_encode('img'), 'mimeType' => 'image/png'], + ['type' => 'audio', 'data' => base64_encode('snd'), 'mimeType' => 'audio/wav'], + ['type' => 'resource_link', 'uri' => 'file:///report.txt', 'name' => 'report'], + ['type' => 'resource', 'resource' => ['uri' => 'file:///a.txt', 'mimeType' => 'text/plain', 'text' => 'a']], + ], + ]); + + $this->assertInstanceOf(ResourceLink::class, $content->content[3]); + $this->assertInstanceOf(EmbeddedResource::class, $content->content[4]); + } + + public function testFilteredContentStillSerializesAsAnArray(): void + { + $blocks = [new TextContent('drop me'), new TextContent('keep me')]; + + // array_filter() preserves keys, so this list starts at index 1. + $kept = array_filter($blocks, static fn (TextContent $block): bool => 'keep me' === $block->text); + $content = new ToolResultContent('call-1', $kept); + + $this->assertSame( + '{"type":"tool_result","toolUseId":"call-1","content":[{"type":"text","text":"keep me"}]}', + json_encode($content), + ); + } + + public function testRejectsNonStandardContentBlocks(): void + { + $this->expectException(InvalidArgumentException::class); + + /* @phpstan-ignore argument.type */ + new ToolResultContent('call-1', [ + new SamplingMessage(Role::User, new TextContent('not a tool result content block')), + ]); + } + + public function testRejectsUnsupportedContentType(): void + { + $this->expectException(InvalidArgumentException::class); + + ToolResultContent::fromArray([ + 'toolUseId' => 'call-1', + 'content' => [['type' => 'tool_use', 'id' => 'x', 'name' => 'y', 'input' => []]], + ]); + } + + public function testRejectsMissingToolUseId(): void + { + $this->expectException(InvalidArgumentException::class); + + ToolResultContent::fromArray(['content' => []]); + } +} diff --git a/tests/Unit/Schema/Content/ToolUseContentTest.php b/tests/Unit/Schema/Content/ToolUseContentTest.php new file mode 100644 index 0000000..686481b --- /dev/null +++ b/tests/Unit/Schema/Content/ToolUseContentTest.php @@ -0,0 +1,104 @@ + 'tool_use', + 'id' => 'call-1', + 'name' => 'weather', + 'input' => ['city' => 'Paris'], + '_meta' => ['provider' => 'test'], + ]); + + $this->assertSame('tool_use', $content->type); + $this->assertSame('call-1', $content->id); + $this->assertSame('weather', $content->name); + $this->assertSame(['city' => 'Paris'], $content->input); + $this->assertSame(['provider' => 'test'], $content->meta); + + $this->assertSame( + '{"type":"tool_use","id":"call-1","name":"weather","input":{"city":"Paris"},"_meta":{"provider":"test"}}', + json_encode($content), + ); + } + + public function testEmptyInputSerializesAsObject(): void + { + $content = new ToolUseContent('call-1', 'ping', []); + + $this->assertSame('{"type":"tool_use","id":"call-1","name":"ping","input":{}}', json_encode($content)); + } + + public function testEmptyInputSurvivesRoundTrip(): void + { + $decoded = json_decode(json_encode(new ToolUseContent('call-1', 'ping', [])), true); + + $this->assertSame([], ToolUseContent::fromArray($decoded)->input); + } + + /** + * @return iterable}> + */ + public static function provideInvalidData(): iterable + { + yield 'missing id' => [['name' => 'weather', 'input' => []]]; + yield 'non-string id' => [['id' => 1, 'name' => 'weather', 'input' => []]]; + yield 'missing name' => [['id' => 'call-1', 'input' => []]]; + yield 'non-string name' => [['id' => 'call-1', 'name' => 1, 'input' => []]]; + yield 'missing input' => [['id' => 'call-1', 'name' => 'weather']]; + yield 'non-array input' => [['id' => 'call-1', 'name' => 'weather', 'input' => 'nope']]; + } + + /** + * @param array $data + */ + #[DataProvider('provideInvalidData')] + public function testInvalidDataIsRejected(array $data): void + { + $this->expectException(InvalidArgumentException::class); + + ToolUseContent::fromArray($data); + } + + public function testRejectsListInput(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ToolUseContent "input" must be a map of argument names, not a list.'); + + /* @phpstan-ignore argument.type (deliberately list-shaped) */ + new ToolUseContent('call-1', 'get_weather', ['Berlin']); + } + + public function testRejectsListInputFromArray(): void + { + $this->expectException(InvalidArgumentException::class); + + ToolUseContent::fromArray(['type' => 'tool_use', 'id' => 'call-1', 'name' => 'x', 'input' => [1]]); + } + + public function testEmptyInputIsAccepted(): void + { + $content = ToolUseContent::fromArray(['type' => 'tool_use', 'id' => 'call-1', 'name' => 'ping', 'input' => []]); + + $this->assertSame([], $content->input); + $this->assertSame('{"type":"tool_use","id":"call-1","name":"ping","input":{}}', json_encode($content)); + } +} diff --git a/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php index fefea47..a9e1173 100644 --- a/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php @@ -71,13 +71,23 @@ final class BooleanSchemaDefinitionTest extends TestCase $this->assertTrue($schema->default); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = BooleanSchemaDefinition::fromArray([]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for boolean schema definition.'); /* @phpstan-ignore argument.type */ - BooleanSchemaDefinition::fromArray([]); + BooleanSchemaDefinition::fromArray(['title' => 42]); } public function testJsonSerializeWithMinimalParams(): void diff --git a/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php index 1bd55cc..ea200a8 100644 --- a/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php @@ -114,13 +114,23 @@ final class EnumSchemaDefinitionTest extends TestCase $this->assertSame(['Poor', 'Fair', 'Good'], $schema->enumNames); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = EnumSchemaDefinition::fromArray(['enum' => ['a', 'b']]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for enum schema definition.'); /* @phpstan-ignore argument.type */ - EnumSchemaDefinition::fromArray(['enum' => ['a', 'b']]); + EnumSchemaDefinition::fromArray(['title' => 42]); } public function testFromArrayWithMissingEnum(): void diff --git a/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php index 167ad08..208f5d2 100644 --- a/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php @@ -137,13 +137,26 @@ final class MultiSelectEnumSchemaDefinitionTest extends TestCase $this->assertSame(3, $schema->maxItems); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = MultiSelectEnumSchemaDefinition::fromArray([ + 'items' => ['type' => 'string', 'enum' => ['a']], + ]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for multi-select enum schema definition.'); /* @phpstan-ignore argument.type */ MultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 42, 'items' => ['type' => 'string', 'enum' => ['a']], ]); } diff --git a/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php index 8ebdee7..ea04038 100644 --- a/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php @@ -133,13 +133,23 @@ final class NumberSchemaDefinitionTest extends TestCase $this->assertSame(10, $schema->maximum); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = NumberSchemaDefinition::fromArray(['type' => 'integer']); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for number schema definition.'); /* @phpstan-ignore argument.type */ - NumberSchemaDefinition::fromArray(['type' => 'integer']); + NumberSchemaDefinition::fromArray(['title' => 42]); } public function testJsonSerializeAsInteger(): void diff --git a/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php index dbb3277..02998cc 100644 --- a/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php @@ -114,13 +114,23 @@ final class StringSchemaDefinitionTest extends TestCase $this->assertSame(100, $schema->maxLength); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = StringSchemaDefinition::fromArray([]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for string schema definition.'); /* @phpstan-ignore argument.type */ - StringSchemaDefinition::fromArray([]); + StringSchemaDefinition::fromArray(['title' => 42]); } public function testJsonSerializeWithMinimalParams(): void diff --git a/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php index f8c706f..2650283 100644 --- a/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php @@ -124,13 +124,23 @@ final class TitledEnumSchemaDefinitionTest extends TestCase $this->assertSame('b', $schema->default); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = TitledEnumSchemaDefinition::fromArray(['oneOf' => [['const' => 'a', 'title' => 'A']]]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for titled enum schema definition.'); /* @phpstan-ignore argument.type */ - TitledEnumSchemaDefinition::fromArray(['oneOf' => [['const' => 'a', 'title' => 'A']]]); + TitledEnumSchemaDefinition::fromArray(['title' => 42]); } public function testFromArrayWithMissingOneOf(): void diff --git a/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php index 6c1b776..170893f 100644 --- a/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php @@ -166,13 +166,26 @@ final class TitledMultiSelectEnumSchemaDefinitionTest extends TestCase $this->assertSame(2, $schema->maxItems); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'items' => ['anyOf' => [['const' => 'a', 'title' => 'A']]], + ]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for titled multi-select enum schema definition.'); /* @phpstan-ignore argument.type */ TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 42, 'items' => ['anyOf' => [['const' => 'a', 'title' => 'A']]], ]); } diff --git a/tests/Unit/Schema/ElicitationModeTest.php b/tests/Unit/Schema/ElicitationModeTest.php new file mode 100644 index 0000000..1502e1e --- /dev/null +++ b/tests/Unit/Schema/ElicitationModeTest.php @@ -0,0 +1,146 @@ +createSchema()); + + $params = $this->paramsOf($request); + + $this->assertArrayNotHasKey('mode', $params); + $this->assertArrayHasKey('requestedSchema', $params); + $this->assertSame(ElicitationMode::Form, $request->mode); + } + + #[TestDox('url elicitation carries mode and url, and no schema')] + public function testUrlCarriesModeAndUrl(): void + { + $request = ElicitRequest::forUrl('Finish setup', 'https://example.com/setup'); + + $params = $this->paramsOf($request); + + $this->assertSame('url', $params['mode']); + $this->assertSame('https://example.com/setup', $params['url']); + $this->assertArrayNotHasKey('requestedSchema', $params); + $this->assertNull($request->requestedSchema); + } + + #[TestDox('a request without mode is still read as form elicitation')] + public function testAbsentModeDefaultsToForm(): void + { + $request = $this->requestFromParams([ + 'message' => 'Your name?', + 'requestedSchema' => [ + 'type' => 'object', + 'properties' => ['name' => ['type' => 'string', 'title' => 'Name']], + ], + ]); + + $this->assertSame(ElicitationMode::Form, $request->mode); + $this->assertNotNull($request->requestedSchema); + } + + #[TestDox('parses an explicit url-mode request')] + public function testParsesUrlMode(): void + { + $request = $this->requestFromParams([ + 'message' => 'Finish setup', + 'mode' => 'url', + 'url' => 'https://example.com/setup', + ]); + + $this->assertSame(ElicitationMode::Url, $request->mode); + $this->assertSame('https://example.com/setup', $request->url); + } + + #[TestDox('url mode without a url is rejected')] + public function testUrlModeRequiresUrl(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->requestFromParams(['message' => 'Finish setup', 'mode' => 'url']); + } + + #[TestDox('form mode without a schema is rejected')] + public function testFormModeRequiresSchema(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->requestFromParams(['message' => 'Your name?']); + } + + #[TestDox('an unknown mode is rejected rather than silently treated as a form')] + public function testUnknownModeRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->requestFromParams(['message' => 'hi', 'mode' => 'telepathy']); + } + + #[TestDox('an explicit null mode is rejected rather than defaulting to form')] + public function testNullModeRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "mode" parameter'); + + $this->requestFromParams([ + 'message' => 'Your name?', + 'mode' => null, + 'requestedSchema' => [ + 'type' => 'object', + 'properties' => ['name' => ['type' => 'string', 'title' => 'Name']], + ], + ]); + } + + private function createSchema(): ElicitationSchema + { + return new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']); + } + + /** + * @param array $params + */ + private function requestFromParams(array $params): ElicitRequest + { + return ElicitRequest::fromArray([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => 'request-1', + 'method' => ElicitRequest::getMethod(), + 'params' => $params, + ]); + } + + /** + * @return array + */ + private function paramsOf(ElicitRequest $request): array + { + /** @var array{params?: array} $encoded */ + $encoded = json_decode(json_encode($request->withId('request-1')) ?: '', true); + + return $encoded['params'] ?? []; + } +} diff --git a/tests/Unit/Schema/Enum/ProtocolVersionTest.php b/tests/Unit/Schema/Enum/ProtocolVersionTest.php new file mode 100644 index 0000000..ca5eb64 --- /dev/null +++ b/tests/Unit/Schema/Enum/ProtocolVersionTest.php @@ -0,0 +1,157 @@ + $version) { + $this->assertTrue( + $version->isAtLeast($version), + \sprintf('%s should be at least itself.', $version->value), + ); + + foreach (\array_slice($cases, $index + 1) as $newer) { + $this->assertTrue( + $newer->isAtLeast($version), + \sprintf('%s is declared after %s and should compare newer.', $newer->value, $version->value), + ); + $this->assertFalse( + $version->isAtLeast($newer), + \sprintf('%s should not compare newer than %s.', $version->value, $newer->value), + ); + } + } + } + + #[TestDox('splits the known revisions into a handshake and a modern era')] + public function testEraSplitCoversEveryCaseExactlyOnce(): void + { + $handshake = ProtocolVersion::handshakeVersions(); + $modern = ProtocolVersion::modernVersions(); + + // A partition: every revision in exactly one era, in declaration order. + $this->assertSame(ProtocolVersion::cases(), [...$handshake, ...$modern]); + } + + #[TestDox('every known revision is assigned to an era on purpose')] + public function testEveryRevisionIsClassifiedExplicitly(): void + { + // Hand-maintained on purpose: a revision appended below FIRST_MODERN_VERSION + // is classified modern by accident, and every other assertion in this file + // stays green while it vanishes from the handshake negotiation. + $eras = [ + '2024-11-05' => false, + '2025-03-26' => false, + '2025-06-18' => false, + '2025-11-25' => false, + '2026-07-28' => true, + ]; + + $this->assertSame( + array_keys($eras), + array_map(static fn (ProtocolVersion $version): string => $version->value, ProtocolVersion::cases()), + 'A revision was added or removed: list it above with the era it belongs to.', + ); + + foreach (ProtocolVersion::cases() as $version) { + $this->assertSame( + $eras[$version->value], + $version->isModern(), + \sprintf('%s is in the wrong era; a handshake revision must be declared above %s.', $version->value, ProtocolVersion::FIRST_MODERN_VERSION->value), + ); + } + } + + #[TestDox('2026-07-28 opens the modern era')] + public function testModernEraStartsAt20260728(): void + { + $this->assertSame(ProtocolVersion::V2026_07_28, ProtocolVersion::FIRST_MODERN_VERSION); + + $this->assertFalse(ProtocolVersion::V2025_11_25->isModern()); + $this->assertTrue(ProtocolVersion::V2026_07_28->isModern()); + } + + #[TestDox('latestHandshake() stops short of the modern era')] + public function testLatestHandshakeStopsBeforeTheModernEra(): void + { + $this->assertSame(ProtocolVersion::V2025_11_25, ProtocolVersion::latestHandshake()); + $this->assertFalse(ProtocolVersion::latestHandshake()->isModern()); + } + + #[TestDox('handshake versions never contain a modern revision')] + public function testHandshakeVersionsExcludeModernRevisions(): void + { + foreach (ProtocolVersion::handshakeVersions() as $version) { + $this->assertFalse($version->isModern(), \sprintf('%s leaked into the handshake era.', $version->value)); + } + } + + #[TestDox('compares by declaration order, not by string value')] + #[DataProvider('provideVersionComparisons')] + public function testIsAtLeast(ProtocolVersion $version, ProtocolVersion $minimum, bool $expected): void + { + $this->assertSame($expected, $version->isAtLeast($minimum)); + } + + /** + * @return iterable + */ + public static function provideVersionComparisons(): iterable + { + yield 'equal' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18, true]; + yield 'newer' => [ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_06_18, true]; + yield 'older' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_03_26, false]; + yield 'across eras' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2024_11_05, true]; + yield 'oldest against newest' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2026_07_28, false]; + } + + #[TestDox('the header-absent default is the revision that introduced the header')] + public function testDefaultHeaderVersion(): void + { + $this->assertSame(ProtocolVersion::V2025_03_26, ProtocolVersion::DEFAULT_HEADER_VERSION); + } + + #[TestDox('SEP-2106 lifts the object-only rule for structuredContent')] + public function testRequiresObjectStructuredContent(): void + { + foreach (ProtocolVersion::handshakeVersions() as $version) { + $this->assertTrue($version->requiresObjectStructuredContent(), \sprintf('%s predates SEP-2106.', $version->value)); + } + + $this->assertFalse(ProtocolVersion::V2026_07_28->requiresObjectStructuredContent()); + } + + #[TestDox('SEP-2164 moves resource-not-found from -32002 to -32602')] + public function testUsesInvalidParamsForResourceNotFound(): void + { + foreach (ProtocolVersion::handshakeVersions() as $version) { + $this->assertFalse( + $version->usesInvalidParamsForResourceNotFound(), + \sprintf('%s predates SEP-2164 and still expects -32002.', $version->value), + ); + } + + $this->assertTrue(ProtocolVersion::V2026_07_28->usesInvalidParamsForResourceNotFound()); + } +} diff --git a/tests/Unit/Schema/Extension/AbstractExtensionTest.php b/tests/Unit/Schema/Extension/AbstractExtensionTest.php new file mode 100644 index 0000000..9ae06ab --- /dev/null +++ b/tests/Unit/Schema/Extension/AbstractExtensionTest.php @@ -0,0 +1,39 @@ +assertSame([], $extension->getMessages()); + $this->assertSame([], $extension->getRequestHandlers()); + } +} diff --git a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php index ec0fd2b..6e2f60a 100644 --- a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php +++ b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php @@ -17,15 +17,17 @@ use Mcp\Schema\Extension\Apps\UiResourceContentMeta; use Mcp\Schema\Extension\Apps\UiResourceCsp; use Mcp\Schema\Extension\Apps\UiResourcePermissions; use Mcp\Schema\Extension\Apps\UiToolMeta; +use Mcp\Schema\Extension\ExtensionInterface; use PHPUnit\Framework\TestCase; class McpAppsTest extends TestCase { - public function testServerExtensionInterface(): void + public function testExtensionInterface(): void { $extension = new McpApps(); - $this->assertSame('io.modelcontextprotocol/ui', $extension->getId()); + $this->assertInstanceOf(ExtensionInterface::class, $extension); + $this->assertSame('io.modelcontextprotocol/ui', (string) $extension->getId()); $this->assertSame(['mimeTypes' => ['text/html;profile=mcp-app']], $extension->getCapabilities()); } diff --git a/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php b/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php index d966111..dbb7e7e 100644 --- a/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php +++ b/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php @@ -57,6 +57,24 @@ class CapabilitiesExtensionsTest extends TestCase $this->assertObjectHasProperty(McpApps::EXTENSION_ID, $json['extensions']); } + public function testServerCapabilitiesSerializeSettinglessExtensionAsObject(): void + { + $caps = new ServerCapabilities(tools: true, extensions: ['acme/thing' => []]); + + $json = json_encode($caps->jsonSerialize(), \JSON_UNESCAPED_SLASHES); + + $this->assertStringContainsString('"acme/thing":{}', (string) $json); + } + + public function testClientCapabilitiesSerializeSettinglessExtensionAsObject(): void + { + $caps = new ClientCapabilities(elicitation: true, extensions: ['acme/thing' => []]); + + $json = json_encode($caps->jsonSerialize(), \JSON_UNESCAPED_SLASHES); + + $this->assertStringContainsString('"acme/thing":{}', (string) $json); + } + public function testServerCapabilitiesJsonSerializeWithoutExtensions(): void { $caps = new ServerCapabilities(tools: true); @@ -113,6 +131,17 @@ class CapabilitiesExtensionsTest extends TestCase $this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 2]], $caps->extensions, 'original is unchanged'); } + public function testClientCapabilitiesWithExtensionsMerges(): void + { + $caps = new ClientCapabilities(elicitation: true, extensions: ['a' => ['x' => 1], 'b' => ['y' => 2]]); + + $merged = $caps->withExtensions(['b' => ['y' => 99], 'c' => ['z' => 3]]); + + $this->assertTrue($merged->elicitation); + $this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 99], 'c' => ['z' => 3]], $merged->extensions); + $this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 2]], $caps->extensions, 'original is unchanged'); + } + public function testClientCapabilitiesWithExtensions(): void { $extensions = [ diff --git a/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php b/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php new file mode 100644 index 0000000..96ff22d --- /dev/null +++ b/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php @@ -0,0 +1,88 @@ + + */ + public static function validIdentifiers(): iterable + { + yield 'official tasks' => ['io.modelcontextprotocol/tasks']; + yield 'official ui' => ['io.modelcontextprotocol/ui']; + yield 'vendor' => ['com.example/my-extension']; + yield 'deep prefix' => ['org.example.api.v2/thing']; + yield 'name with dots' => ['com.example/a.b.c']; + yield 'name with underscores' => ['com.example/a_b']; + yield 'digits inside labels' => ['com.example2/x1']; + } + + #[DataProvider('validIdentifiers')] + #[TestDox('a well-formed identifier is accepted and stringifies back to itself')] + public function testValidIdentifiers(string $identifier): void + { + $this->assertSame($identifier, (string) new ExtensionIdentifier($identifier)); + } + + /** + * @return iterable + */ + public static function invalidIdentifiers(): iterable + { + yield 'no prefix' => ['tasks', 'has no prefix']; + yield 'empty name' => ['com.example/', 'not a valid extension name']; + yield 'prefix label starting with a digit' => ['1com.example/x', 'not a valid prefix']; + yield 'prefix label ending with a hyphen' => ['com.example-/x', 'not a valid prefix']; + yield 'empty prefix label' => ['com..example/x', 'not a valid prefix']; + yield 'name starting with a dot' => ['com.example/.x', 'not a valid extension name']; + yield 'name ending with a hyphen' => ['com.example/x-', 'not a valid extension name']; + yield 'space in the name' => ['com.example/my extension', 'not a valid extension name']; + } + + #[DataProvider('invalidIdentifiers')] + #[TestDox('a malformed identifier is refused at construction, with the reason')] + public function testInvalidIdentifiers(string $identifier, string $reason): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($reason); + + new ExtensionIdentifier($identifier); + } + + /** + * @return iterable + */ + public static function reservations(): iterable + { + yield 'io.modelcontextprotocol is reserved' => ['io.modelcontextprotocol/tasks', true]; + yield 'dev.mcp is reserved' => ['dev.mcp/thing', true]; + yield 'org.modelcontextprotocol.api is reserved' => ['org.modelcontextprotocol.api/thing', true]; + yield 'com.mcp.tools is reserved' => ['com.mcp.tools/thing', true]; + yield 'com.example.mcp is not: the second label is example' => ['com.example.mcp/thing', false]; + yield 'com.example is not' => ['com.example/thing', false]; + yield 'a single-label prefix has no second label' => ['example/thing', false]; + } + + #[DataProvider('reservations')] + #[TestDox('the reserved second labels are recognised, and only those')] + public function testReservedPrefixes(string $identifier, bool $reserved): void + { + $this->assertSame($reserved, (new ExtensionIdentifier($identifier))->isReserved()); + } +} diff --git a/tests/Unit/Schema/IconTest.php b/tests/Unit/Schema/IconTest.php index 500a81b..65e00e2 100644 --- a/tests/Unit/Schema/IconTest.php +++ b/tests/Unit/Schema/IconTest.php @@ -12,6 +12,7 @@ namespace Mcp\Tests\Unit\Schema; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\IconTheme; use Mcp\Schema\Icon; use PHPUnit\Framework\TestCase; @@ -85,4 +86,19 @@ class IconTest extends TestCase $this->assertSame($dataUri, $icon->src); } + + public function testFromArrayReadsTheme(): void + { + $icon = Icon::fromArray(['src' => 'https://example.com/icon.png', 'theme' => 'dark']); + + $this->assertSame(IconTheme::Dark, $icon->theme); + $this->assertSame('dark', $icon->jsonSerialize()['theme']); + } + + public function testFromArrayRejectsUnknownTheme(): void + { + $this->expectException(InvalidArgumentException::class); + + Icon::fromArray(['src' => 'https://example.com/icon.png', 'theme' => 'sepia']); + } } diff --git a/tests/Unit/Schema/ImplementationTest.php b/tests/Unit/Schema/ImplementationTest.php index 1e19dea..3268b6b 100644 --- a/tests/Unit/Schema/ImplementationTest.php +++ b/tests/Unit/Schema/ImplementationTest.php @@ -26,6 +26,7 @@ final class ImplementationTest extends TestCase $this->assertNull($implementation->description); $this->assertNull($implementation->icons); $this->assertNull($implementation->websiteUrl); + $this->assertNull($implementation->title); } public function testFromArrayWithMinimalData(): void @@ -148,6 +149,36 @@ final class ImplementationTest extends TestCase Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'icons' => 'nope']); } + public function testFromArrayThrowsOnNonStringDescription(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "description" in Implementation data.'); + + /* @phpstan-ignore argument.type */ + Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'description' => 42]); + } + + public function testFromArrayThrowsOnNonStringTitle(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "title" in Implementation data.'); + + /* @phpstan-ignore argument.type */ + Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'title' => ['nope']]); + } + + public function testFromArrayReadsTitle(): void + { + $implementation = Implementation::fromArray([ + 'name' => 'my-client', + 'version' => '1.0.0', + 'title' => 'My Client', + ]); + + $this->assertSame('My Client', $implementation->title); + $this->assertArrayHasKey('title', $implementation->jsonSerialize()); + } + public function testJsonSerializeRoundTrip(): void { $implementation = Implementation::fromArray([ diff --git a/tests/Unit/Schema/JsonRpc/ErrorCodesTest.php b/tests/Unit/Schema/JsonRpc/ErrorCodesTest.php new file mode 100644 index 0000000..464f347 --- /dev/null +++ b/tests/Unit/Schema/JsonRpc/ErrorCodesTest.php @@ -0,0 +1,78 @@ +assertSame(Error::HEADER_MISMATCH, $error->code); + $this->assertSame(-32020, $error->code); + } + + #[TestDox('missing client capability carries -32021 and the required capabilities')] + public function testMissingClientCapability(): void + { + $error = Error::forMissingRequiredClientCapability( + 'Sampling is required', + new ClientCapabilities(sampling: true), + 'req-1', + ); + + $this->assertSame(-32021, $error->code); + + /** @var array{requiredCapabilities: ClientCapabilities} $data */ + $data = $error->data; + $this->assertInstanceOf(ClientCapabilities::class, $data['requiredCapabilities']); + + $encoded = json_decode(json_encode($error) ?: '', true); + $this->assertArrayHasKey('sampling', $encoded['error']['data']['requiredCapabilities']); + } + + #[TestDox('unsupported version carries -32022 plus the requested and supported versions')] + public function testUnsupportedProtocolVersion(): void + { + $error = Error::forUnsupportedProtocolVersion( + '1900-01-01', + [ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_06_18], + 'req-1', + ); + + $this->assertSame(-32022, $error->code); + $this->assertSame([ + 'requested' => '1900-01-01', + 'supported' => ['2025-11-25', '2025-06-18'], + ], $error->data); + } + + #[TestDox('the supported list survives JSON encoding as a plain array')] + public function testUnsupportedVersionEncoding(): void + { + $error = Error::forUnsupportedProtocolVersion('1900-01-01', ProtocolVersion::handshakeVersions(), 'req-1'); + + $encoded = json_decode(json_encode($error) ?: '', true); + + $this->assertSame( + array_map(static fn (ProtocolVersion $v): string => $v->value, ProtocolVersion::handshakeVersions()), + $encoded['error']['data']['supported'], + ); + } +} diff --git a/tests/Unit/Schema/JsonRpc/NotificationTest.php b/tests/Unit/Schema/JsonRpc/NotificationTest.php index 0d7d7c4..b2580de 100644 --- a/tests/Unit/Schema/JsonRpc/NotificationTest.php +++ b/tests/Unit/Schema/JsonRpc/NotificationTest.php @@ -53,4 +53,44 @@ final class NotificationTest extends TestCase $this->assertSame($expectedMeta, $notification->jsonSerialize()); } + + public function testMetaDoesNotOverrideMetaFromParams(): void + { + $notificationImplementation = new class extends Notification { + public static function getMethod(): string + { + return 'notifications/dummy'; + } + + public static function fromParams(?array $params): self + { + return new self(); + } + + protected function getParams(): array + { + return [ + '_meta' => ['key' => 'from-params'], + ]; + } + }; + + $notification = $notificationImplementation::fromArray([ + 'jsonrpc' => '2.0', + 'method' => 'notifications/dummy', + 'params' => [ + '_meta' => ['key' => 'from-meta'], + ], + ]); + + $expected = [ + 'jsonrpc' => '2.0', + 'method' => 'notifications/dummy', + 'params' => [ + '_meta' => ['key' => 'from-params'], + ], + ]; + + $this->assertSame($expected, $notification->jsonSerialize()); + } } diff --git a/tests/Unit/Schema/JsonRpc/RequestTest.php b/tests/Unit/Schema/JsonRpc/RequestTest.php index f400a90..d23b6a9 100644 --- a/tests/Unit/Schema/JsonRpc/RequestTest.php +++ b/tests/Unit/Schema/JsonRpc/RequestTest.php @@ -55,4 +55,41 @@ final class RequestTest extends TestCase $this->assertSame($expectedMeta, $notification->jsonSerialize()); } + + public function testMetaDoesNotOverrideMetaFromParams(): void + { + $requestImplementation = new class extends Request { + public static function getMethod(): string + { + return 'foo/bar'; + } + + public static function fromParams(?array $params): static + { + return new self(); + } + + protected function getParams(): array + { + return [ + '_meta' => ['key' => 'from-params'], + ]; + } + }; + + $request = $requestImplementation::fromParams(null) + ->withId('12345') + ->withMeta(['key' => 'from-meta']); + + $expected = [ + 'jsonrpc' => '2.0', + 'id' => '12345', + 'method' => 'foo/bar', + 'params' => [ + '_meta' => ['key' => 'from-params'], + ], + ]; + + $this->assertSame($expected, $request->jsonSerialize()); + } } diff --git a/tests/Unit/Schema/NonObjectOutputSchemaTest.php b/tests/Unit/Schema/NonObjectOutputSchemaTest.php new file mode 100644 index 0000000..8e0fb0b --- /dev/null +++ b/tests/Unit/Schema/NonObjectOutputSchemaTest.php @@ -0,0 +1,166 @@ +, required: string[]|null} + */ + private static function validInputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => ['q' => ['type' => 'string']], + 'required' => null, + ]; + } + + /** + * @param array $outputSchema + */ + #[TestDox('outputSchema accepts a non-object root')] + #[DataProvider('provideNonObjectSchemas')] + public function testAcceptsNonObjectOutputSchema(array $outputSchema): void + { + $tool = Tool::fromArray([ + 'name' => 'demo', + 'inputSchema' => self::validInputSchema(), + 'outputSchema' => $outputSchema, + ]); + + $this->assertSame($outputSchema, $tool->outputSchema); + } + + /** + * @return iterable}> + */ + public static function provideNonObjectSchemas(): iterable + { + yield 'array' => [['type' => 'array', 'items' => ['type' => 'string']]]; + yield 'string' => [['type' => 'string']]; + yield 'number' => [['type' => 'number']]; + yield 'boolean' => [['type' => 'boolean']]; + yield 'composition without a root type' => [['oneOf' => [['type' => 'string'], ['type' => 'number']]]]; + } + + #[TestDox('inputSchema still requires an object root, since tool arguments are always an object')] + public function testInputSchemaStillRequiresObjectRoot(): void + { + $this->expectException(InvalidArgumentException::class); + + /* @phpstan-ignore-next-line argument.type (deliberately invalid: an array root must be rejected) */ + Tool::fromArray([ + 'name' => 'demo', + 'inputSchema' => ['type' => 'array', 'properties' => [], 'required' => null], + ]); + } + + #[TestDox('structuredContent holds any JSON value')] + #[DataProvider('provideStructuredValues')] + public function testStructuredContentHoldsAnyJsonValue(mixed $value): void + { + $result = new CallToolResult([new TextContent('ok')], false, $value); + + $this->assertSame($value, $result->structuredContent); + } + + /** + * @return iterable + */ + public static function provideStructuredValues(): iterable + { + yield 'list' => [[1, 2, 3]]; + yield 'string' => ['hello']; + yield 'int' => [42]; + yield 'float' => [1.5]; + yield 'true' => [true]; + yield 'zero' => [0]; + yield 'false' => [false]; + yield 'empty string' => ['']; + yield 'empty array' => [[]]; + } + + /** + * `null` is the only value that means absent, so every other JSON value — + * falsy ones included — reaches the wire. This matches the sibling field on + * {@see \Mcp\Schema\Content\ToolResultContent}, which carries the same tool + * result inside a sampling turn. + */ + #[TestDox('every non-null value reaches the wire, falsy ones included')] + #[DataProvider('provideStructuredValues')] + public function testNonNullValueIsSerialized(mixed $value): void + { + $result = new CallToolResult([new TextContent('ok')], false, $value); + + $serialized = $result->jsonSerialize(); + + $this->assertArrayHasKey('structuredContent', $serialized); + $this->assertSame($value, $serialized['structuredContent']); + } + + #[TestDox('a null structuredContent is omitted entirely')] + public function testNullStructuredContentOmitted(): void + { + $result = new CallToolResult([new TextContent('ok')]); + + $this->assertArrayNotHasKey('structuredContent', $result->jsonSerialize()); + } + + #[TestDox('round-trips any JSON structuredContent through fromArray')] + #[DataProvider('provideStructuredValues')] + public function testRoundTrip(mixed $value): void + { + $result = CallToolResult::fromArray([ + 'content' => [['type' => 'text', 'text' => 'ok']], + 'structuredContent' => $value, + ]); + + $this->assertSame($value, $result->structuredContent); + } + + #[TestDox('an object structuredContent still round-trips unchanged')] + public function testObjectRoundTrip(): void + { + $result = CallToolResult::fromArray([ + 'content' => [['type' => 'text', 'text' => 'ok']], + 'structuredContent' => ['temperature' => 22.5], + ]); + + $this->assertSame(['temperature' => 22.5], $result->structuredContent); + $this->assertSame(['temperature' => 22.5], $result->jsonSerialize()['structuredContent']); + } + + #[TestDox('the empty root schema serializes as {} rather than []')] + public function testEmptyOutputSchemaSerializesAsObject(): void + { + $tool = Tool::fromArray([ + 'name' => 'demo', + 'inputSchema' => self::validInputSchema(), + 'outputSchema' => [], + ]); + + $this->assertSame('{}', json_encode($tool->jsonSerialize()['outputSchema'])); + } +} diff --git a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php index 57f790a..fb065e6 100644 --- a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php +++ b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php @@ -14,8 +14,13 @@ namespace Mcp\Tests\Unit\Schema\Request; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\SamplingMessage; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Content\ToolResultContent; +use Mcp\Schema\Content\ToolUseContent; use Mcp\Schema\Enum\Role; +use Mcp\Schema\Enum\ToolChoiceMode; use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Tool; +use Mcp\Schema\ToolChoice; use PHPUnit\Framework\TestCase; final class CreateSamplingMessageRequestTest extends TestCase @@ -48,4 +53,142 @@ final class CreateSamplingMessageRequestTest extends TestCase /* @phpstan-ignore argument.type */ new CreateSamplingMessageRequest($messages, 150); } + + public function testToolsAndToolChoiceRoundTrip(): void + { + $tool = new Tool('weather', null, ['type' => 'object', 'properties' => [], 'required' => null], 'Get weather', null); + $request = new CreateSamplingMessageRequest( + [new SamplingMessage(Role::User, new TextContent('Weather in Paris?'))], + 150, + tools: [$tool], + toolChoice: new ToolChoice(ToolChoiceMode::Required), + ); + + $payload = $request->withId(1)->jsonSerialize(); + $this->assertSame('weather', $payload['params']['tools'][0]->name); + $this->assertSame(ToolChoiceMode::Required, $payload['params']['toolChoice']->mode); + + $hydrated = CreateSamplingMessageRequest::fromArray(json_decode(json_encode($payload, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR)); + $this->assertSame('weather', $hydrated->tools[0]->name); + $this->assertSame(ToolChoiceMode::Required, $hydrated->toolChoice->mode); + } + + public function testValidToolFlowPasses(): void + { + $this->expectNotToPerformAssertions(); + + $this->requestFor([ + new SamplingMessage(Role::User, new TextContent('Weather in Paris and London?')), + new SamplingMessage(Role::Assistant, [ + new ToolUseContent('call-1', 'weather', ['city' => 'Paris']), + new ToolUseContent('call-2', 'weather', ['city' => 'London']), + ]), + new SamplingMessage(Role::User, [ + new ToolResultContent('call-1', [new TextContent('18 C')]), + new ToolResultContent('call-2', [new TextContent('15 C')]), + ]), + new SamplingMessage(Role::Assistant, new TextContent('Paris is warmer.')), + ])->validateToolFlow(); + } + + public function testToolResultsMixedWithOtherContentAreRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])), + new SamplingMessage(Role::User, [ + new ToolResultContent('call-1', [new TextContent('18 C')]), + new TextContent('and also...'), + ]), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Tool results mixed with other content.'); + + $request->validateToolFlow(); + } + + public function testToolUseInUserMessageIsRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::User, new ToolUseContent('call-1', 'weather', [])), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ToolUseContent is only valid in assistant sampling messages.'); + + $request->validateToolFlow(); + } + + public function testToolResultInAssistantMessageIsRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::Assistant, new ToolResultContent('call-1', [new TextContent('18 C')])), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ToolResultContent is only valid in user sampling messages.'); + + $request->validateToolFlow(); + } + + public function testUnansweredToolUseIsRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::Assistant, [ + new ToolUseContent('call-1', 'weather', []), + new ToolUseContent('call-2', 'weather', []), + ]), + new SamplingMessage(Role::User, [new ToolResultContent('call-1', [new TextContent('18 C')])]), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Tool result missing in request.'); + + $request->validateToolFlow(); + } + + public function testTrailingToolUseIsRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Tool result missing in request.'); + + $request->validateToolFlow(); + } + + public function testToolUseFollowedByPlainMessageIsRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])), + new SamplingMessage(Role::User, new TextContent('never mind')), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Tool result missing in request.'); + + $request->validateToolFlow(); + } + + public function testUnsolicitedToolResultIsRejected(): void + { + $request = $this->requestFor([ + new SamplingMessage(Role::User, new ToolResultContent('call-9', [new TextContent('18 C')])), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Tool result "call-9" does not answer a preceding tool use.'); + + $request->validateToolFlow(); + } + + /** + * @param SamplingMessage[] $messages + */ + private function requestFor(array $messages): CreateSamplingMessageRequest + { + return new CreateSamplingMessageRequest($messages, 150); + } } diff --git a/tests/Unit/Schema/ResourceDefinitionTest.php b/tests/Unit/Schema/ResourceDefinitionTest.php index ab39f41..e6bbe56 100644 --- a/tests/Unit/Schema/ResourceDefinitionTest.php +++ b/tests/Unit/Schema/ResourceDefinitionTest.php @@ -116,6 +116,30 @@ class ResourceDefinitionTest extends TestCase $this->assertArrayNotHasKey('title', $data); } + /** + * The specification puts no pattern on `name`; its own examples use + * names such as "main.rs" and "Project Files". + */ + #[DataProvider('provideSpecExampleNames')] + public function testConstructorAcceptsAnyName(string $name): void + { + $resource = new ResourceDefinition( + uri: self::VALID_URI, + name: $name, + ); + + $this->assertSame($name, $resource->name); + } + + public static function provideSpecExampleNames(): iterable + { + yield 'file name with dot' => ['main.rs']; + yield 'display name with space' => ['Project Files']; + yield 'uppercase file name' => ['README.md']; + yield 'namespaced' => ['acme/reports:quarterly']; + yield 'unicode' => ['Ünïcödé 名前']; + } + #[DataProvider('provideInvalidResources')] public function testFromArrayInvalid(array $input, string $expectedExceptionMessage): void { diff --git a/tests/Unit/Schema/ResourceTemplateTest.php b/tests/Unit/Schema/ResourceTemplateTest.php index 3e2908d..b99c9d0 100644 --- a/tests/Unit/Schema/ResourceTemplateTest.php +++ b/tests/Unit/Schema/ResourceTemplateTest.php @@ -114,6 +114,30 @@ class ResourceTemplateTest extends TestCase $this->assertArrayNotHasKey('title', $data); } + /** + * The specification puts no pattern on `name`; its own examples use + * names such as "main.rs" and "Project Files". + */ + #[DataProvider('provideSpecExampleNames')] + public function testConstructorAcceptsAnyName(string $name): void + { + $resource = new ResourceTemplate( + uriTemplate: self::VALID_URI, + name: $name, + ); + + $this->assertSame($name, $resource->name); + } + + public static function provideSpecExampleNames(): iterable + { + yield 'file name with dot' => ['main.rs']; + yield 'display name with space' => ['Project Files']; + yield 'uppercase file name' => ['README.md']; + yield 'namespaced' => ['acme/reports:quarterly']; + yield 'unicode' => ['Ünïcödé 名前']; + } + #[DataProvider('provideInvalidResources')] public function testFromArrayInvalid(array $input, string $expectedExceptionMessage): void { diff --git a/tests/Unit/Schema/Result/CallToolResultTest.php b/tests/Unit/Schema/Result/CallToolResultTest.php new file mode 100644 index 0000000..f7c2b6f --- /dev/null +++ b/tests/Unit/Schema/Result/CallToolResultTest.php @@ -0,0 +1,112 @@ + [ + [ + 'type' => 'resource_link', + 'uri' => 'file:///project/src/main.rs', + 'name' => 'main.rs', + 'mimeType' => 'text/x-rust', + ], + ], + 'isError' => false, + ]); + + $this->assertCount(1, $result->content); + $this->assertInstanceOf(ResourceLink::class, $result->content[0]); + $this->assertSame('file:///project/src/main.rs', $result->content[0]->uri); + $this->assertSame('main.rs', $result->content[0]->name); + $this->assertSame('text/x-rust', $result->content[0]->mimeType); + } + + public function testFromArrayDeserializesMixedContentTypes(): void + { + $result = CallToolResult::fromArray([ + 'content' => [ + ['type' => 'text', 'text' => 'search results'], + ['type' => 'resource_link', 'uri' => 'file:///a.png', 'name' => 'a.png'], + ['type' => 'resource_link', 'uri' => 'file:///b.png', 'name' => 'b.png'], + ], + ]); + + $this->assertCount(3, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertInstanceOf(ResourceLink::class, $result->content[1]); + $this->assertInstanceOf(ResourceLink::class, $result->content[2]); + } + + public function testFromArrayRejectsUnknownContentType(): void + { + $this->expectException(InvalidArgumentException::class); + + CallToolResult::fromArray([ + 'content' => [ + ['type' => 'not-a-real-type'], + ], + ]); + } + + public function testJsonSerializeIncludesResourceLinkContent(): void + { + $result = new CallToolResult([ + new ResourceLink('file:///project/src/main.rs', 'main.rs'), + ]); + + $data = $result->jsonSerialize(); + + $this->assertSame([ + 'type' => 'resource_link', + 'uri' => 'file:///project/src/main.rs', + 'name' => 'main.rs', + ], $data['content'][0]->jsonSerialize()); + } + + public function testRoundTripWithResourceLinkAlongsideOtherContentTypes(): void + { + $original = new CallToolResult([ + new TextContent('25 results found'), + new ResourceLink('file:///a.png', 'a.png', mimeType: 'image/png'), + new ImageContent(base64_encode('binary'), 'image/png'), + new AudioContent(base64_encode('binary'), 'audio/mpeg'), + EmbeddedResource::fromText('file:///readme.txt', 'hello'), + ]); + + $decoded = json_decode(json_encode($original), true); + $rehydrated = CallToolResult::fromArray($decoded); + + $this->assertCount(5, $rehydrated->content); + $this->assertInstanceOf(TextContent::class, $rehydrated->content[0]); + $this->assertInstanceOf(ResourceLink::class, $rehydrated->content[1]); + $this->assertInstanceOf(ImageContent::class, $rehydrated->content[2]); + $this->assertInstanceOf(AudioContent::class, $rehydrated->content[3]); + $this->assertInstanceOf(EmbeddedResource::class, $rehydrated->content[4]); + + $this->assertSame('file:///a.png', $rehydrated->content[1]->uri); + $this->assertSame('a.png', $rehydrated->content[1]->name); + $this->assertSame('image/png', $rehydrated->content[1]->mimeType); + } +} diff --git a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php new file mode 100644 index 0000000..4196105 --- /dev/null +++ b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php @@ -0,0 +1,128 @@ + 'assistant', + 'content' => [ + ['type' => 'text', 'text' => 'Checking weather.'], + ['type' => 'tool_use', 'id' => 'call-1', 'name' => 'weather', 'input' => ['city' => 'Paris']], + ], + 'model' => 'test-model', + 'stopReason' => 'toolUse', + '_meta' => ['traceId' => 'trace-1'], + ]); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertInstanceOf(ToolUseContent::class, $result->content[1]); + $this->assertSame('toolUse', $result->stopReason); + $this->assertSame('toolUse', $result->jsonSerialize()['stopReason']); + $this->assertSame(['traceId' => 'trace-1'], $result->jsonSerialize()['_meta']); + } + + public function testProviderSpecificStopReasonIsPreserved(): void + { + $result = CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => ['type' => 'text', 'text' => 'Done'], + 'model' => 'test-model', + 'stopReason' => 'provider-specific', + ]); + + $this->assertSame('provider-specific', $result->stopReason); + $this->assertSame('provider-specific', $result->jsonSerialize()['stopReason']); + } + + public function testKnownStopReasonStaysAString(): void + { + $result = CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => ['type' => 'text', 'text' => 'Done'], + 'model' => 'test-model', + 'stopReason' => 'endTurn', + ]); + + $this->assertSame('endTurn', $result->stopReason); + } + + public function testSingleContentBlockKeepsItsShape(): void + { + $result = CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => ['type' => 'text', 'text' => 'Done'], + 'model' => 'test-model', + ]); + + $this->assertInstanceOf(TextContent::class, $result->content); + $this->assertCount(1, $result->getContentBlocks()); + $this->assertSame('{"type":"text","text":"Done"}', json_encode($result->jsonSerialize()['content'])); + } + + public function testFilteredContentStillSerializesAsAnArray(): void + { + $blocks = [new TextContent('thinking'), new ToolUseContent('call-1', 'weather', [])]; + + // array_filter() preserves keys, so this list starts at index 1. + $toolUses = array_filter($blocks, static fn ($block): bool => $block instanceof ToolUseContent); + $result = new CreateSamplingMessageResult(Role::Assistant, $toolUses, 'test-model'); + + $this->assertSame( + '[{"type":"tool_use","id":"call-1","name":"weather","input":{}}]', + json_encode($result->jsonSerialize()['content']), + ); + } + + public function testNonAssistantRoleIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('CreateSamplingMessageResult role must be "assistant".'); + + CreateSamplingMessageResult::fromArray([ + 'role' => 'user', + 'content' => ['type' => 'text', 'text' => 'Done'], + 'model' => 'test-model', + ]); + } + + public function testEmptyContentIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => [], + 'model' => 'test-model', + ]); + } + + public function testToolResultContentIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => ['type' => 'tool_result', 'toolUseId' => 'call-1', 'content' => []], + 'model' => 'test-model', + ]); + } +} diff --git a/tests/Unit/Schema/Result/ElicitResultTest.php b/tests/Unit/Schema/Result/ElicitResultTest.php index 6ffd15c..b946c77 100644 --- a/tests/Unit/Schema/Result/ElicitResultTest.php +++ b/tests/Unit/Schema/Result/ElicitResultTest.php @@ -13,6 +13,7 @@ namespace Mcp\Tests\Unit\Schema\Result; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ElicitationMode; use Mcp\Schema\Result\ElicitResult; use PHPUnit\Framework\TestCase; @@ -85,7 +86,8 @@ final class ElicitResultTest extends TestCase public function testFromArrayWithInvalidAction(): void { - $this->expectException(\ValueError::class); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "action" value "invalid"'); ElicitResult::fromArray(['action' => 'invalid']); } @@ -98,6 +100,23 @@ final class ElicitResultTest extends TestCase ElicitResult::fromArray(['action' => 'accept']); } + public function testFromArrayWithUrlModeAllowsContentlessAccept(): void + { + $result = ElicitResult::fromArray(['action' => 'accept'], ElicitationMode::Url); + + $this->assertSame(ElicitAction::Accept, $result->action); + $this->assertNull($result->content); + $this->assertTrue($result->isAccepted()); + } + + public function testFromArrayWithExplicitFormModeRequiresContent(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Content must be provided when action is "accept"'); + + ElicitResult::fromArray(['action' => 'accept'], ElicitationMode::Form); + } + public function testIsAccepted(): void { $acceptResult = new ElicitResult(ElicitAction::Accept, ['name' => 'John']); @@ -158,4 +177,15 @@ final class ElicitResultTest extends TestCase 'action' => 'cancel', ], $result->jsonSerialize()); } + + public function testUrlModeRejectsContent(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Content must not be provided for a url-mode elicitation result.'); + + ElicitResult::fromArray( + ['action' => 'accept', 'content' => ['name' => 'Ada']], + ElicitationMode::Url, + ); + } } diff --git a/tests/Unit/Schema/Result/ListRootsResultTest.php b/tests/Unit/Schema/Result/ListRootsResultTest.php new file mode 100644 index 0000000..46729fb --- /dev/null +++ b/tests/Unit/Schema/Result/ListRootsResultTest.php @@ -0,0 +1,110 @@ +assertSame($roots, $result->roots); + $this->assertNull($result->meta); + } + + public function testFromArray(): void + { + $result = ListRootsResult::fromArray([ + 'roots' => [ + ['uri' => 'file:///home/user/project', 'name' => 'project'], + ['uri' => 'file:///tmp'], + ], + ]); + + $this->assertCount(2, $result->roots); + $this->assertSame('file:///home/user/project', $result->roots[0]->uri); + $this->assertSame('project', $result->roots[0]->name); + $this->assertSame('file:///tmp', $result->roots[1]->uri); + $this->assertNull($result->roots[1]->name); + $this->assertNull($result->meta); + } + + public function testFromArrayWithMeta(): void + { + $result = ListRootsResult::fromArray([ + 'roots' => [['uri' => 'file:///tmp']], + '_meta' => ['requestId' => 'abc'], + ]); + + $this->assertSame(['requestId' => 'abc'], $result->meta); + } + + public function testFromArrayWithMissingRoots(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "roots"'); + + /* @phpstan-ignore argument.type */ + ListRootsResult::fromArray([]); + } + + public function testFromArrayWithNonArrayRoot(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid root in ListRootsResult data, expected an array.'); + + /* @phpstan-ignore argument.type */ + ListRootsResult::fromArray([ + 'roots' => ['file:///tmp'], + ]); + } + + public function testFromArrayRejectsNonFileUri(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must start with "file://"'); + + ListRootsResult::fromArray([ + 'roots' => [['uri' => 'https://example.com']], + ]); + } + + public function testFromArrayRoundTrip(): void + { + $data = [ + 'roots' => [ + ['uri' => 'file:///home/user/project', 'name' => 'project'], + ['uri' => 'file:///tmp'], + ], + '_meta' => ['foo' => 'bar'], + ]; + + $result = ListRootsResult::fromArray($data); + + $this->assertSame($data, json_decode(json_encode($result), true)); + } + + public function testJsonSerializeWithoutMeta(): void + { + $result = new ListRootsResult([new Root('file:///tmp')]); + + $this->assertSame([ + 'roots' => [['uri' => 'file:///tmp']], + ], json_decode(json_encode($result), true)); + } +} diff --git a/tests/Unit/Schema/ToolChoiceTest.php b/tests/Unit/Schema/ToolChoiceTest.php new file mode 100644 index 0000000..2b5e7cd --- /dev/null +++ b/tests/Unit/Schema/ToolChoiceTest.php @@ -0,0 +1,72 @@ + + */ + public static function provideModes(): iterable + { + yield 'auto' => [ToolChoiceMode::Auto]; + yield 'required' => [ToolChoiceMode::Required]; + yield 'none' => [ToolChoiceMode::None]; + } + + #[DataProvider('provideModes')] + public function testRoundTrip(ToolChoiceMode $mode): void + { + $choice = new ToolChoice($mode); + + $this->assertSame(\sprintf('{"mode":"%s"}', $mode->value), json_encode($choice)); + $this->assertSame($mode, ToolChoice::fromArray(json_decode(json_encode($choice), true))->mode); + } + + public function testModeDefaultsToAuto(): void + { + $this->assertSame(ToolChoiceMode::Auto, (new ToolChoice())->mode); + $this->assertSame(ToolChoiceMode::Auto, ToolChoice::fromArray([])->mode); + } + + public function testUnknownModeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid tool choice mode "any".'); + + ToolChoice::fromArray(['mode' => 'any']); + } + + public function testNonStringModeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "mode" in ToolChoice data.'); + + /* @phpstan-ignore argument.type */ + ToolChoice::fromArray(['mode' => 1]); + } + + public function testExplicitNullModeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "mode" in ToolChoice data.'); + + /* @phpstan-ignore argument.type (deliberately null, as malformed wire data would be) */ + ToolChoice::fromArray(['mode' => null]); + } +} diff --git a/tests/Unit/Schema/ToolHeaderAnnotationTest.php b/tests/Unit/Schema/ToolHeaderAnnotationTest.php new file mode 100644 index 0000000..8fab282 --- /dev/null +++ b/tests/Unit/Schema/ToolHeaderAnnotationTest.php @@ -0,0 +1,160 @@ + $properties + */ + private static function tool(array $properties): Tool + { + return new Tool( + name: 'a_tool', + title: null, + inputSchema: ['type' => 'object', 'properties' => $properties, 'required' => null], + description: 'x', + annotations: null, + ); + } + + #[TestDox('a well-formed annotation is accepted')] + public function testValidAnnotationIsAccepted(): void + { + $tool = self::tool([ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'], + 'dry_run' => ['type' => 'boolean', 'x-mcp-header' => 'Dry-Run'], + 'query' => ['type' => 'string'], + ]); + + $this->assertSame('a_tool', $tool->name); + } + + #[TestDox('an annotation on a nested property is accepted: the chain is all properties')] + public function testNestedAnnotationIsAccepted(): void + { + $tool = self::tool([ + 'target' => [ + 'type' => 'object', + 'properties' => ['region' => ['type' => 'string', 'x-mcp-header' => 'Region']], + ], + ]); + + $this->assertSame('a_tool', $tool->name); + } + + /** + * @return iterable, string}> + */ + public static function invalidAnnotations(): iterable + { + yield 'empty name' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => '']], + 'is empty', + ]; + + yield 'name with a space' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => 'My Header']], + 'not a valid HTTP field name', + ]; + + yield 'name with a newline' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => "X\nInjected: yes"]], + 'not a valid HTTP field name', + ]; + + yield 'name with a carriage return' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => "X\rY"]], + 'not a valid HTTP field name', + ]; + + yield 'name with a colon' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => 'X:Y']], + 'not a valid HTTP field name', + ]; + + yield 'duplicate, differing only in case' => [ + [ + 'a' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'b' => ['type' => 'string', 'x-mcp-header' => 'region'], + ], + 'declared twice', + ]; + + yield 'on a number' => [ + ['a' => ['type' => 'number', 'x-mcp-header' => 'Amount']], + 'cannot be mirrored', + ]; + + yield 'on an array' => [ + ['a' => ['type' => 'array', 'items' => ['type' => 'string'], 'x-mcp-header' => 'Tags']], + 'only string, integer and boolean', + ]; + + yield 'on an object' => [ + ['a' => ['type' => 'object', 'x-mcp-header' => 'Blob']], + 'only string, integer and boolean', + ]; + + yield 'non-string annotation value' => [ + ['a' => ['type' => 'string', 'x-mcp-header' => 42]], + 'is empty', + ]; + + yield 'duplicate across nesting levels' => [ + [ + 'a' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'nested' => [ + 'type' => 'object', + 'properties' => ['b' => ['type' => 'string', 'x-mcp-header' => 'Region']], + ], + ], + 'declared twice', + ]; + } + + /** + * @param array $properties + */ + #[DataProvider('invalidAnnotations')] + #[TestDox('an out-of-bounds annotation makes the tool definition invalid')] + public function testInvalidAnnotationIsRefused(array $properties, string $reason): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/'.preg_quote($reason, '/').'/'); + + self::tool($properties); + } + + #[TestDox('an annotation the walk cannot reach statically is simply not seen')] + public function testUnreachableAnnotationIsIgnored(): void + { + // Under `items`, so it is not reachable through `properties` alone. + // The spec calls such a definition invalid; this SDK does not mirror + // what it cannot reach, and does not pretend the annotation exists. + $tool = self::tool([ + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'x-mcp-header' => 'Bad Name With Spaces'], + ], + ]); + + $this->assertSame('a_tool', $tool->name); + } +} diff --git a/tests/Unit/Schema/ToolTest.php b/tests/Unit/Schema/ToolTest.php index dd6861c..dc71189 100644 --- a/tests/Unit/Schema/ToolTest.php +++ b/tests/Unit/Schema/ToolTest.php @@ -97,4 +97,196 @@ class ToolTest extends TestCase $this->assertSame($original->name, $restored->name); $this->assertSame($original->description, $restored->description); } + + public function testConstructorNormalizesEmptyInputSchemaPropertiesToObject(): void + { + $tool = new Tool( + name: 'no_params', + title: null, + inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], + description: null, + annotations: null, + ); + + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']); + $this->assertSame('{"name":"no_params","inputSchema":{"type":"object","properties":{},"required":null}}', json_encode($tool)); + } + + public function testConstructorNormalizesEmptyPropertiesAfterJsonDecodeRoundTrip(): void + { + /** @var array{type: 'object', properties: array, required: null} $schema */ + $schema = json_decode('{"type":"object","properties":{},"required":null}', true); + $this->assertSame([], $schema['properties']); + + $tool = new Tool('t', null, $schema, null, null); + + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']); + $this->assertStringContainsString('"properties":{}', (string) json_encode($tool)); + } + + public function testFromArrayNormalizesNestedEmptyPropertiesRecursively(): void + { + $tool = Tool::fromArray([ + 'name' => 't', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'filter' => ['type' => 'object', 'properties' => []], + ], + 'required' => null, + ], + ]); + + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']['filter']['properties']); + $this->assertStringContainsString('"properties":{}', (string) json_encode($tool->inputSchema['properties']['filter'])); + } + + public function testConstructorNormalizesEmptyOutputSchemaProperties(): void + { + $tool = new Tool( + name: 't', + title: null, + inputSchema: ['type' => 'object', 'properties' => ['q' => ['type' => 'string']], 'required' => null], + description: null, + annotations: null, + outputSchema: ['type' => 'object', 'properties' => []], + ); + + $this->assertInstanceOf(\stdClass::class, $tool->outputSchema['properties']); + $this->assertStringContainsString('"outputSchema":{"type":"object","properties":{}}', (string) json_encode($tool)); + } + + public function testConstructorNormalizesEmptyPropertiesInsideArrayItems(): void + { + $tool = new Tool( + name: 't', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'rows' => [ + 'type' => 'array', + 'items' => ['type' => 'object', 'properties' => []], + ], + ], + 'required' => null, + ], + description: null, + annotations: null, + ); + + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']['rows']['items']['properties']); + } + + /** + * Each case is a schema that is already valid JSON: decoding it collapses every `{}` + * to `[]`, and normalization has to restore it verbatim. + * + * @return iterable + */ + public static function emptySubSchemaProvider(): iterable + { + yield 'top-level properties' => ['{"type":"object","properties":{}}']; + yield 'nested properties' => ['{"type":"object","properties":{"filter":{"type":"object","properties":{}}}}']; + yield 'property schema' => ['{"type":"object","properties":{"anything":{}}}']; + yield 'items schema' => ['{"type":"object","properties":{"tags":{"type":"array","items":{}}}}']; + yield 'tuple items schema' => ['{"type":"object","properties":{"pair":{"type":"array","items":[{"type":"object","properties":{}},{}]}}}']; + yield 'additionalItems schema' => ['{"type":"object","properties":{"tags":{"type":"array","additionalItems":{}}}}']; + yield 'additionalProperties schema' => ['{"type":"object","properties":{"map":{"type":"object","additionalProperties":{}}}}']; + yield 'propertyNames schema' => ['{"type":"object","properties":{"map":{"type":"object","propertyNames":{}}}}']; + yield 'contains schema' => ['{"type":"object","properties":{"tags":{"type":"array","contains":{}}}}']; + yield 'unevaluatedItems schema' => ['{"type":"object","properties":{"tags":{"type":"array","unevaluatedItems":{}}}}']; + yield 'unevaluatedProperties schema' => ['{"type":"object","properties":{"map":{"type":"object","unevaluatedProperties":{}}}}']; + yield 'not schema' => ['{"type":"object","properties":{"a":{"not":{}}}}']; + yield 'if/then/else schemas' => ['{"type":"object","properties":{"a":{"if":{},"then":{"type":"object","properties":{}},"else":{}}}}']; + yield '$defs entry' => ['{"type":"object","properties":{"a":{"$ref":"#/$defs/E"}},"$defs":{"E":{"type":"object","properties":{}}}}']; + yield 'definitions entry' => ['{"type":"object","properties":{"a":{"$ref":"#/definitions/E"}},"definitions":{"E":{}}}']; + yield 'patternProperties entry' => ['{"type":"object","properties":{"map":{"type":"object","patternProperties":{"^x":{}}}}}']; + yield 'dependentSchemas entry' => ['{"type":"object","properties":{"a":{"type":"string"}},"dependentSchemas":{"a":{}}}']; + yield 'combinator branches' => ['{"type":"object","properties":{"a":{"anyOf":[{},{"type":"object","properties":{}}],"oneOf":[{}],"allOf":[{}]}}}']; + yield 'prefixItems entry' => ['{"type":"object","properties":{"a":{"type":"array","prefixItems":[{},{"type":"object","properties":{}}]}}}']; + } + + #[DataProvider('emptySubSchemaProvider')] + public function testConstructorNormalizesEmptySubSchemas(string $schemaJson): void + { + /** @var array{type: 'object', properties: array, required: string[]|null} $schema */ + $schema = json_decode($schemaJson, true, 512, \JSON_THROW_ON_ERROR); + + $tool = new Tool(name: 't', title: null, inputSchema: $schema, description: null, annotations: null); + + $this->assertSame($schemaJson, json_encode($tool->inputSchema, \JSON_UNESCAPED_SLASHES)); + } + + #[DataProvider('emptySubSchemaProvider')] + public function testConstructorNormalizesEmptySubSchemasInOutputSchema(string $schemaJson): void + { + /** @var array{type: 'object', properties?: array} $schema */ + $schema = json_decode($schemaJson, true, 512, \JSON_THROW_ON_ERROR); + + $tool = new Tool( + name: 't', + title: null, + inputSchema: self::validInputSchema(), + description: null, + annotations: null, + outputSchema: $schema, + ); + + $this->assertSame($schemaJson, json_encode($tool->outputSchema, \JSON_UNESCAPED_SLASHES)); + } + + /** + * @return iterable + */ + public static function preservedEmptyArrayProvider(): iterable + { + yield 'empty combinator list' => ['{"type":"object","properties":{},"allOf":[]}']; + yield 'empty prefixItems list' => ['{"type":"object","properties":{},"prefixItems":[]}']; + yield 'empty required list' => ['{"type":"object","properties":{},"required":[]}']; + yield 'empty enum list' => ['{"type":"object","properties":{"a":{"enum":[]}}}']; + yield 'empty dependentRequired list' => ['{"type":"object","properties":{"a":{}},"dependentRequired":{"a":[]}}']; + } + + /** + * Keywords that hold JSON arrays — not sub-schemas — must keep encoding as `[]`. + */ + #[DataProvider('preservedEmptyArrayProvider')] + public function testConstructorLeavesNonSchemaEmptyArraysAlone(string $schemaJson): void + { + /** @var array{type: 'object', properties: array, required: string[]|null} $schema */ + $schema = json_decode($schemaJson, true, 512, \JSON_THROW_ON_ERROR); + + $tool = new Tool(name: 't', title: null, inputSchema: $schema, description: null, annotations: null); + + $this->assertSame($schemaJson, json_encode($tool->inputSchema, \JSON_UNESCAPED_SLASHES)); + } + + /** + * Regression test for #151: `SchemaGenerator` emits `items: {}` for untyped arrays, + * but a client decoding that payload gets `items: []` back — re-serializing it used to + * hand strict clients the very schema #151 fixed. + */ + public function testFromArrayRoundTripPreservesEmptyItemsSchema(): void + { + $tool = new Tool( + name: 't', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => ['tags' => ['type' => 'array', 'items' => new \stdClass()]], + 'required' => null, + ], + description: null, + annotations: null, + ); + + $wire = (string) json_encode($tool); + $this->assertStringContainsString('"items":{}', $wire); + + /** @var array{name: string, inputSchema: array{type: 'object', properties: array, required: string[]|null}} $decoded */ + $decoded = json_decode($wire, true, 512, \JSON_THROW_ON_ERROR); + + $this->assertSame($wire, json_encode(Tool::fromArray($decoded))); + } } diff --git a/tests/Unit/Schema/Wire/McpHeaderTest.php b/tests/Unit/Schema/Wire/McpHeaderTest.php new file mode 100644 index 0000000..7b8d98e --- /dev/null +++ b/tests/Unit/Schema/Wire/McpHeaderTest.php @@ -0,0 +1,93 @@ + + */ + public static function provideValues(): iterable + { + yield 'plain ascii' => ['us-west1', 'us-west1']; + yield 'empty string' => ['', '']; + yield 'interior spaces stay plain' => ['us west 1', 'us west 1']; + yield 'integer' => [42, '42']; + yield 'boolean true' => [true, 'true']; + yield 'boolean false' => [false, 'false']; + yield 'non-ascii is wrapped' => ['Hello, 世界', '=?base64?SGVsbG8sIOS4lueVjA==?=']; + yield 'leading space is wrapped' => [' us-west1', '=?base64?IHVzLXdlc3Qx?=']; + yield 'trailing space is wrapped' => ['us-west1 ', '=?base64?dXMtd2VzdDEg?=']; + yield 'newline is wrapped' => ["line1\nline2", '=?base64?bGluZTEKbGluZTI=?=']; + yield 'tab is wrapped' => ["\tindented", '=?base64?CWluZGVudGVk?=']; + yield 'a literal already shaped like the wrapper is wrapped again' => ['=?base64?SGVsbG8=?=', '=?base64?PT9iYXNlNjQ/U0dWc2JHOD0/PQ==?=']; + } + + #[DataProvider('provideValues')] + #[TestDox('renders a mirrored argument as a header value')] + public function testEncode(mixed $value, string $expected): void + { + $this->assertSame($expected, McpHeader::encode($value)); + } + + #[DataProvider('provideValues')] + #[TestDox('what the client wraps, the server recovers unchanged')] + public function testRoundTrip(mixed $value, string $encoded): void + { + $expected = match (true) { + \is_bool($value) => $value ? 'true' : 'false', + default => (string) $value, + }; + + $this->assertSame($expected, McpHeader::decode($encoded)); + } + + #[TestDox('a value that cannot be mirrored gets no header at all')] + public function testUnmirrorableValue(): void + { + // A float has no single decimal spelling for a receiver to compare + // against, which is why SEP-2243 forbids the annotation on `number`. + $this->assertNull(McpHeader::encode(3.14159)); + $this->assertNull(McpHeader::encode(['a'])); + $this->assertNull(McpHeader::encode(null)); + } + + #[TestDox('a corrupted wrapper is refused rather than silently decoded')] + public function testCorruptWrapperIsRefused(): void + { + $this->assertNull(McpHeader::decode('=?base64?not valid base64!?=')); + } + + #[TestDox('names the subject of the methods that address one, and nothing else')] + public function testNameFor(): void + { + $this->assertSame('my-tool', McpHeader::nameFor('tools/call', ['name' => 'my-tool'])); + $this->assertSame('my-prompt', McpHeader::nameFor('prompts/get', ['name' => 'my-prompt'])); + $this->assertSame('file:///x', McpHeader::nameFor('resources/read', ['uri' => 'file:///x'])); + $this->assertSame('t-1', McpHeader::nameFor('tasks/get', ['taskId' => 't-1'])); + + $this->assertNull(McpHeader::nameFor('tools/list', [])); + $this->assertNull(McpHeader::nameFor('tools/call', null)); + } +} diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 255a142..790af5f 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -15,17 +15,29 @@ use Mcp\Capability\Registry; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\RegistryInterface; +use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\LogicException; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Extension\Apps\McpApps; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use Mcp\Server; +use Mcp\Server\Builder; use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Handler\Request\InitializeHandler; +use Mcp\Server\Protocol; use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Stateless\StatelessProtocol; +use Mcp\Server\Subscription\InMemoryNotificationBus; +use Mcp\Server\Subscription\PublishingEventDispatcher; +use Mcp\Tests\Unit\Server\Extension\ThingExtension; +use Mcp\Tests\Unit\Server\Extension\ThingListHandler; +use Mcp\Tests\Unit\Server\Extension\ThingListRequest; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -171,6 +183,113 @@ final class BuilderTest extends TestCase $this->assertSame($builder, $builder->setLazyLoading(false)); } + #[TestDox('One builder configuration is resolved once, however many dispatchers come out of it')] + public function testAssembledPartsAreSharedAcrossEras(): void + { + $loader = $this->createMock(LoaderInterface::class); + // Twice would mean two registries behind one endpoint, and a change + // made through one of them invisible to the other. + $loader->expects($this->once())->method('load'); + + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setLazyLoading(false) + ->addLoader($loader); + + $builder->build(); + $builder->buildStateless(); + } + + #[TestDox('A built server carries a dispatcher for each era, so one endpoint serves both')] + public function testBuildProducesBothEras(): void + { + $server = Server::builder()->setServerInfo('test', '1.0.0')->build(); + + $this->assertInstanceOf(StatelessProtocol::class, self::statelessProtocol($server)); + } + + #[TestDox('withoutModernEra() leaves the server with the handshake era alone')] + public function testWithoutModernEra(): void + { + $builder = Server::builder()->setServerInfo('test', '1.0.0'); + + $this->assertSame($builder, $builder->withoutModernEra()); + $this->assertNull(self::statelessProtocol($builder->build())); + } + + #[TestDox('setModernVersions() narrows what the modern leg answers for')] + public function testSetModernVersions(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setModernVersions([ProtocolVersion::V2026_07_28]) + ->build(); + + $this->assertSame([ProtocolVersion::V2026_07_28], self::statelessProtocol($server)?->supportedVersions()); + } + + #[TestDox('setModernVersions() rejects a handshake-era revision, which the classifier would never route there')] + public function testSetModernVersionsRejectsHandshakeRevision(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(ProtocolVersion::V2025_11_25->value); + + Server::builder()->setModernVersions([ProtocolVersion::V2025_11_25]); + } + + private static function statelessProtocol(Server $server): ?StatelessProtocol + { + $property = new \ReflectionProperty(Server::class, 'statelessProtocol'); + + return $property->getValue($server); + } + + #[TestDox('An extension identifier must be a valid _meta prefix')] + public function testEnableExtensionRejectsUnprefixedIdentifier(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('has no prefix'); + + Server::builder()->enableExtension(new ThingExtension('things')); + } + + #[TestDox('A method-providing extension contributes the message classes its methods decode into')] + public function testEnableExtensionRegistersItsMessages(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->enableExtension(new ThingExtension()) + ->build(); + + $factory = (new \ReflectionProperty(Protocol::class, 'messageFactory')) + ->getValue((new \ReflectionProperty(Server::class, 'protocol'))->getValue($server)); + + $decoded = $factory->create('{"jsonrpc":"2.0","id":1,"method":"com.example/things.list"}'); + + $this->assertInstanceOf(ThingListRequest::class, $decoded[0]); + } + + #[TestDox('enableExtension() throws when two enabled extensions define the same RPC method')] + public function testEnableExtensionRejectsClaimedMethod(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('com.example/things.list'); + + Server::builder()->enableExtension(new ThingExtension('com.example/things-a'), new ThingExtension('com.example/things-b')); + } + + #[TestDox('A method-providing extension contributes the handlers serving its methods')] + public function testEnableExtensionRegistersItsHandlers(): void + { + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->enableExtension(new ThingExtension()); + + $handlers = (new \ReflectionProperty(Builder::class, 'requestHandlers'))->getValue($builder); + + $this->assertContainsOnlyInstancesOf(ThingListHandler::class, $handlers); + } + #[TestDox('Lazy loading (default) advertises tools from configured sources without running loaders')] public function testLazyLoadingAdvertisesFromConfiguredSourcesWithoutLoading(): void { @@ -206,6 +325,45 @@ final class BuilderTest extends TestCase $this->assertFalse($capabilities->tools); } + #[TestDox('setServerInfo() forwards the title to the advertised serverInfo')] + public function testSetServerInfoForwardsTitle(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0', 'A test server', null, 'https://example.com', 'Test Server') + ->build(); + + $serverInfo = $this->extractServerInfo($server); + + $this->assertSame('test', $serverInfo->name); + $this->assertSame('A test server', $serverInfo->description); + $this->assertSame('https://example.com', $serverInfo->websiteUrl); + $this->assertSame('Test Server', $serverInfo->title); + } + + #[TestDox('setServerInfo() leaves the title absent when it is not given')] + public function testSetServerInfoTitleIsOptional(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->build(); + + $this->assertNull($this->extractServerInfo($server)->title); + } + + private function extractServerInfo(Server $server): Implementation + { + $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); + $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); + + foreach ($requestHandlers as $handler) { + if ($handler instanceof InitializeHandler) { + return $handler->configuration->serverInfo; + } + } + + $this->fail('InitializeHandler not found in request handlers'); + } + private function extractServerCapabilities(Server $server): ServerCapabilities { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); @@ -249,6 +407,42 @@ final class BuilderTest extends TestCase $this->fail('CallToolHandler not found in request handlers'); } + + public function testBuildingWithASuppliedRegistryDoesNotAnnounceTheLoadAsAChange(): void + { + // Otherwise every build publishes one list_changed per element, for no change. + $bus = new InMemoryNotificationBus(); + $registry = new Registry(new PublishingEventDispatcher($bus)); + + Server::builder() + ->setRegistry($registry) + ->setNotificationBus($bus) + ->addTool(static fn (): string => 'ok', 'alpha') + ->addTool(static fn (): string => 'ok', 'beta') + ->build(); + + $this->assertSame(0, $bus->cursor()); + $this->assertTrue($registry->hasTool('alpha')); + $this->assertTrue($registry->hasTool('beta')); + + // A real change after the build is still published. + $registry->unregisterTool('alpha'); + + $this->assertSame(1, $bus->cursor()); + } + + public function testAThirdPartyRegistryIsStillLoadedThroughThePlainLoader(): void + { + $registry = $this->createMock(RegistryInterface::class); + $registry->expects($this->once()) + ->method('registerTool') + ->with($this->callback(static fn (Tool $tool): bool => 'alpha' === $tool->name)); + + Server::builder() + ->setRegistry($registry) + ->addTool(static fn (): string => 'ok', 'alpha') + ->build(); + } } /** diff --git a/tests/Unit/Server/ClientGatewayTest.php b/tests/Unit/Server/ClientGatewayTest.php new file mode 100644 index 0000000..2b70fd6 --- /dev/null +++ b/tests/Unit/Server/ClientGatewayTest.php @@ -0,0 +1,240 @@ +createMock(SessionInterface::class); + $session->method('get')->with('client_capabilities', [])->willReturn(['roots' => []]); + + $gateway = new ClientGateway($session); + + $this->assertTrue($gateway->supportsRoots()); + } + + public function testSupportsRootsReturnsFalseWhenNotAdvertised(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('client_capabilities', [])->willReturn(['sampling' => []]); + + $gateway = new ClientGateway($session); + + $this->assertFalse($gateway->supportsRoots()); + } + + public function testSupportsSamplingReturnsTrueWhenAdvertised(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('client_capabilities', [])->willReturn(['sampling' => []]); + + $gateway = new ClientGateway($session); + + $this->assertTrue($gateway->supportsSampling()); + } + + public function testSupportsSamplingReturnsFalseWhenNotAdvertised(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('client_capabilities', [])->willReturn(['roots' => []]); + + $gateway = new ClientGateway($session); + + $this->assertFalse($gateway->supportsSampling()); + } + + public function testSupportsSamplingToolsReflectsTheSubCapability(): void + { + $this->assertTrue($this->gatewayFor(['sampling' => ['tools' => []]])->supportsSamplingTools()); + $this->assertFalse($this->gatewayFor(['sampling' => []])->supportsSamplingTools()); + $this->assertFalse($this->gatewayFor([])->supportsSamplingTools()); + } + + public function testSupportsSamplingToolsReadsTheObjectShapeToo(): void + { + $capabilities = (new ClientCapabilities(samplingTools: true))->jsonSerialize(); + + $this->assertTrue($this->gatewayFor((array) $capabilities)->supportsSamplingTools()); + } + + public function testSupportsExtensionReflectsTheNegotiatedExtensions(): void + { + $this->assertTrue($this->gatewayFor(['extensions' => [McpApps::EXTENSION_ID => ['mimeTypes' => [McpApps::MIME_TYPE]]]])->supportsExtension(McpApps::EXTENSION_ID)); + $this->assertFalse($this->gatewayFor(['extensions' => ['io.example/other' => []]])->supportsExtension(McpApps::EXTENSION_ID)); + $this->assertFalse($this->gatewayFor([])->supportsExtension(McpApps::EXTENSION_ID)); + + // Live sessions hold the serialized object shape. + $capabilities = (new ClientCapabilities(extensions: [McpApps::EXTENSION_ID => (new McpApps())->getCapabilities()]))->jsonSerialize(); + $this->assertTrue($this->gatewayFor((array) $capabilities)->supportsExtension(McpApps::EXTENSION_ID)); + } + + public function testSupportsElicitationUrlReflectsTheSubCapability(): void + { + $this->assertTrue($this->gatewayFor(['elicitation' => ['url' => []]])->supportsElicitationUrl()); + // An `elicitation` naming no mode declares form mode alone. + $this->assertFalse($this->gatewayFor(['elicitation' => []])->supportsElicitationUrl()); + $this->assertFalse($this->gatewayFor(['elicitation' => ['form' => []]])->supportsElicitationUrl()); + } + + public function testElicitUrlSendsAUrlModeRequest(): void + { + $gateway = $this->gatewayFor(['elicitation' => ['url' => []]]); + + $request = null; + $result = $this->runInFiber( + static fn (): ElicitResult => $gateway->elicitUrl('Authorize the app', 'https://example.com/consent'), + // A url-mode accept carries no content — the interaction happened out of band. + $this->response(['action' => 'accept']), + ElicitRequest::class, + $request, + ); + + $this->assertInstanceOf(ElicitRequest::class, $request); + $this->assertSame(ElicitationMode::Url, $request->mode); + $this->assertSame('https://example.com/consent', $request->url); + $this->assertInstanceOf(ElicitResult::class, $result); + $this->assertSame(ElicitAction::Accept, $result->action); + $this->assertNull($result->content); + } + + public function testElicitUrlRejectsAClientThatOnlySupportsForms(): void + { + $gateway = $this->gatewayFor(['elicitation' => []]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/elicitation\.url/'); + + $gateway->elicitUrl('Authorize the app', 'https://example.com/consent'); + } + + public function testElicitStillSendsFormMode(): void + { + $gateway = $this->gatewayFor([]); + + $request = null; + $result = $this->runInFiber( + static fn (): ElicitResult => $gateway->elicit('Your name?', new ElicitationSchema(['name' => new StringSchemaDefinition('Name')])), + $this->response(['action' => 'accept', 'content' => ['name' => 'Ada']]), + ElicitRequest::class, + $request, + ); + + $this->assertInstanceOf(ElicitRequest::class, $request); + $this->assertSame(ElicitationMode::Form, $request->mode); + $this->assertSame(['name' => 'Ada'], $result->content); + } + + public function testListRootsReturnsRootsFromClient(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $gateway = new ClientGateway($session); + + $response = $this->response([ + 'roots' => [ + ['uri' => 'file:///home/user/project', 'name' => 'project'], + ], + ]); + + $result = $this->runInFiber(static fn (): ListRootsResult => $gateway->listRoots(), $response); + + $this->assertInstanceOf(ListRootsResult::class, $result); + $this->assertCount(1, $result->roots); + $this->assertSame('file:///home/user/project', $result->roots[0]->uri); + } + + public function testListRootsThrowsClientExceptionOnError(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $gateway = new ClientGateway($session); + + $error = Error::forInternalError('nope', '1'); + + $this->expectException(ClientException::class); + + $this->runInFiber(static fn (): ListRootsResult => $gateway->listRoots(), $error); + } + + /** + * @param array $capabilities the client capabilities the session reports + */ + private function gatewayFor(array $capabilities): ClientGateway + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $session->method('get')->willReturnCallback( + static fn (string $key, mixed $default = null): mixed => 'client_capabilities' === $key ? $capabilities : $default, + ); + + return new ClientGateway($session); + } + + /** + * Runs the gateway call inside a Fiber, asserts it suspends with the expected + * request, then resumes it with the given client response. + * + * @param Response>|Error $response + * @param class-string $expectedRequest + * @param ?Request $request receives the request the gateway suspended with + * + * @param-out Request $request + */ + private function runInFiber(\Closure $call, Response|Error $response, string $expectedRequest = ListRootsRequest::class, mixed &$request = null): mixed + { + $fiber = new \Fiber($call); + $suspend = $fiber->start(); + + $this->assertIsArray($suspend); + $this->assertSame('request', $suspend['type']); + $this->assertInstanceOf($expectedRequest, $suspend['request']); + + $request = $suspend['request']; + + $fiber->resume($response); + + return $fiber->getReturn(); + } + + /** + * @param array $result + * + * @return Response> + */ + private function response(array $result): Response + { + return new Response('1', $result); + } +} diff --git a/tests/Unit/Server/Extension/ThingExtension.php b/tests/Unit/Server/Extension/ThingExtension.php new file mode 100644 index 0000000..88d862d --- /dev/null +++ b/tests/Unit/Server/Extension/ThingExtension.php @@ -0,0 +1,47 @@ +id); + } + + public function getCapabilities(): array + { + return ['flavour' => 'vanilla']; + } + + public function getMessages(): array + { + return [ThingListRequest::class]; + } + + public function getRequestHandlers(): iterable + { + yield new ThingListHandler(); + } +} diff --git a/tests/Unit/Server/Extension/ThingListHandler.php b/tests/Unit/Server/Extension/ThingListHandler.php new file mode 100644 index 0000000..8c34b88 --- /dev/null +++ b/tests/Unit/Server/Extension/ThingListHandler.php @@ -0,0 +1,33 @@ + + */ +final class ThingListHandler implements RequestHandlerInterface +{ + public function supports(Request $request): bool + { + return $request instanceof ThingListRequest; + } + + public function handle(Request $request, SessionInterface $session): Response + { + return new Response($request->getId(), new ThingListResult(['a', 'b'])); + } +} diff --git a/tests/Unit/Server/Extension/ThingListRequest.php b/tests/Unit/Server/Extension/ThingListRequest.php new file mode 100644 index 0000000..53d5396 --- /dev/null +++ b/tests/Unit/Server/Extension/ThingListRequest.php @@ -0,0 +1,32 @@ + $things + */ + public function __construct( + public readonly array $things, + ) { + } + + /** + * @return array{things: list} + */ + public function jsonSerialize(): array + { + return ['things' => $this->things]; + } +} diff --git a/tests/Unit/Server/Extension/UnservedThingExtension.php b/tests/Unit/Server/Extension/UnservedThingExtension.php new file mode 100644 index 0000000..43a9207 --- /dev/null +++ b/tests/Unit/Server/Extension/UnservedThingExtension.php @@ -0,0 +1,42 @@ +assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); + $this->assertEquals(Error::INVALID_PARAMS, $response->code); } public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): void @@ -207,16 +208,8 @@ class CallToolHandlerTest extends TestCase ->willThrowException($exception); $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'Error while executing tool "failing_tool": "Tool execution failed".', - [ - 'tool' => 'failing_tool', - 'arguments' => ['param' => 'value', '_session' => $this->session, '_request' => $request], - 'exception' => $exception, - ], - ); + ->expects($this->atLeastOnce()) + ->method('debug'); $response = $this->handler->handle($request, $this->session); @@ -231,6 +224,61 @@ class CallToolHandlerTest extends TestCase $this->assertEquals('Tool execution failed', $result->content[0]->text); } + public function testHandleToolCallExceptionLogsAtDebugLevel(): void + { + $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); + $exception = new ToolCallException('Expected tool failure'); + $logger = new class extends AbstractLogger { + public array $records = []; + + // @phpstan-ignore missingType.parameter (compatible with psr/log 1.x) + public function log($level, $message, array $context = []): void + { + $this->records[] = ['level' => $level, 'message' => (string) $message, 'context' => $context]; + } + }; + $handler = new CallToolHandler($this->registry, $this->referenceHandler, $logger); + $toolReference = $this->createToolReference('failing_tool', static function () { + return 'unused'; + }); + + $this->registry + ->expects($this->once()) + ->method('getTool') + ->with('failing_tool') + ->willReturn($toolReference); + + $arguments = ['param' => 'value', '_session' => $this->session, '_request' => $request]; + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, $arguments) + ->willThrowException($exception); + + $handler->handle($request, $this->session); + + $failureRecords = array_values(array_filter( + $logger->records, + static fn (array $record): bool => str_contains($record['message'], 'Expected tool failure'), + )); + + $this->assertSame([ + [ + 'level' => 'debug', + 'message' => 'Error while executing tool "failing_tool": "Expected tool failure".', + 'context' => [ + 'tool' => 'failing_tool', + 'arguments' => $arguments, + 'exception' => $exception, + ], + ], + ], $failureRecords); + $this->assertSame([], array_values(array_filter( + $logger->records, + static fn (array $record): bool => \in_array($record['level'], ['error', 'critical'], true), + ))); + } + public function testHandleWithNullResult(): void { $request = $this->createCallToolRequest('null_tool', []); @@ -270,7 +318,7 @@ class CallToolHandlerTest extends TestCase $this->assertInstanceOf(CallToolHandler::class, $handler); } - public function testHandleLogsErrorWithCorrectParameters(): void + public function testHandleLogsToolCallException(): void { $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); $exception = new ToolCallException('Custom error message'); @@ -291,16 +339,8 @@ class CallToolHandlerTest extends TestCase ->willThrowException($exception); $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'Error while executing tool "test_tool": "Custom error message".', - [ - 'tool' => 'test_tool', - 'arguments' => ['key1' => 'value1', 'key2' => 42, '_session' => $this->session, '_request' => $request], - 'exception' => $exception, - ], - ); + ->expects($this->atLeastOnce()) + ->method('debug'); $response = $this->handler->handle($request, $this->session); @@ -336,6 +376,14 @@ class CallToolHandlerTest extends TestCase ->with($toolReference, ['param' => 'value', '_session' => $this->session, '_request' => $request]) ->willThrowException($exception); + $this->logger + ->expects($this->once()) + ->method('error') + ->with('Unhandled error during tool execution', [ + 'name' => 'failing_tool', + 'exception' => $exception, + ]); + $response = $this->handler->handle($request, $this->session); // Generic exceptions should return Error, not Response @@ -477,6 +525,143 @@ class CallToolHandlerTest extends TestCase $this->assertArrayNotHasKey('structuredContent', $response->result->jsonSerialize()); } + /** + * @dataProvider provideStructuredContentRevisions + */ + public function testStructuredContentFollowsTheNegotiatedRevision(?string $negotiated, ?array $expected): void + { + $listResult = [['id' => 1], ['id' => 2]]; + $request = $this->createCallToolRequest('list_things', []); + $toolReference = $this->createToolReference('list_things', static fn () => $listResult); + + $this->session + ->method('get') + ->with('protocol_version') + ->willReturn($negotiated); + + $this->registry + ->method('getTool') + ->willReturn($toolReference); + + $this->referenceHandler + ->method('handle') + ->willReturn($listResult); + + $toolReference + ->method('formatResult') + ->willReturn([new TextContent('[{"id":1},{"id":2}]')]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expected, $response->result->structuredContent); + } + + /** + * How a revision is resolved is {@see \Mcp\Server\RequestContext}'s business + * and covered there; this only pins that the handler applies it. + * + * @return iterable}> + */ + public static function provideStructuredContentRevisions(): iterable + { + // A list is only emittable from 2026-07-28 (SEP-2106) on. Without a + // negotiated revision the handler assumes the stricter handshake rule. + yield 'no negotiated revision' => [null, null]; + yield '2025-11-25' => ['2025-11-25', null]; + yield '2026-07-28' => ['2026-07-28', [['id' => 1], ['id' => 2]]]; + } + + /** + * @dataProvider provideSelfBuiltResults + */ + public function testSelfBuiltResultIsSentUnchangedAndOnlyWarnedAbout( + ?string $negotiated, + ?array $structuredContent, + int $expectedWarnings, + ): void { + $request = $this->createCallToolRequest('build_result', []); + $toolReference = $this->createToolReference('build_result', static fn () => null); + $callToolResult = new CallToolResult([new TextContent('Built by hand')], false, $structuredContent); + + $this->session + ->method('get') + ->with('protocol_version') + ->willReturn($negotiated); + + $this->registry + ->method('getTool') + ->willReturn($toolReference); + + $this->referenceHandler + ->method('handle') + ->willReturn($callToolResult); + + $toolReference + ->expects($this->never()) + ->method('formatResult'); + + $this->logger + ->expects($this->exactly($expectedWarnings)) + ->method('warning'); + + $response = $this->handler->handle($request, $this->session); + + // Warned about, never rewritten: building the result is an explicit opt-out. + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($callToolResult, $response->result); + $this->assertSame($structuredContent, $response->result->structuredContent); + } + + /** + * @return iterable, int}> + */ + public static function provideSelfBuiltResults(): iterable + { + yield 'list before SEP-2106' => ['2025-11-25', [['id' => 1]], 1]; + yield 'list without a negotiated revision' => [null, [['id' => 1]], 1]; + yield 'list from SEP-2106 on' => ['2026-07-28', [['id' => 1]], 0]; + yield 'object before SEP-2106' => ['2025-11-25', ['items' => [['id' => 1]]], 0]; + yield 'none at all' => ['2025-11-25', null, 0]; + // Dropped by `CallToolResult::jsonSerialize()` anyway, so nothing to warn about. + yield 'empty' => ['2025-11-25', [], 0]; + } + + public function testDeclaredOutputSchemaWithoutStructuredContentIsLogged(): void + { + $listResult = [['id' => 1]]; + $request = $this->createCallToolRequest('list_things', []); + $toolReference = $this->createToolReference('list_things', static fn () => $listResult, [ + 'type' => 'object', + 'properties' => ['items' => ['type' => 'array']], + ]); + + $this->registry + ->method('getTool') + ->willReturn($toolReference); + + $this->referenceHandler + ->method('handle') + ->willReturn($listResult); + + $toolReference + ->method('formatResult') + ->willReturn([new TextContent('[{"id":1}]')]); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with( + $this->stringContains('outputSchema'), + $this->callback(static fn (array $context): bool => 'list_things' === $context['name'] && 'array' === $context['result_type']), + ); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertNull($response->result->structuredContent); + } + public function testValidationError(): void { $schema = [ diff --git a/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php b/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php index 63eefba..b35347a 100644 --- a/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php @@ -11,9 +11,11 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; +use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\RegistryInterface; +use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CompletionCompleteRequest; use Mcp\Schema\ResourceTemplate; @@ -23,6 +25,7 @@ use Mcp\Server\Session\SessionInterface; use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; class CompletionCompleteHandlerTest extends TestCase { @@ -79,6 +82,42 @@ class CompletionCompleteHandlerTest extends TestCase $this->assertEquals(new CompletionCompleteResult(['alpha'], 1, false), $response->result); } + public function testLogsProviderFailureBeforeReturningInternalError(): void + { + $uri = 'file://users/alice'; + $request = $this->createCompletionRequest($uri, ['name' => 'id', 'value' => 'al']); + + $exception = new \RuntimeException('Provider blew up'); + $provider = $this->createMock(ProviderInterface::class); + $provider->method('getCompletions')->willThrowException($exception); + + $templateReference = new ResourceTemplateReference( + new ResourceTemplate('file://users/{id}', 'user'), + static fn () => null, + ['id' => $provider], + ); + + $this->registry + ->method('getResource') + ->with($uri) + ->willReturn($templateReference); + + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->once()) + ->method('error') + ->with( + 'Error while handling completion request: Provider blew up', + ['exception' => $exception], + ); + + $handler = new CompletionCompleteHandler($this->registry, null, $logger); + $response = $handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame('Error while handling completion request', $response->message); + } + /** * @param array{ name: string, value: string } $argument */ diff --git a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php index 204f928..501fe1a 100644 --- a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -251,7 +251,7 @@ class GetPromptHandlerTest extends TestCase $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals(Error::INVALID_PARAMS, $response->code); $this->assertEquals('Prompt not found: "nonexistent_prompt".', $response->message); } diff --git a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php index c56eac4..25df23b 100644 --- a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php @@ -20,6 +20,7 @@ use Mcp\Schema\ServerCapabilities; use Mcp\Server\Configuration; use Mcp\Server\Handler\Request\InitializeHandler; use Mcp\Server\Session\SessionInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -39,29 +40,18 @@ class InitializeHandlerTest extends TestCase $handler = new InitializeHandler($configuration); $session = $this->createMock(SessionInterface::class); - $session->expects($this->exactly(2)) + $session->expects($this->exactly(3)) ->method('set') ->willReturnCallback(function (string $key, mixed $value): void { match ($key) { 'client_info' => $this->assertSame(['name' => 'client-app', 'version' => '1.0.0'], $value), 'client_capabilities' => $this->assertEquals(new \stdClass(), $value), + 'protocol_version' => $this->assertSame(ProtocolVersion::V2024_11_05->value, $value), default => $this->fail("Unexpected session key: {$key}"), }; }); - $request = InitializeRequest::fromArray([ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => 'request-1', - 'method' => InitializeRequest::getMethod(), - 'params' => [ - 'protocolVersion' => ProtocolVersion::V2024_11_05->value, - 'capabilities' => [], - 'clientInfo' => [ - 'name' => 'client-app', - 'version' => '1.0.0', - ], - ], - ]); + $request = $this->createInitializeRequest(ProtocolVersion::V2024_11_05->value); $response = $handler->handle($request, $session); @@ -76,4 +66,140 @@ class InitializeHandlerTest extends TestCase $result->jsonSerialize()['protocolVersion'] ); } + + #[TestDox('answers an unpinned handshake per the negotiation table')] + #[DataProvider('provideNegotiationTable')] + public function testNegotiationTable(string $requested, ProtocolVersion $expected): void + { + $handler = new InitializeHandler($this->createConfiguration()); + + $response = $handler->handle( + $this->createInitializeRequest($requested), + $this->createStub(SessionInterface::class), + ); + + \assert($response->result instanceof InitializeResult); + $this->assertNotNull($response->result->protocolVersion); + $this->assertSame($expected, $response->result->protocolVersion); + + // No row may resolve to a modern revision, whatever the client sent — that + // era has no `initialize`, so the answer would be unusable for both sides. + $this->assertFalse($response->result->protocolVersion->isModern()); + } + + /** + * The negotiation table from docs/server-builder.md, one data set per case a + * client can present. Keeping the rows in a single provider is what stops the + * documented table and the tested behaviour from drifting apart. + * + * @return iterable + */ + public static function provideNegotiationTable(): iterable + { + $counterOffer = ProtocolVersion::latestHandshake(); + + // Driven off the enum rather than a literal list, so a new revision is + // covered by the era it is declared in the moment it lands. + foreach (ProtocolVersion::handshakeVersions() as $version) { + yield \sprintf('supported %s -> echoed back', $version->value) => [$version->value, $version]; + } + + yield 'unknown future revision -> counter-offer' => ['2099-01-01', $counterOffer]; + yield 'not a revision at all -> counter-offer' => ['banana', $counterOffer]; + yield 'empty -> counter-offer' => ['', $counterOffer]; + + foreach (ProtocolVersion::modernVersions() as $version) { + yield \sprintf('modern %s -> counter-offer', $version->value) => [$version->value, $counterOffer]; + } + } + + #[TestDox('a modern version pinned in configuration cannot leak into the handshake')] + public function testModernConfiguredVersionFallsBackToHandshakeSet(): void + { + $handler = new InitializeHandler($this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $response = $handler->handle( + $this->createInitializeRequest(ProtocolVersion::V2025_06_18->value), + $this->createStub(SessionInterface::class), + ); + + \assert($response->result instanceof InitializeResult); + $this->assertSame(ProtocolVersion::V2025_06_18, $response->result->protocolVersion); + } + + #[TestDox('a pinned version wins over a different version requested by the client')] + public function testPinnedVersionOverridesClientRequest(): void + { + $handler = new InitializeHandler($this->createConfiguration(ProtocolVersion::V2025_03_26)); + + $response = $handler->handle( + $this->createInitializeRequest(ProtocolVersion::V2025_11_25->value), + $this->createStub(SessionInterface::class), + ); + + \assert($response->result instanceof InitializeResult); + $this->assertSame(ProtocolVersion::V2025_03_26, $response->result->protocolVersion); + } + + #[TestDox('stores the negotiated version on the session')] + public function testStoresNegotiatedVersionOnSession(): void + { + $handler = new InitializeHandler($this->createConfiguration()); + + $stored = []; + $session = $this->createMock(SessionInterface::class); + $session->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$stored): void { + $stored[$key] = $value; + }); + + $handler->handle($this->createInitializeRequest(ProtocolVersion::V2025_06_18->value), $session); + + $this->assertSame(ProtocolVersion::V2025_06_18->value, $stored['protocol_version'] ?? null); + } + + #[TestDox('falls back to empty defaults when constructed without a configuration')] + public function testHandlesMissingConfiguration(): void + { + // The reads in handle() sit on the left of ??, which has isset semantics and + // therefore tolerates the null without a nullsafe operator. This test is what + // proves that, rather than the shape of the accessor. + $handler = new InitializeHandler(); + + $response = $handler->handle( + $this->createInitializeRequest(ProtocolVersion::V2025_06_18->value), + $this->createStub(SessionInterface::class), + ); + + \assert($response->result instanceof InitializeResult); + $this->assertEquals(new ServerCapabilities(), $response->result->capabilities); + $this->assertEquals(new Implementation(), $response->result->serverInfo); + $this->assertNull($response->result->instructions); + $this->assertSame(ProtocolVersion::V2025_06_18, $response->result->protocolVersion); + } + + private function createConfiguration(?ProtocolVersion $protocolVersion = null): Configuration + { + return new Configuration( + serverInfo: new Implementation('server', '1.2.3'), + capabilities: new ServerCapabilities(), + protocolVersion: $protocolVersion, + ); + } + + private function createInitializeRequest(string $protocolVersion): InitializeRequest + { + return InitializeRequest::fromArray([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => 'request-1', + 'method' => InitializeRequest::getMethod(), + 'params' => [ + 'protocolVersion' => $protocolVersion, + 'capabilities' => [], + 'clientInfo' => [ + 'name' => 'client-app', + 'version' => '1.0.0', + ], + ], + ]); + } } diff --git a/tests/Unit/Server/Handler/Request/ResourceSubscribeHandlerTest.php b/tests/Unit/Server/Handler/Request/ResourceSubscribeHandlerTest.php new file mode 100644 index 0000000..d16519e --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ResourceSubscribeHandlerTest.php @@ -0,0 +1,127 @@ +registry = $this->createMock(RegistryInterface::class); + $this->subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); + $this->session = $this->createMock(SessionInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->handler = new ResourceSubscribeHandler($this->registry, $this->subscriptionManager, $this->logger); + } + + public function testSupportsResourceSubscribeRequest(): void + { + $request = $this->createRequest('file://test.txt'); + + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandleSuccessfulSubscribe(): void + { + $uri = 'file://test.txt'; + $request = $this->createRequest($uri); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($this->createMock(\Mcp\Capability\Registry\ResourceReference::class)); + + $this->subscriptionManager + ->expects($this->once()) + ->method('subscribe') + ->with($this->session, $uri); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + } + + /** + * @dataProvider provideResourceNotFoundRevisions + */ + public function testHandleResourceNotFoundIsProtocolVersionAware(?string $negotiated, int $expectedCode): void + { + $uri = 'file://nonexistent/file.txt'; + $request = $this->createRequest($uri); + $exception = new ResourceNotFoundException($uri); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willThrowException($exception); + + $this->session + ->method('get') + ->with('protocol_version') + ->willReturn($negotiated); + + $this->subscriptionManager + ->expects($this->never()) + ->method('subscribe'); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals($expectedCode, $response->code); + } + + /** + * @return iterable + */ + public static function provideResourceNotFoundRevisions(): iterable + { + yield 'no negotiated revision' => [null, Error::RESOURCE_NOT_FOUND]; + yield '2025-11-25' => ['2025-11-25', Error::RESOURCE_NOT_FOUND]; + yield '2026-07-28' => ['2026-07-28', Error::INVALID_PARAMS]; + } + + private function createRequest(string $uri): ResourceSubscribeRequest + { + return ResourceSubscribeRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => ResourceSubscribeRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'uri' => $uri, + ], + ]); + } +} diff --git a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeHandlerTest.php b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeHandlerTest.php new file mode 100644 index 0000000..daa82f5 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeHandlerTest.php @@ -0,0 +1,127 @@ +registry = $this->createMock(RegistryInterface::class); + $this->subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); + $this->session = $this->createMock(SessionInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->handler = new ResourceUnsubscribeHandler($this->registry, $this->subscriptionManager, $this->logger); + } + + public function testSupportsResourceUnsubscribeRequest(): void + { + $request = $this->createRequest('file://test.txt'); + + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandleSuccessfulUnsubscribe(): void + { + $uri = 'file://test.txt'; + $request = $this->createRequest($uri); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($this->createMock(\Mcp\Capability\Registry\ResourceReference::class)); + + $this->subscriptionManager + ->expects($this->once()) + ->method('unsubscribe') + ->with($this->session, $uri); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + } + + /** + * @dataProvider provideResourceNotFoundRevisions + */ + public function testHandleResourceNotFoundIsProtocolVersionAware(?string $negotiated, int $expectedCode): void + { + $uri = 'file://nonexistent/file.txt'; + $request = $this->createRequest($uri); + $exception = new ResourceNotFoundException($uri); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willThrowException($exception); + + $this->session + ->method('get') + ->with('protocol_version') + ->willReturn($negotiated); + + $this->subscriptionManager + ->expects($this->never()) + ->method('unsubscribe'); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals($expectedCode, $response->code); + } + + /** + * @return iterable + */ + public static function provideResourceNotFoundRevisions(): iterable + { + yield 'no negotiated revision' => [null, Error::RESOURCE_NOT_FOUND]; + yield '2025-11-25' => ['2025-11-25', Error::RESOURCE_NOT_FOUND]; + yield '2026-07-28' => ['2026-07-28', Error::INVALID_PARAMS]; + } + + private function createRequest(string $uri): ResourceUnsubscribeRequest + { + return ResourceUnsubscribeRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => ResourceUnsubscribeRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'uri' => $uri, + ], + ]); + } +} diff --git a/tests/Unit/Server/InputRequiredShimTest.php b/tests/Unit/Server/InputRequiredShimTest.php new file mode 100644 index 0000000..137270f --- /dev/null +++ b/tests/Unit/Server/InputRequiredShimTest.php @@ -0,0 +1,229 @@ + new Response(1, new CallToolResult([new TextContent('done')]))); + $expected = $handler->handle(self::request(), self::session()); + + $answer = $this->drive($handler, $expected, self::session()); + + $this->assertSame('done', self::text($answer)); + } + + #[TestDox('an ask is sent to the client and the handler re-entered with the answer')] + public function testOneRoundTrip(): void + { + $entries = 0; + $handler = self::handler(static function (Request $request, SessionInterface $session) use (&$entries): Response { + ++$entries; + $answer = self::inputContext($session)?->elicitResult('who'); + + if (null === $answer) { + return new Response(1, new InputRequiredResult(['who' => self::ask()])); + } + + return new Response(1, new CallToolResult([new TextContent('Hello, '.($answer->content['name'] ?? '?').'!')])); + }); + + $answer = $this->drive($handler, $handler->handle(self::request(), $session = self::session()), $session, [ + new Response(7, ['action' => 'accept', 'content' => ['name' => 'Ada']]), + ]); + + $this->assertSame('Hello, Ada!', self::text($answer)); + $this->assertSame(2, $entries, 're-entry is re-execution: the handler runs once per round'); + } + + #[TestDox('a client that declines is an answer, and the handler decides what it means')] + public function testDeclineReachesTheHandler(): void + { + $handler = self::handler(static function (Request $request, SessionInterface $session): Response { + $answer = self::inputContext($session)?->elicitResult('who'); + + if (null === $answer) { + return new Response(1, new InputRequiredResult(['who' => self::ask()])); + } + + return new Response(1, new CallToolResult([new TextContent($answer->isDeclined() ? 'declined' : 'accepted')])); + }); + + $answer = $this->drive($handler, $handler->handle(self::request(), $session = self::session()), $session, [ + new Response(7, ['action' => 'decline']), + ]); + + $this->assertSame('declined', self::text($answer)); + } + + #[TestDox('a handler that never stops asking is failed rather than looped forever')] + public function testRoundLimit(): void + { + $handler = self::handler(static fn (): Response => new Response(1, new InputRequiredResult(['who' => self::ask()]))); + + $answer = $this->drive( + $handler, + $handler->handle(self::request(), $session = self::session()), + $session, + array_fill(0, 3, new Response(7, ['action' => 'accept', 'content' => ['name' => 'Ada']])), + new InputRequiredShim(maxRounds: 2), + ); + + $this->assertInstanceOf(Error::class, $answer); + $this->assertStringContainsString('more than 2 times', $answer->message); + } + + #[TestDox('an ask the client never declared it could answer is refused, and nothing is sent')] + public function testUndeclaredCapabilityIsRefused(): void + { + $handler = self::handler(static fn (): Response => new Response(1, new InputRequiredResult(['who' => self::ask()]))); + + // No `elicitation` in the session's declared capabilities. + $session = self::session(declares: []); + + $answer = $this->drive($handler, $handler->handle(self::request(), $session), $session); + + $this->assertInstanceOf(Error::class, $answer); + $this->assertSame(-32021, $answer->jsonSerialize()['error']['code']); + } + + /** + * Runs the shim the way the transport does: inside a fiber, answering each + * suspension with the next queued client response. + * + * @param RequestHandlerInterface $handler + * @param Response|Error $first + * @param list|Error> $answers + * + * @return Response|Error + */ + private function drive( + RequestHandlerInterface $handler, + Response|Error $first, + SessionInterface $session, + array $answers = [], + ?InputRequiredShim $shim = null, + ): Response|Error { + $shim ??= new InputRequiredShim(); + $request = self::request(); + + $fiber = new \Fiber(static fn (): Response|Error => $shim->fulfill($first, $handler, $request, $session, null)); + + $suspended = $fiber->start(); + + while ($fiber->isSuspended()) { + $this->assertIsArray($suspended); + $this->assertSame('request', $suspended['type'], 'the shim only ever suspends to send a client request'); + $this->assertNotEmpty($answers, 'the shim sent more requests than the test queued answers for'); + + $suspended = $fiber->resume(array_shift($answers)); + } + + /** @var Response|Error $return */ + $return = $fiber->getReturn(); + + return $return; + } + + private static function ask(): ElicitRequest + { + return new ElicitRequest('Who?', new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name'])); + } + + private static function request(): CallToolRequest + { + return (new CallToolRequest('greet', []))->withId(1); + } + + /** + * @param array $declares + */ + private static function session(array $declares = ['elicitation' => []]): SessionInterface + { + $session = new Session(new InMemorySessionStore()); + $session->set('client_capabilities', $declares); + + return $session; + } + + private static function inputContext(SessionInterface $session): ?InputContext + { + $context = $session->get(InputContext::class); + + return $context instanceof InputContext ? $context : null; + } + + /** + * @param \Closure(Request, SessionInterface): (Response|Error) $handle + * + * @return RequestHandlerInterface + */ + private static function handler(\Closure $handle): RequestHandlerInterface + { + return new class($handle) implements RequestHandlerInterface { + public function __construct(private readonly \Closure $handle) + { + } + + public function supports(Request $request): bool + { + return true; + } + + public function handle(Request $request, SessionInterface $session): Response|Error + { + return ($this->handle)($request, $session); + } + }; + } + + /** + * @param Response|Error $result + */ + private static function text(Response|Error $result): string + { + self::assertInstanceOf(Response::class, $result); + self::assertInstanceOf(CallToolResult::class, $result->result); + $first = $result->result->content[0] ?? null; + self::assertInstanceOf(TextContent::class, $first); + + return $first->text; + } +} diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index f1d1c83..175db74 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -21,12 +21,17 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\LoggingMessageNotification; use Mcp\Schema\Request\CallToolRequest; +use Mcp\Schema\Request\PingRequest; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Protocol; +use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Session\SessionManager; use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Transport\TransportInterface; +use Mcp\Tests\Unit\Fixtures\ThrowingRequest; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -80,6 +85,30 @@ final class ProtocolTest extends TestCase ); } + #[TestDox('An id-less error response from the client is logged and ignored, not stored under a collapsed session key')] + public function testIdLessErrorResponseIsIgnored(): void + { + $session = $this->createMock(SessionInterface::class); + $session->expects($this->never())->method('set'); + + $this->sessionManager->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}}', + $sessionId + ); + } + #[TestDox('A single request is handled only by the first matching handler')] public function testRequestHandledByFirstMatchingHandler(): void { @@ -297,6 +326,40 @@ final class ProtocolTest extends TestCase ); } + #[TestDox('Unrecoverable parse error does not fabricate an empty-string id')] + public function testParseErrorDoesNotFabricateEmptyStringId(): void + { + $sentPayload = null; + $this->transport->expects($this->once()) + ->method('send') + ->willReturnCallback(static function ($data) use (&$sentPayload) { + $sentPayload = $data; + }); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + // Well-formed JSON nested past PHP's json_decode() depth limit (512), mirroring + // issue #333: json_decode() throws "Maximum stack depth exceeded" so the request + // carries a real numeric id (900512) that cannot be recovered once decoding fails. + $deeplyNested = str_repeat('[', 600).str_repeat(']', 600); + $input = '{"jsonrpc":"2.0","id":900512,"method":"initialize","params":'.$deeplyNested.'}'; + + $protocol->processInput($this->transport, $input, null); + + $this->assertNotNull($sentPayload); + $decoded = json_decode($sentPayload, true); + $this->assertSame(Error::PARSE_ERROR, $decoded['error']['code']); + // The original id is genuinely unrecoverable after a parse failure: it must never be + // fabricated as an empty string, and — per the MCP `RequestId` schema, which never + // allows `null` — the key must be omitted rather than sent as `id: null`. + $this->assertArrayNotHasKey('id', $decoded, 'Unrecoverable parse error must omit id, not fabricate one'); + } + #[TestDox('Invalid message structure returns error')] public function testInvalidMessageStructureReturnsError(): void { @@ -349,6 +412,221 @@ final class ProtocolTest extends TestCase $this->assertEquals(Error::INVALID_REQUEST, $message['error']['code']); } + #[TestDox('An unexpected throwable while creating a message returns an internal error under its id')] + public function testUnexpectedThrowableWhileCreatingMessagesReturnsInternalError(): void + { + $sent = null; + $this->transport->expects($this->once()) + ->method('send') + ->willReturnCallback(static function ($data) use (&$sent) { + $sent = $data; + }); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: new MessageFactory([ThrowingRequest::class]), + sessionManager: $this->sessionManager, + ); + + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "id": 1, "method": "test/throwing"}', + Uuid::v4() + ); + + $decoded = json_decode((string) $sent, true); + $this->assertSame(Error::INTERNAL_ERROR, $decoded['error']['code']); + $this->assertSame(1, $decoded['id'], 'The peer must be able to correlate the failure with its request.'); + $this->assertStringNotContainsString('must not leak', $decoded['error']['message']); + } + + #[TestDox('A batch that fails to hydrate is answered once, under the empty id')] + public function testBatchThatFailsToHydrateIsAnsweredOnce(): void + { + $sent = []; + $this->transport->method('send')->willReturnCallback(static function ($data) use (&$sent) { + $sent[] = $data; + }); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: new MessageFactory([PingRequest::class, ThrowingRequest::class]), + sessionManager: $this->sessionManager, + ); + + $protocol->processInput( + $this->transport, + '[{"jsonrpc": "2.0", "id": 1, "method": "ping"}, {"jsonrpc": "2.0", "id": 2, "method": "test/throwing"}]', + Uuid::v4() + ); + + $this->assertCount(1, $sent); + + $decoded = json_decode($sent[0], true); + $this->assertSame(Error::INTERNAL_ERROR, $decoded['error']['code']); + $this->assertSame('', $decoded['id'], 'The failure cannot be attributed to one request of the batch.'); + } + + #[TestDox('A batch holding only notifications is not answered when processing fails')] + public function testBatchOfNotificationsIsNotAnsweredWhenProcessingFails(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('save')->willThrowException(new \RuntimeException('storage is gone')); + + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); + + $this->transport->expects($this->never())->method('send'); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + $protocol->processInput( + $this->transport, + '[{"jsonrpc": "2.0", "method": "notifications/initialized"}]', + Uuid::v4() + ); + } + + #[TestDox('An unexpected throwable while saving the session does not answer a notification')] + public function testUnexpectedThrowableWhileSavingSessionDoesNotEscape(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('save')->willThrowException(new \RuntimeException('storage is gone')); + + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); + + // JSON-RPC forbids answering a notification, so the failure is only logged. + $this->transport->expects($this->never())->method('send'); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + Uuid::v4() + ); + } + + #[TestDox('A failing notification event listener does not produce a response')] + public function testFailingNotificationListenerDoesNotProduceResponse(): void + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); + + $queue = []; + $session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) { + return '_mcp.outgoing_queue' === $key ? $queue : $default; + }); + $session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + $queue = $value; + } + }); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher->method('dispatch')->willReturnCallback(static function ($event) { + if ($event instanceof NotificationEvent) { + throw new \RuntimeException('listener blew up'); + } + + return $event; + }); + + $this->transport->expects($this->never())->method('send'); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + eventDispatcher: $dispatcher, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + $sessionId + ); + + $this->assertSame([], $protocol->consumeOutgoingMessages($sessionId)); + } + + /** + * @return iterable + */ + public static function recoverableIdProvider(): iterable + { + yield 'positive int' => ['{"jsonrpc": "2.0", "id": 42, "params": {}}', 42]; + yield 'zero int (truthiness trap)' => ['{"jsonrpc": "2.0", "id": 0, "params": {}}', 0]; + yield 'string id' => ['{"jsonrpc": "2.0", "id": "req-1", "params": {}}', 'req-1']; + } + + #[DataProvider('recoverableIdProvider')] + #[TestDox('Invalid but parseable message preserves its recoverable id')] + public function testInvalidMessagePreservesRecoverableId(string $input, string|int $expectedId): void + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); + + // Configure session mock for queue operations (mirrors testInvalidMessageStructureReturnsError). + $queue = []; + $session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + return $queue; + } + + return $default; + }); + + $session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + $queue = $value; + } + }); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + $sessionId = Uuid::v4(); + // Valid JSON carrying a real id but missing method/result/error: the message is + // structurally invalid, yet its id IS recoverable from the decoded payload. + $protocol->processInput( + $this->transport, + $input, + $sessionId + ); + + $outgoing = $protocol->consumeOutgoingMessages($sessionId); + $this->assertCount(1, $outgoing); + + $message = json_decode($outgoing[0]['message'], true); + $this->assertArrayHasKey('error', $message); + $this->assertEquals(Error::INVALID_REQUEST, $message['error']['code']); + $this->assertSame($expectedId, $message['id'], 'Invalid-but-parseable message must preserve its recoverable id, not return ""'); + } + #[TestDox('Request without handler returns method not found error')] public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void { @@ -513,7 +791,65 @@ final class ProtocolTest extends TestCase $message = json_decode($outgoing[0]['message'], true); $this->assertArrayHasKey('error', $message); $this->assertEquals(Error::INTERNAL_ERROR, $message['error']['code']); - $this->assertStringContainsString('Unexpected error', $message['error']['message']); + $this->assertSame('Internal server error.', $message['error']['message']); + $this->assertStringNotContainsString('Unexpected error', $message['error']['message']); + } + + #[TestDox('Failure while dispatching an outbound request is answered under the inbound request id')] + public function testOutboundRequestFailureIsAnsweredUnderInboundRequestId(): void + { + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willReturnCallback(static function (): Response { + // Suspend with an outbound, id-less request, as sampling/elicitation handlers do. + \Fiber::suspend(['type' => 'request', 'request' => new PingRequest(), 'timeout' => 5]); + + return new Response(1, []); + }); + + $exception = new \RuntimeException('Transport unavailable'); + $this->transport->method('attachFiberToSession')->willThrowException($exception); + + $sessionManager = new SessionManager(new InMemorySessionStore()); + + $events = []; + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher->method('dispatch')->willReturnCallback(static function (object $event) use (&$events): object { + $events[] = $event; + + return $event; + }); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $sessionManager, + eventDispatcher: $dispatcher, + ); + + $session = $sessionManager->create(); + $session->save(); + $sessionId = $session->getId(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "id": 1, "method": "ping"}', + $sessionId + ); + + $errorEvents = array_values(array_filter($events, static fn (object $event): bool => $event instanceof ErrorEvent)); + $this->assertCount(1, $errorEvents); + $this->assertSame($exception, $errorEvents[0]->getThrowable()); + $this->assertSame(1, $errorEvents[0]->getError()->getId()); + + $outgoing = $protocol->consumeOutgoingMessages($sessionId); + $errors = array_values(array_filter( + array_map(static fn (array $outgoingMessage): array => json_decode($outgoingMessage['message'], true), $outgoing), + static fn (array $message): bool => isset($message['error']), + )); + $this->assertCount(1, $errors); + $this->assertSame(1, $errors[0]['id']); + $this->assertSame(Error::INTERNAL_ERROR, $errors[0]['error']['code']); } #[TestDox('Notification handler exceptions are caught and logged')] diff --git a/tests/Unit/Server/RequestContextTest.php b/tests/Unit/Server/RequestContextTest.php new file mode 100644 index 0000000..fcbe65d --- /dev/null +++ b/tests/Unit/Server/RequestContextTest.php @@ -0,0 +1,89 @@ +createSession('2025-06-18'), + $this->createRequest(), + ); + + $this->assertSame(ProtocolVersion::V2025_06_18, $context->getProtocolVersion()); + } + + public function testPerRequestMetaTakesPrecedenceOverTheSession(): void + { + // Modern revisions have no `initialize`, so the revision travels with every + // single request instead of being negotiated once. + $context = new RequestContext( + $this->createSession('2025-11-25'), + $this->createRequest(['io.modelcontextprotocol/protocolVersion' => '2026-07-28']), + ); + + $this->assertSame(ProtocolVersion::V2026_07_28, $context->getProtocolVersion()); + } + + /** + * @dataProvider provideUnusableVersions + */ + public function testUnusableVersionFallsBackToTheNewestHandshakeRevision(mixed $stored): void + { + $context = new RequestContext( + $this->createSession($stored), + $this->createRequest(), + ); + + $this->assertSame(ProtocolVersion::latestHandshake(), $context->getProtocolVersion()); + } + + /** + * @return iterable + */ + public static function provideUnusableVersions(): iterable + { + yield 'never negotiated' => [null]; + yield 'unknown revision' => ['1999-01-01']; + yield 'not a string' => [20260728]; + } + + private function createSession(mixed $protocolVersion): SessionInterface + { + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('protocol_version')->willReturn($protocolVersion); + + return $session; + } + + /** + * @param array|null $meta + */ + private function createRequest(?array $meta = null): CallToolRequest + { + $request = CallToolRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => CallToolRequest::getMethod(), + 'id' => 'test-request', + 'params' => ['name' => 'test_tool', 'arguments' => []], + ]); + + return null === $meta ? $request : $request->withMeta($meta); + } +} diff --git a/tests/Unit/Server/Session/FileSessionStoreTest.php b/tests/Unit/Server/Session/FileSessionStoreTest.php new file mode 100644 index 0000000..ed22e86 --- /dev/null +++ b/tests/Unit/Server/Session/FileSessionStoreTest.php @@ -0,0 +1,231 @@ +directory = sys_get_temp_dir().'/mcp-file-session-store-'.bin2hex(random_bytes(6)); + } + + protected function tearDown(): void + { + if (!is_dir($this->directory)) { + return; + } + + @chmod($this->directory, 0775); + + foreach (glob($this->directory.'/*') ?: [] as $file) { + @unlink($file); + } + + @rmdir($this->directory); + } + + #[TestDox('creates the session directory when it does not exist yet')] + public function testCreatesMissingDirectory(): void + { + new FileSessionStore($this->directory); + + $this->assertDirectoryExists($this->directory); + } + + #[TestDox('round-trips a session payload through the filesystem')] + public function testWriteThenRead(): void + { + $store = new FileSessionStore($this->directory); + $id = new UuidV4(); + + $store->write($id, 'payload'); + + $this->assertTrue($store->exists($id)); + $this->assertSame('payload', $store->read($id)); + } + + #[TestDox('gc() only deletes expired session files, never foreign files in the directory')] + public function testGcLeavesForeignFilesAlone(): void + { + $store = new FileSessionStore($this->directory, ttl: 60); + $id = new UuidV4(); + $store->write($id, 'payload'); + + $foreign = $this->directory.'/important.lock'; + file_put_contents($foreign, 'not a session'); + + // Make everything stale + $expired = time() - 120; + touch($this->directory.'/'.$id->toRfc4122(), $expired); + touch($foreign, $expired); + + $deleted = $store->gc(); + + $this->assertEquals([$id], $deleted); + $this->assertFileDoesNotExist($this->directory.'/'.$id->toRfc4122()); + $this->assertFileExists($foreign); + } + + #[TestDox('rejects an unwritable directory with the SDK\'s own exception')] + public function testUnwritableDirectoryThrowsPackageException(): void + { + mkdir($this->directory, 0775, true); + chmod($this->directory, 0555); + clearstatcache(true, $this->directory); + + if (is_writable($this->directory)) { + $this->markTestSkipped('Permission bits do not restrict writes here (running as root, or a filesystem that ignores them).'); + } + + // The store's only throw must stay inside the package hierarchy, so a + // consumer catching ExceptionInterface sees it rather than a bare SPL + // RuntimeException escaping the SDK. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(\sprintf('Session directory "%s" is not writable.', $this->directory)); + + try { + new FileSessionStore($this->directory); + } catch (RuntimeException $e) { + $this->assertInstanceOf(ExceptionInterface::class, $e); + + throw $e; + } + } + + #[TestDox('logs a warning when a session file cannot be read')] + public function testUnreadableSessionFileLogsWarning(): void + { + $logger = new WarningCollectingLogger(); + $store = new FileSessionStore($this->directory, logger: $logger); + $id = new UuidV4(); + + $store->write($id, 'payload'); + + $path = $this->directory.\DIRECTORY_SEPARATOR.$id->toRfc4122(); + chmod($path, 0000); + clearstatcache(true, $path); + + if (is_readable($path)) { + $this->markTestSkipped('Permission bits do not restrict reads here (running as root, or a filesystem that ignores them).'); + } + + $this->assertFalse($store->read($id)); + $this->assertCount(1, $logger->warnings); + $this->assertSame('Failed to read session file.', $logger->warnings[0]['message']); + $this->assertSame($path, $logger->warnings[0]['context']['path']); + } + + #[TestDox('logs a warning when a session file cannot be written')] + public function testUnwritableSessionFileLogsWarning(): void + { + $logger = new WarningCollectingLogger(); + $store = new FileSessionStore($this->directory, logger: $logger); + + chmod($this->directory, 0555); + clearstatcache(true, $this->directory); + + if (is_writable($this->directory)) { + $this->markTestSkipped('Permission bits do not restrict writes here (running as root, or a filesystem that ignores them).'); + } + + $this->assertFalse($store->write(new UuidV4(), 'payload')); + $this->assertCount(1, $logger->warnings); + $this->assertSame('Failed to write session file.', $logger->warnings[0]['message']); + } + + #[TestDox('logs a warning when the session directory cannot be opened for garbage collection')] + public function testGcLogsWarningWhenDirectoryVanished(): void + { + $logger = new WarningCollectingLogger(); + $store = new FileSessionStore($this->directory, logger: $logger); + + rmdir($this->directory); + + $this->assertSame([], $store->gc()); + $this->assertCount(1, $logger->warnings); + $this->assertSame('Failed to open session directory for garbage collection.', $logger->warnings[0]['message']); + $this->assertSame($this->directory, $logger->warnings[0]['context']['directory']); + } + + #[TestDox('gc() warns and does not report a session whose file survived deletion')] + public function testGcLogsWarningWhenExpiredFileCannotBeDeleted(): void + { + $logger = new WarningCollectingLogger(); + $store = new FileSessionStore($this->directory, ttl: 60, logger: $logger); + $id = new UuidV4(); + $store->write($id, 'payload'); + + $path = $this->directory.\DIRECTORY_SEPARATOR.$id->toRfc4122(); + touch($path, time() - 120); + + chmod($this->directory, 0555); + clearstatcache(true, $this->directory); + + if (is_writable($this->directory)) { + $this->markTestSkipped('Permission bits do not restrict writes here (running as root, or a filesystem that ignores them).'); + } + + // The file is still there, so its id must not be reported as deleted. + $this->assertSame([], $store->gc()); + $this->assertFileExists($path); + $this->assertCount(1, $logger->warnings); + $this->assertSame('Failed to delete expired session file.', $logger->warnings[0]['message']); + $this->assertSame($path, $logger->warnings[0]['context']['path']); + } + + #[TestDox('stays silent on the happy path')] + public function testHappyPathLogsNothing(): void + { + $logger = new WarningCollectingLogger(); + $store = new FileSessionStore($this->directory, logger: $logger); + $id = new UuidV4(); + + $store->write($id, 'payload'); + $store->read($id); + $store->destroy($id); + $store->gc(); + + $this->assertSame([], $logger->warnings); + } +} + +/** + * Keeps every warning with its context, so a silent failure can be told apart + * from one the operator was told about. + */ +final class WarningCollectingLogger extends AbstractLogger +{ + /** @var list}> */ + public array $warnings = []; + + /** + * @param string|\Stringable $message + * @param array $context + */ + public function log($level, $message, array $context = []): void + { + if (LogLevel::WARNING === $level) { + $this->warnings[] = ['message' => (string) $message, 'context' => $context]; + } + } +} diff --git a/tests/Unit/Server/Session/SessionTest.php b/tests/Unit/Server/Session/SessionTest.php index 40483d3..a6b1697 100644 --- a/tests/Unit/Server/Session/SessionTest.php +++ b/tests/Unit/Server/Session/SessionTest.php @@ -245,6 +245,21 @@ class SessionTest extends TestCase $this->assertFalse($this->session->has('nonexistent')); } + public function testForgetDoesNotCreateIntermediateSegments(): void + { + $this->session->forget('a.b'); + + $this->assertSame([], $this->session->all()); + } + + public function testForgetDoesNotOverwriteNonArrayIntermediateValue(): void + { + $this->session->set('key', 'string_value'); + $this->session->forget('key.nested'); + + $this->assertSame('string_value', $this->session->get('key')); + } + public function testSessionCanStoreVariousDataTypes(): void { $this->session->set('string', 'value'); diff --git a/tests/Unit/Server/Stateless/ElicitationReplayTest.php b/tests/Unit/Server/Stateless/ElicitationReplayTest.php new file mode 100644 index 0000000..83cc710 --- /dev/null +++ b/tests/Unit/Server/Stateless/ElicitationReplayTest.php @@ -0,0 +1,151 @@ + 'accept', 'content' => ['n' => 'ada']]; + + private static function codec(): RequestStateCodec + { + return new RequestStateCodec(str_repeat('k', 32)); + } + + private static function request(): ElicitRequest + { + return ElicitRequest::forForm('Who?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); + } + + #[TestDox('an unnamed ask is filed under its position, a named one under its name')] + public function testKeysFallBackToPosition(): void + { + $replay = new ElicitationReplay(null); + + $this->assertSame('elicitation_1', $replay->key(null)); + $this->assertSame('seat', $replay->key('seat')); + // Counted even when named, so a position always means the same ask. + $this->assertSame('elicitation_3', $replay->key(null)); + } + + #[TestDox('an answer from this round is handed to the handler')] + public function testAnswerFromThisRound(): void + { + $replay = new ElicitationReplay(new InputContext(['who' => self::ACCEPTED])); + + $this->assertSame(self::ACCEPTED, $replay->answer('who')); + $this->assertNull($replay->answer('other')); + } + + #[TestDox('an answer from an earlier round is read back out of the state')] + public function testAnswerCarriedInState(): void + { + $replay = new ElicitationReplay(new InputContext([], [ + ElicitationReplay::CARRIED_ANSWERS => ['who' => self::ACCEPTED], + ])); + + $this->assertSame(self::ACCEPTED, $replay->answer('who')); + } + + #[TestDox('what this round answered wins over what an earlier one did')] + public function testThisRoundOverridesTheCarriedAnswer(): void + { + $fresh = ['action' => 'accept', 'content' => ['n' => 'grace']]; + + $replay = new ElicitationReplay(new InputContext(['who' => $fresh], [ + ElicitationReplay::CARRIED_ANSWERS => ['who' => self::ACCEPTED], + ])); + + $this->assertSame($fresh, $replay->answer('who')); + } + + #[TestDox('an answer that does not parse counts as no answer, and is not carried on')] + public function testMalformedAnswerIsDropped(): void + { + // Accepted, but a form-mode acceptance has to carry content. + $replay = new ElicitationReplay(new InputContext(['who' => ['action' => 'accept']]), self::codec()); + + $this->assertNull($replay->answer('who')); + + $ask = $replay->ask('who', self::request()); + + $this->assertNull($ask->requestState); + } + + #[TestDox('an answer rejected this round is not carried into the next one')] + public function testRejectedAnswerIsNotCarriedOn(): void + { + $codec = self::codec(); + $replay = new ElicitationReplay(new InputContext([], [ + ElicitationReplay::CARRIED_ANSWERS => ['who' => ['action' => 'accept']], + ]), $codec); + + $this->assertNull($replay->answer('who')); + $this->assertNull($replay->ask('who', self::request())->requestState); + } + + #[TestDox('a url-mode answer is read against its own mode')] + public function testUrlModeAnswer(): void + { + $replay = new ElicitationReplay(new InputContext(['consent' => ['action' => 'accept']])); + + // Content is required in form mode and forbidden in url mode, so the + // same answer only parses under the mode it was asked in. + $this->assertNull($replay->answer('consent')); + $this->assertSame(['action' => 'accept'], $replay->answer('consent', ElicitationMode::Url)); + } + + #[TestDox('a first ask carries no state, since nothing has been answered yet')] + public function testFirstAskCarriesNoState(): void + { + $ask = (new ElicitationReplay(null, self::codec()))->ask('who', self::request()); + + $this->assertSame(['who'], array_keys($ask->inputRequests)); + $this->assertNull($ask->requestState); + } + + #[TestDox('a later ask seals what is already answered, beside the handler\'s own payload')] + public function testLaterAskSealsTheAnswers(): void + { + $codec = self::codec(); + $replay = new ElicitationReplay(new InputContext(['who' => self::ACCEPTED], ['booking' => 42]), $codec); + + $ask = $replay->ask('seat', self::request()); + + $this->assertIsString($ask->requestState); + $this->assertSame([ + 'booking' => 42, + ElicitationReplay::CARRIED_ANSWERS => ['who' => self::ACCEPTED], + ], $codec->verify($ask->requestState)); + } + + #[TestDox('there is nowhere to keep an answer without a signing key')] + public function testSealingNeedsASigningKey(): void + { + $replay = new ElicitationReplay(new InputContext(['who' => self::ACCEPTED])); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches('/setRequestState/'); + + $replay->ask('seat', self::request()); + } +} diff --git a/tests/Unit/Server/Stateless/InputContextTest.php b/tests/Unit/Server/Stateless/InputContextTest.php new file mode 100644 index 0000000..3425500 --- /dev/null +++ b/tests/Unit/Server/Stateless/InputContextTest.php @@ -0,0 +1,111 @@ + ['action' => 'accept', 'content' => ['name' => 'ada']]]); + + $result = $context->elicitResult('who'); + + $this->assertNotNull($result); + $this->assertSame(ElicitAction::Accept, $result->action); + $this->assertSame(['name' => 'ada'], $result->content); + } + + #[TestDox('an accepted url-mode answer needs no content')] + public function testUrlModeElicitResultNeedsNoContent(): void + { + $context = new InputContext(['consent' => ['action' => 'accept']]); + + $this->assertNull($context->elicitResult('consent')); + $this->assertNotNull($context->elicitResult('consent', ElicitationMode::Url)); + } + + #[TestDox('a sampling answer comes back typed')] + public function testSamplingResultIsParsed(): void + { + $context = new InputContext(['capital' => [ + 'role' => 'assistant', + 'content' => ['type' => 'text', 'text' => 'Paris'], + 'model' => 'a-model', + 'stopReason' => 'endTurn', + ]]); + + $result = $context->samplingResult('capital'); + + $this->assertNotNull($result); + $this->assertSame('a-model', $result->model); + $blocks = $result->getContentBlocks(); + $this->assertInstanceOf(TextContent::class, $blocks[0]); + $this->assertSame('Paris', $blocks[0]->text); + } + + #[TestDox('a roots answer comes back typed')] + public function testRootsResultIsParsed(): void + { + $context = new InputContext(['roots' => ['roots' => [['uri' => 'file:///work', 'name' => 'work']]]]); + + $result = $context->rootsResult('roots'); + + $this->assertNotNull($result); + $this->assertCount(1, $result->roots); + $this->assertSame('file:///work', $result->roots[0]->uri); + } + + #[TestDox('an answer that was never given reads as absent')] + public function testMissingKeyIsNull(): void + { + $context = new InputContext(); + + $this->assertNull($context->elicitResult('who')); + $this->assertNull($context->samplingResult('who')); + $this->assertNull($context->rootsResult('who')); + $this->assertFalse($context->has('who')); + } + + #[TestDox('a malformed answer reads as absent, so the handler asks again rather than fails')] + public function testMalformedAnswerIsNull(): void + { + $context = new InputContext([ + 'who' => ['action' => 'not-an-action'], + 'capital' => ['role' => 'user', 'content' => []], + 'roots' => ['roots' => 'not a list'], + ]); + + $this->assertNull($context->elicitResult('who')); + $this->assertNull($context->samplingResult('capital')); + $this->assertNull($context->rootsResult('roots')); + + // Still present — the client did answer, just not with something usable. + $this->assertTrue($context->has('who')); + } + + #[TestDox('the verified request state is carried alongside the answers')] + public function testRequestStateIsCarried(): void + { + $context = new InputContext(['a' => ['action' => 'cancel']], ['round' => 2]); + + $this->assertSame(['round' => 2], $context->requestState()); + $this->assertSame(['a'], array_keys($context->all())); + } +} diff --git a/tests/Unit/Server/Stateless/RequestStateCodecTest.php b/tests/Unit/Server/Stateless/RequestStateCodecTest.php new file mode 100644 index 0000000..6b84b8f --- /dev/null +++ b/tests/Unit/Server/Stateless/RequestStateCodecTest.php @@ -0,0 +1,128 @@ + 2, 'tool' => 'do_thing', 'nested' => ['a' => 1]]; + + $this->assertSame($payload, $codec->verify($codec->mint($payload))); + } + + #[TestDox('a state signed by another key is refused')] + public function testForeignKeyRefused(): void + { + $state = (new RequestStateCodec(self::OTHER_KEY))->mint(['round' => 1]); + + $this->expectException(RequestStateException::class); + (new RequestStateCodec(self::KEY))->verify($state); + } + + #[TestDox('any edit to the wire value invalidates it')] + public function testTamperingIsDetected(): void + { + $codec = new RequestStateCodec(self::KEY); + $state = $codec->mint(['admin' => false]); + + foreach ([$state.'-TAMPERED', substr($state, 0, -2), str_replace('.', 'X.', $state)] as $tampered) { + try { + $codec->verify($tampered); + $this->fail(\sprintf('Expected "%s" to be rejected.', $tampered)); + } catch (RequestStateException) { + $this->addToAssertionCount(1); + } + } + } + + #[TestDox('a re-signed payload from a different key cannot be swapped in')] + public function testPayloadSwapRefused(): void + { + $mine = new RequestStateCodec(self::KEY); + $theirs = new RequestStateCodec(self::OTHER_KEY); + + // Body from one, MAC from the other: each half is well-formed alone. + [$body] = explode('.', $mine->mint(['admin' => false])); + [, $mac] = explode('.', $theirs->mint(['admin' => true])); + + $this->expectException(RequestStateException::class); + $mine->verify($body.'.'.$mac); + } + + #[TestDox('an expired state is refused even though its signature is good')] + public function testExpiryEnforced(): void + { + $codec = new RequestStateCodec(self::KEY, ttlSeconds: 60); + $state = $codec->mint(['round' => 1], now: 1_000); + + $this->assertSame(['round' => 1], $codec->verify($state, now: 1_059)); + + $this->expectException(RequestStateException::class); + $codec->verify($state, now: 1_061); + } + + #[TestDox('a malformed value is refused rather than parsed')] + public function testMalformedRefused(): void + { + $codec = new RequestStateCodec(self::KEY); + + foreach (['', 'not-a-state', 'one.two.three', '.', 'YQ.'] as $bad) { + try { + $codec->verify($bad); + $this->fail(\sprintf('Expected "%s" to be rejected.', $bad)); + } catch (RequestStateException) { + $this->addToAssertionCount(1); + } + } + } + + #[TestDox('the failure reason never says more than a category')] + public function testFailureReasonsAreOpaque(): void + { + $codec = new RequestStateCodec(self::KEY); + + try { + $codec->verify((new RequestStateCodec(self::OTHER_KEY))->mint(['secret' => 'value'])); + $this->fail('Expected rejection.'); + } catch (RequestStateException $e) { + // Anything beyond a category is a hint to whoever is probing. + $this->assertContains($e->getMessage(), ['malformed', 'mac', 'expired']); + $this->assertStringNotContainsString('secret', $e->getMessage()); + } + } + + #[TestDox('a key too short to be safe is refused at construction')] + public function testShortKeyRefused(): void + { + $this->expectException(InvalidArgumentException::class); + new RequestStateCodec('too-short'); + } + + #[TestDox('a non-positive TTL is refused at construction')] + public function testNonPositiveTtlRefused(): void + { + $this->expectException(InvalidArgumentException::class); + new RequestStateCodec(self::KEY, ttlSeconds: 0); + } +} diff --git a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php new file mode 100644 index 0000000..43b4cc0 --- /dev/null +++ b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php @@ -0,0 +1,351 @@ +validator = new StandardHeaderValidator(); + } + + #[TestDox('a request whose headers agree with its body passes')] + public function testConsistentRequestPasses(): void + { + $this->assertNull($this->validator->validate( + 'tools/call', + ['name' => 'do_thing', 'arguments' => []], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'do_thing'], + )); + } + + #[TestDox('a missing Mcp-Method is rejected')] + public function testMissingMethodHeaderRejected(): void + { + $this->assertStringContainsString( + 'Mcp-Method', + (string) $this->validator->validate('tools/list', null, []), + ); + } + + #[TestDox('an Mcp-Method that disagrees with the body is rejected')] + public function testMismatchedMethodHeaderRejected(): void + { + $this->assertStringContainsString( + 'does not match', + (string) $this->validator->validate('tools/list', null, ['Mcp-Method' => 'prompts/list']), + ); + } + + #[TestDox('header names compare case-insensitively')] + #[DataProvider('headerCasings')] + public function testHeaderNameCasingIsIgnored(string $name): void + { + $this->assertNull($this->validator->validate('tools/list', null, [$name => 'tools/list'])); + } + + /** + * @return iterable + */ + public static function headerCasings(): iterable + { + yield 'canonical' => ['Mcp-Method']; + yield 'lowercase' => ['mcp-method']; + yield 'uppercase' => ['MCP-METHOD']; + yield 'mixed' => ['mCp-MeThOd']; + } + + #[TestDox('surrounding whitespace is not part of a header value (RFC 9110 §5.5)')] + public function testOptionalWhitespaceIsTrimmed(): void + { + $this->assertNull($this->validator->validate( + 'tools/call', + ['name' => 'do_thing'], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => ' do_thing '], + )); + } + + #[TestDox('Mcp-Name is required when the body carries a name, and rejected when it disagrees')] + public function testNameHeaderMustMatchBody(): void + { + $body = ['name' => 'do_thing']; + + $this->assertStringContainsString('Missing required Mcp-Name', (string) $this->validator->validate( + 'tools/call', + $body, + ['Mcp-Method' => 'tools/call'], + )); + + $this->assertStringContainsString('does not match', (string) $this->validator->validate( + 'tools/call', + $body, + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'something_else'], + )); + } + + #[TestDox('a Base64-wrapped Mcp-Name is decoded before it is compared')] + #[DataProvider('wrappedNames')] + public function testWrappedNameHeaderIsDecoded(string $method, string $member, string $subject): void + { + $this->assertNull($this->validator->validate( + $method, + [$member => $subject], + [ + 'Mcp-Method' => $method, + 'Mcp-Name' => '=?base64?'.base64_encode($subject).'?=', + ], + )); + } + + /** + * @return iterable + */ + public static function wrappedNames(): iterable + { + yield 'non-ASCII tool name' => ['tools/call', 'name', 'grüße_welt']; + yield 'CJK prompt name' => ['prompts/get', 'name', '天気予報']; + yield 'resource URI with a non-ASCII path' => ['resources/read', 'uri', 'file:///projects/münchen/config.json']; + yield 'value padded with spaces' => ['tools/call', 'name', ' padded ']; + yield 'value matching the sentinel pattern' => ['tools/call', 'name', '=?base64?literal?=']; + } + + #[TestDox('a wrapped Mcp-Name that disagrees with the body is still rejected')] + public function testWrappedNameHeaderStillHasToMatch(): void + { + $this->assertStringContainsString('does not match', (string) $this->validator->validate( + 'tools/call', + ['name' => 'grüße_welt'], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => '=?base64?'.base64_encode('etwas_anderes').'?='], + )); + } + + #[TestDox('a malformed Base64 wrapper on Mcp-Name is refused, not compared raw')] + public function testMalformedWrappedNameHeaderIsRefused(): void + { + $this->assertStringContainsString('well-formed Base64', (string) $this->validator->validate( + 'tools/call', + ['name' => 'do_thing'], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => '=?base64?SGVsbG8!?='], + )); + } + + #[TestDox('a method that carries no name does not require the header')] + public function testNamelessMethodNeedsNoNameHeader(): void + { + $this->assertNull($this->validator->validate('tools/list', null, ['Mcp-Method' => 'tools/list'])); + } + + #[TestDox('each method names its subject through its own params member')] + #[DataProvider('nameSources')] + public function testNameIsReadFromTheRightMember(string $method, array $params, ?string $expected): void + { + $this->assertSame($expected, StandardHeaderValidator::nameFor($method, $params)); + } + + /** + * @return iterable, ?string}> + */ + public static function nameSources(): iterable + { + yield 'tools/call uses name' => ['tools/call', ['name' => 'a'], 'a']; + yield 'prompts/get uses name' => ['prompts/get', ['name' => 'b'], 'b']; + yield 'resources/read uses uri' => ['resources/read', ['uri' => 'test://c'], 'test://c']; + yield 'tasks/get uses taskId' => ['tasks/get', ['taskId' => 'd'], 'd']; + yield 'tools/list has no subject' => ['tools/list', [], null]; + yield 'non-string name is ignored' => ['tools/call', ['name' => 42], null]; + } + + #[TestDox('an annotation on a nested property is found through the properties chain')] + public function testNestedMirroredPropertyIsFound(): void + { + $schema = [ + 'type' => 'object', + 'properties' => [ + 'target' => [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + ], + ], + 'top' => ['type' => 'string', 'x-mcp-header' => 'Top'], + ], + ]; + + $this->assertSame( + ['Region' => ['target', 'region'], 'Top' => ['top']], + StandardHeaderValidator::mirroredProperties($schema), + ); + } + + #[TestDox('an annotation the chain cannot reach statically is not mirrored')] + #[DataProvider('unreachableAnnotations')] + public function testUnreachableAnnotationsAreNotMirrored(array $properties): void + { + $this->assertSame([], StandardHeaderValidator::mirroredProperties([ + 'type' => 'object', + 'properties' => $properties, + ])); + } + + /** + * @return iterable}> + */ + public static function unreachableAnnotations(): iterable + { + yield 'under items' => [['a' => ['type' => 'array', 'items' => ['type' => 'string', 'x-mcp-header' => 'X']]]]; + yield 'under anyOf' => [['a' => ['anyOf' => [['type' => 'string', 'x-mcp-header' => 'X']]]]]; + yield 'under if' => [['a' => ['if' => ['type' => 'string', 'x-mcp-header' => 'X']]]]; + } + + #[TestDox('an integer is compared numerically, so 42 and 42.0 agree')] + public function testIntegerParamsCompareNumerically(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertNull($validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => 42]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '42.0'], + )); + + $this->assertStringContainsString('does not match', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => 42]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '43'], + )); + } + + #[TestDox('a numeric-looking string argument keeps its exact-match comparison')] + public function testNumericLookingStringArgumentIsNotComparedNumerically(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertStringContainsString('does not match', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => '042']], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '42'], + )); + + $this->assertNull($validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => '042']], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '042'], + )); + } + + #[TestDox('a scientific-notation header is not accepted as a decimal number')] + public function testScientificNotationHeaderIsRejected(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertStringContainsString('does not match', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['retries' => 40]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '4e1'], + )); + } + + #[TestDox('a nested mirrored argument is read at its exact path')] + public function testNestedMirroredArgumentIsChecked(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertNull($validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['target' => ['region' => 'us-west1']]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Region' => 'us-west1'], + )); + + $this->assertStringContainsString('Missing required Mcp-Param-Region', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => ['target' => ['region' => 'us-west1']]], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored'], + )); + + // Absent at that path, so no header is expected. + $this->assertNull($validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => []], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored'], + )); + } + + private static function registryWithMirroredTool(): RegistryInterface + { + $registry = new Registry(); + $registry->registerTool( + new Tool( + name: 'mirrored', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'], + 'target' => [ + 'type' => 'object', + 'properties' => ['region' => ['type' => 'string', 'x-mcp-header' => 'Region']], + ], + ], + 'required' => null, + ], + description: 'x', + annotations: null, + ), + static fn (): string => 'ok', + ); + + return $registry; + } + + #[TestDox('a plain header value decodes to itself')] + public function testPlainValuePassesThroughDecode(): void + { + $this->assertSame('Hello', StandardHeaderValidator::decode('Hello')); + } + + #[TestDox('a well-formed Base64 wrapper decodes to its contents')] + public function testValidBase64Decodes(): void + { + $this->assertSame('Hello', StandardHeaderValidator::decode('=?base64?'.base64_encode('Hello').'?=')); + } + + #[TestDox('malformed Base64 is refused rather than salvaged')] + #[DataProvider('malformedBase64')] + public function testMalformedBase64IsRefused(string $encoded): void + { + // PHP's decoder returns bytes for most of these; accepting them would + // let a corrupted header silently compare equal. + $this->assertNull(StandardHeaderValidator::decode('=?base64?'.$encoded.'?=')); + } + + /** + * @return iterable + */ + public static function malformedBase64(): iterable + { + yield 'missing padding' => ['SGVsbG8']; + yield 'out-of-alphabet character' => ['SGVsbG8!']; + yield 'stray whitespace' => ['SGVs bG8=']; + } +} diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php new file mode 100644 index 0000000..62c7eb7 --- /dev/null +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -0,0 +1,1206 @@ + $capabilities + */ + private static function protocol(array $capabilities = []): StatelessProtocol + { + return Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->addTool(static fn (): string => 'ok', name: 'plain_tool', description: 'Returns a fixed string') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + return implode(',', array_keys(array_filter([ + 'roots' => $gateway->supportsRoots(), + 'sampling' => $gateway->supportsSampling(), + 'sampling.tools' => $gateway->supportsSamplingTools(), + 'elicitation' => $gateway->supportsElicitation(), + 'elicitation.url' => $gateway->supportsElicitationUrl(), + ]))) ?: 'none'; + }, + name: 'probe_capabilities', + description: 'Reports which client capabilities the gateway can see', + ) + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + $gateway->progress(0, 100, 'starting'); + $gateway->progress(100, 100, 'done'); + + return 'progressed'; + }, + name: 'progress_tool', + description: 'Reports progress while it works', + ) + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + foreach ([LoggingLevel::Debug, LoggingLevel::Info, LoggingLevel::Warning, LoggingLevel::Error] as $level) { + $gateway->log($level, $level->value.' message'); + } + + return 'logged'; + }, + name: 'logging_tool', + description: 'Emits one message at each of four levels', + ) + ->addTool( + static function (): never { + throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'needs sampling'); + }, + name: 'capability_tool', + description: 'Always reports a missing client capability', + ) + ->addTool( + static function (RequestContext $context): string { + $answer = $context->getClientGateway()->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); + + return 'hello '.($answer->content['n'] ?? '?'); + }, + name: 'elicits_directly', + description: 'Asks through the gateway, the way a handshake-era handler does', + ) + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + $gateway->progress(0, 100, 'starting'); + + $answer = $gateway->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); + + return 'hello '.($answer->content['n'] ?? '?'); + }, + name: 'elicits_after_progress', + description: 'Reports progress, then asks through the gateway', + ) + ->addTool( + static function (RequestContext $context): string { + // A kind this revision removed rather than reshaped: kept as + // a fixture so the refusal has something to refuse. + $context->getClientGateway()->request(new ListRootsRequest()); + + return 'unreachable'; + }, + name: 'asks_for_roots', + description: 'Asks the client directly for something this revision removed', + ) + ->addTool( + static fn (): InputRequiredResult => new InputRequiredResult([ + 'consent' => ElicitRequest::forUrl('Approve out of band', 'https://example.com/consent'), + ]), + name: 'asks_by_url', + description: 'Asks through a url-mode elicitation', + ) + ->addTool( + static function (RequestContext $context): InputRequiredResult { + $context->getClientGateway()->progress(0, 100, 'starting'); + + return new InputRequiredResult([ + 'consent' => ElicitRequest::forUrl('Approve out of band', 'https://example.com/consent'), + ]); + }, + name: 'asks_by_url_after_progress', + description: 'Emits progress, then asks through a url-mode elicitation', + ) + ->addTool( + static function (RequestContext $context): string { + $pairs = []; + foreach ($context->getTraceContext() as $key => $value) { + $pairs[] = $key.'='.$value; + } + + return implode(';', $pairs) ?: 'none'; + }, + name: 'probe_trace', + description: 'Reports the trace context it was called with', + ) + ->addResource(static fn (): string => 'body', 'test://static', 'static', 'A static resource') + ->addResource( + static function (RequestContext $context): string|InputRequiredResult { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('who')) { + return new InputRequiredResult([ + 'who' => new ElicitRequest('Who is asking?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])), + ]); + } + + return 'body for '.($input->response('who')['content']['n'] ?? '?'); + }, + 'test://gated', + 'gated', + 'A resource that asks who is reading before it answers', + ) + ->buildStateless([ProtocolVersion::V2026_07_28]); + } + + /** + * @param array $params + * @param array $extraHeaders + * + * @return array + */ + private static function call(StatelessProtocol $protocol, string $method, array $params = [], array $extraHeaders = [], array $capabilities = []): array + { + // Merged, not replaced: a test may add its own `_meta` members. + $params['_meta'] = [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => (object) $capabilities, + ...($params['_meta'] ?? []), + ]; + + return self::callWithHeaders($protocol, $method, $params, [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => $method, + ...$extraHeaders, + ]); + } + + /** + * The header-exact variant: nothing is filled in, so a test can leave a + * required header out. + * + * @param array $params + * @param array $headers + * + * @return array + */ + private static function callWithHeaders( + StatelessProtocol $protocol, + string $method, + array $params = [], + array $headers = [], + ?string $metaVersion = null, + ): array { + $params['_meta'] ??= [ + RequestMeta::PROTOCOL_VERSION => $metaVersion ?? ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]; + + $result = $protocol->handle( + json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR), + $headers, + ); + + return [ + 'status' => $result->httpStatus, + 'body' => json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR), + ]; + } + + #[TestDox('the gateway sees the capabilities this request declared, not an empty session')] + public function testClientCapabilitiesReachTheGateway(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'probe_capabilities', 'arguments' => []], + ['Mcp-Name' => 'probe_capabilities'], + ['roots' => new \stdClass(), 'elicitation' => ['url' => new \stdClass()], 'sampling' => ['tools' => new \stdClass()]], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame( + 'roots,sampling,sampling.tools,elicitation,elicitation.url', + $answer['body']['result']['content'][0]['text'], + ); + } + + #[TestDox('a client declaring nothing is reported as declaring nothing')] + public function testEmptyCapabilitiesReportNone(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'probe_capabilities', 'arguments' => []], + ['Mcp-Name' => 'probe_capabilities'], + ); + + $this->assertSame('none', $answer['body']['result']['content'][0]['text']); + } + + /** + * @return iterable}> + */ + public static function removedMethods(): iterable + { + yield 'initialize' => ['initialize', []]; + yield 'ping' => ['ping', []]; + yield 'logging/setLevel' => ['logging/setLevel', ['level' => 'info']]; + yield 'resources/subscribe' => ['resources/subscribe', ['uri' => 'test://static']]; + yield 'resources/unsubscribe' => ['resources/unsubscribe', ['uri' => 'test://static']]; + } + + /** + * @param array $params + */ + #[DataProvider('removedMethods')] + #[TestDox('a method this revision removed is answered as unknown')] + public function testRemovedMethodsAreUnknown(string $method, array $params): void + { + $answer = self::call(self::protocol(), $method, $params); + + $this->assertSame(404, $answer['status']); + $this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']); + } + + /** + * Drains a streaming result into the frames it would write. + * + * @return list> + */ + private static function frames(StatelessResult $result): array + { + $frames = []; + + foreach (($result->frames)() as $frame) { + if (null !== $frame) { + $frames[] = $frame; + } + } + + return $frames; + } + + /** + * @param array $meta + */ + private static function callStreaming(StatelessProtocol $protocol, string $tool, array $meta = []): StatelessResult + { + return $protocol->handle(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 7, + 'method' => 'tools/call', + 'params' => [ + 'name' => $tool, + 'arguments' => [], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ...$meta, + ], + ], + ], \JSON_THROW_ON_ERROR), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'tools/call', + 'Mcp-Name' => $tool, + 'Accept' => 'application/json, text/event-stream', + ]); + } + + #[TestDox('progress notifications reach the client on the response stream')] + public function testProgressStreamsBeforeTheResponse(): void + { + $result = self::callStreaming(self::protocol(), 'progress_tool', ['progressToken' => 'tok-1']); + + $this->assertTrue($result->isStream()); + + $frames = self::frames($result); + + $this->assertCount(3, $frames); + $this->assertSame('notifications/progress', $frames[0]['method']); + $this->assertSame('tok-1', $frames[0]['params']['progressToken']); + $this->assertSame('notifications/progress', $frames[1]['method']); + $this->assertSame(7, $frames[2]['id']); + $this->assertSame('complete', $frames[2]['result']['resultType']); + } + + #[TestDox('without a progress token the handler emits nothing and gets a plain response')] + public function testProgressWithoutATokenIsNotStreamed(): void + { + $result = self::callStreaming(self::protocol(), 'progress_tool'); + + $this->assertFalse($result->isStream()); + $this->assertSame(200, $result->httpStatus); + } + + #[TestDox('a client that will not read a stream gets its notifications dropped, not a stream')] + public function testNotificationsAreDroppedWithoutAnAcceptingClient(): void + { + $result = self::protocol()->handle(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 7, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'progress_tool', + 'arguments' => [], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + 'progressToken' => 'tok-1', + ], + ], + ], \JSON_THROW_ON_ERROR), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'tools/call', + 'Mcp-Name' => 'progress_tool', + 'Accept' => 'application/json', + ]); + + $this->assertFalse($result->isStream()); + $this->assertSame(200, $result->httpStatus); + } + + #[TestDox('a request naming no log level receives no log notifications')] + public function testLoggingIsSilentWithoutARequestedLevel(): void + { + $result = self::callStreaming(self::protocol(), 'logging_tool'); + + $this->assertFalse($result->isStream()); + } + + #[TestDox('a request naming a log level receives the messages at or above it')] + public function testLoggingHonoursTheRequestedLevel(): void + { + $frames = self::frames(self::callStreaming(self::protocol(), 'logging_tool', [RequestMeta::LOG_LEVEL => 'warning'])); + + $levels = []; + foreach ($frames as $frame) { + if ('notifications/message' === ($frame['method'] ?? null)) { + $levels[] = $frame['params']['level']; + } + } + + // The tool emits debug, info, warning and error. + $this->assertSame(['warning', 'error'], $levels); + } + + #[TestDox('a lower requested level lets more through')] + public function testLoggingAtDebugLetsEverythingThrough(): void + { + $frames = self::frames(self::callStreaming(self::protocol(), 'logging_tool', [RequestMeta::LOG_LEVEL => 'debug'])); + + $levels = []; + foreach ($frames as $frame) { + if ('notifications/message' === ($frame['method'] ?? null)) { + $levels[] = $frame['params']['level']; + } + } + + $this->assertSame(['debug', 'info', 'warning', 'error'], $levels); + } + + #[TestDox('an error raised before any notification keeps its own status')] + public function testEarlyFailureIsStillAStatusCode(): void + { + $result = self::callStreaming(self::protocol(), 'capability_tool'); + + $this->assertFalse($result->isStream()); + $this->assertSame(400, $result->httpStatus); + + $body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR); + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $body['error']['code']); + } + + #[TestDox('a server-initiated request this revision removed is refused')] + public function testServerInitiatedRequestIsRefused(): void + { + $result = self::callStreaming(self::protocol(), 'asks_for_roots'); + + $body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR); + + $this->assertSame(500, $result->httpStatus); + $this->assertStringContainsString('no server-initiated requests', $body['error']['message']); + } + + #[TestDox('a gateway elicitation becomes the ask this revision carries in the result')] + public function testGatewayElicitationBecomesAnAsk(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'elicits_directly', 'arguments' => []], + ['Mcp-Name' => 'elicits_directly'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame('input_required', $answer['body']['result']['resultType']); + // Unnamed, so filed under its position among the handler's asks. + $this->assertSame('elicitation/create', $answer['body']['result']['inputRequests']['elicitation_1']['method']); + // Nothing is answered yet, so there is nothing to carry. + $this->assertArrayNotHasKey('requestState', $answer['body']['result']); + } + + #[TestDox('the retry resumes the gateway call the ask came from')] + public function testGatewayElicitationResumesOnRetry(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + [ + 'name' => 'elicits_directly', + 'arguments' => [], + 'inputResponses' => ['elicitation_1' => ['action' => 'accept', 'content' => ['n' => 'ada']]], + ], + ['Mcp-Name' => 'elicits_directly'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame('hello ada', $answer['body']['result']['content'][0]['text']); + } + + #[TestDox('a gateway ask that follows a notification ends the stream it opened')] + public function testGatewayElicitationEndsAnOpenStream(): void + { + $result = self::callStreaming(self::protocol(), 'elicits_after_progress', [ + 'progressToken' => 'p1', + RequestMeta::CLIENT_CAPABILITIES => ['elicitation' => new \stdClass()], + ]); + + $this->assertTrue($result->isStream()); + + $frames = self::frames($result); + $this->assertSame('notifications/progress', $frames[0]['method']); + $this->assertSame('input_required', $frames[1]['result']['resultType']); + } + + #[TestDox('an ask the client did not declare it can answer is refused before it is minted')] + public function testUndeclaredGatewayElicitationIsRefused(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'elicits_directly', 'arguments' => []], + ['Mcp-Name' => 'elicits_directly'], + ); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $answer['body']['error']['code']); + } + + /** + * A handler asking twice: the second round has to remember the first + * round's answer, which is what the `requestState` is for. + */ + private static function twoAskProtocol(bool $signed = true): StatelessProtocol + { + $builder = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + $schema = new ElicitationSchema(['v' => new StringSchemaDefinition('V')], ['v']); + + $when = $gateway->elicit('When?', $schema, key: 'when'); + $seat = $gateway->elicit('Seat?', $schema, key: 'seat'); + + return $when->content['v'].'/'.$seat->content['v']; + }, + name: 'books_flight', + description: 'Asks twice before it answers', + ); + + if ($signed) { + $builder->setRequestState(str_repeat('k', 32)); + } + + return $builder->buildStateless([ProtocolVersion::V2026_07_28]); + } + + /** + * @param array $params + * + * @return array + */ + private static function book(StatelessProtocol $protocol, array $params = []): array + { + return self::call( + $protocol, + 'tools/call', + ['name' => 'books_flight', 'arguments' => [], ...$params], + ['Mcp-Name' => 'books_flight'], + ['elicitation' => new \stdClass()], + ); + } + + #[TestDox('a handler asking twice is served one ask per round, remembering the first')] + public function testNamedAsksCarryAcrossRounds(): void + { + $protocol = self::twoAskProtocol(); + + $first = self::book($protocol); + $this->assertSame('input_required', $first['body']['result']['resultType']); + $this->assertSame(['when'], array_keys($first['body']['result']['inputRequests'])); + // Nothing answered yet, so nothing to carry. + $this->assertArrayNotHasKey('requestState', $first['body']['result']); + + $second = self::book($protocol, [ + 'inputResponses' => ['when' => ['action' => 'accept', 'content' => ['v' => 'morning']]], + ]); + $this->assertSame('input_required', $second['body']['result']['resultType']); + $this->assertSame(['seat'], array_keys($second['body']['result']['inputRequests'])); + $this->assertIsString($second['body']['result']['requestState']); + + // The client echoes the state and answers only what it was just asked. + $third = self::book($protocol, [ + 'inputResponses' => ['seat' => ['action' => 'accept', 'content' => ['v' => 'aisle']]], + 'requestState' => $second['body']['result']['requestState'], + ]); + $this->assertSame(200, $third['status']); + $this->assertSame('morning/aisle', $third['body']['result']['content'][0]['text']); + } + + #[TestDox('carrying an answer to the next round without a signing key fails loudly')] + public function testCarryingAnAnswerNeedsASigningKey(): void + { + $answer = self::book(self::twoAskProtocol(signed: false), [ + 'inputResponses' => ['when' => ['action' => 'accept', 'content' => ['v' => 'morning']]], + ]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::INTERNAL_ERROR, $answer['body']['error']['code']); + } + + #[TestDox('an answer that does not parse is asked for again')] + public function testMalformedAnswerIsAskedAgain(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + [ + 'name' => 'elicits_directly', + 'arguments' => [], + // Accepted, but a form-mode acceptance carries content. + 'inputResponses' => ['elicitation_1' => ['action' => 'accept']], + ], + ['Mcp-Name' => 'elicits_directly'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame('input_required', $answer['body']['result']['resultType']); + $this->assertArrayHasKey('elicitation_1', $answer['body']['result']['inputRequests']); + } + + #[TestDox('a request\'s trace context reaches the handler')] + public function testTraceContextReachesTheHandler(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + [ + 'name' => 'probe_trace', + 'arguments' => [], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + 'baggage' => 'tenant=acme', + ], + ], + ['Mcp-Name' => 'probe_trace'], + ); + + $this->assertSame( + 'traceparent=00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01;baggage=tenant=acme', + $answer['body']['result']['content'][0]['text'], + ); + } + + #[TestDox('a native traceparent/tracestate header reaches the handler when `_meta` carries none')] + public function testHttpTraceHeadersReachTheHandler(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'probe_trace', 'arguments' => []], + [ + 'Mcp-Name' => 'probe_trace', + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + 'tracestate' => 'acme=1', + ], + ); + + $this->assertSame( + 'traceparent=00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01;tracestate=acme=1', + $answer['body']['result']['content'][0]['text'], + ); + } + + #[TestDox('`_meta` trace context wins over a conflicting native header')] + public function testMetaTraceContextWinsOverHeader(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + [ + 'name' => 'probe_trace', + 'arguments' => [], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + 'traceparent' => '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + ], + ], + [ + 'Mcp-Name' => 'probe_trace', + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + ], + ); + + $this->assertSame( + 'traceparent=00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + $answer['body']['result']['content'][0]['text'], + ); + } + + #[TestDox('notifications caused by a traced request carry its trace context')] + public function testNotificationsCarryTheTraceContext(): void + { + $result = self::callStreaming(self::protocol(), 'progress_tool', [ + 'progressToken' => 'tok-1', + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + ]); + + $frames = self::frames($result); + + $this->assertSame( + '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + $frames[0]['params']['_meta']['traceparent'], + ); + } + + #[TestDox('an untraced request adds no trace metadata')] + public function testUntracedRequestAddsNothing(): void + { + $frames = self::frames(self::callStreaming(self::protocol(), 'progress_tool', ['progressToken' => 'tok-1'])); + + $this->assertArrayNotHasKey('traceparent', $frames[0]['params']['_meta'] ?? []); + } + + #[TestDox('a missing resource answers -32602 with the uri, not the retired -32002')] + public function testResourceNotFoundUsesInvalidParams(): void + { + $answer = self::call( + self::protocol(), + 'resources/read', + ['uri' => 'test://absent'], + ['Mcp-Name' => 'test://absent'], + ); + + $this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']); + $this->assertSame('test://absent', $answer['body']['error']['data']['uri']); + } + + #[TestDox('an unknown tool is a bad parameter, not an unknown method')] + public function testUnknownToolUsesInvalidParams(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'no_such_tool', 'arguments' => []], + ['Mcp-Name' => 'no_such_tool'], + ); + + $this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']); + } + + #[TestDox('an unknown prompt is a bad parameter too')] + public function testUnknownPromptUsesInvalidParams(): void + { + $answer = self::call( + self::protocol(), + 'prompts/get', + ['name' => 'no_such_prompt', 'arguments' => []], + ['Mcp-Name' => 'no_such_prompt'], + ); + + $this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']); + } + + #[TestDox('this revision never emits the codes it reserved')] + public function testReservedCodesAreNeverEmitted(): void + { + $protocol = self::protocol(); + + $answers = [ + self::call($protocol, 'resources/read', ['uri' => 'test://absent'], ['Mcp-Name' => 'test://absent']), + self::call($protocol, 'tools/call', ['name' => 'nope', 'arguments' => []], ['Mcp-Name' => 'nope']), + self::call($protocol, 'prompts/get', ['name' => 'nope', 'arguments' => []], ['Mcp-Name' => 'nope']), + ]; + + foreach ($answers as $answer) { + // -32002 (resource not found) and -32042 (url elicitation required) + // are reserved by earlier revisions and never reused. + $this->assertNotContains($answer['body']['error']['code'], [-32002, -32042]); + } + } + + #[TestDox('resources/read can ask for input before it answers')] + public function testResourceReadCanAskForInput(): void + { + $answer = self::call( + self::protocol(), + 'resources/read', + ['uri' => 'test://gated'], + ['Mcp-Name' => 'test://gated'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame('input_required', $answer['body']['result']['resultType']); + $this->assertArrayHasKey('who', $answer['body']['result']['inputRequests']); + + // Interim results are not cacheable and carry no hints. + $this->assertArrayNotHasKey('ttlMs', $answer['body']['result']); + $this->assertArrayNotHasKey('cacheScope', $answer['body']['result']); + } + + #[TestDox('an ask the client cannot answer is refused with -32021, not sent')] + public function testUndeclaredInputRequestIsRefused(): void + { + // The client declared nothing, so it has no way to answer an + // elicitation — and a retry carrying one could never arrive. + $answer = self::call( + self::protocol(), + 'resources/read', + ['uri' => 'test://gated'], + ['Mcp-Name' => 'test://gated'], + ); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $answer['body']['error']['code']); + $this->assertArrayHasKey('elicitation', $answer['body']['error']['data']['requiredCapabilities']); + } + + #[TestDox('url-mode elicitation needs its own declaration, which form does not satisfy')] + public function testUrlElicitationNeedsItsOwnDeclaration(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'asks_by_url', 'arguments' => []], + ['Mcp-Name' => 'asks_by_url'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $answer['body']['error']['code']); + $this->assertArrayHasKey('url', $answer['body']['error']['data']['requiredCapabilities']['elicitation']); + } + + #[TestDox('a client declaring url mode gets the ask')] + public function testUrlElicitationPassesWhenDeclared(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'asks_by_url', 'arguments' => []], + ['Mcp-Name' => 'asks_by_url'], + ['elicitation' => ['url' => new \stdClass()]], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame('input_required', $answer['body']['result']['resultType']); + } + + #[TestDox('an ask the client cannot answer is refused with -32021 even mid-stream')] + public function testUndeclaredInputRequestIsRefusedWhenStreamed(): void + { + // The handler emits progress before its InputRequiredResult, so the + // answer goes out on the response stream, not a plain 400 — the gate + // still has to apply there. + $result = self::callStreaming(self::protocol(), 'asks_by_url_after_progress', ['progressToken' => 'tok-1']); + + $this->assertTrue($result->isStream()); + + $frames = self::frames($result); + $last = json_decode(json_encode($frames[array_key_last($frames)], \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR); + + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $last['error']['code']); + $this->assertArrayHasKey('url', $last['error']['data']['requiredCapabilities']['elicitation']); + } + + #[TestDox('a resource can set its own freshness, overriding the policy')] + public function testResourceAuthoredCacheHintsWin(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->setCachePolicy(CachePolicy::default(60_000, CacheScope::Public)) + ->addResource( + static fn (): ReadResourceResult => new ReadResourceResult( + [new TextResourceContents('test://volatile', 'text/plain', 'now')], + ttlMs: 250, + cacheScope: CacheScope::Private, + ), + 'test://volatile', + 'volatile', + 'A resource that decides its own freshness', + ) + ->addResource(static fn (): string => 'body', 'test://plain', 'plain', 'A resource that defers to policy') + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $volatile = self::call($protocol, 'resources/read', ['uri' => 'test://volatile'], ['Mcp-Name' => 'test://volatile']); + $this->assertSame(250, $volatile['body']['result']['ttlMs']); + $this->assertSame('private', $volatile['body']['result']['cacheScope']); + + $plain = self::call($protocol, 'resources/read', ['uri' => 'test://plain'], ['Mcp-Name' => 'test://plain']); + $this->assertSame(60_000, $plain['body']['result']['ttlMs']); + $this->assertSame('public', $plain['body']['result']['cacheScope']); + } + + #[TestDox('a first-round read carries caching hints')] + public function testFirstRoundReadIsCacheable(): void + { + $answer = self::call( + self::protocol(), + 'resources/read', + ['uri' => 'test://static'], + ['Mcp-Name' => 'test://static'], + ); + + $this->assertArrayHasKey('ttlMs', $answer['body']['result']); + $this->assertArrayHasKey('cacheScope', $answer['body']['result']); + } + + #[TestDox('a result produced by a multi round-trip retry carries no caching hints')] + public function testMrtrRetryResultIsNotCacheable(): void + { + $answer = self::call( + self::protocol(), + 'resources/read', + [ + 'uri' => 'test://gated', + 'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['n' => 'ada']]], + ], + ['Mcp-Name' => 'test://gated'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame('complete', $answer['body']['result']['resultType']); + // The inputs are not part of any cache key, so the answer must not be + // presented as reusable. + $this->assertArrayNotHasKey('ttlMs', $answer['body']['result']); + $this->assertArrayNotHasKey('cacheScope', $answer['body']['result']); + } + + #[TestDox('a listen stream acknowledges, then carries what the client asked for')] + public function testListenStreamDeliversSubscribedNotifications(): void + { + $bus = new InMemoryNotificationBus(); + + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->setCapabilities(new ServerCapabilities(toolsListChanged: true, resourcesListChanged: true, resourcesSubscribe: true)) + ->setNotificationBus($bus) + ->setSubscriptionLifetime(0.8) + ->addTool(static fn (): string => 'ok', name: 'a_tool', description: 'x') + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $result = $protocol->handle(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 42, + 'method' => 'subscriptions/listen', + 'params' => [ + 'notifications' => ['toolsListChanged' => true, 'resourceSubscriptions' => ['file:///watched']], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ], + ], + ], \JSON_THROW_ON_ERROR), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'subscriptions/listen', + ]); + + $this->assertTrue($result->isStream()); + + $frames = []; + $published = false; + + foreach (($result->frames)() as $frame) { + if (null === $frame) { + // Publish once the stream is established, so the notifications + // arrive the way a concurrent request would deliver them. + if (!$published) { + $bus->publish(new ToolListChangedNotification()); + $bus->publish(new PromptListChangedNotification()); + $bus->publish(new ResourceUpdatedNotification('file:///watched')); + $bus->publish(new ResourceUpdatedNotification('file:///ignored')); + $published = true; + } + + continue; + } + + $frames[] = $frame; + } + + $methods = array_map(static fn (array $frame): string => $frame['method'] ?? 'response', $frames); + + // The acknowledgment MUST come first, the declined types must not come + // at all, and the graceful closure ends it. + $this->assertSame([ + 'notifications/subscriptions/acknowledged', + 'notifications/tools/list_changed', + 'notifications/resources/updated', + 'response', + ], $methods); + + $this->assertSame(['toolsListChanged' => true, 'resourceSubscriptions' => ['file:///watched']], (array) $frames[0]['params']['notifications']); + $this->assertSame('file:///watched', $frames[2]['params']['uri']); + + // Every message on the stream names the subscription it belongs to. + foreach ([$frames[0], $frames[1], $frames[2]] as $frame) { + $this->assertSame(42, $frame['params']['_meta'][RequestMeta::SUBSCRIPTION_ID]); + } + + $this->assertSame(42, $frames[3]['id']); + $this->assertSame(42, $frames[3]['result']['_meta'][RequestMeta::SUBSCRIPTION_ID]); + $this->assertSame('complete', $frames[3]['result']['resultType']); + } + + #[TestDox('the acknowledgment drops types the server cannot honour')] + public function testAcknowledgmentReflectsWhatTheServerCanDo(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->setCapabilities(new ServerCapabilities(toolsListChanged: true, promptsListChanged: false)) + ->setNotificationBus(new InMemoryNotificationBus()) + ->setSubscriptionLifetime(0.05) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $result = $protocol->handle(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'subscriptions/listen', + 'params' => [ + 'notifications' => ['toolsListChanged' => true, 'promptsListChanged' => true], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ], + ], + ], \JSON_THROW_ON_ERROR), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'subscriptions/listen', + ]); + + $first = null; + foreach (($result->frames)() as $frame) { + if (null !== $frame) { + $first = $frame; + break; + } + } + + $this->assertSame(['toolsListChanged' => true], (array) $first['params']['notifications']); + } + + #[TestDox('an extension method is served by the extension that claims it')] + public function testExtensionMethodIsServed(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->enableExtension(new ThingExtension()) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::callWithHeaders($protocol, 'com.example/things.list', [], [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'com.example/things.list', + ]); + + $this->assertSame(200, $answer['status']); + $this->assertSame(['a', 'b'], $answer['body']['result']['things']); + } + + #[TestDox('the extension is advertised under capabilities.extensions')] + public function testExtensionIsAdvertised(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->enableExtension(new ThingExtension()) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::call($protocol, 'server/discover'); + + $this->assertSame(['flavour' => 'vanilla'], (array) $answer['body']['result']['capabilities']['extensions']['com.example/things']); + } + + #[TestDox('a method of an extension this server has never heard of stays generic')] + public function testUnknownExtensionMethodStaysGeneric(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::callWithHeaders($protocol, 'com.example/things.list', [], [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'com.example/things.list', + ]); + + $this->assertSame(404, $answer['status']); + $this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']); + // The extension was never enabled, so it never entered the method map + // — there is nothing to name it by. + $this->assertStringContainsString('com.example/things.list', $answer['body']['error']['message']); + $this->assertStringNotContainsString('extension', $answer['body']['error']['message']); + } + + #[TestDox('a method of an extension this server does not serve says so by name')] + public function testUnservedExtensionMethodNamesItsExtension(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->enableExtension(new UnservedThingExtension()) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::callWithHeaders($protocol, 'com.example/things.list', [], [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'com.example/things.list', + ]); + + $this->assertSame(404, $answer['status']); + $this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']); + $this->assertStringContainsString('com.example/unserved-things', $answer['body']['error']['message']); + } + + #[TestDox('a notification is acknowledged with no body, never answered')] + public function testNotificationIsAcknowledged(): void + { + $result = self::protocol()->handle( + json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/something', 'params' => []], \JSON_THROW_ON_ERROR), + ['Mcp-Method' => 'notifications/something'], + ); + + $this->assertTrue($result->isEmpty()); + $this->assertSame(202, $result->httpStatus); + } + + #[TestDox('a notification for a removed method is refused with no body')] + public function testRemovedNotificationIsRefused(): void + { + $result = self::protocol()->handle( + json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], \JSON_THROW_ON_ERROR), + ['Mcp-Method' => 'notifications/initialized'], + ); + + $this->assertTrue($result->isEmpty()); + $this->assertSame(400, $result->httpStatus); + } + + #[TestDox('an error for a request whose id could not be read omits the id')] + public function testUnreadableIdIsOmittedRatherThanEmptied(): void + { + $result = self::protocol()->handle('{"jsonrpc":"2.0","id":{"bad":1},"method":"tools/list"}', []); + + $body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR); + + $this->assertArrayNotHasKey('id', $body); + $this->assertSame(Error::INVALID_REQUEST, $body['error']['code']); + } + + #[TestDox('an error for a readable id still echoes it')] + public function testReadableIdIsEchoed(): void + { + $answer = self::callWithHeaders(self::protocol(), 'tools/list', [], ['Mcp-Method' => 'tools/list']); + + $this->assertSame(1, $answer['body']['id']); + } + + #[TestDox('a POST without the MCP-Protocol-Version header is refused')] + public function testMissingProtocolVersionHeaderIsRefused(): void + { + $answer = self::callWithHeaders( + self::protocol(), + 'tools/list', + [], + ['Mcp-Method' => 'tools/list'], + ); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::HEADER_MISMATCH, $answer['body']['error']['code']); + $this->assertStringContainsString('MCP-Protocol-Version', $answer['body']['error']['message']); + } + + #[TestDox('a header contradicting the _meta version outranks an unsupported version')] + public function testContradictingProtocolVersionHeaderIsRefused(): void + { + $answer = self::callWithHeaders( + self::protocol(), + 'tools/list', + [], + ['Mcp-Method' => 'tools/list', 'MCP-Protocol-Version' => '2025-11-25'], + ); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::HEADER_MISMATCH, $answer['body']['error']['code']); + } + + #[TestDox('an unsupported version carries the supported set the client can retry from')] + public function testUnsupportedProtocolVersionCarriesSupportedSet(): void + { + $answer = self::callWithHeaders( + self::protocol(), + 'tools/list', + [], + ['Mcp-Method' => 'tools/list', 'MCP-Protocol-Version' => '1900-01-01'], + '1900-01-01', + ); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::UNSUPPORTED_PROTOCOL_VERSION, $answer['body']['error']['code']); + $this->assertSame('1900-01-01', $answer['body']['error']['data']['requested']); + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['body']['error']['data']['supported']); + } + + #[TestDox('elicitation without a named mode reports form, not url')] + public function testElicitationDefaultsToFormMode(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'probe_capabilities', 'arguments' => []], + ['Mcp-Name' => 'probe_capabilities'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame('elicitation', $answer['body']['result']['content'][0]['text']); + } +} diff --git a/tests/Unit/Server/Subscription/NotificationBusTest.php b/tests/Unit/Server/Subscription/NotificationBusTest.php new file mode 100644 index 0000000..7c16200 --- /dev/null +++ b/tests/Unit/Server/Subscription/NotificationBusTest.php @@ -0,0 +1,217 @@ + + */ + public static function buses(): iterable + { + yield 'in memory' => [new InMemoryNotificationBus()]; + yield 'psr-16' => [new Psr16NotificationBus(self::arrayCache())]; + } + + #[DataProvider('buses')] + #[TestDox('a subscriber reads what is published after it opened')] + public function testReadsForwardFromItsCursor(NotificationBusInterface $bus): void + { + $bus->publish(new ToolListChangedNotification()); + + // Opened after the first notification, so it must not see it. + $cursor = $bus->cursor(); + + $bus->publish(new PromptListChangedNotification()); + $bus->publish(new ResourceUpdatedNotification('file:///a')); + + [$found, $next] = $bus->since($cursor); + + $this->assertCount(2, $found); + $this->assertInstanceOf(PromptListChangedNotification::class, $found[0]); + $this->assertInstanceOf(ResourceUpdatedNotification::class, $found[1]); + $this->assertSame('file:///a', $found[1]->uri); + + // Reading again from the returned cursor yields nothing new. + [$again] = $bus->since($next); + $this->assertSame([], $again); + } + + #[DataProvider('buses')] + #[TestDox('a quiet bus hands back nothing')] + public function testQuietBusIsEmpty(NotificationBusInterface $bus): void + { + [$found] = $bus->since($bus->cursor()); + + $this->assertSame([], $found); + } + + #[DataProvider('buses')] + #[TestDox('two subscribers at different cursors each get their own view')] + public function testCursorsAreIndependent(NotificationBusInterface $bus): void + { + $early = $bus->cursor(); + $bus->publish(new ToolListChangedNotification()); + $late = $bus->cursor(); + $bus->publish(new PromptListChangedNotification()); + + $this->assertCount(2, $bus->since($early)[0]); + $this->assertCount(1, $bus->since($late)[0]); + } + + #[TestDox('the backlog is bounded, so a stream that went away cannot grow it forever')] + public function testBacklogIsBounded(): void + { + $bus = new InMemoryNotificationBus(backlog: 3); + + $cursor = $bus->cursor(); + for ($i = 0; $i < 10; ++$i) { + $bus->publish(new ToolListChangedNotification()); + } + + $this->assertCount(3, $bus->since($cursor)[0]); + } + + #[TestDox('a backlog below one entry is refused')] + public function testZeroBacklogIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + + new InMemoryNotificationBus(backlog: 0); + } + + #[TestDox('a registry change event becomes a notification on the bus')] + public function testRegistryEventsArePublished(): void + { + $bus = new InMemoryNotificationBus(); + $cursor = $bus->cursor(); + + (new PublishingEventDispatcher($bus))->dispatch(new ToolListChangedEvent()); + + [$found] = $bus->since($cursor); + + $this->assertCount(1, $found); + $this->assertInstanceOf(ToolListChangedNotification::class, $found[0]); + } + + #[TestDox('an event the publisher does not know is passed on untouched')] + public function testUnknownEventIsPassedThrough(): void + { + $bus = new InMemoryNotificationBus(); + $cursor = $bus->cursor(); + $event = new \stdClass(); + + $this->assertSame($event, (new PublishingEventDispatcher($bus))->dispatch($event)); + $this->assertSame([], $bus->since($cursor)[0]); + } + + #[TestDox('an inner dispatcher still sees every event')] + public function testInnerDispatcherStillRuns(): void + { + $seen = []; + $inner = new class($seen) implements \Psr\EventDispatcher\EventDispatcherInterface { + /** @param array $seen */ + public function __construct(public array &$seen) + { + } + + public function dispatch(object $event): object + { + $this->seen[] = $event; + + return $event; + } + }; + + (new PublishingEventDispatcher(new InMemoryNotificationBus(), $inner))->dispatch(new ToolListChangedEvent()); + + $this->assertCount(1, $inner->seen); + } + + private static function arrayCache(): CacheInterface + { + return new class implements CacheInterface { + /** @var array */ + private array $values = []; + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + public function set(string $key, mixed $value, int|\DateInterval|null $ttl = null): bool + { + $this->values[$key] = $value; + + return true; + } + + public function delete(string $key): bool + { + unset($this->values[$key]); + + return true; + } + + public function clear(): bool + { + $this->values = []; + + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + foreach ($keys as $key) { + yield $key => $this->get($key, $default); + } + } + + public function setMultiple(iterable $values, int|\DateInterval|null $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set((string) $key, $value, $ttl); + } + + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + $this->delete($key); + } + + return true; + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->values); + } + }; + } +} diff --git a/tests/Unit/Server/Transport/DualEraRoutingTest.php b/tests/Unit/Server/Transport/DualEraRoutingTest.php new file mode 100644 index 0000000..d73d94b --- /dev/null +++ b/tests/Unit/Server/Transport/DualEraRoutingTest.php @@ -0,0 +1,280 @@ +factory = new Psr17Factory(); + } + + #[TestDox('the initialize handshake is answered on the endpoint that also serves the modern era')] + public function testHandshakeStillWorks(): void + { + $answer = $this->post($this->server(), $this->handshake()); + + $this->assertSame(200, $answer['status']); + $this->assertSame(ProtocolVersion::V2025_11_25->value, $answer['body']['result']['protocolVersion']); + } + + #[TestDox('server/discover is answered on that same endpoint, with no handshake before it')] + public function testDiscoverOnTheSameEndpoint(): void + { + $answer = $this->post($this->server(), $this->enveloped('server/discover'), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'server/discover', + ]); + + $this->assertSame(200, $answer['status']); + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['body']['result']['supportedVersions']); + } + + #[TestDox('one tool answers both eras, from one registry')] + public function testOneToolServesBothEras(): void + { + $server = $this->server(); + + $handshake = $this->post($server, $this->handshake()); + $session = $handshake['session']; + $this->assertNotSame('', $session, 'the handshake leg still mints a session'); + + $legacy = $this->post($server, json_encode([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => ['name' => 'echo_tool', 'arguments' => ['text' => 'legacy']], + ], \JSON_THROW_ON_ERROR), [ + 'Mcp-Session-Id' => $session, + 'MCP-Protocol-Version' => ProtocolVersion::V2025_11_25->value, + ]); + + $modern = $this->post($server, $this->enveloped('tools/call', [ + 'name' => 'echo_tool', + 'arguments' => ['text' => 'modern'], + ]), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'tools/call', + 'Mcp-Name' => 'echo_tool', + ]); + + $this->assertSame('echo:legacy', $legacy['body']['result']['content'][0]['text']); + $this->assertSame('echo:modern', $modern['body']['result']['content'][0]['text']); + + // The modern answer carries the wire fields its revision adds; the + // handshake one does not. Same handler, two codecs. + $this->assertSame('complete', $modern['body']['result']['resultType']); + $this->assertArrayNotHasKey('resultType', $legacy['body']['result']); + } + + #[TestDox('a modern claim contradicted by the header is refused before either leg sees it')] + public function testHeaderContradictingTheClaimIsRefused(): void + { + $answer = $this->post($this->server(), $this->enveloped('tools/list'), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2025_11_25->value, + ]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32020, $answer['body']['error']['code']); + } + + #[TestDox('a modern header the body does not back up is refused, naming the member it wants')] + public function testModernHeaderWithoutAnEnvelopeIsRefused(): void + { + $answer = $this->post($this->server(), json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ], \JSON_THROW_ON_ERROR), ['MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32602, $answer['body']['error']['code']); + $this->assertStringContainsString(RequestMeta::PROTOCOL_VERSION, $answer['body']['error']['message']); + } + + #[TestDox('the version middleware lets a modern header through instead of turning it away')] + public function testVersionMiddlewareAcceptsModernRevisions(): void + { + $answer = $this->post($this->server(), $this->enveloped('server/discover'), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'server/discover', + ]); + + $this->assertSame(200, $answer['status']); + } + + #[TestDox('an unknown header with no claim behind it is refused by the handshake leg, offering its revisions')] + public function testUnknownVersionHeaderIsRefused(): void + { + $answer = $this->post($this->server(), json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + ], \JSON_THROW_ON_ERROR), ['MCP-Protocol-Version' => '2030-01-01']); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32022, $answer['body']['error']['code']); + $this->assertSame( + array_map(static fn (ProtocolVersion $v): string => $v->value, ProtocolVersion::handshakeVersions()), + $answer['body']['error']['data']['supported'], + ); + } + + #[TestDox('an unknown revision claimed in the envelope is answered by the modern leg, offering its own')] + public function testUnknownClaimReachesTheModernLeg(): void + { + $body = json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => ['_meta' => [ + RequestMeta::PROTOCOL_VERSION => '2099-01-01', + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]], + ], \JSON_THROW_ON_ERROR); + + $answer = $this->post($this->server(), $body, ['MCP-Protocol-Version' => '2099-01-01']); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32022, $answer['body']['error']['code']); + // The claim named the envelope mechanism, so the answer names the + // revisions that mechanism has — not the handshake ones it cannot use. + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['body']['error']['data']['supported']); + } + + #[TestDox('a server built without the modern era refuses a modern claim, naming what it does serve')] + public function testHandshakeOnlyServerRefusesModernTraffic(): void + { + $answer = $this->post($this->server(modern: false), $this->enveloped('server/discover'), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'server/discover', + ]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32022, $answer['body']['error']['code']); + $this->assertNotContains(ProtocolVersion::V2026_07_28->value, $answer['body']['error']['data']['supported']); + } + + #[TestDox('a DELETE still ends a handshake-era session')] + public function testDeleteReachesTheHandshakeLeg(): void + { + $server = $this->server(); + $session = $this->post($server, $this->handshake())['session']; + + $request = $this->factory->createServerRequest('DELETE', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Mcp-Session-Id', $session); + + $response = $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory)); + + $this->assertSame(200, $response->getStatusCode()); + } + + private function server(bool $modern = true): Server + { + $builder = Server::builder() + ->setServerInfo('dual-era-server', '1.0.0') + ->addTool(static fn (string $text = ''): string => 'echo:'.$text, name: 'echo_tool', description: 'Echoes its argument'); + + if (!$modern) { + $builder->withoutModernEra(); + } + + return $builder->build(); + } + + private function handshake(): string + { + return json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => ProtocolVersion::V2025_11_25->value, + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'handshake-client', 'version' => '1.0.0'], + ], + ], \JSON_THROW_ON_ERROR); + } + + /** + * @param array $params + */ + private function enveloped(string $method, array $params = []): string + { + $params['_meta'] = [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]; + + return json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR); + } + + /** + * @param array $headers + * + * @return array{status: int, session: string, body: array} + */ + private function post(Server $server, string $body, array $headers = []): array + { + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Content-Type', 'application/json') + ->withHeader('Accept', 'application/json, text/event-stream') + ->withBody($this->factory->createStream($body)); + + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + $response = $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory)); + + return [ + 'status' => $response->getStatusCode(), + 'session' => $response->getHeaderLine('Mcp-Session-Id'), + 'body' => self::decode($response), + ]; + } + + /** + * @return array + */ + private static function decode(ResponseInterface $response): array + { + $payload = (string) $response->getBody(); + + if ('' === $payload) { + return []; + } + + return json_decode($payload, true, flags: \JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Unit/Server/Transport/Fixture/ShortReadStream.php b/tests/Unit/Server/Transport/Fixture/ShortReadStream.php new file mode 100644 index 0000000..98a7eda --- /dev/null +++ b/tests/Unit/Server/Transport/Fixture/ShortReadStream.php @@ -0,0 +1,110 @@ +contents, $this->offset, min($length, $this->chunkSize)); + $this->offset += \strlen($chunk); + + return $chunk; + } + + public function eof(): bool + { + return $this->offset >= \strlen($this->contents); + } + + public function getSize(): ?int + { + return $this->advertiseSize ? \strlen($this->contents) : null; + } + + public function isSeekable(): bool + { + return false; + } + + public function __toString(): string + { + return $this->contents; + } + + public function getContents(): string + { + return substr($this->contents, $this->offset); + } + + public function close(): void + { + } + + public function detach() + { + return null; + } + + public function tell(): int + { + return $this->offset; + } + + public function seek(int $offset, int $whence = \SEEK_SET): void + { + throw new \RuntimeException('Not seekable.'); + } + + public function rewind(): void + { + throw new \RuntimeException('Not seekable.'); + } + + public function isWritable(): bool + { + return false; + } + + public function write(string $string): int + { + throw new \RuntimeException('Not writable.'); + } + + public function isReadable(): bool + { + return true; + } + + public function getMetadata(?string $key = null) + { + return null === $key ? [] : null; + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php index ad216d5..143b432 100644 --- a/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php +++ b/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php @@ -44,12 +44,12 @@ final class ProtocolVersionMiddlewareTest extends MiddlewareTestCase $this->assertSame(400, $response->getStatusCode()); } - #[TestDox('accepts every version declared in the ProtocolVersion enum')] + #[TestDox('accepts every handshake-era version by default')] public function testAcceptsSupportedVersions(): void { $middleware = new ProtocolVersionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); - foreach (ProtocolVersion::cases() as $version) { + foreach (ProtocolVersion::handshakeVersions() as $version) { $request = $this->factory->createServerRequest('POST', 'http://localhost/') ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, $version->value); @@ -59,6 +59,21 @@ final class ProtocolVersionMiddlewareTest extends MiddlewareTestCase } } + #[TestDox('rejects modern-era versions by default, since the server cannot serve them yet')] + public function testRejectsModernVersionsByDefault(): void + { + $middleware = new ProtocolVersionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + + foreach (ProtocolVersion::modernVersions() as $version) { + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, $version->value); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(400, $response->getStatusCode(), 'Expected '.$version->value.' to be rejected.'); + } + } + #[TestDox('rejects unsupported well-formed version with 400')] public function testRejectsUnsupportedVersion(): void { diff --git a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php index 29c86d6..fd86d02 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php @@ -293,6 +293,88 @@ class OidcDiscoveryTest extends TestCase $this->assertSame('https://auth.example.com/.well-known/openid-configuration', $requestedUrls[1]); } + #[TestDox('issuer with path and trailing slash is discovered and matched verbatim')] + public function testIssuerWithPathAndTrailingSlash(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $requestedUrls = []; + $issuer = 'https://auth.example.com/application/o/mcp/'; + $validMetadata = [ + 'issuer' => $issuer, + 'authorization_endpoint' => 'https://auth.example.com/application/o/authorize/', + 'token_endpoint' => 'https://auth.example.com/application/o/token/', + 'jwks_uri' => 'https://auth.example.com/application/o/mcp/jwks/', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(3)) + ->method('sendRequest') + ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $validMetadata): ResponseInterface { + $requestedUrls[] = (string) $request->getUri(); + + if (3 !== \count($requestedUrls)) { + return $factory->createResponse(404); + } + + return $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($validMetadata, \JSON_THROW_ON_ERROR)), + ); + }); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $metadata = $discovery->discover($issuer); + + $this->assertSame($issuer, $metadata['issuer']); + $this->assertSame('https://auth.example.com/.well-known/oauth-authorization-server/application/o/mcp', $requestedUrls[0]); + $this->assertSame('https://auth.example.com/.well-known/openid-configuration/application/o/mcp', $requestedUrls[1]); + $this->assertSame('https://auth.example.com/application/o/mcp/.well-known/openid-configuration', $requestedUrls[2]); + } + + #[TestDox('issuer without path but with trailing slash is discovered and matched verbatim')] + public function testIssuerWithoutPathWithTrailingSlash(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $requestedUrls = []; + $issuer = 'https://auth.example.com/'; + $validMetadata = [ + 'issuer' => $issuer, + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth/token', + 'jwks_uri' => 'https://auth.example.com/.well-known/jwks.json', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $validMetadata): ResponseInterface { + $requestedUrls[] = (string) $request->getUri(); + + return $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($validMetadata, \JSON_THROW_ON_ERROR)), + ); + }); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $metadata = $discovery->discover($issuer); + + $this->assertSame($issuer, $metadata['issuer']); + $this->assertSame(['https://auth.example.com/.well-known/oauth-authorization-server'], $requestedUrls); + } + private function skipIfPsrHttpClientIsMissing(): void { if (!interface_exists(ClientInterface::class)) { diff --git a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php index fbb51a9..5016c73 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php @@ -83,4 +83,33 @@ class ProtectedResourceMetadataTest extends TestCase new ProtectedResourceMetadata([]); } + + #[TestDox('the SDK advertises exactly the scopes it was given, and never adds offline_access')] + public function testScopesAreOperatorSuppliedOnly(): void + { + // SEP-2207: `offline_access` is a refresh-token scope, not something a + // resource requires. Nothing in the SDK injects it — this pins that, so + // a future default cannot quietly start advertising one. + $metadata = new ProtectedResourceMetadata( + resource: 'https://api.example.com/mcp', + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read', 'mcp:write'], + ); + + $data = $metadata->jsonSerialize(); + + $this->assertSame(['mcp:read', 'mcp:write'], $data['scopes_supported']); + $this->assertNotContains('offline_access', $data['scopes_supported']); + } + + #[TestDox('no scopes given means no scopes_supported member at all')] + public function testNoScopesMeansNoMember(): void + { + $metadata = new ProtectedResourceMetadata( + resource: 'https://api.example.com/mcp', + authorizationServers: ['https://auth.example.com'], + ); + + $this->assertArrayNotHasKey('scopes_supported', $metadata->jsonSerialize()); + } } diff --git a/tests/Unit/Server/Transport/StatelessHttpTransportTest.php b/tests/Unit/Server/Transport/StatelessHttpTransportTest.php new file mode 100644 index 0000000..08e96a4 --- /dev/null +++ b/tests/Unit/Server/Transport/StatelessHttpTransportTest.php @@ -0,0 +1,140 @@ +factory = new Psr17Factory(); + } + + private function protocol(): StatelessProtocol + { + return Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->addTool(static fn (string $text = ''): string => 'echo:'.$text, name: 'echo_tool', description: 'Echoes its argument') + ->buildStateless([ProtocolVersion::V2026_07_28]); + } + + /** + * A `tools/call` body padded so it comfortably exceeds one 8 KiB read. + */ + private function callBody(int $padding = 0): string + { + return json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'echo_tool', + 'arguments' => ['text' => str_repeat('x', $padding)], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ], + ], + ], \JSON_THROW_ON_ERROR); + } + + private function post(string $body): ServerRequestInterface + { + return $this->factory + ->createServerRequest('POST', 'http://localhost/mcp') + ->withHeader('Host', 'localhost') + ->withHeader('MCP-Protocol-Version', ProtocolVersion::V2026_07_28->value) + ->withHeader('Mcp-Method', 'tools/call') + ->withHeader('Mcp-Name', 'echo_tool') + ->withBody($this->factory->createStream($body)); + } + + #[TestDox('a body arriving in short reads is assembled whole, not truncated')] + public function testShortReadsDoNotTruncateTheBody(): void + { + $body = $this->callBody(20_000); + + // PSR-7 promises only *up to* the requested length; a stream over + // php://input or a chunked transfer routinely returns less. + $request = $this->post('')->withBody(new ShortReadStream($body, 64)); + + $response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory))->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + + $decoded = json_decode((string) $response->getBody(), true, flags: \JSON_THROW_ON_ERROR); + $this->assertSame('echo:'.str_repeat('x', 20_000), $decoded['result']['content'][0]['text']); + } + + #[TestDox('a body over the cap is refused with 413')] + public function testOversizedBodyIsRefused(): void + { + $request = $this->post($this->callBody(2048)); + + $response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory, maxBodyBytes: 256))->handle($request); + + $this->assertSame(413, $response->getStatusCode()); + } + + #[TestDox('an oversized body is refused even when its size is not advertised')] + public function testOversizedUnsizedBodyIsRefused(): void + { + $request = $this->post('')->withBody(new ShortReadStream($this->callBody(2048), 64, advertiseSize: false)); + + $response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory, maxBodyBytes: 256))->handle($request); + + $this->assertSame(413, $response->getStatusCode()); + } + + #[TestDox('a notification POST is acknowledged with 202 and no body')] + public function testNotificationPostIsAccepted(): void + { + $body = json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'notifications/something', + 'params' => [], + ], \JSON_THROW_ON_ERROR); + + $request = $this->post($body)->withHeader('Mcp-Method', 'notifications/something'); + + $response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory))->handle($request); + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('', (string) $response->getBody()); + } + + #[TestDox('GET and DELETE are refused: there is no session to address')] + public function testOnlyPostIsAccepted(): void + { + $transport = new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory); + + foreach (['GET', 'DELETE', 'PUT'] as $method) { + $request = $this->factory + ->createServerRequest($method, 'http://localhost/mcp') + ->withHeader('Host', 'localhost'); + + $this->assertSame(405, $transport->handle($request)->getStatusCode(), $method); + } + } +} diff --git a/tests/Unit/Server/Transport/StdioTransportTest.php b/tests/Unit/Server/Transport/StdioTransportTest.php new file mode 100644 index 0000000..dbfcec2 --- /dev/null +++ b/tests/Unit/Server/Transport/StdioTransportTest.php @@ -0,0 +1,101 @@ +createTransport(str_repeat('a', 100)."\n", $messages, maxLineBytes: 16); + + $this->pumpToEof($transport); + + $this->assertSame([], $messages, 'the over-length line must never be dispatched'); + } + + #[TestDox('processing resumes with the next line after an over-length line is discarded')] + public function testRecoversAfterOverlongLine(): void + { + $messages = []; + $transport = $this->createTransport(str_repeat('a', 100)."\n".'{"valid":1}'."\n", $messages, maxLineBytes: 16); + + $this->pumpToEof($transport); + + $this->assertSame(['{"valid":1}'], $messages); + } + + #[TestDox('a normal line within the cap is dispatched')] + public function testNormalLineIsDispatched(): void + { + $messages = []; + $transport = $this->createTransport('{"jsonrpc":"2.0","id":1}'."\n", $messages); + + $this->pumpToEof($transport); + + $this->assertSame(['{"jsonrpc":"2.0","id":1}'], $messages); + } + + #[TestDox('the line byte cap must be a positive number of bytes')] + public function testRejectsNonPositiveCap(): void + { + $this->expectException(InvalidArgumentException::class); + + new StdioTransport(input: $this->stream(''), output: $this->stream(''), maxLineBytes: 0); + } + + /** + * @param list $messages + */ + private function createTransport(string $input, array &$messages, int $maxLineBytes = 4 * 1024 * 1024): StdioTransport + { + $transport = new StdioTransport( + input: $this->stream($input), + output: $this->stream(''), + maxLineBytes: $maxLineBytes, + ); + + $transport->onMessage(static function ($transport, string $payload) use (&$messages): void { + $messages[] = $payload; + }); + + return $transport; + } + + /** + * @return resource + */ + private function stream(string $contents) + { + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $contents); + rewind($stream); + + return $stream; + } + + private function pumpToEof(StdioTransport $transport): void + { + $processInput = new \ReflectionMethod($transport, 'processInput'); + $input = (new \ReflectionProperty($transport, 'input'))->getValue($transport); + + for ($i = 0; $i < 1000 && !feof($input); ++$i) { + $processInput->invoke($transport); + } + } +} diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 22b7fef..186432f 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -12,19 +12,23 @@ namespace Mcp\Tests\Unit\Server\Transport; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\JsonRpc\Error; use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; use Mcp\Server\Transport\StreamableHttpTransport; +use Mcp\Server\Transport\TransportInterface; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; +use Psr\Clock\ClockInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; +use Symfony\Component\Uid\Uuid; final class StreamableHttpTransportTest extends TestCase { @@ -147,6 +151,23 @@ final class StreamableHttpTransportTest extends TestCase $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); } + #[TestDox('a custom middleware list carrying ProtocolVersionMiddleware warns that it will reject the modern era')] + public function testCustomMiddlewareWithProtocolVersionMiddlewareWarns(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('ProtocolVersionMiddleware')); + + new StreamableHttpTransport( + $this->factory->createServerRequest('POST', 'http://localhost/')->withHeader('Host', 'localhost'), + $this->factory, + $this->factory, + $logger, + [new CorsMiddleware(), new DnsRebindingProtectionMiddleware(), new ProtocolVersionMiddleware()], + ); + } + #[TestDox('null middleware does not trigger the empty-list warning')] public function testNullMiddlewareDoesNotWarn(): void { @@ -330,6 +351,92 @@ final class StreamableHttpTransportTest extends TestCase new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 0); } + #[TestDox('an immediate response is consumed once and not replayed on a later POST')] + public function testImmediateResponseIsNotReplayedOnSecondPost(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withBody($this->factory->createStream('{"jsonrpc":"2.0","id":1,"method":"ping"}')); + + $transport = new StreamableHttpTransport($request, $this->factory, $this->factory); + + $calls = 0; + $transport->onMessage(static function (TransportInterface $transport, string $payload) use (&$calls): void { + if (1 === ++$calls) { + $transport->send('{"jsonrpc":"2.0","id":1,"result":{}}', ['status_code' => 200]); + } + }); + + $first = $transport->listen(); + + $this->assertSame(200, $first->getStatusCode()); + $this->assertSame('{"jsonrpc":"2.0","id":1,"result":{}}', (string) $first->getBody()); + + $second = $transport->listen(); + + $this->assertSame(2, $calls); + $this->assertSame(202, $second->getStatusCode()); + $this->assertSame('', (string) $second->getBody()); + } + + #[TestDox('the polling loop times out a pending request via the injected clock')] + public function testPollingLoopTimesOutPendingRequestViaInjectedClock(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withBody($this->factory->createStream('{"jsonrpc":"2.0","id":1,"method":"ping"}')); + + $requestedAt = 1_000_000; + + // Frozen 121s after the pending request was issued — past its 120s timeout. + $clock = new class($requestedAt + 121) implements ClockInterface { + public function __construct(private readonly int $timestamp) + { + } + + public function now(): \DateTimeImmutable + { + return (new \DateTimeImmutable())->setTimestamp($this->timestamp); + } + }; + + $transport = new StreamableHttpTransport( + $request, + $this->factory, + $this->factory, + clock: $clock, + ); + + $received = null; + $fiber = new \Fiber(static function () use (&$received) { + $received = \Fiber::suspend(); + + return null; + }); + $fiber->start(); + + $transport->onMessage(static function (TransportInterface $transport) use ($fiber): void { + $transport->attachFiberToSession($fiber, Uuid::v4()); + }); + $transport->setOutgoingMessagesProvider(static fn (): array => []); + $transport->setResponseFinder(static fn () => null); + $transport->setPendingRequestsProvider(static fn (): array => [ + ['request_id' => 1, 'timestamp' => $requestedAt, 'timeout' => 120], + ]); + + $response = $transport->listen(); + + $this->assertSame('text/event-stream', $response->getHeaderLine('Content-Type')); + + $this->expectOutputString(''); + $response->getBody()->getContents(); + + $this->assertTrue($fiber->isTerminated()); + $this->assertInstanceOf(Error::class, $received); + } + private function stubAuth401(): MiddlewareInterface { return new class($this->factory) implements MiddlewareInterface { diff --git a/tests/Unit/Server/Wire/CachePolicyTest.php b/tests/Unit/Server/Wire/CachePolicyTest.php new file mode 100644 index 0000000..4a65aa5 --- /dev/null +++ b/tests/Unit/Server/Wire/CachePolicyTest.php @@ -0,0 +1,113 @@ +assertSame(0, $policy->ttlFor('tools/list')); + $this->assertSame(CacheScope::Private, $policy->scopeFor('tools/list')); + } + + #[TestDox('a default applies to every method')] + public function testDefaultApplies(): void + { + $policy = CachePolicy::default(60_000, CacheScope::Public); + + $this->assertSame(60_000, $policy->ttlFor('tools/list')); + $this->assertSame(60_000, $policy->ttlFor('resources/read')); + $this->assertSame(CacheScope::Public, $policy->scopeFor('resources/read')); + } + + #[TestDox('a per-method override wins, and leaves the others alone')] + public function testPerMethodOverride(): void + { + $policy = CachePolicy::default(60_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public); + + $this->assertSame(3_600_000, $policy->ttlFor('tools/list')); + $this->assertSame(CacheScope::Public, $policy->scopeFor('tools/list')); + $this->assertSame(60_000, $policy->ttlFor('resources/read')); + $this->assertSame(CacheScope::Private, $policy->scopeFor('resources/read')); + } + + #[TestDox('the policy is immutable')] + public function testWithMethodDoesNotMutate(): void + { + $base = CachePolicy::default(1_000); + $narrowed = $base->withMethod('tools/list', 5_000); + + $this->assertSame(1_000, $base->ttlFor('tools/list')); + $this->assertSame(5_000, $narrowed->ttlFor('tools/list')); + } + + #[TestDox('a negative TTL is refused: the spec requires zero or more')] + public function testNegativeTtlIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + + CachePolicy::default(-1); + } + + #[TestDox('a negative per-method TTL is refused too')] + public function testNegativePerMethodTtlIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + + CachePolicy::default(0)->withMethod('tools/list', -5); + } + + #[TestDox('the codec stamps the policy onto a cacheable result')] + public function testCodecAppliesThePolicy(): void + { + $codec = new Rev2026Codec(null, CachePolicy::default(60_000)->withMethod('tools/list', 3_600_000, CacheScope::Public)); + + $tools = $codec->encodeResult('tools/list', ['tools' => []]); + $this->assertSame(3_600_000, $tools['ttlMs']); + $this->assertSame('public', $tools['cacheScope']); + + $read = $codec->encodeResult('resources/read', ['contents' => []]); + $this->assertSame(60_000, $read['ttlMs']); + $this->assertSame('private', $read['cacheScope']); + } + + #[TestDox('a value the result carries wins over the policy')] + public function testAuthoredValueWins(): void + { + $codec = new Rev2026Codec(null, CachePolicy::default(60_000, CacheScope::Public)); + + $encoded = $codec->encodeResult('resources/read', ['contents' => [], 'ttlMs' => 500, 'cacheScope' => 'private']); + + $this->assertSame(500, $encoded['ttlMs']); + $this->assertSame('private', $encoded['cacheScope']); + } + + #[TestDox('a method the spec does not make cacheable is left alone')] + public function testNonCacheableMethodIsUntouched(): void + { + $encoded = (new Rev2026Codec(null, CachePolicy::default(60_000)))->encodeResult('tools/call', ['content' => []]); + + $this->assertArrayNotHasKey('ttlMs', $encoded); + $this->assertArrayNotHasKey('cacheScope', $encoded); + } +} diff --git a/tests/Unit/Server/Wire/InboundClassifierTest.php b/tests/Unit/Server/Wire/InboundClassifierTest.php new file mode 100644 index 0000000..282fa0a --- /dev/null +++ b/tests/Unit/Server/Wire/InboundClassifierTest.php @@ -0,0 +1,254 @@ + $headers + */ + #[DataProvider('provideLegacyTraffic')] + #[TestDox('$_dataName is handshake-era traffic')] + public function testClassifiesAsLegacy(string $method, ?string $body, array $headers = []): void + { + $classification = (new InboundClassifier())->classify($method, $body, $headers); + + $this->assertFalse($classification->isRejected(), 'expected a routing decision, got a rejection'); + $this->assertFalse($classification->modern); + } + + /** + * @return iterable}> + */ + public static function provideLegacyTraffic(): iterable + { + yield 'the initialize handshake' => ['POST', self::message('initialize')]; + + yield 'a request with no envelope claim' => ['POST', self::message('tools/list')]; + + yield 'a notification with no envelope claim' => ['POST', self::notification('notifications/initialized')]; + + yield 'a GET, which the modern era has no use for' => ['GET', null]; + + yield 'a DELETE ending a session' => ['DELETE', null]; + + yield 'a claim naming a handshake revision' => [ + 'POST', + self::message('tools/list', ProtocolVersion::V2025_11_25->value), + [self::VERSION_HEADER => ProtocolVersion::V2025_11_25->value], + ]; + + yield 'a header naming a handshake revision' => [ + 'POST', + self::message('tools/list'), + [self::VERSION_HEADER => ProtocolVersion::V2025_11_25->value], + ]; + + // The endpoint's version middleware is what answers this, naming every + // revision the endpoint serves. Routing it modern would answer with the + // modern leg's shorter list instead. + yield 'an unknown header with nothing in the body to back it' => [ + 'POST', + self::message('tools/list'), + [self::VERSION_HEADER => '2030-01-01'], + ]; + + yield 'a batch of handshake-era messages' => [ + 'POST', + '['.self::message('tools/list').','.self::message('prompts/list').']', + ]; + + yield 'an empty body' => ['POST', '']; + + yield 'a body that is not JSON' => ['POST', 'not json at all']; + + yield 'a JSON body that is not an object' => ['POST', '"a string"']; + } + + /** + * @param array $headers + */ + #[DataProvider('provideModernTraffic')] + #[TestDox('$_dataName is modern-era traffic')] + public function testClassifiesAsModern(string $body, array $headers, string $expectedVersion): void + { + $classification = (new InboundClassifier())->classify('POST', $body, $headers); + + $this->assertFalse($classification->isRejected(), 'expected a routing decision, got a rejection'); + $this->assertTrue($classification->modern); + $this->assertSame($expectedVersion, $classification->claimedVersion); + } + + /** + * @return iterable, string}> + */ + public static function provideModernTraffic(): iterable + { + yield 'a request claiming the modern revision' => [ + self::message('tools/list', ProtocolVersion::V2026_07_28->value), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + ProtocolVersion::V2026_07_28->value, + ]; + + // The header is a cross-check, not the decision: a claim on its own is + // enough evidence, and the leg it routes to is what says the header was + // required. + yield 'a claim with no header at all' => [ + self::message('server/discover', ProtocolVersion::V2026_07_28->value), + [], + ProtocolVersion::V2026_07_28->value, + ]; + + // Routed rather than refused so the answer can name what this endpoint + // does serve, which only the modern leg knows. + yield 'a claim naming a revision this SDK has never heard of' => [ + self::message('tools/list', '2099-01-01'), + [self::VERSION_HEADER => '2099-01-01'], + '2099-01-01', + ]; + + // `initialize` is handshake-era by definition — but a claim outranks the + // method name, and the modern leg answers it with method-not-found. + yield 'an initialize carrying a modern claim' => [ + self::message('initialize', ProtocolVersion::V2026_07_28->value), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + ProtocolVersion::V2026_07_28->value, + ]; + + // A notification has no claim of its own under this revision, so the + // header is all the evidence there is. + yield 'a notification under a modern header' => [ + self::notification('notifications/progress'), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + ProtocolVersion::V2026_07_28->value, + ]; + } + + /** + * @param array $headers + */ + #[DataProvider('provideRejectedTraffic')] + #[TestDox('$_dataName is refused at the edge')] + public function testRejects(string $body, array $headers, int $code, int $status): void + { + $classification = (new InboundClassifier())->classify('POST', $body, $headers); + + $this->assertTrue($classification->isRejected()); + $this->assertSame($code, $classification->error?->jsonSerialize()['error']['code']); + $this->assertSame($status, $classification->httpStatus); + } + + /** + * @return iterable, int, int}> + */ + public static function provideRejectedTraffic(): iterable + { + yield 'a header contradicting the claim' => [ + self::message('tools/list', ProtocolVersion::V2026_07_28->value), + [self::VERSION_HEADER => ProtocolVersion::V2025_11_25->value], + -32020, + 400, + ]; + + yield 'a handshake claim contradicted by a modern header' => [ + self::message('tools/list', ProtocolVersion::V2025_11_25->value), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + -32020, + 400, + ]; + + yield 'a modern header on a request carrying no envelope' => [ + self::message('tools/list'), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + -32602, + 400, + ]; + + yield 'a claim that is not a string' => [ + '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"'.RequestMeta::PROTOCOL_VERSION.'":42}}}', + [], + -32602, + 400, + ]; + + yield 'a batch holding a modern claim' => [ + '['.self::message('tools/list').','.self::message('prompts/list', ProtocolVersion::V2026_07_28->value).']', + [], + -32600, + 400, + ]; + } + + #[TestDox('the header cross-check is case-insensitive, as HTTP field names are')] + public function testHeaderLookupIgnoresCase(): void + { + $classification = (new InboundClassifier())->classify( + 'POST', + self::message('tools/list', ProtocolVersion::V2026_07_28->value), + ['mcp-protocol-version' => ProtocolVersion::V2025_11_25->value], + ); + + $this->assertTrue($classification->isRejected()); + } + + #[TestDox('an empty header value counts as no header, not as a contradiction')] + public function testEmptyHeaderIsAbsent(): void + { + $classification = (new InboundClassifier())->classify( + 'POST', + self::message('tools/list', ProtocolVersion::V2026_07_28->value), + [self::VERSION_HEADER => ''], + ); + + $this->assertFalse($classification->isRejected()); + $this->assertTrue($classification->modern); + } + + #[TestDox('the shared cross-check reports only a genuine disagreement')] + public function testCrossCheckVersion(): void + { + $this->assertNull(InboundClassifier::crossCheckVersion(null, '2026-07-28')); + $this->assertNull(InboundClassifier::crossCheckVersion('2026-07-28', '2026-07-28')); + $this->assertNotNull(InboundClassifier::crossCheckVersion('2025-11-25', '2026-07-28')); + } + + private static function message(string $method, ?string $claim = null): string + { + $params = null === $claim ? [] : ['_meta' => [ + RequestMeta::PROTOCOL_VERSION => $claim, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]]; + + return json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR); + } + + private static function notification(string $method): string + { + return json_encode(['jsonrpc' => '2.0', 'method' => $method, 'params' => []], \JSON_THROW_ON_ERROR); + } +}