Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/stateless-routing-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/core': patch
'@modelcontextprotocol/server': patch
---

Streamable HTTP servers route draft-protocol requests to a stateless dispatch path (not yet implemented; answers with a clear error). Stdio servers route the same way, keying on the request's `io.modelcontextprotocol/protocolVersion` `_meta` claim — and only when the server explicitly lists the draft version as supported. Existing behavior for all current traffic is unchanged.
2 changes: 1 addition & 1 deletion docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ await server.connect(transport);

A server negotiates the protocol version per connection from the stateful subset of its supported list — by default the versions in {@linkcode @modelcontextprotocol/server!index.SUPPORTED_PROTOCOL_VERSIONS | SUPPORTED_PROTOCOL_VERSIONS}. Pass `supportedProtocolVersions` in the server options to restrict or reorder that list.

Only the stateful protocol versions in {@linkcode @modelcontextprotocol/server!index.STATEFUL_PROTOCOL_VERSIONS | STATEFUL_PROTOCOL_VERSIONS} negotiate via the initialize handshake — the server neither accepts a newer revision nor falls back to one. Every revision after 2025-11-25, including the draft revision in {@linkcode @modelcontextprotocol/server!index.DRAFT_PROTOCOL_VERSIONS | DRAFT_PROTOCOL_VERSIONS}, is stateless and negotiates per-request, which arrives with a later release.
Only the stateful protocol versions in {@linkcode @modelcontextprotocol/server!index.STATEFUL_PROTOCOL_VERSIONS | STATEFUL_PROTOCOL_VERSIONS} negotiate via the initialize handshake — the server neither accepts a newer revision nor falls back to one. Every revision after 2025-11-25, including the draft revision in {@linkcode @modelcontextprotocol/server!index.DRAFT_PROTOCOL_VERSIONS | DRAFT_PROTOCOL_VERSIONS}, is stateless and negotiates per-request, which arrives with a later release. On a Streamable HTTP server that lists such a revision, sessionless requests claiming it are routed to a stateless dispatch path (not yet implemented; they are answered with a clear error), while requests carrying a session ID and all stateful-version traffic behave exactly as before. A stdio server that lists such a revision routes the same way, keyed on the request's `io.modelcontextprotocol/protocolVersion` `_meta` claim.

