Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/forwarded-notification-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': minor
---

`ServerEvent` takes optional `params`, which the `subscriptions/listen` router delivers with its own subscription ID, so a gateway can forward change notifications with their `_meta` and extension params.
5 changes: 5 additions & 0 deletions .changeset/per-request-transport-revision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': patch
---

Connecting a `Server` or `McpServer` to a `PerRequestHTTPServerTransport` classified at a modern revision that `supportedProtocolVersions` lists and the SDK implements now binds that revision, instead of answering `-32022`.
2 changes: 2 additions & 0 deletions docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ createMcpHandler(buildServer);

Every serving recipe — [stdio](../serving/stdio.md), [HTTP](../serving/http.md) — applies to this server unchanged.

For a custom HTTP entry built from `classifyInboundRequest` and `PerRequestHTTPServerTransport`, list the modern revision in the server's `supportedProtocolVersions`: `server.connect(transport)` then binds the instance to the revision the transport was classified at, and a modern revision the server does not list, or the SDK does not implement, is answered with `-32022`. Read the client's identity from `ctx.mcpReq.envelope`; the deprecated `getClientVersion()` and `getClientCapabilities()` stay empty on such an instance. Use a fresh instance for each per-request exchange, as the built-in HTTP entry does.

## Reach the low level from `McpServer`

Every `McpServer` owns its `Server` as `mcp.server`, so drop down per method, never per program. Declare the extra capability in the constructor, keep `registerTool` for the tools, and hand-register the one method `McpServer` has no API for.
Expand Down
3 changes: 2 additions & 1 deletion docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ wire-only members directly. If you are on `@modelcontextprotocol/sdk` (v1.x), st
Nothing in v2 puts a 2026-07-28 byte on the wire by default: a hand-constructed
`Client` / `Server` / `McpServer` keeps speaking the 2025-era protocol it was written
for. Serving or speaking 2026-07-28 is always an explicit opt-in via one of the entries
below.
below, or by listing the revision on a hand-built server connected to a
`PerRequestHTTPServerTransport` (see [the low-level server guide](../advanced/low-level-server.md)).

## Contents

Expand Down
2 changes: 2 additions & 0 deletions docs/servers/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ const shared = createMcpHandler(() => buildJobsServer(), { bus });

The in-memory bus never leaves the process, so one process needs nothing more. Run more than one and you implement the two-method `ServerEventBus` interface — `publish` and `subscribe` — over your own pub/sub backend, then pass the same instance to every handler; see [Sessions, state, and scaling](../serving/sessions-state-scaling.md).

When forwarding another server's notifications, include their params on the event: `bus.publish({ kind: 'tools_list_changed', params: notification.params })`. The router preserves custom `_meta` and extension params while replacing the subscription ID with the receiving stream's ID. For `resource_updated`, the event's `uri` controls both filtering and the delivered URI.

## Recap

- `sendToolListChanged()`, `sendPromptListChanged()`, and `sendResourceListChanged()` push a list-changed notification to connected clients.
Expand Down
14 changes: 14 additions & 0 deletions packages/server/src/server/perRequestTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,21 @@ interface SseSink {
keepAliveTimer?: ReturnType<typeof setInterval>;
}

let readClassification: (transport: PerRequestHTTPServerTransport) => MessageClassification;

/**
* The per-request micro-transport: a real, connected `Transport` whose whole
* lifetime is one HTTP exchange. See the module documentation for the
* response shapes it produces.
*
Comment thread
claude[bot] marked this conversation as resolved.
* Connecting a `Server` binds the instance to the classified modern revision
* when its `supportedProtocolVersions` lists it and the SDK implements it.
*/
export class PerRequestHTTPServerTransport implements Transport {
static {
readClassification = transport => transport._classification;
}

onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: <T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void;
Expand Down Expand Up @@ -439,3 +448,8 @@ export class PerRequestHTTPServerTransport implements Transport {
}
}
}

/** Package-internal: the classification a per-request transport serves; `undefined` for any other transport. Not public API. */
Comment thread
KKonstantinov marked this conversation as resolved.
export function perRequestClassificationOf(transport: Transport): MessageClassification | undefined {
return transport instanceof PerRequestHTTPServerTransport ? readClassification(transport) : undefined;
}
31 changes: 24 additions & 7 deletions packages/server/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,13 @@ import {
ProtocolErrorCode,
SdkError,
SdkErrorCode,
SUPPORTED_MODERN_PROTOCOL_VERSIONS,
withRequestStateValue
} from '@modelcontextprotocol/core-internal';
import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims';

import { coerceEmbeddedInputRequest, LegacyInputRequiredShim, resolveLegacyShimOptions } from './legacyInputRequiredShim';
import { perRequestClassificationOf } from './perRequestTransport';

/**
* The request methods whose 2026-07-28 result vocabulary includes
Expand Down Expand Up @@ -290,6 +292,7 @@ export class Server extends Protocol<ServerContext> {
private _requestStateVerify?: (state: string, ctx: ServerContext) => unknown | Promise<unknown>;
private _inputRequiredServing: { maxRounds: number; roundTimeoutMs: number; legacyShim: boolean };
private _legacyShim?: LegacyInputRequiredShim;
private _boundFromTransport = false;

/** Lazily-built legacy shim; the loop lives in legacyInputRequiredShim.ts behind a narrow host contract. */
private _legacyInputRequiredShim(): LegacyInputRequiredShim {
Expand Down Expand Up @@ -340,9 +343,7 @@ export class Server extends Protocol<ServerContext> {

// server/discover is installed only when the supported-versions list
// carries a modern revision: a legacy-only server keeps answering -32601.
// A hand-constructed instance is never era-bound, so the handler stays
// unreachable behind the era gate until a serving entry (createMcpHandler,
// serveStdio) marks the instance as serving the 2026-07-28 era.
// The handler becomes reachable when a serving entry or connect() binds a modern revision.
if (modernProtocolVersions(this._supportedProtocolVersions).length > 0) {
this.setRequestHandler('server/discover', () => this._ondiscover());
}
Expand All @@ -360,6 +361,22 @@ export class Server extends Protocol<ServerContext> {
'Use a separate Server instance per connection: this instance is already connected to a transport. To connect it to a new transport, call close() first.'
);
}
// A new connection never inherits a binding taken from an earlier per-request transport.
if (this._boundFromTransport) {
this._boundFromTransport = false;
this._negotiatedProtocolVersion = undefined;
}
// A per-request transport classified at a modern revision this instance lists and the SDK implements binds that revision.
const revision = perRequestClassificationOf(transport)?.revision;
if (
revision !== undefined &&
!this._servedModernEra() &&
SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(revision) &&
modernProtocolVersions(this._supportedProtocolVersions).includes(revision)
Comment thread
claude[bot] marked this conversation as resolved.
) {
this._negotiatedProtocolVersion = revision;
this._boundFromTransport = true;
}
await super.connect(transport);
}

Expand Down Expand Up @@ -966,8 +983,8 @@ export class Server extends Protocol<ServerContext> {
* @deprecated Read client identity from the per-request handler context instead: on
* 2026-07-28 (per-request envelope) requests `ctx.mcpReq.envelope` carries the client's
* declared capabilities, while on 2025-era connections this accessor keeps returning the
* `initialize`-scoped value. The accessor remains functional — instances serving the
* 2026-07-28 era are backfilled per request from the validated envelope.
* `initialize`-scoped value. The accessor remains functional where `createMcpHandler`
* serves the 2026-07-28 era: it backfills it per request from the validated envelope.
*/
getClientCapabilities(): ClientCapabilities | undefined {
return this._clientCapabilities;
Expand All @@ -979,8 +996,8 @@ export class Server extends Protocol<ServerContext> {
* @deprecated Read client identity from the per-request handler context instead: on
* 2026-07-28 (per-request envelope) requests `ctx.mcpReq.envelope` carries the client's
* name and version, while on 2025-era connections this accessor keeps returning the
* `initialize`-scoped value. The accessor remains functional — instances serving the
* 2026-07-28 era are backfilled per request from the validated envelope.
* `initialize`-scoped value. The accessor remains functional where `createMcpHandler`
* serves the 2026-07-28 era: it backfills it per request from the validated envelope.
*/
getClientVersion(): Implementation | undefined {
return this._clientVersion;
Expand Down
24 changes: 14 additions & 10 deletions packages/server/src/server/serverEventBus.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ServerCapabilities, SubscriptionFilter } from '@modelcontextprotocol/core-internal';
import type { JSONRPCNotification, ServerCapabilities, SubscriptionFilter } from '@modelcontextprotocol/core-internal';

/**
* A change event a server publishes for delivery on open `subscriptions/listen`
Expand All @@ -9,14 +9,18 @@ import type { ServerCapabilities, SubscriptionFilter } from '@modelcontextprotoc
* - `resources_list_changed` → `notifications/resources/list_changed`
* - `resource_updated` → `notifications/resources/updated` (carries the URI)
*
* The bus carries the EVENT, not the wire shape — the entry's listen router
* owns subscription-id stamping and per-stream filtering.
* The bus carries the event and any forwarded notification params — the
* entry's listen router owns subscription-id stamping and per-stream filtering.
*/
export type ServerEvent =
export type ServerEvent = (
| { kind: 'tools_list_changed' }
| { kind: 'prompts_list_changed' }
| { kind: 'resources_list_changed' }
| { kind: 'resource_updated'; uri: string };
| { kind: 'resource_updated'; uri: string }
) & {
/** Forwarded notification params; the router stamps its own subscription ID and the event's resource URI. */
params?: JSONRPCNotification['params'];
};

/**
* The server-side change-event seam for `subscriptions/listen`.
Expand Down Expand Up @@ -176,19 +180,19 @@ export function honoredSubset(requested: SubscriptionFilter, capabilities?: Serv
}

/** Map a {@linkcode ServerEvent} onto its wire notification `{method, params}`. */
export function serverEventToNotification(event: ServerEvent): { method: string; params?: { uri: string } } {
export function serverEventToNotification(event: ServerEvent): { method: string; params?: JSONRPCNotification['params'] } {
switch (event.kind) {
case 'tools_list_changed': {
return { method: 'notifications/tools/list_changed' };
return { method: 'notifications/tools/list_changed', ...(event.params !== undefined && { params: event.params }) };
}
case 'prompts_list_changed': {
return { method: 'notifications/prompts/list_changed' };
return { method: 'notifications/prompts/list_changed', ...(event.params !== undefined && { params: event.params }) };
}
case 'resources_list_changed': {
return { method: 'notifications/resources/list_changed' };
return { method: 'notifications/resources/list_changed', ...(event.params !== undefined && { params: event.params }) };
}
case 'resource_updated': {
return { method: 'notifications/resources/updated', params: { uri: event.uri } };
return { method: 'notifications/resources/updated', params: { ...event.params, uri: event.uri } };
}
}
}
Loading
Loading