diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml index f12f3792..61dae6fc 100644 --- a/.github/workflows/conformance-weekly.yaml +++ b/.github/workflows/conformance-weekly.yaml @@ -1,9 +1,9 @@ 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. +# Runs the MCP conformance suite weekly against the newest published +# @modelcontextprotocol/conformance. The on:pull_request pipeline pins an exact +# version; this schedule catches upstream releases that add scenarios between +# PRs, so the pin never silently goes stale. # # 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 @@ -17,10 +17,26 @@ permissions: contents: write issues: write +# The PR pipeline pins an exact conformance version so a PR only goes red for +# reasons in the PR. That pin is only safe because this job tracks the moving +# target instead — on both revisions, since each rides a different dist-tag: +# the dated one is on `latest`, the draft one only on `alpha`. 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: + # Both legs hit the same URL: one fixture, built through + # Builder::build(), serves whichever era the request claims. + - spec-version: '2025-11-25' + dist-tag: latest + baseline: conformance-baseline.yml + - spec-version: '2026-07-28' + dist-tag: alpha + baseline: conformance-baseline-2026-07-28.yml steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -35,9 +51,17 @@ 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/ + --suite all + --spec-version ${{ matrix.spec-version }} + --expected-failures ${{ matrix.baseline }} + --output-dir results + # The badge tracks the released revision, so only the dated entry feeds + # it; the draft entry runs purely to catch upstream drift. - name: Generate score badge - if: always() + if: always() && matrix.spec-version == '2025-11-25' run: php tests/Conformance/score.php server - name: Show docker logs on failure if: failure() @@ -46,20 +70,30 @@ 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 - name: Upload score badge - if: always() + if: always() && matrix.spec-version == '2025-11-25' uses: actions/upload-artifact@v7 with: name: server-badge path: tests/Conformance/server-conformance.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.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,20 +107,26 @@ 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() + if: always() && matrix.spec-version == '2025-11-25' run: php tests/Conformance/score.php client - 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 - name: Upload score badge - if: always() + if: always() && matrix.spec-version == '2025-11-25' uses: actions/upload-artifact@v7 with: name: client-badge diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index f7396e60..402e22b6 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -5,6 +5,12 @@ permissions: contents: read pull-requests: write +env: + # Pinned so a PR only goes red for reasons in the PR. The 2026-07-28 + # scenarios ship on the `alpha` dist-tag; `latest` (0.1.x) has none of them. + # conformance-weekly.yaml is what tracks upstream releases. + CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.11" + jobs: unit: runs-on: ubuntu-latest @@ -95,8 +101,24 @@ 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 + # One entry per lifecycle, not per spec revision: `--spec-version` is + # cumulative across the dated revisions, so 2025-11-25 already covers + # 2025-06-18 and 2025-03-26. 2026-07-28 is not cumulative with them — it + # is the SEP-2575 stateless wire, served from its own endpoint with its + # own baseline, so it needs its own run. + matrix: + include: + - spec-version: '2025-11-25' + path: '/' + baseline: conformance-baseline.yml + - spec-version: '2026-07-28' + path: '/stateless' + baseline: conformance-baseline-2026-07-28.yml + steps: - name: Checkout uses: actions/checkout@v7 @@ -118,7 +140,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() @@ -146,8 +173,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.yml + - spec-version: '2026-07-28' + baseline: conformance-baseline-2026-07-28.yml + steps: - name: Checkout uses: actions/checkout@v7 @@ -171,7 +207,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() diff --git a/.gitignore b/.gitignore index 5ea477c0..c5d87ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c980e05e..58d8bff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,34 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] Drop the SDK-only `[a-zA-Z0-9_-]+` restriction on `ResourceDefinition::$name` and `ResourceTemplate::$name`: the specification puts no pattern on `name` (its own examples use `main.rs` and `Project Files`), and the classes also parse what a peer sent, so the rule rejected conformant servers. Any string is now accepted; the URI / URI template validation is unchanged. * Add `ClientGateway::supportsExtension()` to check whether the client negotiated a protocol extension (e.g. `McpApps::EXTENSION_ID`) before offering UI-linked tools, plus `Client\Builder::enableExtension()` and `ClientCapabilities::withExtensions()` so hosts advertise extensions the same way servers do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. * Deprecate the Roots, Sampling and Logging features per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`): the schema, client and server classes that make them up are marked `@deprecated` with the suggested migration (tool arguments or resource URIs instead of roots; an LLM provider's API instead of sampling; stderr or OpenTelemetry instead of logging), and exercising them — `ClientGateway::log()` / `sample()` / `listRoots()`, or registering a client-side `SamplingRequestHandler`, `ListRootsRequestHandler` or `LoggingNotificationHandler` — triggers a silenced `E_USER_DEPRECATED` via `symfony/deprecation-contracts`. Everything remains fully functional until removal. +* Fix the MCP Apps example being unusable over HTTP. It was the only example without a session store, so every request under `php -S` or PHP-FPM — each its own process — was answered "Session not found or has expired". It now uses a `FileSessionStore` like the others, and is covered by Inspector snapshot tests that pin what the extension actually puts on the wire: the `ui://` resource's `text/html;profile=mcp-app` MIME type and `ui` marker, and the `_meta.ui` on the tool that links the two. +* Serialize an extension's settings as `{}` rather than `[]` when it has none, in both `ServerCapabilities` and `ClientCapabilities`. An extension declaring support with no settings was advertised as an empty JSON array, which the wire schema rejects. +* Fix a union type losing a branch during schema generation, in the two places it was dropped. `mapPhpTypeToJsonSchemaType()` tested for array syntax before splitting the union, so `string[]|int` mapped to `array` alone; and `applyArrayConstraints()` then overwrote the type with `array` whenever the union contained one, dropping the scalar branch a second time. A tool declaring such a parameter rejected a valid integer argument. The split now happens first and only at the top level — `array` and `array{a: int|string}` are still one type rather than two that do not exist — and the array branch keeps its `items` alongside the others, which is what 2020-12 means by `items`: a constraint that applies only when the instance is an array. Adds `SchemaGenerator::splitUnion()`. +* Report an unsupported JSON Schema `$schema` dialect as such, naming the dialect, instead of as an opaque internal validation failure. +* Add the extensions framework SEP-2133 defines, which MCP Apps sits on. `Builder::enableExtension()` now validates the identifier against the `_meta` key naming rules — a prefix is mandatory, since an unprefixed name has no owner — through the new `Mcp\Schema\Extension\ExtensionIdentifier`, which also recognises the `modelcontextprotocol`/`mcp` second labels the specification reserves. An extension implementing the new `MethodProvidingExtensionInterface` contributes both its message classes (registered with `MessageFactory`, without which its methods cannot be decoded at all) and the handlers serving them, and a server that does not enable it answers `-32601` naming the extension rather than a bare "no handler found". `MessageFactory::make()` takes an `$additional` list of message classes, and `RequestHandlerInterface`'s result template is now covariant so a handler declaring a concrete result satisfies a collection typed by the interface. +* Mark the Roots, Sampling and Logging features `@deprecated` per SEP-2577, across the 24 schema, client and server classes that make them up, each naming the suggested migration (tool arguments or resource URIs instead of roots; an LLM provider's API instead of sampling; stderr or OpenTelemetry instead of logging). They remain fully functional for at least twelve months, and no runtime notice is emitted — the handshake era is still the majority of live traffic, and a notice there would be noise. +* Complete `x-mcp-header` mirroring (SEP-2243). Only top-level properties were inspected, so an annotation on a nested property was silently unenforced; the walk now follows a chain of `properties` keys to any depth, and reads the argument at that exact path. Integer values compare numerically, so a client writing `42.0` for a body value of `42` is no longer rejected. `Tool` now refuses a definition whose `x-mcp-header` is empty, is not an HTTP field name (CR/LF injection included), collides case-insensitively with another, or sits on a `number`, array or object property — the specification calls such a definition invalid, and the earliest place to say so is where the tool is defined. +* Carry W3C trace context through a request (SEP-414). `traceparent`, `tracestate` and `baggage` in a request's `_meta` are exposed to handlers as `RequestContext::getTraceContext()` and echoed onto the notifications that request causes, so a span stays joined across the response stream. Values pass through exactly as they arrived — validating or regenerating them belongs to the tracing library, not the protocol — and no OpenTelemetry dependency is added. +* Deliver notifications on a `subscriptions/listen` stream, which previously acknowledged and then carried nothing for the rest of its life (SEP-2575). New `Mcp\Server\Subscription\NotificationBusInterface` with two implementations: `InMemoryNotificationBus` for stdio and persistent runtimes, and `Psr16NotificationBus` for PHP-FPM, where the worker holding the stream open and the worker publishing are different processes. Set one with `Builder::setNotificationBus()`; registry changes are published automatically through a `PublishingEventDispatcher` that wraps whatever PSR-14 dispatcher was configured, since PSR-14 gives the SDK no way to register a listener on someone else's. Streams read forward from the cursor they opened at, filter against the client's requested types, and stamp `io.modelcontextprotocol/subscriptionId` on every frame. +* Add `Builder::setSubscriptionLifetime()`. A listen stream was closed after a hard-coded 30 seconds; the real ceiling is the runtime's (`max_execution_time` under PHP-FPM), and `0` now means "until the client or the runtime ends it", which is what a persistent runtime wants. +* Make the SEP-2549 caching hints configurable instead of frozen. The modern lifecycle stamped every cacheable result with `ttlMs: 0, cacheScope: "private"` — conformant, and a flat refusal to let anything be cached. New `Mcp\Server\Wire\CachePolicy` carries a default and per-method overrides, set through `Builder::setCachePolicy()`; the conservative "nothing is fresh, nothing is shared" remains the default, since `public` lets a shared proxy serve one caller's answer to another and only the operator can make that call. A `ReadResourceResult` may also carry its own `ttlMs`/`cacheScope`, which win over the policy, and `ReadResourceHandler` now passes a hand-built result through instead of re-wrapping its contents. +* Add typed readers to `Mcp\Server\Stateless\InputContext`: `elicitResult()`, `samplingResult()` and `rootsResult()` deserialize a multi round-trip answer instead of handing back the raw array `response()` returns. A malformed answer reads as absent rather than throwing, so a handler asks again — which is what the spec says a server SHOULD do when the information it needs is still missing. +* Refuse to send an `inputRequests` entry the client cannot answer (SEP-2322). A server MUST NOT ask for input the client did not declare support for — the retry could never carry it — but nothing between a handler and the wire compared the two. `StatelessProtocol` now checks each ask against the request's declared `clientCapabilities` and answers `-32021` with the missing set in `data.requiredCapabilities`, logging the handler's mistake. New `Mcp\Server\Stateless\InputRequestCapabilities` holds the mapping; url-mode elicitation needs its own declaration, which a bare `elicitation` does not satisfy, and a sampling ask carrying `tools`/`toolChoice` needs `sampling.tools`. +* Let `resources/read` answer with an `InputRequiredResult`, which the multi round-trip requests pattern permits alongside `tools/call` and `prompts/get` (SEP-2322). `ReadResourceHandler` fed one into the resource formatter instead of returning it, so a resource that needed input could not ask for it. +* Never attach caching hints to a result produced by a multi round-trip retry (SEP-2549). Such a result depends on `inputResponses`/`requestState`, which are not part of any cache key, so the spec forbids caching it — but it comes back as `resultType: "complete"` and was picking up `ttlMs`/`cacheScope` like any other. `WireCodecInterface::encodeResult()` takes a `$cacheable` flag for this. +* Answer a request over a response stream when the handler has something to send mid-call, closing the last MUST-level gap in the modern (2026-07-28) lifecycle. `StatelessProtocol` now runs handlers in a fiber, so `$gateway->progress()` and `$gateway->log()` work there as they do in the handshake era; previously they threw `FiberError` and surfaced as `-32603`. The stream opens only if the handler actually emits something *and* the client's `Accept` admits `text/event-stream`, and the choice is made after the handler's first suspension — so a request that turns out to need `-32021` or `-32602` is still answered with the status the spec fixes for it, rather than an error frame under a `200`. +* Honour `io.modelcontextprotocol/logLevel` (SEP-2575), which replaced the `logging/setLevel` RPC. It was parsed off every request and then ignored. A request naming no level receives no `notifications/message` at all, as the spec requires; one naming a level receives the messages at or above it. Adds `LoggingLevel::severity()` and `LoggingLevel::isAtLeast()`, which `ClientLogger` now shares. +* Refuse a server-initiated `sampling/createMessage`, `elicitation/create` or `roots/list` under the modern lifecycle with a `LogicException` naming `InputRequiredResult` and `RequestContext::getInputContext()`. The revision carries those in the result (MRTR) and forbids putting a request on a response stream; the previous failure was an opaque `FiberError`. +* Refuse a JSON Schema that is unsafe or ruinous to validate, before `opis/json-schema` walks it (SEP-2106). New `Mcp\Capability\Discovery\SchemaComplexityGuard`, wired into `SchemaValidator` by default and configurable through its constructor, rejects two shapes: a `$ref` naming anything outside the document (an SSRF primitive if it were ever dereferenced — the SDK registers no resolver, and the guard now says so up front instead of letting it surface as an opaque "unresolved reference"), and a composition that expands past a subschema budget, a nesting depth, or a property-map size. The budget resolves same-document `$ref`s, so the `$defs`-compressed form of a composition bomb — a few hundred bytes on the wire, a million subschema evaluations to walk — is caught along with the expanded one. Measured: sixteen nested two-branch `anyOf`s went from 9.0s to refused-in-0.1s. Recursive schemas and long reference chains still pass. `SchemaValidator` also caps reported errors at 100 and reports an unsupported `$schema` dialect as such rather than as an internal fault. +* [BC Break] Answer a not-found subject with `-32602` (Invalid params) instead of `-32002`, which the 2026-07-28 revision reserves and forbids emitting (SEP-2164). `resources/read` picks the code from the revision serving the request — `-32602` with the uri in `error.data` from `2026-07-28` on, `-32002` below, since earlier peers still expect it. `prompts/get` for an unknown prompt and `completion/complete` for an unknown reference switch to `-32602` in *every* revision: `-32002` was never the code for those. `tools/call` for an unknown tool switches from `-32601` to `-32602` for the same reason — `tools/call` exists, it is the name in its params that does not. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`. +* Render `MissingRequiredClientCapabilityException` as `-32021` when a prompt or resource handler raises it, not only a tool handler. Both handlers absorbed it into their blanket `\Throwable` catch and reported `-32603`. +* [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. +* Acknowledge a JSON-RPC notification POSTed to the modern-lifecycle endpoint with `202 Accepted` and no body, as the Streamable HTTP binding requires. It was dispatched as though it were a request and answered with a JSON-RPC error carrying an empty id, which a client probing for the server's protocol era could read as a fallback signal. +* Fix `StatelessHttpTransport` truncating request bodies that arrive in short reads. It took the whole payload from a single `StreamInterface::read()`, which PSR-7 only promises to fill *up to* the requested length — a stream over `php://input` or a chunked transfer routinely returns less, turning a valid request into `-32700`. Both HTTP transports now share a `ReadsBoundedBody` trait that reads incrementally against the cap. +* Reject a modern-lifecycle POST that omits the `MCP-Protocol-Version` header with `-32020`, as SEP-2243 requires: it was accepted whenever the body's `_meta` carried a version, leaving the header — the value intermediaries route on — unenforced. The requirement follows the presence of a `StandardHeaderValidator`, so a transport without a header layer is unaffected, and `StatelessProtocol` now warns once at construction when it is built without one. +* Decode the `=?base64?…?=` sentinel on the `Mcp-Name` header before comparing it to the request body (SEP-2243). The comparison was made against the raw header, so every conformant client carrying a tool or prompt name — or, far more commonly, a resource URI — outside the header-safe ASCII set was rejected with `-32020`. A malformed wrapper is now its own rejection reason instead of comparing as a literal. +* Answer `resources/subscribe`, `resources/unsubscribe` and `notifications/roots/list_changed` as unknown methods under the modern (2026-07-28) lifecycle, which removed all three. They were still dispatched to their handlers and answered `200 OK`, recording subscriptions nothing in that lifecycle reads. The handlers stay registered for the handshake era. +* Fix `ClientGateway`'s capability probes reporting `false` for every request served through the modern (2026-07-28) lifecycle: `supportsRoots()`, `supportsSampling()`, `supportsSamplingTools()`, `supportsSamplingContext()`, `supportsElicitation()` and `supportsElicitationUrl()` read connection state that only the `initialize` handshake ever wrote, so a tool guarding on them silently took the unsupported branch. `StatelessProtocol` now writes the request's declared capabilities and protocol version under the same session keys. * Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`. * Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before. * Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. diff --git a/Makefile b/Makefile index c886bc5e..dc75bba2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,13 @@ -.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 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 + +# Pinned to the same version CI runs (see .github/workflows/pipeline.yaml), so +# a local pass means a green pipeline. The 2026-07-28 scenarios ship on the +# `alpha` dist-tag; `latest` (0.1.x) has none of them. +# +# To try a local conformance checkout instead: +# make conformance-draft-server CONFORMANCE="node ../conformance/dist/index.js" +CONFORMANCE_VERSION ?= 0.2.0-alpha.11 +CONFORMANCE ?= npx --yes @modelcontextprotocol/conformance@$(CONFORMANCE_VERSION) deps-stable: composer update --prefer-stable @@ -31,15 +40,34 @@ 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 + cd tests/Conformance && $(CONFORMANCE) server --url http://localhost:8000/ --suite all --spec-version 2025-11-25 --expected-failures conformance-baseline.yml --output-dir results || true php tests/Conformance/score.php server 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 + cd tests/Conformance && $(CONFORMANCE) client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2025-11-25 --expected-failures conformance-baseline.yml --output-dir results || true php tests/Conformance/score.php client +# --- 2026-07-28 (SEP-2575 stateless lifecycle) ------------------------------ +# Same runner as above, different lifecycle: `--spec-version` is cumulative +# across the dated revisions, but 2026-07-28 is the stateless wire, served from +# its own endpoint against its own baseline. + +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 + 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 + coverage: XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage diff --git a/docs/index.md b/docs/index.md index 91162290..a1f6f663 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,6 +3,7 @@ - [MCP Elements](mcp-elements.md) — Core capabilities (Tools, Resources, Resource Templates, and Prompts) with registration methods. - [Server Builder](server-builder.md) — Fluent builder class for creating and configuring MCP server instances. - [Client](client.md) — Client SDK for connecting to and communicating with MCP servers. +- [The 2026-07-28 Lifecycle](stateless-lifecycle.md) — The stateless protocol revision: per-request metadata, `server/discover`, multi round-trip requests, caching and subscriptions. - [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them. - [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications. - [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources). diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index 10195eb3..084f51d2 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -1,6 +1,13 @@ # Client Communication -MCP supports various ways a server can communicate back to a server on top of the main request-response flow. +MCP supports various ways a server can communicate back to a client on top of the main request-response flow. + +> **Protocol revision `2026-07-28`.** This page describes the handshake era, where a server sends its own +> JSON-RPC requests to the client. The modern lifecycle removed that: sampling, elicitation and roots are +> carried back inside the *result* instead, and `ClientGateway::sample()`, `elicit()` and `listRoots()` +> raise a `LogicException` there. Logging and progress still work as described below — they simply travel +> on the request's own response stream, and the client opts into each. See +> [The 2026-07-28 Lifecycle](stateless-lifecycle.md). ## Table of Contents diff --git a/docs/stateless-lifecycle.md b/docs/stateless-lifecycle.md new file mode 100644 index 00000000..45e3cb2d --- /dev/null +++ b/docs/stateless-lifecycle.md @@ -0,0 +1,391 @@ +# The 2026-07-28 lifecycle + +Protocol revision `2026-07-28` removed the `initialize` handshake and protocol-level sessions. Everything a +server needs to answer a request now travels *in* that request, which means any process can answer any +request and none of them need to share state. + +This guide covers what changes for a server author. Tools, resources, prompts and their handlers are +unaffected — the same registrations serve either lifecycle. + +- [The two eras](#the-two-eras) +- [Building a stateless server](#building-a-stateless-server) +- [Per-request metadata](#per-request-metadata) +- [Multi round-trip requests](#multi-round-trip-requests) +- [Progress and logging](#progress-and-logging) +- [Caching](#caching) +- [Subscriptions](#subscriptions) +- [Serving both eras](#serving-both-eras) +- [What was removed](#what-was-removed) + +## The two eras + +| | Handshake era (`2025-11-25` and earlier) | Modern era (`2026-07-28`) | +| --- | --- | --- | +| Opening | `initialize` / `notifications/initialized` | none | +| Version | negotiated once, kept on the session | declared on every request | +| Capabilities | exchanged once | declared on every request | +| Discovery | `initialize` result | `server/discover` | +| Sessions | `Mcp-Session-Id` | removed | +| Server → client requests | sent as JSON-RPC requests | returned in the result (MRTR) | +| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` | +| Dispatcher | `Protocol` | `StatelessProtocol` | +| HTTP entry | `StreamableHttpTransport` — the same one, for both | + +`ProtocolVersion::isModern()` tells the two apart, and `Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` +is where the boundary sits. + +## Building a stateless server + +There is nothing to build differently. `Builder::build()` produces a `Server` carrying a dispatcher for +each era, and `StreamableHttpTransport` decides per request which of them answers: + +```php +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->addTool(static fn (string $city): string => "17°C in {$city}", name: 'get_weather', description: '…') + ->build(); + +(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request))); +``` + +That one endpoint answers `initialize` and `server/discover` alike. See +[Serving both eras](#serving-both-eras) for how the decision is made and how to opt out of it. + +Modern-era requests accept `POST` only; a `GET` or `DELETE` is a handshake-era session operation and is +routed as one. + +A full example lives in [`examples/server/stateless-lifecycle/server.php`](../examples/server/stateless-lifecycle/server.php). + +## Per-request metadata + +Every request **must** carry two members in `params._meta`, and the HTTP layer mirrors some of them into +headers so an intermediary can route without parsing the body: + +| `_meta` key | Required | Header | +| --- | --- | --- | +| `io.modelcontextprotocol/protocolVersion` | yes | `MCP-Protocol-Version` | +| `io.modelcontextprotocol/clientCapabilities` | yes | — | +| `io.modelcontextprotocol/clientInfo` | no | — | +| `io.modelcontextprotocol/logLevel` | no | — | +| `progressToken` | no | — | +| `traceparent`, `tracestate`, `baggage` | no | — | + +Plus `Mcp-Method` on every request, and `Mcp-Name` on `tools/call`, `prompts/get` and `resources/read`. +A header that disagrees with the body is refused with `-32020`; a missing required `_meta` member with +`-32602`; an unsupported version with `-32022`, carrying the supported set for the client to retry from. + +Handlers read the metadata through `RequestContext`: + +```php +$context->getProtocolVersion(); // the revision serving this request +$context->getClientCapabilities(); // what this client declared, or null in the handshake era +$context->getTraceContext(); // traceparent / tracestate / baggage, verbatim +``` + +`ClientGateway`'s capability probes — `supportsElicitation()`, `supportsSampling()`, `supportsRoots()` and +the sub-capability variants — read the same declaration, so they work in both eras. + +### Mirroring a tool argument into a header + +A tool parameter annotated with `x-mcp-header` is mirrored into `Mcp-Param-{Name}` by the client, and the +server checks that the two agree: + +```php +->addTool( + static fn (string $region, string $query): string => …, + name: 'execute_sql', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'query' => ['type' => 'string'], + ], + 'required' => ['region', 'query'], + ], +) +``` + +The annotation must name a valid HTTP field, be unique case-insensitively, and sit on a `string`, `integer` +or `boolean` property reachable through `properties` keys alone. `Tool` refuses a definition that breaks +any of those rather than letting it fail later as a header mismatch. + +## Multi round-trip requests + +There are no server-initiated requests in this revision. A server that needs sampling, elicitation or roots +**returns** the ask, and the client retries the original call carrying the answers. + +This is the shape to write handlers in even if you also serve handshake-era clients — the SDK fulfils the +same ask over their connection instead. See [What a handler forks on](#what-a-handler-forks-on). + +```php +use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\InputRequiredResult; + +static function (RequestContext $context): CallToolResult|InputRequiredResult { + $answer = $context->getInputContext()?->elicitResult('who'); + + if (null === $answer) { + return new InputRequiredResult( + ['who' => new ElicitRequest('Your name?', $schema)], + requestState: $context->mintRequestState(['asked' => 'who']), + ); + } + + return new CallToolResult([new TextContent("Hello, {$answer->content['name']}!")]); +} +``` + +`tools/call`, `prompts/get` and `resources/read` may answer this way; nothing else may. + +**Reading the answers.** `InputContext` hands them back typed — `elicitResult()`, `samplingResult()`, +`rootsResult()` — and returns `null` for an answer that is absent *or* malformed. Both mean the same thing +to a handler: ask again. `response()` is still there for the raw array. + +**`requestState`.** Whatever the server needs to remember between rounds. It travels through the client, so +it is attacker-controlled on return; `mintRequestState()` seals it with an HMAC and a TTL, and a state that +fails verification never reaches a handler. Configure the key with `Builder::setRequestState()`: + +```php +->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600) +``` + +The **same key must reach every process that might serve the retry**. A per-process random value works only +for a single-process deployment. Nothing secret belongs in the payload — it is signed, not encrypted. + +**Capabilities.** A server must not ask for input the client cannot provide. The SDK checks each ask against +the request's declared capabilities and answers `-32021` — with the missing set in +`data.requiredCapabilities` — rather than sending an ask that could never be answered. Url-mode elicitation +needs its own `elicitation.url` declaration; a bare `elicitation` means form mode only. + +**What not to call.** `ClientGateway::sample()`, `elicit()`, `elicitUrl()` and `listRoots()` belong to the +handshake era. Calling one under this revision raises a `LogicException` naming `InputRequiredResult` as the +replacement. + +## Progress and logging + +Both travel on the request's own response stream, and both are opt-in by the client: + +- **Progress** — the client sends `_meta.progressToken`; without one, `$gateway->progress()` sends nothing. +- **Logging** — the client sends `_meta["io.modelcontextprotocol/logLevel"]`; without it the server **must + not** emit `notifications/message` at all, and does not. + +```php +static function (RequestContext $context): string { + $client = $context->getClientGateway(); + $client->log(LoggingLevel::Info, 'Reindexing shard 1 of 3'); + $client->progress(1, 3, 'Shard 1 of 3'); + + return 'done'; +} +``` + +The server answers with a single JSON object when the handler emits nothing, and opens an SSE stream when it +does — so an error that has to carry a specific status still gets one, and a handler that talks gets a +stream. Trace context from the request is echoed onto every notification it causes. + +## Caching + +`server/discover`, the four list methods and `resources/read` **must** carry `ttlMs` and `cacheScope`. The +default is `ttlMs: 0, cacheScope: "private"` — conformant, and a flat refusal to let anything be cached. +Say what you actually mean: + +```php +use Mcp\Schema\Enum\CacheScope; +use Mcp\Server\Wire\CachePolicy; + +->setCachePolicy( + CachePolicy::default(30_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), +) +``` + +`public` lets a shared proxy serve one caller's answer to another, so use it only for results that do not +vary by caller. A `ReadResourceResult` may set its own `ttlMs`/`cacheScope`, which win over the policy. +Results produced by an MRTR retry are never given hints: their inputs are not part of any cache key. + +## Subscriptions + +`subscriptions/listen` replaces the HTTP `GET` stream and `resources/subscribe`. The client opens a +long-lived POST whose response stream carries the notification types it asked for; the server acknowledges +first with `notifications/subscriptions/acknowledged`, reporting the subset it agreed to honour. + +Delivery needs a bus, because the process that publishes and the process holding the stream open are often +not the same one: + +```php +use Mcp\Server\Subscription\InMemoryNotificationBus; +use Mcp\Server\Subscription\Psr16NotificationBus; + +// stdio, or a persistent runtime where the whole server is one process +->setNotificationBus(new InMemoryNotificationBus()) + +// PHP-FPM: the publisher and the stream are different workers +->setNotificationBus(new Psr16NotificationBus($cache)) +``` + +Registry changes (`registerTool()`, `unregisterPrompt()`, …) are published automatically. Anything else — +`notifications/resources/updated` above all — is published by the application: + +```php +$bus->publish(new ResourceUpdatedNotification('file:///project/config.json')); +``` + +`Builder::setSubscriptionLifetime()` bounds how long a stream is held before the server closes it +gracefully. The real ceiling is the runtime's: under PHP-FPM a stream cannot outlive `max_execution_time`. +Pass `0` for "until the client or the runtime ends it". + +## Serving both eras + +One endpoint serves both, and the client picks nothing. Every request is classified once, before anything +else looks at it, and routed to the lifecycle it belongs to. The decision is **body-primary**: + +| Evidence | Routed to | +| --- | --- | +| `params._meta` names a modern revision | modern era | +| `params._meta` names a handshake revision | handshake era | +| no such member | handshake era — `initialize` included | +| a notification with no member, under a modern header | modern era | +| `GET` / `DELETE` | handshake era | + +The `MCP-Protocol-Version` header never decides. It is cross-checked against the body, and a request whose +header contradicts its `_meta` is refused with `-32020` before either leg sees it — the check has to happen +at the edge, because a body claiming a handshake revision routes to a leg that has no such check of its +own. A modern header on a request carrying no envelope is refused with `-32602` naming the member it wants. + +An unrecognised revision goes to whichever leg can answer it best: claimed in the envelope, the modern leg +answers, naming the modern revisions it serves; named only in a header, the handshake leg answers, naming +the handshake ones. + +Both legs come from **one** builder configuration — one registry, one set of handler instances, one session +manager. A tool registered once is reachable from both, and a change made through one is visible to the +other. + +To serve the handshake era alone, say so: + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->withoutModernEra() + ->build(); +``` + +That server refuses a modern claim with `-32022`, naming the handshake revisions it does serve. +`setModernVersions()` narrows the modern leg instead of removing it. + +For the opposite — an endpoint that serves the modern era and nothing else — build the dispatcher on its +own and mount it on `StatelessHttpTransport`: + +```php +$protocol = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->buildStateless([ProtocolVersion::V2026_07_28]); + +(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request)); +``` + +### What a handler forks on + +Nothing. Tools, resources, prompts, structured output, progress and errors do not care which era called, +and neither does the one thing that looks like it should: **asking the user something**. + +Write it the 2026-07-28 way — return an `InputRequiredResult` naming what you need, read the answer off +`RequestContext::getInputContext()` when the call comes back. On a handshake-era connection the SDK's +input-required shim fulfils the same ask over that connection's own channel: each embedded request goes +out as the real `elicitation/create` / `sampling/createMessage` / `roots/list`, and the handler is +re-entered with the answers under the keys it asked for. It is on by default; +[`examples/server/elicitation`](../examples/server/elicitation) and +[`examples/server/client-communication`](../examples/server/client-communication) are written this way and +name no era anywhere. + +Two things to know about it. + +**Re-entry is re-execution.** The handler runs again from the top each round, so it has to re-derive where +it is from what came back rather than from anything it kept. That is already true of the modern era — the +client retries the whole call there — so a portable handler is written that way regardless. It is only new +if you were relying on `ClientGateway::elicit()` suspending mid-body and keeping your locals; that keeps +working untouched, since nothing here runs unless a handler *returns* an ask. + +**Each round holds the request open.** The shim waits for the client's answer inside the originating +request, which on a process-per-request runtime means it holds a worker for as long as the user takes. +That is the same cost `ClientGateway::elicit()` already pays on that leg, but the shim makes it reachable +from handlers that never mention it — so size `setInputRequiredLimits()` against your pool. + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + // Re-entries per request, and seconds to wait for one answer. + ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) + ->build(); +``` + +`withoutInputRequiredShim()` turns it off, so such a handler fails on a handshake-era connection instead of +being fulfilled behind your back. + +## Writing a client for this revision + +One line selects the lifecycle; nothing else about the API changes. + +```php +$client = Client::builder() + ->setClientInfo('my-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($myElicitationHandler) + ->build(); + +$client->connect(new HttpTransport('https://example.com/mcp')); + +$client->callTool('greet', []); +``` + +What that changes underneath: + +- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the server's + identity, and a server that does not answer it still yields a usable connection — the method is + optional. If discovery *does* report `supportedVersions` and the configured revision is not among + them, the client moves to a modern revision the server lists, or refuses the connection outright + rather than talking past it. +- **An envelope on every request**, carrying the revision, the declared capabilities and the client + identity. The capabilities are what let a server decide, per request, whether it may ask for input. +- **Headers on every POST** — `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` where the method + addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into `Mcp-Param-*`, which + requires the client to have listed the tool first; `tools/list` is what populates that knowledge. + A tool whose annotations are malformed is dropped from the listing and refused if called, since the + client cannot produce the headers it demands. +- **Multi round-trip calls are answered by the client.** A result of `resultType: "input_required"` is + resolved through the same request handlers that served server-initiated requests in the handshake era, + and the call is re-sent with `inputResponses` and the server's `requestState` echoed back byte for + byte, under a new JSON-RPC id. The caller sees one call and one result. + +Headers are an HTTP concern, so a transport opts into them by implementing `HeaderAwareTransportInterface`; +`HttpTransport` does, `StdioTransport` has nothing to carry them on. Everything else — the envelope, the +skipped handshake, the round-trip loop — applies to both. + +See `examples/client/stateless_lifecycle_client.php` for a runnable version. + +## What was removed + +Answered with `404` and `-32601` by a modern server: + +- `initialize`, `notifications/initialized` +- `ping` +- `logging/setLevel` — replaced by `_meta["io.modelcontextprotocol/logLevel"]` +- `resources/subscribe`, `resources/unsubscribe` — replaced by the `resourceSubscriptions` filter of + `subscriptions/listen` +- `notifications/roots/list_changed` + +Also gone: `Mcp-Session-Id`, the HTTP `GET` stream, and SSE resumability (`Last-Event-ID`). A broken +response stream loses the request; the client re-issues it with a new id. + +Error code `-32002` (resource not found) is retired in favour of `-32602`, and must not be emitted by a +server of this revision. The SDK picks the code from the revision serving the request, so a handshake-era +client still gets `-32002`. + +Roots, sampling and logging are all **deprecated** as of this revision. They remain functional for at least +twelve months; new servers should pass directories through tool arguments or resource URIs instead of roots, +integrate with an LLM provider directly instead of sampling, and log to `stderr` or OpenTelemetry instead of +`notifications/message`. diff --git a/docs/transports.md b/docs/transports.md index 4caee513..ffa1496e 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -146,7 +146,6 @@ When the `middleware` argument is omitted (or set to `null`), the transport inst |-------|------------|---------| | 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. @@ -159,6 +158,13 @@ The default stack can be inspected and recomposed via the public factory: $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](stateless-lifecycle.md#serving-both-eras). + ### CORS Configuration CORS is handled by `CorsMiddleware`. To enable cross-origin browser requests, configure it explicitly and pass it diff --git a/examples/client/README.md b/examples/client/README.md index 3e3bc092..c2121719 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -22,6 +22,19 @@ 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 +``` + ## 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/stateless_lifecycle_client.php b/examples/client/stateless_lifecycle_client.php new file mode 100644 index 00000000..917028e5 --- /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/server/README.md b/examples/server/README.md index a9326395..779d8a43 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` speaks protocol revision `2026-07-28`, which removed the `initialize` +handshake and protocol-level sessions. It is HTTP-only and cannot be driven by the Inspector, which +opens with `initialize`: + +```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 cbe3fb5c..99fcfdaf 100644 --- a/examples/server/bootstrap.php +++ b/examples/server/bootstrap.php @@ -28,6 +28,13 @@ }); /** + * 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 1e895c48..c066b432 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; @@ -31,10 +36,14 @@ public function __construct( * Demonstrates the server side of the "roots" client capability: the tool * issues a roots/list request that the client answers from its own handler. * - * @return array{status: string, message: string, roots?: list} + * 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: 'inspect_workspace_roots', description: 'Ask the client for its workspace roots via a roots/list request.')] - public function inspectWorkspaceRoots(RequestContext $context): array + public function inspectWorkspaceRoots(RequestContext $context): array|InputRequiredResult { $clientGateway = $context->getClientGateway(); @@ -45,7 +54,11 @@ public function inspectWorkspaceRoots(RequestContext $context): array ]; } - $result = $clientGateway->listRoots(); + $result = $context->getInputContext()?->rootsResult('roots'); + + if (null === $result) { + return new InputRequiredResult(['roots' => new ListRootsRequest()]); + } $roots = []; foreach ($result->roots as $root) { @@ -62,10 +75,10 @@ public function inspectWorkspaceRoots(RequestContext $context): array } /** - * @return array{incident: string, recommended_actions: string, model: string} + * @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 + public function coordinateIncident(RequestContext $context, string $incidentTitle): array|InputRequiredResult { $clientGateway = $context->getClientGateway(); $clientGateway->log(LoggingLevel::Warning, \sprintf('Incident triage started: %s', $incidentTitle)); @@ -90,7 +103,15 @@ public function coordinateIncident(RequestContext $context, string $incidentTitl implode(', ', $steps) ); - $result = $clientGateway->sample($prompt, 350, 90, ['temperature' => 0.5]); + $result = $context->getInputContext()?->samplingResult('recommendation'); + + if (null === $result) { + 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/elicitation/ElicitationHandlers.php b/examples/server/elicitation/ElicitationHandlers.php index aaa1328a..95a19d78 100644 --- a/examples/server/elicitation/ElicitationHandlers.php +++ b/examples/server/elicitation/ElicitationHandlers.php @@ -17,6 +17,9 @@ use Mcp\Schema\Elicitation\EnumSchemaDefinition; use Mcp\Schema\Elicitation\NumberSchemaDefinition; use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Result\ElicitResult; +use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\RequestContext; use Psr\Log\LoggerInterface; @@ -43,10 +46,10 @@ public function __construct( * - String field with date format for reservation date * - Enum field for dietary restrictions with human-readable labels * - * @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}} + * @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}}|InputRequiredResult */ #[McpTool(name: 'book_restaurant', description: 'Book a restaurant reservation, collecting details via elicitation.')] - public function bookRestaurant(RequestContext $context, string $restaurantName): array + public function bookRestaurant(RequestContext $context, string $restaurantName): array|InputRequiredResult { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -55,8 +58,6 @@ public function bookRestaurant(RequestContext $context, string $restaurantName): ]; } - $client = $context->getClientGateway(); - $this->logger->info(\sprintf('Starting reservation process for restaurant: %s', $restaurantName)); $schema = new ElicitationSchema( @@ -85,12 +86,19 @@ enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'], 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, ); + // Modern era, first round: the ask travels back as the result and the + // client retries this whole call carrying the answer. + if ($result instanceof InputRequiredResult) { + return $result; + } + if ($result->isDeclined()) { $this->logger->info('User declined to provide reservation details.'); @@ -154,10 +162,10 @@ enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'], * * Demonstrates the simplest elicitation pattern - a yes/no confirmation. * - * @return array{status: string, message: string} + * @return array{status: string, message: string}|InputRequiredResult */ #[McpTool(name: 'confirm_action', description: 'Request user confirmation before proceeding with an action.')] - public function confirmAction(RequestContext $context, string $actionDescription): array + public function confirmAction(RequestContext $context, string $actionDescription): array|InputRequiredResult { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -166,8 +174,6 @@ public function confirmAction(RequestContext $context, string $actionDescription ]; } - $client = $context->getClientGateway(); - $schema = new ElicitationSchema( properties: [ 'confirm' => new BooleanSchemaDefinition( @@ -179,11 +185,17 @@ public function confirmAction(RequestContext $context, string $actionDescription 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 instanceof InputRequiredResult) { + return $result; + } + if (!$result->isAccepted()) { return [ 'status' => 'not_confirmed', @@ -222,10 +234,10 @@ public function confirmAction(RequestContext $context, string $actionDescription * * Demonstrates elicitation with optional fields and enum with labels. * - * @return array{status: string, message: string, feedback?: array{rating: string, comments: string}} + * @return array{status: string, message: string, feedback?: array{rating: string, comments: string}}|InputRequiredResult */ #[McpTool(name: 'collect_feedback', description: 'Collect user feedback via elicitation form.')] - public function collectFeedback(RequestContext $context, string $topic): array + public function collectFeedback(RequestContext $context, string $topic): array|InputRequiredResult { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -234,8 +246,6 @@ public function collectFeedback(RequestContext $context, string $topic): array ]; } - $client = $context->getClientGateway(); - $schema = new ElicitationSchema( properties: [ 'rating' => new EnumSchemaDefinition( @@ -253,11 +263,17 @@ enumNames: ['1 - Poor', '2 - Fair', '3 - Good', '4 - Very Good', '5 - Excellent' 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 instanceof InputRequiredResult) { + return $result; + } + if (!$result->isAccepted()) { return [ 'status' => 'skipped', @@ -288,4 +304,26 @@ enumNames: ['1 - Poor', '2 - Fair', '3 - Good', '4 - Very Good', '5 - Excellent' ], ]; } + + /** + * Ask the user one question. + * + * Written the way revision 2026-07-28 asks: the question is *returned*, the + * client answers it and retries the whole call, and the answer comes back + * through the input context under the same key. Nothing here names an era — + * on a handshake-era connection the SDK fulfils the same ask over that + * connection's own channel and re-enters the tool with the answer. + * + * The caller gets an {@see ElicitResult} once there is one, or an + * {@see InputRequiredResult} to hand straight back to its own caller. + */ + private function ask( + RequestContext $context, + string $key, + string $message, + ElicitationSchema $schema, + ): ElicitResult|InputRequiredResult { + return $context->getInputContext()?->elicitResult($key) + ?? new InputRequiredResult([$key => new ElicitRequest($message, $schema)]); + } } diff --git a/examples/server/mcp-apps/server.php b/examples/server/mcp-apps/server.php index d8ca2592..37df5f36 100644 --- a/examples/server/mcp-apps/server.php +++ b/examples/server/mcp-apps/server.php @@ -18,12 +18,17 @@ 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()) + // Every request is its own PHP process under `php -S` and php-fpm alike, so + // the handshake era needs somewhere outside memory to keep the session. + // Without this the example works over stdio and nowhere else. + ->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 00000000..e08a8709 --- /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/phpstan-baseline.neon b/phpstan-baseline.neon index f5901b28..a84b39c1 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/spec-report.md b/spec-report.md new file mode 100644 index 00000000..cbaf6981 --- /dev/null +++ b/spec-report.md @@ -0,0 +1,248 @@ +# MCP 2026-07-28 — Gap Analysis and Status + +**Branch:** `2026spec-findings`, branched from `feature/2026-07-28` @ `800ac39` +**Spec audited:** `../modelcontextprotocol` — `schema/2026-07-28/schema.ts` (3197 lines) and `docs/specification/2026-07-28/**` +**Upstream tracker:** `modelcontextprotocol/php-sdk` — 76 open issues, 33 carrying the `2026-07-28` label +**Audit date:** 2026-08-15 · **Last updated:** 2026-08-15 + +--- + +## 0. Status at a glance + +The original audit found three structural holes and eight bugs. **All eight bugs and all three structural +holes are closed**, along with the extensions framework, subscriptions delivery, request-scoped +streaming, the caching policy, deprecations, docs and end-user examples. The Tasks extension is +carved out to its own PR (#428) and is not part of this branch. The **client** now +speaks the revision too, so both halves of the SDK are on 2026-07-28. + +| | Before | After | +| --- | --- | --- | +| Modern conformance (`make conformance-draft-server`) | 138 passed / 3 failed | **151/151** | +| Handshake conformance (`make conformance-server`) | 39/39 | **80/80** | +| Server baseline entries | 3 | **0** | +| Modern client conformance (`make conformance-draft-client`) | not gated | **baseline clean** | +| Unit tests | 1222 | **1512** | +| Integration tests | 33 | **64** | +| Inspector snapshot tests | 97 | **103** | +| PHPStan | 7 pre-existing errors | 7 pre-existing errors | + +Both sides now pass both revisions outright. The two server failures the branch had disclaimed turned out +to be fixture bugs, not SDK ones — a resource template echoing back its own `{id}` pattern instead of the +resolved URI, and a `json_schema_2020_12_tool` neither conformance server defined. The check counts jumped +because the runner is now pinned to a version that carries the 2026-07-28 scenarios and is run with +`--suite all` on both revisions. + +**What is done** — §A lifecycle & transport · §B MRTR · §C headers & metadata · §D results & caching · +§E errors & schema · §F subscriptions · §G1 extensions framework · §I1 deprecations · §I3 docs · +the client side of all of it. + +**What remains** — §3.1 MCP Apps ergonomics (an attribute and a scheme check) · §3.2 Authorization (mostly +blocked: there is no client-side OAuth for those rules to constrain yet) · §3.3 `anyOf` for a union of two +different array shapes · §3.4 conformance traceability. None is a MUST-level gap. +These are tracked in [§3](#3-remaining-work) with the same requirement/evidence/action shape as the original +audit. Everything else in this document is kept as the record of what was found and how it was closed. + +--- + +## 1. What was closed + +Each entry names the commit. Read the commit message for the reasoning; this table is the index. + +### Bugs (§J of the original audit) + +| # | Severity | Summary | Commit | +| --- | --- | --- | --- | +| B1 | High | `ClientGateway`'s six `supports*()` probes always returned `false` under the modern lifecycle — the session key they read is only written by `InitializeHandler`. Silent wrong answers, and it undermined the very capability guard MRTR asks handlers to write. | `781f1cb` | +| B2 | High | `resources/subscribe` / `resources/unsubscribe` still dispatched and answered `200 OK`, recording subscriptions nothing in that lifecycle reads. | `d104f51` | +| B3 | High | `Mcp-Name` compared to the body **without** Base64-sentinel decoding, so any non-ASCII resource URI or tool name was refused `-32020`. `decode()` existed but was wired only into the `Mcp-Param-*` path. | `ce804b9` | +| B4 | High | A missing `MCP-Protocol-Version` header was accepted, leaving the value intermediaries route on unenforced. | `c093a28` | +| B5 | Medium | `-32002` emitted where the revision forbids it; `prompts/get` and `completion/complete` used it for an unknown *name*, which it never meant; `tools/call` answered `-32601` for an unknown tool. | `4135c80` | +| B6 | Medium | A notification POST was dispatched as a request and answered with a JSON-RPC error carrying `"id": ""` — on the very path a client uses to tell a modern server from a legacy one. | `4aca43f` | +| B7 | Medium | `StatelessHttpTransport` took the whole body from one `read()`, which PSR-7 only fills *up to* the requested length; a chunked transfer truncated into `-32700`. | `fde1b8a` | +| B8 | Low | `-32021` was only reachable from `tools/call`; prompt and resource handlers absorbed the exception into `-32603`. | `4135c80` | + +### Structural holes + +| Area | What was missing | Commit | +| --- | --- | --- | +| §A5/§A6 | No request-scoped SSE stream, so `progress()` and `log()` reached for a fiber that was not there and came back as `-32603`. `io.modelcontextprotocol/logLevel` was parsed and discarded. | `8761e5a` | +| §F1–F3 | `subscriptions/listen` acknowledged, slept 30 s and closed. With `resources/subscribe` also gone, the revision had no server-push at all. | `81e7c04` | +| §G1 | `enableExtension()` took any string and advertised it; an extension could not add a method, because `MessageFactory` could not decode one. | `bfd7dcf` | +| §G2 | Tasks (SEP-2663) absent entirely — no schema, no store, no `tasks/*` surface. | carved out to PR #428 | + +### Everything else + +| § | Item | Commit | +| --- | --- | --- | +| B2 | `resources/read` could not answer with an `InputRequiredResult` | `bc894bd` | +| B3 | No guard against asking for an undeclared client capability | `1f2d392` | +| B4 | `InputContext` handed back raw arrays; `ClientGateway`'s handshake-era methods failed opaquely | `2f30c35`, `8761e5a` | +| B5 | MRTR retries were given caching hints their inputs cannot key | `bc894bd` | +| C4 | `x-mcp-header` inspected only top-level properties; no annotation validation; string-compared integers | `929e06d` | +| C5 | W3C trace context (`traceparent`/`tracestate`/`baggage`) not propagated | `8b202b3` | +| D2 | Caching hints frozen at `ttlMs: 0, cacheScope: private` with no way to change them | `50219dc` | +| E2 | Reserved error codes still emitted | `4135c80` | +| E4 | Unsupported `$schema` dialect reported as an internal fault; a union type silently lost a branch | `8a87215`, `3cf7aa2` | +| E5 | No `$ref` SSRF statement and no composition-DoS bound | `8a87215` | +| I1 | Roots / Sampling / Logging not marked deprecated | `eadd9fe` | +| I3 | Nothing in `docs/` described the modern lifecycle | `07fb337` | +| — | No end-user example of the revision | `9a7f3f0` | +| G2 | Tasks: schema, stores, `tasks/*` surface, capability gating | carved out to PR #428 | +| — | MCP Apps example unusable over HTTP; no snapshots pinning its `_meta` | `94b399a` | + +### Notes worth carrying forward + +- **`$ref` SSRF was already safe, but only by omission.** Opis registers no network resolver, so an + external `$ref` failed as "unresolved reference". `SchemaComplexityGuard` now states the rule and tests + it. The composition bound was a real hole: sixteen nested two-branch `anyOf`s took **9.0 s** and 65 536 + error objects; measured, `Validator::setMaxErrors()` bounds the *report*, not the walk, so the guard is + structural and runs first. The same bomb written with `$defs` is a few hundred bytes, which is why the + estimate resolves same-document `$ref`s rather than counting nodes. +- **Streaming is decided after the handler's first suspension.** Deciding earlier would have forced + `-32021` and `-32602` onto an SSE frame under a `200`, breaking the statuses the spec fixes for them. +- **The notification bus needs shared storage under PHP-FPM.** In-memory delivery looked fine in unit tests + and failed the conformance subscription checks, because the worker holding the stream and the worker + publishing are different processes. `Psr16NotificationBus` is what makes those checks pass. +- **`Error::$id` is now nullable.** An error whose id could not be read omits the member instead of sending + `"id": ""`. This also closes upstream #333 and the id half of #381. + +--- + +## 2. Spec delta at a glance + +`✅` implemented · `🟡` partial · `❌` absent. + +| Area | Change | Status | +| --- | --- | --- | +| Lifecycle | `initialize` / `notifications/initialized` removed | ✅ | +| Lifecycle | `server/discover` — servers **MUST** implement | ✅ | +| Lifecycle | Per-request `_meta` (version, capabilities, clientInfo, logLevel) | ✅ | +| Lifecycle | `ping`, `logging/setLevel`, `roots/list_changed` removed | ✅ | +| Lifecycle | `resources/subscribe` / `resources/unsubscribe` removed | ✅ | +| Transport | `Mcp-Session-Id` removed; GET/DELETE → 405 | ✅ | +| Transport | Per-request SSE response stream for progress/logging | ✅ | +| Transport | Notification POST → `202 Accepted` | ✅ | +| Transport | `Last-Event-ID` / resumability removed | ✅ | +| Subscriptions | `subscriptions/listen` + acknowledgment + delivery | ✅ | +| MRTR | `InputRequiredResult`, `inputRequests`, `inputResponses`, `requestState` | ✅ | +| MRTR | Supported on `tools/call`, `prompts/get`, `resources/read` | ✅ | +| Results | `resultType` required on every result | ✅ | +| Results | `ttlMs` + `cacheScope` on the six cacheable methods | ✅ | +| Headers | `Mcp-Method` / `Mcp-Name` required, Base64 sentinel decoded | ✅ | +| Headers | `x-mcp-header` → `Mcp-Param-*`, nested and validated | ✅ | +| Observability | W3C trace context in `_meta` (SEP-414) | ✅ | +| Errors | `-32020` / `-32021` / `-32022`; `-32002` retired | ✅ | +| Schema | `outputSchema` / `structuredContent` widened (SEP-2106) | ✅ | +| Schema | `$ref` SSRF and composition bounds | ✅ | +| Schema | 2020-12 vocabulary in the generator | 🟡 §3.3 | +| Extensions | `extensions` capability + negotiation framework (SEP-2133) | ✅ | +| Extensions | Tasks (SEP-2663) | ✅ — shipped separately in PR #428 | +| Extensions | MCP Apps (SEP-1865) | ✅ (ergonomics outstanding, §3.1) | +| Deprecation | Roots / Sampling / Logging marked deprecated (SEP-2577) | ✅ | +| Auth | SEP-2351 / 837 / 2352 / 2468 / 2207 / 2350 | 🟡 §3.2 — mostly blocked on there being no OAuth client | + +--- + +## 3. Remaining work + +Ordered by the sequence a future session should take them in. + +### 3.1 MCP Apps — ergonomics only, upstream #351 + +**Done.** The extension works end to end today and is now pinned: `enableExtension(new McpApps())`, a `ui://` +resource carrying `text/html;profile=mcp-app` and the `ui` marker, and a tool carrying `UiToolMeta` that +links to it. The example was also *broken over HTTP* — it was the only one without a session store, so every +request under `php -S` got "Session not found or has expired" — and is now covered by Inspector snapshots +that pin the `_meta` both halves travel in (`94b399a`). That closes the test half of #352. + +**Remaining (small).** A `#[McpUiResource]` attribute, so an app resource can be declared the way every +other element can instead of through `addResource()` with a hand-written marker; and validation that a +resource declaring the app MIME type also uses the `ui://` scheme, and vice versa. Both are ergonomics — +nothing is unreachable without them. + +### 3.2 Authorization — split differently from how the issue list reads + +Audited rather than implemented, and the shape is not what the seven open issues suggest. + +**The client-side items cannot be done yet.** #360 (validate `iss`), #361 (key credentials by issuer), +#363 (`offline_access`), #376 (`application_type` in DCR) and #377 (RFC 8414 suffix) are all rules about how +an OAuth *client* behaves — and this SDK has no client-side OAuth at all. `src/Client/` holds Builder, +Configuration, Handler, Protocol, State and Transport; there is no auth directory, and +`grep -rl 'oauth\|Bearer' src/Client/` returns nothing. That is what upstream #315–#325 are: eleven issues +building the OAuth client from scratch. The five `2026-07-28` items are constraints *on that work*, and each +should be folded into the issue that builds the piece it constrains, not attempted separately. + +**#364 (SEP-2207, PRM must not advertise `offline_access` as required) has nothing to fix.** +`ProtectedResourceMetadata::$scopesSupported` is entirely operator-supplied; nothing in `src/` writes +`offline_access` anywhere. Pinned with a regression test (`ProtectedResourceMetadataTest`) so a future +default cannot quietly start advertising one. The remaining substance is guidance for operators, which +belongs in `docs/authorization.md`. + +**#362 (SEP-2350, per-operation scopes in a 403) is half-present.** The mechanism exists: +`JwtTokenValidator::requireScopes()` produces a `403` with `insufficient_scope`, and +`AuthorizationMiddleware::buildAuthenticateHeader()` already emits `scope="…"` in the `WWW-Authenticate` +challenge alongside `resource_metadata` and `error` — which is what RFC 6750 §3.1 asks for. What is missing +is *per-operation*: nothing declares which scopes a given tool or resource needs, so `requireScopes()` can +only be called by the application with a set it works out itself. Closing it needs per-element scope +metadata, which is the same seam as upstream #159 (expose request-level `securitySchema` to handlers) and +should be designed with it. + +**Undecided.** The changelog deprecates RFC 7591 DCR in favour of Client ID Metadata Documents +(`changelog.mdx:93-99`). No upstream issue tracks it, and `adr/0001` puts the authorization server itself out +of scope, so the SDK's position on CIMD needs an explicit decision. + +--- + +### 3.3 JSON Schema 2020-12 generation — P1, upstream #356 + +**Done already:** an unsupported `$schema` dialect is reported as such (`8a87215`), and a union type no +longer loses a branch — in both places it was dropped (`3cf7aa2`, `555e962`). `string[]|int` now generates +`{"type": ["array","integer"], "items": …}`, which accepts either branch and refuses a float. + +**Remaining.** A union of two *different* array shapes (`string[]|int[]`) still collapses to one `array` +with whichever `items` is inferred first, because a `type` array cannot carry per-branch keywords. `anyOf` +with a schema per branch is the 2020-12 answer for that case. `$defs` + `$ref` for repeated object shapes, +and extending `#[Schema]` so composition keywords are expressible without hand-writing the whole schema, +follow from the same work. + +Related: #370 (`additionalProperties` unsupported) and #397 (phpstan/psalm number intervals). + +--- + +### 3.4 Conformance traceability — P2, upstream #367, #368 + +`tests/Conformance/conformance-baseline-2026-07-28.yml` records no server-side failures at all. #367 asks +to wire SEP traceability files into the runner and surface per-SEP pass rates; #368 is the Tier 2 gap +analysis, for which this document is input. + +--- + +## 4. Verification + +Four conformance runs, one per (role × lifecycle). All four are clean against their baselines, and all +four run in CI — `.github/workflows/pipeline.yaml` matrixes each role over the two revisions. + +``` +make conformance-server # handshake era, at / — 80/80 +make conformance-draft-server # modern era, same / — 151/151 +make conformance-client # handshake — baseline clean (auth stack absent, §3.2) +make conformance-draft-client # modern — baseline clean (auth stack absent, §3.2) + +vendor/bin/phpunit --testsuite=unit # 1512 +vendor/bin/phpunit --testsuite=integration # 64, boots the examples over real HTTP +vendor/bin/phpunit --testsuite=inspector # 103 (7 skipped); handshake-era examples only +vendor/bin/phpstan --memory-limit=-1 # 7 pre-existing errors, all alreadyNarrowedType under PHP 8.5 +vendor/bin/php-cs-fixer fix +``` + +The conformance runner is pinned to the same version CI uses. The 2026-07-28 scenarios ship on the `alpha` +dist-tag only — `latest` (0.1.x) carries none of them — so `conformance-weekly.yaml` tracks `latest` for +the dated revision and `alpha` for the draft one, which is what keeps the pin from going stale. + +**The Inspector cannot reach a modern-lifecycle server.** It opens with `initialize`, which this revision +removed, so `tests/Inspector/` covers the handshake-era examples only. The +`stateless-lifecycle` example is verified instead by two integration tests, one per direction: +`StatelessLifecycleTest` drives it with hand-built HTTP the way a conforming client would — discovery, a +tool call, both MRTR rounds, a tampered `requestState`, and the response stream carrying interleaved +progress and log notifications — and `StatelessClientTest` drives it with the SDK's own client, which is +what proves that client is conforming. diff --git a/src/Capability/Discovery/SchemaComplexityGuard.php b/src/Capability/Discovery/SchemaComplexityGuard.php new file mode 100644 index 00000000..3a0b8a86 --- /dev/null +++ b/src/Capability/Discovery/SchemaComplexityGuard.php @@ -0,0 +1,258 @@ + + */ +final class SchemaComplexityGuard +{ + /** + * Keywords whose value is a map of name to subschema, rather than a + * subschema itself. Their keys are user-chosen and must not be read as + * keywords. + */ + private const SCHEMA_MAPS = ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']; + + /** + * @param int $maxDepth how deeply subschemas may nest + * @param int $maxSubschemas ceiling on estimated subschema evaluations + * @param int $maxProperties ceiling on named subschemas in any one map + */ + public function __construct( + private readonly int $maxDepth = 32, + private readonly int $maxSubschemas = 10_000, + private readonly int $maxProperties = 1_000, + ) { + } + + /** + * @param array|object $schema + * + * @return string|null the reason to refuse, or null when the schema is within bounds + */ + public function check(array|object $schema): ?string + { + $root = self::toArray($schema); + + if (null !== $reason = $this->findExternalRef($root, 0)) { + return $reason; + } + + try { + $this->cost($root, $root, [], 0, new \stdClass()); + } catch (\OverflowException $e) { + return $e->getMessage(); + } + + return null; + } + + /** + * @param array $node + */ + private function findExternalRef(array $node, int $depth): ?string + { + if ($depth > $this->maxDepth) { + return \sprintf('Schema nests deeper than the %d levels this validator accepts.', $this->maxDepth); + } + + foreach ($node as $key => $value) { + if ('$ref' === $key && \is_string($value) && !str_starts_with($value, '#')) { + return \sprintf('Schema contains the non-local reference "%s"; only same-document "#" references are resolved.', $value); + } + + if (\is_array($value) && null !== $reason = $this->findExternalRef($value, $depth + 1)) { + return $reason; + } + } + + return null; + } + + /** + * Estimated subschema evaluations $node can trigger. + * + * @param array $node + * @param array $root + * @param list $stack pointers currently being resolved, so a cycle is not followed twice + * @param \stdClass $memo cost per already-resolved pointer + * + * @throws \OverflowException as soon as the running estimate passes the ceiling + */ + private function cost(array $node, array $root, array $stack, int $depth, object $memo): int + { + if ($depth > $this->maxDepth) { + throw new \OverflowException(\sprintf('Schema nests deeper than the %d levels this validator accepts.', $this->maxDepth)); + } + + if (isset($node['$ref']) && \is_string($node['$ref'])) { + return $this->refCost($node['$ref'], $root, $stack, $depth, $memo); + } + + $total = 1; + + foreach ($node as $key => $value) { + if (!\is_array($value)) { + continue; + } + + if (\in_array($key, self::SCHEMA_MAPS, true)) { + if (\count($value) > $this->maxProperties) { + throw new \OverflowException(\sprintf('Schema declares more than %d entries under "%s".', $this->maxProperties, $key)); + } + + foreach ($value as $subschema) { + if (\is_array($subschema)) { + $total += $this->cost($subschema, $root, $stack, $depth + 1, $memo); + } + } + + $this->assertWithinBudget($total); + + continue; + } + + // Everything else holding an array is either a subschema or a list + // of them; a keyword holding plain data contributes nothing but is + // harmless to walk, since only its own nesting is counted. + if (array_is_list($value)) { + foreach ($value as $subschema) { + if (\is_array($subschema)) { + $total += $this->cost($subschema, $root, $stack, $depth + 1, $memo); + } + } + } else { + $total += $this->cost($value, $root, $stack, $depth + 1, $memo); + } + + $this->assertWithinBudget($total); + } + + return $total; + } + + /** + * @param array $root + * @param list $stack + */ + private function refCost(string $pointer, array $root, array $stack, int $depth, object $memo): int + { + // A back-edge: recursive schemas are legitimate, and how far one + // unrolls is decided by the data, not the schema. + if (\in_array($pointer, $stack, true)) { + return 1; + } + + if (isset($memo->{$pointer})) { + return $memo->{$pointer}; + } + + $target = self::resolve($pointer, $root); + + if (null === $target) { + // Unresolvable same-document pointers are the validator's business + // to report; nothing here can be expensive. + return 1; + } + + // Depth is lexical nesting, which following a reference is not: a long + // chain of `$defs` referring to one another is flat and cheap. What + // bounds this is the subschema budget and the cycle check above, and + // the pointer set is finite, so the recursion is too. + $cost = $this->cost($target, $root, [...$stack, $pointer], $depth, $memo); + $memo->{$pointer} = $cost; + + return $cost; + } + + /** + * Resolves a same-document JSON pointer (`#`, `#/$defs/name`). + * + * @param array $root + * + * @return array|null + */ + private static function resolve(string $pointer, array $root): ?array + { + if ('#' === $pointer || '' === $pointer) { + return $root; + } + + if (!str_starts_with($pointer, '#/')) { + return null; + } + + $node = $root; + + foreach (explode('/', substr($pointer, 2)) as $segment) { + $segment = str_replace(['~1', '~0'], ['/', '~'], rawurldecode($segment)); + + if (!\is_array($node) || !\array_key_exists($segment, $node)) { + return null; + } + + $node = $node[$segment]; + } + + return \is_array($node) ? $node : null; + } + + private function assertWithinBudget(int $total): void + { + if ($total > $this->maxSubschemas) { + throw new \OverflowException(\sprintf('Schema composes more than %d subschemas, which this validator refuses to walk.', $this->maxSubschemas)); + } + } + + /** + * @param array|object $schema + * + * @return array + */ + private static function toArray(array|object $schema): array + { + if (\is_array($schema)) { + return $schema; + } + + /** @var array $decoded */ + $decoded = json_decode(json_encode($schema, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR); + + return $decoded; + } +} diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 80779c70..c43aab7b 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -237,7 +237,17 @@ private function buildParameterSchema(array $paramInfo, ?array $methodLevelParam // 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 @@ -326,7 +336,12 @@ private function buildVariadicParameterSchema(array $paramInfo): array // Apply parameter-level Schema attributes first if (!empty($paramInfo['parameter_schema'])) { - $paramSchema = array_merge($paramSchema, $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'; } @@ -455,11 +470,25 @@ private function applyArrayConstraints(array $paramSchema, array $paramInfo): ar } } - 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'; } } @@ -655,6 +684,42 @@ private function getTypeStringFromReflection(?\ReflectionType $type, bool $nativ 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 +729,28 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array { $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)]+\s*>$/i', $normalizedType)) { return ['integer']; } - // PRIORITY 4: Handle unions (recursive) - if (str_contains($normalizedType, '|')) { - $types = explode('|', $normalizedType); - $jsonTypes = []; - foreach ($types as $type) { - $mapped = $this->mapPhpTypeToJsonSchemaType(trim($type)); - $jsonTypes = array_merge($jsonTypes, $mapped); - } - - return array_values(array_unique($jsonTypes)); - } - // PRIORITY 5: Handle simple built-in types return match ($normalizedType) { 'string', 'scalar' => ['string'], diff --git a/src/Capability/Discovery/SchemaValidator.php b/src/Capability/Discovery/SchemaValidator.php index 56174bdc..ae4d3969 100644 --- a/src/Capability/Discovery/SchemaValidator.php +++ b/src/Capability/Discovery/SchemaValidator.php @@ -30,11 +30,23 @@ */ class SchemaValidator { + /** + * Ceiling on reported errors. Opis walks the whole schema regardless — this + * only bounds the array built out of it, which a composition blow-up can + * make the larger cost of the two. {@see SchemaComplexityGuard} is what + * bounds the walk. + */ + private const MAX_REPORTED_ERRORS = 100; + private ?Validator $jsonSchemaValidator = null; + private SchemaComplexityGuard $complexityGuard; + public function __construct( private LoggerInterface $logger = new NullLogger(), + ?SchemaComplexityGuard $complexityGuard = null, ) { + $this->complexityGuard = $complexityGuard ?? new SchemaComplexityGuard(); } /** @@ -81,6 +93,14 @@ public function validateAgainstJsonSchema(mixed $data, array|object $schema): ar return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Internal validation preparation error.']]; } + // Before the validator sees it: a schema can be cheap to send and + // ruinous to walk, and refusing it is only possible up front. + if (null !== $reason = $this->complexityGuard->check($schemaObject)) { + $this->logger->warning('MCP SDK: Refused a schema the complexity guard rejected.', ['reason' => $reason]); + + return [['pointer' => '', 'keyword' => 'schema', 'message' => $reason]]; + } + $validator = $this->getJsonSchemaValidator(); try { @@ -92,6 +112,13 @@ public function validateAgainstJsonSchema(mixed $data, array|object $schema): ar 'schema' => json_encode($schemaObject), ]); + // "Unsupported draft-XXXX" is the one failure here that is the + // schema's doing rather than ours, and the spec asks for an error + // that names the dialect. + if (str_contains($e->getMessage(), 'Unsupported draft')) { + return [['pointer' => '', 'keyword' => '$schema', 'message' => \sprintf('Unsupported JSON Schema dialect: %s. This validator supports 2020-12 (the default when no "$schema" is given) and the drafts opis/json-schema implements.', $e->getMessage())]]; + } + return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: '.$e->getMessage()]]; } @@ -124,7 +151,12 @@ private function getJsonSchemaValidator(): Validator { if (null === $this->jsonSchemaValidator) { $this->jsonSchemaValidator = new Validator(); - // Potentially configure resolver here if needed later + $this->jsonSchemaValidator->setMaxErrors(self::MAX_REPORTED_ERRORS); + // No resolver is registered, and none should be: a `$ref` naming an + // absolute URI must never be fetched, which is a MUST in the + // specification's JSON Schema rules. SchemaComplexityGuard refuses + // such a schema before it reaches here, so this is the second of + // two locks rather than the only one. } return $this->jsonSchemaValidator; @@ -169,6 +201,13 @@ private function convertDataForValidator(mixed $data): mixed */ private function collectSubErrors(ValidationError $error, array &$collectedErrors): void { + // The error tree fans out with the schema, so a composition-heavy + // schema produces far more leaves than Opis's own cap admits. Past the + // ceiling there is nothing left to learn from another one. + if (\count($collectedErrors) >= self::MAX_REPORTED_ERRORS) { + return; + } + $subErrors = $error->subErrors(); if (empty($subErrors)) { $collectedErrors[] = [ diff --git a/src/Capability/Logger/ClientLogger.php b/src/Capability/Logger/ClientLogger.php index ae0dd773..f94deb67 100644 --- a/src/Capability/Logger/ClientLogger.php +++ b/src/Capability/Logger/ClientLogger.php @@ -51,7 +51,7 @@ public function log($level, $message, array $context = []): void $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; } @@ -79,24 +79,4 @@ private function convertToMcpLevel($level): ?LoggingLevel 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/Client.php b/src/Client.php index ed5abc6f..532f60a2 100644 --- a/src/Client.php +++ b/src/Client.php @@ -175,7 +175,16 @@ public function listTools(?string $cursor = null): ListToolsResult $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); } /** @@ -188,6 +197,15 @@ public function listTools(?string $cursor = null): ListToolsResult */ 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); diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index e9eabded..b0fcaea8 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -16,16 +16,25 @@ 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; @@ -41,6 +50,16 @@ */ 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; @@ -49,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 readonly 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 @@ -67,6 +101,20 @@ public function __construct( 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; } /** @@ -80,18 +128,52 @@ public function __construct( public function connect(TransportInterface $transport, Configuration $config): void { $this->transport = $transport; + + 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 * @@ -99,18 +181,12 @@ public function connect(TransportInterface $transport, Configuration $config): v */ public function initialize(Configuration $config): Response|Error { - $offered = $config->protocolVersion; - if ($offered->isModern()) { - // Only handshake era spec versions need the initialize call, so if we - // end up here, we fall back to the latest handshake version. - $offered = ProtocolVersion::latestHandshake(); - - $this->logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, offering the newest handshake revision instead.', [ - 'configured' => $config->protocolVersion->value, - 'offered' => $offered->value, - ]); + if (null !== $this->envelope) { + return $this->discover($config); } + $offered = $config->protocolVersion; + $request = new InitializeRequest( $offered->value, $config->capabilities, @@ -156,12 +232,127 @@ public function initialize(Configuration $config): Response|Error 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 @@ -170,18 +361,122 @@ public function initialize(Configuration $config): Response|Error */ 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]); + } + + if (null === $this->envelope) { + return $this->exchange($payload, $timeout); + } + + for ($attempt = 0; $attempt < self::MAX_ROUND_TRIPS; ++$attempt) { + $response = $this->exchange($payload, $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. + $payload['params'] = [ + ...($payload['params'] ?? []), + 'inputResponses' => $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)); + } + + /** + * 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 withAcceptedVersion(Error $error): ?ProtocolVersion + { + if (Error::UNSUPPORTED_PROTOCOL_VERSION !== $error->code || null === $this->envelope) { + return null; } + $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->sendRequest($request); + $this->send($payload, 'request'); $immediate = $this->state->consumeResponse($requestId); if (null !== $immediate) { @@ -205,28 +500,48 @@ public function request(Request $request, int $timeout, bool $withProgress = fal } /** - * Send a request to the server. + * Send a notification to the server (fire and forget). */ - private function sendRequest(Request $request): void + public function sendNotification(Notification $notification): void { - $this->logger->debug('Sending request', [ - 'id' => $request->getId(), - 'method' => $request::getMethod(), + $this->send($notification->jsonSerialize(), 'notification'); + } + + /** + * 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, ]); - $encoded = json_encode($request, \JSON_THROW_ON_ERROR); - $this->transport?->send($encoded); + $this->transport?->send(json_encode($payload, \JSON_THROW_ON_ERROR)); } /** - * Send a notification to the server (fire and forget). + * @param array $payload + * @param array $meta + * + * @return array */ - public function sendNotification(Notification $notification): void + private static function withMeta(array $payload, array $meta): array { - $this->logger->debug('Sending notification', ['method' => $notification::getMethod()]); + $params = \is_array($payload['params'] ?? null) ? $payload['params'] : []; + $existing = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; - $encoded = json_encode($notification, \JSON_THROW_ON_ERROR); - $this->transport?->send($encoded); + $params['_meta'] = [...$existing, ...$meta]; + $payload['params'] = $params; + + return $payload; } /** diff --git a/src/Client/Stateless/HeaderFactory.php b/src/Client/Stateless/HeaderFactory.php new file mode 100644 index 00000000..3640cf93 --- /dev/null +++ b/src/Client/Stateless/HeaderFactory.php @@ -0,0 +1,75 @@ + + */ +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; + + // A response to a server-initiated request carries no method to mirror; + // the version header is unconditional and still applies. + if (!\is_string($method)) { + return [McpHeader::PROTOCOL_VERSION => $protocolVersion->value]; + } + + $params = \is_array($payload['params'] ?? null) ? $payload['params'] : null; + + $headers = [ + McpHeader::PROTOCOL_VERSION => $protocolVersion->value, + McpHeader::METHOD => $method, + ]; + + 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; + } +} diff --git a/src/Client/Stateless/InputRequestResolver.php b/src/Client/Stateless/InputRequestResolver.php new file mode 100644 index 00000000..a592542d --- /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 00000000..fcc1cac4 --- /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 00000000..7dd32aac --- /dev/null +++ b/src/Client/Stateless/ToolCatalog.php @@ -0,0 +1,207 @@ + + */ +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 = Tool::checkHeaderAnnotations($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 00000000..998a0446 --- /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 ddb662f7..b5499e85 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -32,7 +32,7 @@ * * @author Kyrian Obikwelu */ -class HttpTransport extends BaseTransport +class HttpTransport extends BaseTransport implements HeaderAwareTransportInterface { private ClientInterface $httpClient; private RequestFactoryInterface $requestFactory; @@ -40,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; @@ -113,6 +116,11 @@ public function connect(): void $this->logger->info('HTTP client connected and initialized', ['endpoint' => $this->endpoint]); } + public function onHeaders(callable $callback): void + { + $this->headerCallback = $callback; + } + public function send(string $data): void { $request = $this->requestFactory->createRequest('POST', $this->endpoint) @@ -124,6 +132,13 @@ public function send(string $data): void $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); } @@ -199,6 +214,27 @@ public function close(): void $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(); diff --git a/src/Exception/MissingRequestMetaException.php b/src/Exception/MissingRequestMetaException.php new file mode 100644 index 00000000..1499205f --- /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 00000000..aa8b0315 --- /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 00000000..2dde880b --- /dev/null +++ b/src/Exception/RequestStateException.php @@ -0,0 +1,24 @@ + + */ +class RequestStateException extends InvalidArgumentException +{ +} diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index d9a895ec..4ff26a41 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -95,10 +95,12 @@ public function __construct( /** * 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); } /** diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index bc71cfc2..75d50ff6 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -188,7 +188,10 @@ public function jsonSerialize(): array|object } 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/Elicitation/AbstractSchemaDefinition.php b/src/Schema/Elicitation/AbstractSchemaDefinition.php index 63fdc219..547d44a5 100644 --- a/src/Schema/Elicitation/AbstractSchemaDefinition.php +++ b/src/Schema/Elicitation/AbstractSchemaDefinition.php @@ -21,13 +21,17 @@ 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. + * + * The specification makes `title` optional on every elicitation schema, so + * its absence is not an error — and treating it as one would have this + * client refuse to read a conformant server's request. * * @param array $data * @@ -35,22 +39,23 @@ public function __construct( */ 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 9cfaa8a3..ad2a3b8c 100644 --- a/src/Schema/Elicitation/BooleanSchemaDefinition.php +++ b/src/Schema/Elicitation/BooleanSchemaDefinition.php @@ -19,12 +19,12 @@ 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, ?string $description = null, public readonly ?bool $default = null, ) { @@ -33,7 +33,7 @@ public function __construct( /** * @param array{ - * title: string, + * title?: string, * description?: string, * default?: bool, * } $data @@ -43,7 +43,7 @@ public static function fromArray(array $data): self 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 @@ public static function fromArray(array $data): self /** * @return array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: bool, * } diff --git a/src/Schema/Elicitation/EnumSchemaDefinition.php b/src/Schema/Elicitation/EnumSchemaDefinition.php index 003bf354..3f9c636c 100644 --- a/src/Schema/Elicitation/EnumSchemaDefinition.php +++ b/src/Schema/Elicitation/EnumSchemaDefinition.php @@ -30,7 +30,7 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition * @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 @@ public function __construct( /** * @param array{ - * title: string, + * title?: string, * enum: string[], * description?: string, * default?: string, @@ -75,7 +75,7 @@ public static function fromArray(array $data): self } 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 @@ enumNames: $data['enumNames'] ?? null, /** * @return array{ * type: string, - * title: string, + * title?: string, * enum: string[], * description?: string, * default?: string, @@ -95,11 +95,13 @@ enumNames: $data['enumNames'] ?? null, */ 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 28046bcf..b02e7591 100644 --- a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php @@ -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 @@ public function __construct( /** * @param array{ - * title: string, + * title?: string, * items: array{type: string, enum: string[]}, * description?: string, * default?: string[], @@ -90,7 +90,7 @@ public static function fromArray(array $data): self } 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 0ad32771..149745b5 100644 --- a/src/Schema/Elicitation/NumberSchemaDefinition.php +++ b/src/Schema/Elicitation/NumberSchemaDefinition.php @@ -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, public readonly bool $integerOnly = false, ?string $description = null, public readonly int|float|null $default = null, @@ -60,7 +60,7 @@ public function __construct( /** * @param array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: int|float, * minimum?: int|float, @@ -75,7 +75,7 @@ public static function fromArray(array $data): self $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 @@ public static function fromArray(array $data): self /** * @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 76319261..59435e43 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, ?string $description = null, public readonly ?string $default = null, public readonly ?string $format = null, @@ -61,7 +61,7 @@ public function __construct( /** * @param array{ - * title: string, + * title?: string, * description?: string, * default?: string, * format?: string, @@ -74,7 +74,7 @@ public static function fromArray(array $data): self 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 @@ public static function fromArray(array $data): self /** * @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 54eb568b..f6c47d4f 100644 --- a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php @@ -24,13 +24,13 @@ 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 @@ public function __construct( /** * @param array{ - * title: string, + * title?: string, * oneOf: list, * description?: string, * default?: string, @@ -74,7 +74,7 @@ public static function fromArray(array $data): self } 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 baab9b0b..ee49a053 100644 --- a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php @@ -23,7 +23,7 @@ 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 @@ public function __construct( /** * @param array{ - * title: string, + * title?: string, * items: array{anyOf: list}, * description?: string, * default?: string[], @@ -95,7 +95,7 @@ public static function fromArray(array $data): self } 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 00000000..2c71cf88 --- /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/LoggingLevel.php b/src/Schema/Enum/LoggingLevel.php index 7b03d137..d6031f98 100644 --- a/src/Schema/Enum/LoggingLevel.php +++ b/src/Schema/Enum/LoggingLevel.php @@ -32,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 b62396bb..03ebddfd 100644 --- a/src/Schema/Enum/ProtocolVersion.php +++ b/src/Schema/Enum/ProtocolVersion.php @@ -115,6 +115,21 @@ 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. */ diff --git a/src/Schema/Enum/ResultType.php b/src/Schema/Enum/ResultType.php new file mode 100644 index 00000000..8319bc49 --- /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/Extension/ExtensionIdentifier.php b/src/Schema/Extension/ExtensionIdentifier.php new file mode 100644 index 00000000..90df1def --- /dev/null +++ b/src/Schema/Extension/ExtensionIdentifier.php @@ -0,0 +1,79 @@ + + */ +final class ExtensionIdentifier +{ + /** 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])?'; + + /** + * @return string|null the reason $identifier is invalid, or null when it is well-formed + */ + public 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; + } + + /** + * Whether $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 static function isReserved(string $identifier): bool + { + $labels = explode('.', strstr($identifier, '/', true) ?: ''); + + return \in_array($labels[1] ?? '', self::RESERVED_LABELS, true); + } +} diff --git a/src/Schema/Extension/MethodProvidingExtensionInterface.php b/src/Schema/Extension/MethodProvidingExtensionInterface.php new file mode 100644 index 00000000..4eb17b6f --- /dev/null +++ b/src/Schema/Extension/MethodProvidingExtensionInterface.php @@ -0,0 +1,50 @@ + + */ +interface MethodProvidingExtensionInterface extends ExtensionInterface +{ + /** + * 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. + * + * @return list|class-string> + */ + public function getMessages(): array; + + /** + * The handlers serving those methods. + * + * @return iterable> + */ + public function getRequestHandlers(): iterable; +} diff --git a/src/Schema/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php index 683ed0dc..ca4a8be3 100644 --- a/src/Schema/JsonRpc/Error.php +++ b/src/Schema/JsonRpc/Error.php @@ -57,12 +57,15 @@ class Error implements MessageInterface public const UNSUPPORTED_PROTOCOL_VERSION = -32022; /** - * @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 + * @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, @@ -77,10 +80,9 @@ final public static function fromArray(array $data): self 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'])) { @@ -93,45 +95,45 @@ final public static function fromArray(array $data): self 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); } - final public static function forHeaderMismatch(string $message, string|int $id = ''): self + final public static function forHeaderMismatch(string $message, string|int|null $id = null): self { return new self($id, self::HEADER_MISMATCH, $message); } @@ -142,7 +144,7 @@ final public static function forHeaderMismatch(string $message, string|int $id = final public static function forMissingRequiredClientCapability( string $message, ClientCapabilities $requiredCapabilities, - string|int $id = '', + string|int|null $id = null, ): self { return new self($id, self::MISSING_REQUIRED_CLIENT_CAPABILITY, $message, [ 'requiredCapabilities' => $requiredCapabilities, @@ -159,7 +161,7 @@ final public static function forMissingRequiredClientCapability( final public static function forUnsupportedProtocolVersion( string $requested, array $supported, - string|int $id = '', + string|int|null $id = null, ): self { return new self($id, self::UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', [ 'requested' => $requested, @@ -167,7 +169,7 @@ final public static function forUnsupportedProtocolVersion( ]); } - public function getId(): string|int + public function getId(): string|int|null { return $this->id; } @@ -175,7 +177,7 @@ public function getId(): string|int /** * @return array{ * jsonrpc: string, - * id: string|int, + * id?: string|int, * error: array{ * code: int, * message: string, @@ -194,10 +196,17 @@ public function jsonSerialize(): array $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/Response.php b/src/Schema/JsonRpc/Response.php index 7f2d82ba..4c64881e 100644 --- a/src/Schema/JsonRpc/Response.php +++ b/src/Schema/JsonRpc/Response.php @@ -14,7 +14,11 @@ 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/Request/DiscoverRequest.php b/src/Schema/Request/DiscoverRequest.php new file mode 100644 index 00000000..24852bc6 --- /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/Result/DiscoverResult.php b/src/Schema/Result/DiscoverResult.php new file mode 100644 index 00000000..22222e97 --- /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/InputRequiredResult.php b/src/Schema/Result/InputRequiredResult.php new file mode 100644 index 00000000..a0fca5e6 --- /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/ReadResourceResult.php b/src/Schema/Result/ReadResourceResult.php index 7fd80009..7c6d0c53 100644 --- a/src/Schema/Result/ReadResourceResult.php +++ b/src/Schema/Result/ReadResourceResult.php @@ -15,6 +15,7 @@ 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 @@ public static function fromArray(array $data): self } } - 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/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index 0e47c61d..33cc1ee1 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -189,7 +189,12 @@ public function jsonSerialize(): array } 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 b5aee7e2..14cb5497 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -125,6 +125,97 @@ public function __construct( // 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` 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 = self::checkHeaderAnnotations($this->inputSchema)) { + throw new InvalidArgumentException(\sprintf('Tool "%s" has an invalid "x-mcp-header" annotation: %s', $this->name, $reason)); + } + } + + /** + * Validates every `x-mcp-header` annotation 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 $inputSchema + * + * @return string|null the reason it is invalid, or null when every annotation is well-formed + */ + public static function checkHeaderAnnotations(array $inputSchema): ?string + { + $seen = []; + + foreach (self::headerAnnotations($inputSchema) 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 + */ + private static function headerAnnotations(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::headerAnnotations($definition, $path)]; + } + + return $found; } /** diff --git a/src/Schema/Wire/McpHeader.php b/src/Schema/Wire/McpHeader.php new file mode 100644 index 00000000..bcf12157 --- /dev/null +++ b/src/Schema/Wire/McpHeader.php @@ -0,0 +1,124 @@ + + */ +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; + } + + 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. + */ + private static function isSafe(string $value): bool + { + if ($value !== trim($value)) { + return false; + } + + return 1 === preg_match('/^[\x20-\x7E]*$/', $value); + } +} diff --git a/src/Server.php b/src/Server.php index 8657610a..a19e8592 100644 --- a/src/Server.php +++ b/src/Server.php @@ -13,6 +13,8 @@ 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 @@ */ 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 @@ public function run(TransportInterface $transport): mixed $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 c01dd731..16073786 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -32,7 +32,9 @@ use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Extension\ExtensionIdentifier; use Mcp\Schema\Extension\ExtensionInterface; +use Mcp\Schema\Extension\MethodProvidingExtensionInterface; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; use Mcp\Schema\Prompt; @@ -55,6 +57,14 @@ 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 +75,17 @@ /** * @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 +124,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,6 +260,27 @@ final class Builder private bool $lazyLoading = 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; + + /** + * Resolved once and shared by every dispatcher this builder produces. + * + * Both eras run the same tools over the same registry, so assembling twice + * would mean two registries, two discovery passes and two session managers + * behind one endpoint — and a change made through one of them invisible to + * the other. + * + * @var AssembledParts|null + */ + private ?array $parts = null; + /** * Sets the server's identity. Required. * @@ -242,6 +300,86 @@ public function setServerInfo( 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; + } + /** * Configures the server's pagination limit. */ @@ -287,11 +425,30 @@ public function enableExtension(ExtensionInterface ...$extensions): self foreach ($extensions as $extension) { $id = $extension->getId(); + if (null !== $reason = ExtensionIdentifier::check($id)) { + throw new LogicException(\sprintf('Invalid extension identifier: %s', $reason)); + } + if (isset($this->extensions[$id])) { throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id)); } $this->extensions[$id] = $extension->getCapabilities(); + + if (!$extension instanceof MethodProvidingExtensionInterface) { + continue; + } + + foreach ($extension->getMessages() as $message) { + $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[$message::getMethod()] = $id; + } + + foreach ($extension->getRequestHandlers() as $handler) { + $this->requestHandlers[] = $handler; + } } return $this; @@ -657,13 +814,175 @@ public function addLoaders(iterable $loaders): self 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 + */ + public function setModernVersions(array $versions): self + { + $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: new StandardHeaderValidator($parts['registry']), + 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(), @@ -702,16 +1021,16 @@ public function build(): Server $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. @@ -750,16 +1069,16 @@ public function build(): Server 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, + ]; } /** @@ -767,9 +1086,11 @@ public function build(): Server * 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 2df6fa36..a849562f 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -402,14 +402,20 @@ private function sendElicitation(ElicitRequest $request, int $timeout): ElicitRe * 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 { $response = \Fiber::suspend([ 'type' => 'request', diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 3d43d0ac..616db3a3 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -14,6 +14,7 @@ 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,13 +23,17 @@ 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 @@ -52,7 +57,7 @@ public function supports(Request $request): bool } /** - * @return Response|Error + * @return Response|Error */ public function handle(Request $request, SessionInterface $session): Response|Error { @@ -68,7 +73,9 @@ public function handle(Request $request, SessionInterface $session): Response|Er } 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; @@ -98,6 +105,11 @@ public function handle(Request $request, SessionInterface $session): Response|Er 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; @@ -135,6 +147,10 @@ public function handle(Request $request, SessionInterface $session): Response|Er ]); 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()), [ 'tool' => $toolName, diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index b3c4d043..a1eab00b 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -85,7 +85,9 @@ public function handle(Request $request, SessionInterface $session): Response|Er 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. + return Error::forInvalidParams($e->getMessage(), $request->getId()); } catch (\Throwable $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 745b9b86..71d6f8c2 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -13,6 +13,7 @@ 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\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 @@ public function supports(Request $request): bool } /** - * @return Response|Error + * @return Response|Error */ public function handle(Request $request, SessionInterface $session): Response|Error { @@ -61,9 +63,18 @@ public function handle(Request $request, SessionInterface $session): Response|Er $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 @@ public function handle(Request $request, SessionInterface $session): Response|Er } 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/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index a9551eff..60755f8f 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -14,19 +14,22 @@ 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 @@ public function supports(Request $request): bool } /** - * @return Response|Error + * @return Response|Error */ public function handle(Request $request, SessionInterface $session): Response|Error { @@ -69,13 +72,30 @@ public function handle(Request $request, SessionInterface $session): Response|Er $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 @@ public function handle(Request $request, SessionInterface $session): Response|Er } 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 d81c0795..9a9c0138 100644 --- a/src/Server/Handler/Request/RequestHandlerInterface.php +++ b/src/Server/Handler/Request/RequestHandlerInterface.php @@ -17,7 +17,11 @@ 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/InputRequiredShim.php b/src/Server/InputRequiredShim.php new file mode 100644 index 00000000..9dff786c --- /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 5a4e358f..dbb6a32b 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -27,6 +27,8 @@ 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; @@ -78,6 +80,8 @@ public function __construct( 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, ) { } @@ -257,6 +261,15 @@ private function handleRequest(TransportInterface $transport, Request $request, $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(); @@ -270,8 +283,18 @@ private function handleRequest(TransportInterface $transport, Request $request, $handlerFound = true; try { + $shim = $this->inputRequiredShim; + $codec = $this->requestStateCodec; + + // The handler runs inside the fiber either way; with the shim + // it runs there once per round, and the wait for each answer is + // the same suspension a handler's own ClientGateway call makes. /** @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(); diff --git a/src/Server/RequestContext.php b/src/Server/RequestContext.php index 1a4f8375..f322a39d 100644 --- a/src/Server/RequestContext.php +++ b/src/Server/RequestContext.php @@ -12,9 +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 @@ -82,6 +87,65 @@ public function getClientGateway(): ClientGateway 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/Stateless/InputContext.php b/src/Server/Stateless/InputContext.php new file mode 100644 index 00000000..d91197b9 --- /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 00000000..1f4287f6 --- /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 00000000..46491269 --- /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 00000000..f418db51 --- /dev/null +++ b/src/Server/Stateless/RequestMeta.php @@ -0,0 +1,122 @@ + + */ +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 + * + * @throws MissingRequestMetaException when a structurally required member is absent or malformed + */ + public static function fromParams(?array $params): 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), + ); + } + + /** + * 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. + * + * @param array $meta + * + * @return array + */ + private static function parseTraceContext(array $meta): array + { + $context = []; + + foreach (self::TRACE_KEYS as $key) { + if (\is_string($meta[$key] ?? null)) { + $context[$key] = $meta[$key]; + } + } + + return $context; + } +} diff --git a/src/Server/Stateless/RequestStateCodec.php b/src/Server/Stateless/RequestStateCodec.php new file mode 100644 index 00000000..9dcf477b --- /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 00000000..e999bd60 --- /dev/null +++ b/src/Server/Stateless/StandardHeaderValidator.php @@ -0,0 +1,296 @@ + + */ +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. + if (is_numeric($argument) && is_numeric($decoded)) { + return $decoded == $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 00000000..1f2b5051 --- /dev/null +++ b/src/Server/Stateless/StatelessProtocol.php @@ -0,0 +1,765 @@ + + */ +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'; + + /** + * @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); + } + + // Read before validation so every error below can still echo it. Null + // when it cannot be read, which is the one case the spec lets an error + // response leave the member out. + // + // 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); + } 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) { + return $this->encode($method, $id, $this->discover()); + } + + if (self::LISTEN_METHOD === $method) { + 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 $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 $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; + + if (!$request instanceof Request) { + return StatelessResult::error($this->unknownMethod($method, $id), 404); + } + + $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, $method, $id, null === $input, $meta)); + } + + 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 refused rather than forwarded: this revision + * carries those in the result (MRTR), and putting one on a response stream + * is something the transport binding forbids outright. + * + * @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)); + + $suspended = $fiber->start(); + + while (!$fiber->isTerminated()) { + $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)) { + throw new LogicException('This protocol revision has no server-initiated requests: return an InputRequiredResult naming what you need instead, and read the answers back through RequestContext::getInputContext(). See the multi round-trip requests pattern.'); + } + + 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; + } + + /** + * 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, string $method, string|int $id, bool $cacheable, RequestMeta $meta): \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; + } + + 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); + } + + $this->logger->error('Uncaught exception handling a modern-era request.', ['method' => $method, 'exception' => $e]); + + return StatelessResult::error(Error::forInternalError($e->getMessage(), $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 00000000..0385ad6d --- /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 $id = '', + 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); + } + + public function isEmpty(): bool + { + return $this->bodyless; + } + + /** + * 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 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 result has no single JSON body; iterate its frames instead.'); + } + + 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 00000000..af93cef2 --- /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 00000000..8569366e --- /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 00000000..2f9b63a1 --- /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 00000000..a1c4e688 --- /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 00000000..f54a356d --- /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 e00a8492..f6755f4c 100644 --- a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php @@ -54,7 +54,7 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface private readonly array $supported; /** - * @param list|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()}; modern revisions are excluded as their per-request negotiation is not served yet. + * @param list|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()}; an endpoint serving the modern era too passes those revisions as well, so a modern header is not turned away before the era is classified. * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) */ diff --git a/src/Server/Transport/Http/StatelessResponder.php b/src/Server/Transport/Http/StatelessResponder.php new file mode 100644 index 00000000..f752fb87 --- /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 00000000..00f47b93 --- /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 00000000..52815b3e --- /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 00000000..c55caedf --- /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/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index cce2bd58..7faedd22 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -13,11 +13,15 @@ use Http\Discovery\Psr17FactoryDiscovery; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; +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\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -28,12 +32,27 @@ 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,12 +64,16 @@ 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 @@ -71,9 +94,14 @@ public function __construct( $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) { @@ -85,6 +113,12 @@ public function __construct( /** * 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 +126,26 @@ public static function defaultMiddleware(): array 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 +155,7 @@ public function send(string $data, array $context): void public function listen(): ResponseInterface { $handler = new MiddlewareRequestHandler( - $this->middleware, + $this->middleware ??= self::defaultMiddleware(), \Closure::fromCallable([$this, 'handleRequest']), ); @@ -117,15 +167,11 @@ protected function handleOptionsRequest(): ResponseInterface 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) { @@ -293,33 +339,10 @@ protected function createErrorResponse(Error $jsonRpcError, int $statusCode): Re /** * 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); } /** @@ -343,6 +366,48 @@ private static function normalizeMiddleware(iterable $middleware): array 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($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 +423,38 @@ private function handleRequest(ServerRequestInterface $request): ResponseInterfa } 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(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($this->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 00000000..e22fb212 --- /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 00000000..20c0aae6 --- /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 00000000..826284df --- /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 00000000..0e1472d3 --- /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 00000000..d3b21eff --- /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 00000000..e183c4b5 --- /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 6d65fd22..d7eac2b3 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 @@ public function toolWithElicitationEnums(RequestContext $context): string 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 62e2e8bd..1d368f52 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 9159c461..b481038e 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 00000000..8d5cadd5 --- /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 74ac8e90..9832f47d 100644 --- a/tests/Conformance/client.php +++ b/tests/Conformance/client.php @@ -16,6 +16,7 @@ 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 @@ 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 @@ public function handle(Request $request): Response 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 @@ public function handle(Request $request): Response $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-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml new file mode 100644 index 00000000..49f2e6a6 --- /dev/null +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -0,0 +1,47 @@ +# Expected conformance failures for spec revision 2026-07-28. +# +# Separate from conformance-baseline.yml because the two revisions exercise +# different lifecycles, not different servers: both suites run against the one +# fixture at /, which serves whichever era a request claims. The dated baseline +# covers the handshake era, this one the modern (SEP-2575) era. +# +# Entries are per-check (scenario:check-id) rather than whole scenarios where a +# scenario is only partly failing, so the parts that work keep reporting and a +# new regression inside them still fails the run. +# +# Run with: +# make conformance-draft-server +# make conformance-draft-client + +# The server speaks 2026-07-28 in full. +server: [] + +# The client speaks it too, apart from authorization: the SDK ships no OAuth +# client, so every scenario that drives a flow has nothing to exercise. Same +# gap the dated baseline records; 2026-07-28 adds the SEP-2468 `iss` and +# SEP-2207 offline-access scenarios on top. Tracked in spec-report.md §3.2. +client: + - 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/server.php b/tests/Conformance/server.php index 02c78c2a..fadf6bf7 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 @@ $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(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(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling') ->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,25 @@ ->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. In-memory is right here: the conformance server is one + // FrankenPHP-less php-fpm pool, and the scenarios publish within a request. + ->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/HttpMcpAppsTest.php b/tests/Inspector/Http/HttpMcpAppsTest.php new file mode 100644 index 00000000..76866f67 --- /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/HttpMcpAppsTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpMcpAppsTest-prompts_list.json new file mode 100644 index 00000000..7292222c --- /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 00000000..2ff24ba4 --- /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 00000000..84e17515 --- /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 00000000..e867d9d2 --- /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 00000000..8d42069d --- /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 00000000..dcb064ab --- /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/Integration/DualEraElicitationTest.php b/tests/Integration/DualEraElicitationTest.php new file mode 100644 index 00000000..b2a443d5 --- /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 00000000..754d8a77 --- /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 00000000..7609322c --- /dev/null +++ b/tests/Integration/DualEraExampleTestCase.php @@ -0,0 +1,135 @@ + + */ +abstract class DualEraExampleTestCase extends TestCase +{ + /** Answers whatever the server elicits, so a tool that asks can complete. */ + protected const ANSWERS = []; + + private Process $server; + 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 + { + $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/HandshakeTest.php b/tests/Integration/HandshakeTest.php index 5e0455ff..5d213209 100644 --- a/tests/Integration/HandshakeTest.php +++ b/tests/Integration/HandshakeTest.php @@ -59,11 +59,16 @@ public static function provideNegotiations(): iterable 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]; - // Neither side reaches the modern era through `initialize`, so - // configuring it falls back to the handshake set on both ends. - yield 'client configured modern' => [ProtocolVersion::V2026_07_28, null, $latest]; + // 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]; - yield 'both configured modern' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28, $latest]; } #[TestDox('the handshake carries the server identity to the client')] diff --git a/tests/Integration/StatelessClientTest.php b/tests/Integration/StatelessClientTest.php new file mode 100644 index 00000000..fbd3d59e --- /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 00000000..1983d234 --- /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/Discovery/SchemaComplexityGuardTest.php b/tests/Unit/Capability/Discovery/SchemaComplexityGuardTest.php new file mode 100644 index 00000000..f26d0c69 --- /dev/null +++ b/tests/Unit/Capability/Discovery/SchemaComplexityGuardTest.php @@ -0,0 +1,220 @@ +guard = new SchemaComplexityGuard(); + } + + /** + * @return iterable}> + */ + public static function ordinarySchemas(): iterable + { + yield 'empty' => [[]]; + yield 'flat object' => [[ + 'type' => 'object', + 'properties' => ['a' => ['type' => 'string'], 'b' => ['type' => 'integer']], + 'required' => ['a'], + ]]; + yield 'nested objects' => [[ + 'type' => 'object', + 'properties' => ['outer' => ['type' => 'object', 'properties' => ['inner' => ['type' => 'string']]]], + ]]; + yield 'array with items' => [['type' => 'array', 'items' => ['type' => 'string']]]; + yield 'modest composition' => [[ + 'type' => 'object', + 'properties' => ['v' => ['anyOf' => [['type' => 'string'], ['type' => 'integer'], ['type' => 'null']]]], + ]]; + yield 'local $ref through $defs' => [[ + '$defs' => ['name' => ['type' => 'string', 'minLength' => 1]], + 'type' => 'object', + 'properties' => ['first' => ['$ref' => '#/$defs/name'], 'last' => ['$ref' => '#/$defs/name']], + ]]; + yield 'if/then/else' => [[ + 'type' => 'object', + 'if' => ['properties' => ['kind' => ['const' => 'a']]], + 'then' => ['required' => ['x']], + 'else' => ['required' => ['y']], + ]]; + yield 'a property literally named $ref' => [[ + 'type' => 'object', + 'properties' => ['$ref' => ['type' => 'string']], + ]]; + } + + /** + * @param array $schema + */ + #[DataProvider('ordinarySchemas')] + #[TestDox('an ordinary schema passes untouched')] + public function testOrdinarySchemasPass(array $schema): void + { + $this->assertNull($this->guard->check($schema)); + } + + /** + * @return iterable + */ + public static function externalRefs(): iterable + { + yield 'https' => ['https://evil.example/schema.json']; + yield 'http' => ['http://169.254.169.254/latest/meta-data/']; + yield 'file' => ['file:///etc/passwd']; + yield 'relative document' => ['common.json#/$defs/name']; + yield 'protocol-relative' => ['//evil.example/schema.json']; + } + + #[DataProvider('externalRefs')] + #[TestDox('a reference outside the document is refused, and nothing is fetched')] + public function testExternalRefIsRefused(string $ref): void + { + $reason = $this->guard->check([ + 'type' => 'object', + 'properties' => ['a' => ['$ref' => $ref]], + ]); + + $this->assertNotNull($reason); + $this->assertStringContainsString('non-local reference', $reason); + $this->assertStringContainsString($ref, $reason); + } + + #[TestDox('a same-document reference is not mistaken for an external one')] + public function testLocalRefIsAllowed(): void + { + $this->assertNull($this->guard->check([ + '$defs' => ['n' => ['type' => 'integer']], + '$ref' => '#/$defs/n', + ])); + } + + #[TestDox('nesting past the depth ceiling is refused')] + public function testExcessiveDepthIsRefused(): void + { + $schema = ['type' => 'string']; + for ($i = 0; $i < 60; ++$i) { + $schema = ['type' => 'object', 'properties' => ['n' => $schema]]; + } + + $this->assertStringContainsString('nests deeper', (string) $this->guard->check($schema)); + } + + #[TestDox('an expanded composition bomb is refused')] + public function testExpandedCompositionBombIsRefused(): void + { + // Fourteen levels: 2^14 branches, but only 28 levels of nesting, so it + // is the subschema budget and not the depth ceiling that refuses it. + $branch = ['type' => 'string']; + for ($i = 0; $i < 14; ++$i) { + $branch = ['anyOf' => [$branch, $branch]]; + } + + $this->assertStringContainsString('subschemas', (string) $this->guard->check($branch)); + } + + #[TestDox('the same bomb written with $defs — a few hundred bytes — is refused too')] + public function testRefCompressedCompositionBombIsRefused(): void + { + // Each level doubles by referencing the level below twice. Linear on the + // wire, exponential to walk: this is the shape a size cap cannot catch. + $defs = ['a0' => ['type' => 'string']]; + for ($i = 1; $i <= 20; ++$i) { + $defs['a'.$i] = ['anyOf' => [['$ref' => '#/$defs/a'.($i - 1)], ['$ref' => '#/$defs/a'.($i - 1)]]]; + } + + $schema = ['$defs' => $defs, '$ref' => '#/$defs/a20']; + + $this->assertLessThan(2048, \strlen((string) json_encode($schema))); + $this->assertStringContainsString('subschemas', (string) $this->guard->check($schema)); + } + + #[TestDox('a long chain of local references is flat, not deep')] + public function testLongLocalRefChainIsAllowed(): void + { + // Following a reference is not nesting: this is 60 links and costs 60 + // steps, which a depth ceiling applied to resolution would refuse. + $defs = ['a0' => ['type' => 'string']]; + for ($i = 1; $i <= 60; ++$i) { + $defs['a'.$i] = ['$ref' => '#/$defs/a'.($i - 1)]; + } + + $this->assertNull($this->guard->check(['$defs' => $defs, '$ref' => '#/$defs/a60'])); + } + + #[TestDox('a recursive schema is allowed: how far it unrolls is the data\'s doing')] + public function testRecursiveSchemaIsAllowed(): void + { + $this->assertNull($this->guard->check([ + '$defs' => [ + 'node' => [ + 'type' => 'object', + 'properties' => [ + 'value' => ['type' => 'string'], + 'children' => ['type' => 'array', 'items' => ['$ref' => '#/$defs/node']], + ], + ], + ], + '$ref' => '#/$defs/node', + ])); + } + + #[TestDox('an oversized property map is refused')] + public function testOversizedPropertyMapIsRefused(): void + { + $properties = []; + for ($i = 0; $i < 1_500; ++$i) { + $properties['p'.$i] = ['type' => 'string']; + } + + $this->assertStringContainsString('entries under "properties"', (string) $this->guard->check([ + 'type' => 'object', + 'properties' => $properties, + ])); + } + + #[TestDox('an unresolvable local pointer is left for the validator to report')] + public function testUnresolvableLocalPointerPasses(): void + { + $this->assertNull($this->guard->check(['$ref' => '#/$defs/missing'])); + } + + #[TestDox('the bounds are configurable')] + public function testBoundsAreConfigurable(): void + { + $schema = [ + 'type' => 'object', + 'properties' => ['a' => ['type' => 'object', 'properties' => ['b' => ['type' => 'string']]]], + ]; + + $this->assertNull((new SchemaComplexityGuard())->check($schema)); + $this->assertStringContainsString('nests deeper', (string) (new SchemaComplexityGuard(maxDepth: 1))->check($schema)); + $this->assertStringContainsString('subschemas', (string) (new SchemaComplexityGuard(maxSubschemas: 2))->check($schema)); + } + + #[TestDox('an object schema is accepted as well as an array one')] + public function testObjectSchemaIsAccepted(): void + { + $schema = json_decode('{"type":"object","properties":{"a":{"$ref":"https://evil.example/s.json"}}}'); + + $this->assertStringContainsString('non-local reference', (string) $this->guard->check($schema)); + } +} diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 548c5d9b..0775b946 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -130,6 +130,30 @@ public function methodLevelArrayArgument(array $profiles): array // ===== 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. */ @@ -326,6 +350,15 @@ public function unionTypes( // ===== 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 scenarios. * diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index b5dbb09b..67114fa5 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -92,6 +92,50 @@ public function testUsesCompleteSchemaDefinitionFromMethodLevelSchemaAttribute() ], $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 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'); diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php new file mode 100644 index 00000000..77980bda --- /dev/null +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php @@ -0,0 +1,117 @@ +}> + */ + 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']); + $this->assertArrayHasKey('items', $schema['properties']['mixedish']); + + $this->assertSame(['integer', 'string'], $schema['properties']['scalars']['type']); + $this->assertSame('array', $schema['properties']['arrayOnly']['type']); + $this->assertSame(['array', 'null'], $schema['properties']['nullableArray']['type']); + } + + #[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]; + + $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)); + } +} diff --git a/tests/Unit/Capability/Discovery/UnionFixture.php b/tests/Unit/Capability/Discovery/UnionFixture.php new file mode 100644 index 00000000..7eb2913a --- /dev/null +++ b/tests/Unit/Capability/Discovery/UnionFixture.php @@ -0,0 +1,26 @@ +assertSame(ProtocolVersion::V2025_06_18->value, $transport->offeredVersion); } - #[TestDox('never offers a modern version over the initialize handshake, and warns about it')] - public function testDoesNotOfferModernVersionOverHandshake(): void + #[TestDox('never sends "initialize" on a modern revision, which removed it')] + public function testModernRevisionSkipsTheHandshake(): void { - $transport = new RecordingTransport(ProtocolVersion::latestHandshake()->value); - $protocol = new Protocol(logger: $logger = new CollectingLogger()); + $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->assertSame(ProtocolVersion::latestHandshake()->value, $transport->offeredVersion); - $this->assertSame([[ - 'configured' => ProtocolVersion::V2026_07_28->value, - 'offered' => ProtocolVersion::latestHandshake()->value, - ]], $logger->warnings); + $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')] @@ -110,38 +154,88 @@ private function createConfiguration(ProtocolVersion $protocolVersion): Configur } /** - * Transport that answers the `initialize` request inline with a canned - * `protocolVersion`, so the handshake resolves without a Fiber round-trip. + * 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) - { + 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{protocolVersion?: string}} $message */ + /** @var array{id?: int|string, method?: string, params?: array} $message */ $message = json_decode($data, true); + $method = $message['method'] ?? null; - if ('initialize' !== ($message['method'] ?? null)) { + if (!\is_string($method)) { return; } - $this->offeredVersion = $message['params']['protocolVersion'] ?? null; + $this->methods[] = $method; + $this->metas[] = $message['params']['_meta'] ?? []; - $this->state->storeResponse($message['id'], [ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => $message['id'], - 'result' => [ + 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, ]); } @@ -179,24 +273,3 @@ 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 00000000..ea4a3a43 --- /dev/null +++ b/tests/Unit/Client/Stateless/HeaderFactoryTest.php @@ -0,0 +1,124 @@ +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); + } + + /** + * @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 00000000..536aa312 --- /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 00000000..2b878373 --- /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/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index 441a500a..fe2c0d2a 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -278,13 +278,15 @@ public function testNotificationMethodUsedAsRequest(): void 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 @@ -365,12 +367,24 @@ public function testResponseWithInvalidIdType(): void $this->assertStringContainsString('id', $results[0]->getMessage()); } - public function testErrorWithInvalidIdType(): void + 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": {"not": "an id"}, "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()); diff --git a/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php index fefea478..a9e11732 100644 --- a/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php @@ -71,13 +71,23 @@ public function testFromArrayWithAllParams(): void $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 1bd55ccd..ea200a86 100644 --- a/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php @@ -114,13 +114,23 @@ public function testFromArrayWithAllParams(): void $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 167ad08e..208f5d2c 100644 --- a/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php @@ -137,13 +137,26 @@ public function testFromArrayWithAllParams(): void $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 8ebdee74..ea040382 100644 --- a/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php @@ -133,13 +133,23 @@ public function testFromArrayWithAllParams(): void $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 dbb3277d..02998cce 100644 --- a/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php @@ -114,13 +114,23 @@ public function testFromArrayWithAllParams(): void $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 f8c706f0..26502832 100644 --- a/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php @@ -124,13 +124,23 @@ public function testFromArrayWithAllParams(): void $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 6c1b776e..170893fb 100644 --- a/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php @@ -166,13 +166,26 @@ public function testFromArrayWithAllParams(): void $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/Enum/ProtocolVersionTest.php b/tests/Unit/Schema/Enum/ProtocolVersionTest.php index 00c50e93..ca5eb648 100644 --- a/tests/Unit/Schema/Enum/ProtocolVersionTest.php +++ b/tests/Unit/Schema/Enum/ProtocolVersionTest.php @@ -141,4 +141,17 @@ public function testRequiresObjectStructuredContent(): void $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/ExtensionIdentifierTest.php b/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php new file mode 100644 index 00000000..a857ac08 --- /dev/null +++ b/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php @@ -0,0 +1,84 @@ + + */ + 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')] + public function testValidIdentifiers(string $identifier): void + { + $this->assertNull(ExtensionIdentifier::check($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, with the reason')] + public function testInvalidIdentifiers(string $identifier, string $reason): void + { + $this->assertStringContainsString($reason, (string) ExtensionIdentifier::check($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, ExtensionIdentifier::isReserved($identifier)); + } +} diff --git a/tests/Unit/Schema/ToolHeaderAnnotationTest.php b/tests/Unit/Schema/ToolHeaderAnnotationTest.php new file mode 100644 index 00000000..8fab2820 --- /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/Wire/McpHeaderTest.php b/tests/Unit/Schema/Wire/McpHeaderTest.php new file mode 100644 index 00000000..16b55735 --- /dev/null +++ b/tests/Unit/Schema/Wire/McpHeaderTest.php @@ -0,0 +1,92 @@ + + */ + 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?=']; + } + + #[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 a29c9f4e..e283d819 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -17,6 +17,7 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; 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; @@ -27,6 +28,7 @@ use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Handler\Request\InitializeHandler; use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Stateless\StatelessProtocol; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -172,6 +174,58 @@ public function testSetLazyLoadingReturnsSelf(): void $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()); + } + + private static function statelessProtocol(Server $server): ?StatelessProtocol + { + $property = new \ReflectionProperty(Server::class, 'statelessProtocol'); + + return $property->getValue($server); + } + #[TestDox('Lazy loading (default) advertises tools from configured sources without running loaders')] public function testLazyLoadingAdvertisesFromConfiguredSourcesWithoutLoading(): void { diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 87a696be..47351dc0 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -183,7 +183,7 @@ public function testHandleToolNotFoundExceptionReturnsError(): void $this->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 diff --git a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php index 204f9280..501fe1af 100644 --- a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -251,7 +251,7 @@ public function testHandlePromptNotFoundExceptionReturnsError(): void $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/InputRequiredShimTest.php b/tests/Unit/Server/InputRequiredShimTest.php new file mode 100644 index 00000000..137270f5 --- /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/Stateless/InputContextTest.php b/tests/Unit/Server/Stateless/InputContextTest.php new file mode 100644 index 00000000..3425500f --- /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 00000000..6b84b8f9 --- /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 00000000..bf2bdf12 --- /dev/null +++ b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php @@ -0,0 +1,321 @@ +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 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 00000000..26e54b4e --- /dev/null +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -0,0 +1,929 @@ + $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 { + // The pattern this revision replaced: kept as a fixture so + // the refusal has something to refuse. + $context->getClientGateway()->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); + + return 'unreachable'; + }, + name: 'elicits_directly', + description: 'Asks the client directly, which this revision forbids', + ) + ->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): 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 is refused with the pattern that replaced it')] + public function testServerInitiatedRequestIsRefused(): void + { + $result = self::callStreaming(self::protocol(), 'elicits_directly'); + + $body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR); + + $this->assertSame(500, $result->httpStatus); + $this->assertStringContainsString('InputRequiredResult', $body['error']['message']); + } + + #[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('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('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 does not serve says so by name')] + public function testDisabledExtensionMethodNamesItsExtension(): 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']); + // Without the extension enabled 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('an extension with a malformed identifier is refused at build time')] + public function testMalformedExtensionIdentifierIsRefused(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches('/has no prefix/'); + + Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->enableExtension(new ThingExtension('things')); + } + + #[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/Stateless/ThingExtension.php b/tests/Unit/Server/Stateless/ThingExtension.php new file mode 100644 index 00000000..d30679fa --- /dev/null +++ b/tests/Unit/Server/Stateless/ThingExtension.php @@ -0,0 +1,46 @@ +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/Stateless/ThingListHandler.php b/tests/Unit/Server/Stateless/ThingListHandler.php new file mode 100644 index 00000000..bd9bb96f --- /dev/null +++ b/tests/Unit/Server/Stateless/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/Stateless/ThingListRequest.php b/tests/Unit/Server/Stateless/ThingListRequest.php new file mode 100644 index 00000000..1c9990ea --- /dev/null +++ b/tests/Unit/Server/Stateless/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/Subscription/NotificationBusTest.php b/tests/Unit/Server/Subscription/NotificationBusTest.php new file mode 100644 index 00000000..7c16200d --- /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 00000000..d73d94b4 --- /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 00000000..98a7eda6 --- /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/OAuth/ProtectedResourceMetadataTest.php b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php index fbb51a91..5016c73a 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php @@ -83,4 +83,33 @@ public function testEmptyAuthorizationServersThrows(): void 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 00000000..08e96a40 --- /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/Wire/CachePolicyTest.php b/tests/Unit/Server/Wire/CachePolicyTest.php new file mode 100644 index 00000000..4a65aa50 --- /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 00000000..282fa0ab --- /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); + } +}