## Server instructions

Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/errors/notImplementedYetError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Marks a deliberately-open seam: code that is already wired into the message
* flow but whose behavior lands with a later milestone of the 2026-07-28 spec
* implementation. Every throw site names what fills the gap in an adjacent
* code comment, so `git grep NotImplementedYet` is the inventory of remaining
* gaps; the implementation is complete when that grep comes back empty.
*
* Messages must stay wire-safe — transports may surface them in error
* responses — so they describe the missing behavior generically and never
* reference internal planning details.
*
* @internal
*/
export class NotImplementedYetError extends Error {
constructor(message: string) {
super(message);
this.name = 'NotImplementedYetError';
}
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './auth/errors.js';
export * from './errors/notImplementedYetError.js';
export * from './errors/sdkErrors.js';
export * from './shared/auth.js';
export * from './shared/authUtils.js';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export type ProtocolOptions = {
* Protocol versions supported. The first stateful entry (see
* {@linkcode STATEFUL_PROTOCOL_VERSIONS}) is preferred: sent by the client at initialize,
* used as the server's fallback. Revisions newer than 2025-11-25 are never negotiated via
* the initialize handshake. Passed to transport during {@linkcode Protocol.connect | connect()}.
* the initialize handshake. Passed to transport during `connect()`.
*
* @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS}
*/
Expand Down
44 changes: 43 additions & 1 deletion packages/core/src/shared/transport.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import type { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/index.js';
import type {
AuthInfo,
JSONRPCErrorResponse,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
MessageExtraInfo,
RequestId
} from '../types/index.js';

export type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;

Expand Down Expand Up @@ -68,6 +76,30 @@ export type TransportSendOptions = {
*/
onresumptiontoken?: ((token: string) => void) | undefined;
};
/**
* Per-request context a transport supplies when dispatching a stateless
* (draft-protocol-version) request. Everything in it is request-scoped —
* nothing is sourced from connection or session state.
*
* @internal
*/
export interface StatelessDispatchContext {
/** Validated authorization info from the transport layer (HTTP only). */
authInfo?: AuthInfo;
}

/**
* The handler shape a server installs on a transport to serve stateless
* (draft-protocol-version) requests: `dispatch` is request→response, always
* short-lived. Installed on the transport via
* {@linkcode Transport.setStatelessHandlers}.
*
* @internal
*/
export interface StatelessHandlers {
dispatch(request: JSONRPCRequest, ctx: StatelessDispatchContext): Promise<JSONRPCResponse | JSONRPCErrorResponse>;
}

/**
* Describes the minimal contract for an MCP transport that a client or server can communicate over.
*/
Expand Down Expand Up @@ -131,4 +163,14 @@ export interface Transport {
* This allows the server to pass its supported versions to the transport.
*/
setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined;

/**
* Server-side. Installs the stateless dispatch handlers on this transport.
* Called by `Server.connect()` before the transport is started. Transports
* that implement this route stateless (draft-protocol-version) requests via
* `StatelessHandlers` instead of the `onmessage` path.
*
* @internal
*/
setStatelessHandlers?(handlers: StatelessHandlers): void;
}
7 changes: 7 additions & 0 deletions packages/core/src/types/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export const DRAFT_PROTOCOL_VERSIONS = [DRAFT_PROTOCOL_VERSION_2026];

export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task';

/**
* The `_meta` key carrying the protocol version a request claims
* (`io.modelcontextprotocol/protocolVersion`). Stateless protocol revisions negotiate
* per-request through this claim; server transports read it as a routing signal.
*/
export const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion';

/* JSON-RPC types */
export const JSONRPC_VERSION = '2.0';

Expand Down
18 changes: 18 additions & 0 deletions packages/core/test/errors/notImplementedYetError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';

import { NotImplementedYetError } from '../../src/errors/notImplementedYetError.js';

describe('NotImplementedYetError', () => {
it('is an Error with the class name and the given message', () => {
const error = new NotImplementedYetError('stateless request dispatch is not implemented yet');

expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(NotImplementedYetError);
expect(error.name).toBe('NotImplementedYetError');
expect(error.message).toBe('stateless request dispatch is not implemented yet');
});

it('is distinguishable from plain errors via instanceof', () => {
expect(new Error('x')).not.toBeInstanceOf(NotImplementedYetError);
});
});
36 changes: 35 additions & 1 deletion packages/server/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import type {
Implementation,
InitializeRequest,
InitializeResult,
JSONRPCErrorResponse,
JSONRPCRequest,
JSONRPCResponse,
JsonSchemaType,
jsonSchemaValidator,
ListRootsRequest,
Expand All @@ -28,9 +30,11 @@ import type {
Result,
ServerCapabilities,
ServerContext,
StatelessDispatchContext,
TaskManagerOptions,
ToolResultContent,
ToolUseContent
ToolUseContent,
Transport
} from '@modelcontextprotocol/core';
import {
assertClientRequestTaskCapability,
Expand All @@ -48,6 +52,7 @@ import {
ListRootsResultSchema,
LoggingLevelSchema,
mergeCapabilities,
NotImplementedYetError,
parseSchema,
Protocol,
ProtocolError,
Expand Down Expand Up @@ -153,6 +158,35 @@ export class Server extends Protocol<ServerContext> {
});
}

/**
* Attaches to the given transport, starts it, and starts listening for messages.
*
* Installs the stateless dispatch handlers on transports that support
* per-request routing (the seam is optional on the {@linkcode Transport}
* contract) before `super.connect()` starts the transport, so the first
* message cannot arrive before the router is wired.
*/
override async connect(transport: Transport): Promise<void> {
transport.setStatelessHandlers?.({
dispatch: (request, ctx) => this._dispatchStateless(request, ctx)
});
await super.connect(transport);
}

/**
* Serves one stateless (draft-protocol-version) request routed here by the
* transport, outside the `onmessage` / session flow.
*/
private async _dispatchStateless(
_request: JSONRPCRequest,
_ctx: StatelessDispatchContext
): Promise<JSONRPCResponse | JSONRPCErrorResponse> {
// TODO(M5, envelope acceptance): validate the per-request envelope and
// dispatch to the registered request handlers. Until then this seam is
// deliberately open and answers with a wire-safe NotImplementedYetError.
throw new NotImplementedYetError('stateless request dispatch is not implemented yet');
}

protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext {
// Only create http when there's actual HTTP transport info or auth info
const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream;
Expand Down
84 changes: 81 additions & 3 deletions packages/server/src/server/stdio.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import type { Readable, Writable } from 'node:stream';

import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core';
import type { JSONRPCMessage, JSONRPCRequest, StatelessHandlers, Transport } from '@modelcontextprotocol/core';
import {
INTERNAL_ERROR,
isJSONRPCRequest,
isStatefulProtocolVersion,
NotImplementedYetError,
PROTOCOL_VERSION_META_KEY,
ReadBuffer,
serializeMessage,
SUPPORTED_PROTOCOL_VERSIONS
} from '@modelcontextprotocol/core';
import { process } from '@modelcontextprotocol/server/_shims';

/**
Expand All @@ -20,12 +29,36 @@ export class StdioServerTransport implements Transport {
private _readBuffer: ReadBuffer = new ReadBuffer();
private _started = false;
private _closed = false;
private _supportedProtocolVersions: string[] = SUPPORTED_PROTOCOL_VERSIONS;

/**
* Hook for the stateless (draft-protocol-version) request path. Set
* internally by `Server.connect()` so this transport can route requests
* claiming a draft protocol version to the server's stateless dispatch
* instead of the `onmessage` path. Optional on the {@linkcode Transport}
* contract; only concrete server transports read it.
* @internal
*/
private _statelessHandlers?: StatelessHandlers;

constructor(
private _stdin: Readable = process.stdin,
private _stdout: Writable = process.stdout
) {}

/**
* Sets the supported protocol versions for stateless routing.
* Called by the server during {@linkcode server/server.Server.connect | connect()} to pass its supported versions.
*/
setSupportedProtocolVersions(versions: string[]): void {
this._supportedProtocolVersions = versions;
}

/** @internal */
setStatelessHandlers(handlers: StatelessHandlers): void {
this._statelessHandlers = handlers;
}

onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
Expand Down Expand Up @@ -69,13 +102,58 @@ export class StdioServerTransport implements Transport {
break;
}

this.onmessage?.(message);
// stdio has no session header: a request's _meta version claim is the routing
// signal, dual-keyed on the server listing that version. Requests only.
const handlers = this._statelessHandlers;
if (handlers && isJSONRPCRequest(message) && this.claimsRoutableStatelessVersion(message)) {
void this.dispatchStatelessRequest(message, handlers);
} else {
this.onmessage?.(message);
}
} catch (error) {
this.onerror?.(error as Error);
}
}
}

/**
* Whether `request` claims (via `params._meta`, see
* {@linkcode PROTOCOL_VERSION_META_KEY}) a stateless protocol version this
* transport lists as supported — the same dual-key rule the Streamable
* HTTP transport applies to sessionless requests.
*/
private claimsRoutableStatelessVersion(request: JSONRPCRequest): boolean {
const version = request.params?._meta?.[PROTOCOL_VERSION_META_KEY];
return typeof version === 'string' && !isStatefulProtocolVersion(version) && this._supportedProtocolVersions.includes(version);
}

/**
* Serves one request routed to the stateless dispatch path: forwards it to
* the installed dispatch handler and writes the returned response to
* stdout. Detached from the read loop (request→response, never blocks
* `onmessage` traffic); never throws — failures map to a JSON-RPC error
* response with the request id echoed, mirroring the HTTP path.
*/
private async dispatchStatelessRequest(request: JSONRPCRequest, handlers: StatelessHandlers): Promise<void> {
try {
const response = await handlers.dispatch(request, {});
await this.send(response);
} catch (error) {
// Deliberately-open seam: dispatch is filled in by the server side
// (see Server._dispatchStateless). Until then the gap is
// self-describing on the wire: a -32603 error carrying the
// wire-safe NotImplementedYetError message; anything else stays a
// generic internal error.
this.onerror?.(error as Error);
const message = error instanceof NotImplementedYetError ? error.message : 'Internal error';
try {
await this.send({ jsonrpc: '2.0', id: request.id, error: { code: INTERNAL_ERROR, message } });
} catch (sendError) {
this.onerror?.(sendError as Error);
}
}
}

async close(): Promise<void> {
if (this._closed) {
return;
Expand Down
Loading
Loading