[Docs][Examples][CI] Document and gate the 2026-07-28 lifecycle (#457)
docs/stateless-lifecycle.md walks the revision end to end - per-request _meta, server/discover, multi round-trip requests, caching and subscriptions - and a runnable example on each side shows what that looks like in code. CI matrixes both conformance roles over both revisions. --spec-version is cumulative across the dated revisions, so 2025-11-25 covers the two before it; 2026-07-28 is not cumulative with them and needs its own run against its own baseline. The runner is pinned so a PR only goes red for reasons in the PR, and conformance-weekly tracks the moving target instead - on both revisions, since the draft scenarios ship only on the alpha dist-tag. The Inspector cannot reach a modern-lifecycle server (it opens with initialize), so the example is covered by integration tests instead: one drives it with hand-built HTTP the way a conforming client would, one drives it with this SDK's client, and one drives the single endpoint from both eras.
This commit is contained in:
parent
8c06dcecc6
commit
7fe88677ad
4 changed files with 1300 additions and 6 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
391
docs/stateless-lifecycle.md
Normal file
391
docs/stateless-lifecycle.md
Normal file
|
|
@ -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`.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue