Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,4 @@ jobs:
cache-dependency-path: pnpm-lock.yaml
- run: pnpm install
- run: pnpm run build:all
- run: pnpm run test:conformance:server
- run: pnpm run test:conformance:server:dual
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
"test:conformance:server": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server",
"test:conformance:server:all": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:all",
"test:conformance:server:run": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:run",
"test:conformance:server:handlehttp": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:handlehttp",
"test:conformance:server:dual": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:dual",
"test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all"
},
"devDependencies": {
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js';
import type {
JSONRPCNotification,
JSONRPCRequest,
MessageExtraInfo,
Notification,
Expand Down Expand Up @@ -179,6 +180,14 @@ export abstract class Protocol<ContextT extends BaseContext> {
return this._dispatcher.dispatch(request, env);
}

/**
* Dispatch one inbound notification to its registered handler. Transport-free
* counterpart to {@linkcode Protocol.dispatch}; consumed by `handleHttp`.
*/
dispatchNotification(notification: JSONRPCNotification): Promise<void> {
return this._dispatcher.dispatchNotification(notification);
}

/**
* Registers a handler to invoke when this protocol object receives a request with the given method.
*
Expand Down
29 changes: 29 additions & 0 deletions packages/middleware/node/src/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,35 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/
*/
export type StreamableHTTPServerTransportOptions = WebStandardStreamableHTTPServerTransportOptions;

/**
* Converts a web-standard `(Request) => Response` handler into a Node.js
* `(IncomingMessage, ServerResponse) => void` handler suitable for express,
* `http.createServer`, etc.
*
* The third parameter (express's `next`) is accepted for middleware compatibility but
* not invoked; errors are written to the response (`@hono/node-server` swallows handler
* rejections internally). Auth info is read from `req.auth`; a pre-parsed body is read from `req.body`
* (e.g. when `express.json()` ran before this handler).
*
* ```ts
* import { handleHttp } from '@modelcontextprotocol/server';
* import { toNodeHttpHandler } from '@modelcontextprotocol/node';
*
* app.all('/mcp', toNodeHttpHandler(handleHttp(mcp, { session })));
* ```
*/
export function toNodeHttpHandler(
handler: (req: Request, extra?: { authInfo?: AuthInfo; parsedBody?: unknown }) => Response | Promise<Response>
): (req: IncomingMessage & { auth?: AuthInfo; body?: unknown }, res: ServerResponse, next?: (err?: unknown) => void) => Promise<void> {
return async (req, res, _next) => {
void _next;
const parsedBody = req.body;
const extra = req.auth !== undefined || parsedBody !== undefined ? { authInfo: req.auth, parsedBody } : undefined;
const listener = getRequestListener(webReq => handler(webReq, extra), { overrideGlobalObjects: false });
await listener(req, res);
};
}

/**
* Server transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification.
* It supports both SSE streaming and direct HTTP responses.
Expand Down
26 changes: 25 additions & 1 deletion packages/middleware/node/test/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,31 @@ import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers';
import * as z from 'zod/v4';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { NodeStreamableHTTPServerTransport } from '../src/streamableHttp.js';
import { NodeStreamableHTTPServerTransport, toNodeHttpHandler } from '../src/streamableHttp.js';

describe('toNodeHttpHandler', () => {
it('does not treat express next() as parsedBody; reads req.body instead', async () => {
let capturedExtra: { parsedBody?: unknown } | undefined;
const handler = toNodeHttpHandler(async (_req, extra) => {
capturedExtra = extra;
return Response.json({ ok: true });
});
const port = await getFreePort();
const server = createServer((req, res) => {
(req as IncomingMessage & { body?: unknown }).body = { jsonrpc: '2.0', method: 'ping', id: 1 };
// Express-style call: third arg is the next() function.
void handler(req as IncomingMessage & { auth?: AuthInfo; body?: unknown }, res, () => {});
});
await new Promise<void>(r => server.listen(port, r));
try {
await fetch(`http://localhost:${port}/`, { method: 'POST' });
expect(capturedExtra?.parsedBody).toEqual({ jsonrpc: '2.0', method: 'ping', id: 1 });
expect(typeof capturedExtra?.parsedBody).not.toBe('function');
} finally {
await new Promise<void>(r => server.close(() => r()));
}
});
});

async function getFreePort() {
return new Promise(res => {
Expand Down
4 changes: 4 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export { Server } from './server/server.js';
// StdioServerTransport is exported from the './stdio' subpath — server stdio has only type-level Node
// imports (erased at compile time), but matching the client's `./stdio` subpath gives consumers a
// consistent shape across packages.
export type { Dispatchable, HandleHttpOptions, HandleHttpRequestExtra } from './server/handleHttp.js';
export { handleHttp } from './server/handleHttp.js';
export type { SessionCompatOptions, SessionValidation } from './server/sessionCompat.js';
export { SessionCompat } from './server/sessionCompat.js';
Comment on lines +34 to +37
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This adds new public API to three published packages (@modelcontextprotocol/server: handleHttp/SessionCompat/Dispatchable/etc; @modelcontextprotocol/node: toNodeHttpHandler; @modelcontextprotocol/core: Protocol.dispatchNotification) but ships no .changeset/*.md. The changeset-bot flagged this conditionally; making the judgment explicit: this is a feature PR and per repo convention should carry a minor changeset for those three packages — unless you're intentionally batching changesets onto the final PR of the R0–R4 stack, in which case worth noting that in the description.

Extended reasoning...

What's missing. The diff exports new public surface from three published packages — packages/server/src/index.ts:34-37 adds handleHttp, SessionCompat, Dispatchable, HandleHttpOptions, HandleHttpRequestExtra, SessionCompatOptions, SessionValidation; packages/middleware/node/src/streamableHttp.ts adds toNodeHttpHandler; packages/core/src/shared/protocol.ts adds the public Protocol.dispatchNotification method (and McpServer.dispatch/dispatchNotification in @modelcontextprotocol/server). The changed-files list contains no .changeset/*.md.

Why it matters. The repo is in changesets pre-release mode (.changeset/pre.json, 2.0.0-alpha), and git log -- .changeset/ shows every recent merged feature/fix/refactor PR (#1855, #1887, #1974, #1901, #1976, #1907, …) shipped a changeset. Without one, merging this PR will not bump @modelcontextprotocol/server, @modelcontextprotocol/node, or @modelcontextprotocol/core on the next changeset version/changeset publish, and the new API won't appear in generated release notes. Consumers tracking the alpha tag could end up on a published version whose changelog doesn't mention handleHttp.

Step-by-step:

  1. PR merges to main with no changeset.
  2. Maintainer runs pnpm changeset version for the next alpha cut.
  3. Changesets sees no entry referencing @modelcontextprotocol/server from this PR → no version bump attributed to this change, no changelog line for handleHttp/SessionCompat/toNodeHttpHandler.
  4. pnpm ci:publish ships the packages; the new exports are present in the tarball but undocumented in CHANGELOG.md. If no other PR happened to bump @modelcontextprotocol/node, that package isn't republished at all and toNodeHttpHandler is unavailable on npm even though @modelcontextprotocol/server's docs reference it.

Why this isn't redundant with the changeset-bot comment. The bot's message is conditional boilerplate ("If these changes should result in a version bump, you need to add a changeset") — it fires identically on a docs-only PR. It does not make the judgment call. This comment does: new public API in three published packages → a changeset is required, and the bot's suggested patch bumps should be minor for an additive feature.

On the stacked-series objection. The PR description says "R3 of decomposition; depends on R0–R2", and it's reasonable to defer changesets to the final PR of a stack. That's why this is filed as a nit/question, not a blocker: either add pnpm changesetminor for @modelcontextprotocol/server, @modelcontextprotocol/node, @modelcontextprotocol/core, or note in the PR description that the changeset lands with R4. Either resolves it.

On the REVIEW.md citation. One verifier noted the original report's pointer to REVIEW.md → "Documentation & Changesets" is slightly off (that section is about validating changeset prose against the implementation, not mandating existence). Acknowledged — the supporting evidence here is the repo's observed convention (.changeset/ history + pre-release mode), not that REVIEW.md line.

Fix. pnpm changeset, select the three packages, choose minor, summary along the lines of "feat(server): add handleHttp() per-request entry, SessionCompat; feat(node): toNodeHttpHandler adapter; feat(core): Protocol.dispatchNotification".

export type {
EventId,
EventStore,
Expand Down
51 changes: 51 additions & 0 deletions packages/server/src/server/handleHttp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { DispatchOutput, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, RequestEnv } from '@modelcontextprotocol/core';

import type { ShttpHandlerOptions, ShttpRequestExtra } from './shttpHandler.js';
import { shttpHandler } from './shttpHandler.js';

async function* unwrap(gen: AsyncIterable<DispatchOutput>): AsyncGenerator<JSONRPCMessage, void, void> {
for await (const out of gen) yield out.message;
}

/**
* Minimal contract {@linkcode handleHttp} requires. Satisfied by `McpServer`,
* `Server`, and any `Protocol` subclass.
*/
export interface Dispatchable {
dispatch(request: JSONRPCRequest, env?: RequestEnv): AsyncIterable<DispatchOutput>;
dispatchNotification(notification: JSONRPCNotification): Promise<void>;
}

/**
* Mounts an `McpServer` (or any `Protocol`) as a web-standard
* `(Request) => Response` handler. Use this to drive a server from an HTTP framework
* without instantiating a transport class:
*
* ```ts
* import { McpServer, handleHttp, SessionCompat } from '@modelcontextprotocol/server';
* import { toNodeHttpHandler } from '@modelcontextprotocol/node';
*
* const mcp = new McpServer({ name: 's', version: '1.0.0' });
* mcp.tool('search', schema, handler);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The JSDoc example calls mcp.tool('search', schema, handler), but McpServer has no .tool() method in v2 — only registerTool(). Since (per the docs comment above) this JSDoc is currently the only user-facing documentation for handleHttp, a user copying it into their editor gets Property 'tool' does not exist on type 'McpServer'. Suggest mcp.registerTool('search', { inputSchema: schema }, handler);, or better, source the example from a type-checked .examples.ts region per repo convention.

Extended reasoning...

What

The @example block in handleHttp's JSDoc (handleHttp.ts:29) reads:

const mcp = new McpServer({ name: 's', version: '1.0.0' });
mcp.tool('search', schema, handler);

McpServer does not have a .tool() method. The v1 variadic shorthand (.tool(), .prompt(), .resource()) was removed in v2 — docs/migration-SKILL.md:214 states: "The variadic .tool(), .prompt(), .resource() methods are removed. Use the register* methods with a config object." A grep of packages/server/src/server/mcp.ts confirms only registerTool() exists; the only occurrence of .tool( anywhere under packages/server/src/ is this JSDoc line itself.

Why it slipped through

Unlike the sibling @example blocks in mcp.ts (e.g. registerTool, registerPrompt), this one is inline — it has no source="./handleHttp.examples.ts#…" annotation, so it bypasses the repo's .examples.ts type-checking convention (CLAUDE.md § JSDoc @example). The example also references undeclared identifiers (schema, handler, app), so it's clearly illustrative pseudocode rather than a sourced snippet — but the method-name error is categorically different: it points users at an API that was removed, not just elided.

Why it matters

handleHttp is the new public entry point this PR introduces, and per the existing comment on line 49 (#3226644453), no prose docs or examples/ entry were added — so this JSDoc is what surfaces in typedoc and IDE hovers as the only usage example. A user following it verbatim hits a compile error on a removed-API method, which is more confusing than a missing example would be (it actively suggests v1 muscle memory still works).

Step-by-step proof

  1. packages/server/src/server/handleHttp.ts:29mcp.tool('search', schema, handler);
  2. grep -n '^\s*tool(' packages/server/src/server/mcp.ts → no matches. McpServer's public methods are connect, dispatch, dispatchNotification, close, registerTool, registerResource, registerPrompt, etc.
  3. docs/migration-SKILL.md documents .tool() as removed; registerTool(name, config, cb) is the v2 replacement.
  4. Therefore new McpServer(...).tool(...) fails typecheck with Property 'tool' does not exist on type 'McpServer'.
  5. The example carries no source= tag, so pnpm docs:check / the .examples.ts companion-file mechanism never compiled it.

Fix

Minimal: change line 29 to

mcp.registerTool('search', { inputSchema: schema }, handler);

Better (matches repo convention): add a packages/server/src/server/handleHttp.examples.ts with a compilable region and reference it via ts source="./handleHttp.examples.ts#handleHttp_basic", the same way mcp.ts does for McpServer_registerTool_basic. That would have caught this at pnpm check:all.

Filed as a nit since it's a doc-comment error, not a runtime defect — but worth fixing before merge given it's the primary discoverability surface for the new API and showcases a removed method.

*
* app.all('/mcp', toNodeHttpHandler(handleHttp(mcp, { session: new SessionCompat() })));
* ```
*
* `mcp.connect(transport)` is not called; each HTTP request flows through
* `mcp.dispatch()` directly. Supply a `SessionCompat` via `options.session`
* to serve clients that send `Mcp-Session-Id` (the pre-2026-06 stateful flow).
*/
export function handleHttp(
mcp: Dispatchable,
options: ShttpHandlerOptions = {}
): (req: Request, extra?: ShttpRequestExtra) => Promise<Response> {
return shttpHandler(
{
onrequest: (req, env?: RequestEnv) => unwrap(mcp.dispatch(req, env)),
onnotification: n => mcp.dispatchNotification(n)
},
options
);
}
Comment on lines +38 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This PR exports a new public API surface (handleHttp, SessionCompat, toNodeHttpHandler, etc.) but doesn't update docs/server.md, docs/server-quickstart.md, or examples/ — only JSDoc is added. If prose docs are intentionally deferred to a later round of the stack, it'd be worth calling that out (and unchecking "documentation as needed"); otherwise users won't discover handleHttp from the published docs. Also note changeset-bot flagged no changeset for these new public exports.

Extended reasoning...

What's missing

This PR adds and exports a significant new public API surface from @modelcontextprotocol/server and @modelcontextprotocol/node:

  • handleHttp — the new web-standard (Request) => Promise<Response> per-request entry point (packages/server/src/index.ts:35)
  • SessionCompat + SessionCompatOptions + SessionValidation (packages/server/src/index.ts:36-37)
  • Dispatchable, HandleHttpOptions, HandleHttpRequestExtra (packages/server/src/index.ts:34)
  • toNodeHttpHandler (packages/middleware/node/src/streamableHttp.ts:41)

A grep for handleHttp|SessionCompat|toNodeHttpHandler across docs/ and examples/ returns no matches. docs/server.md and docs/server-quickstart.md exist but were not updated, and no new example was added under examples/server/.

Why it matters

REVIEW.md's Tests & docs checklist (highest-priority repo instructions) states:

New feature: verify prose documentation is added (not just JSDoc), and assess whether examples/ needs a new or updated example.

handleHttp is positioned as the stateless/serverless SHTTP path (per the PR description and SEP-2260) — a major alternative to mcp.connect(transport). With only JSDoc, users reading the published docs site won't know it exists or when to choose it over NodeStreamableHTTPServerTransport. The PR currently checks "[x] I have added or updated documentation as needed", but the linked gist is a review guide, not user-facing documentation.

Step-by-step

  1. packages/server/src/index.ts lines 34-37 export handleHttp, SessionCompat, Dispatchable, HandleHttpOptions, HandleHttpRequestExtra, SessionCompatOptions, SessionValidation — all new public symbols.
  2. packages/middleware/node/src/streamableHttp.ts exports toNodeHttpHandler — also new and public.
  3. The PR's changed-files list contains no entries under docs/ and no entries under examples/.
  4. grep -r 'handleHttp\|SessionCompat\|toNodeHttpHandler' docs/ examples/ → zero hits.
  5. The PR checklist marks documentation as done, but no prose docs were added.

Caveat / suggested fix

This is R3 of a decomposition stack (per "Additional context"), so docs may be deliberately deferred to a later R. If so, that's reasonable — but it should be stated explicitly (and the "documentation as needed" checkbox unchecked or annotated) so the gap isn't lost when the stack lands. If not deferred, the fix is to add a section to docs/server.md covering handleHttp + SessionCompat (when to use it vs. the transport class, stateless vs. session-compat mode) and ideally an examples/server/ entry mirroring everythingServerHandleHttp.ts.

Relatedly, changeset-bot flagged that no changeset was added — these are new public exports in @modelcontextprotocol/server and @modelcontextprotocol/node, so a changeset is warranted unless that's also deferred to the final R.


export { type ShttpHandlerOptions as HandleHttpOptions, type ShttpRequestExtra as HandleHttpRequestExtra } from './shttpHandler.js';
17 changes: 17 additions & 0 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ import type {
CompleteRequestResourceTemplate,
CompleteResult,
CreateTaskResult,
DispatchOutput,
GetPromptResult,
Implementation,
JSONRPCNotification,
JSONRPCRequest,
ListPromptsResult,
ListResourcesResult,
ListToolsResult,
LoggingMessageNotification,
Prompt,
PromptReference,
ReadResourceResult,
RequestEnv,
Resource,
ResourceTemplateReference,
Result,
Expand Down Expand Up @@ -111,6 +115,19 @@ export class McpServer {
return await this.server.connect(transport);
}

/**
* Transport-free per-request entry; forwards to {@linkcode Server}`.dispatch`.
* Exposed so `handleHttp(mcp, ...)` accepts an {@linkcode McpServer} directly.
*/
dispatch(request: JSONRPCRequest, env?: RequestEnv): AsyncGenerator<DispatchOutput, void, void> {
return this.server.dispatch(request, env);
}

/** Forwards to {@linkcode Server}`.dispatchNotification` for the `handleHttp` path. */
dispatchNotification(notification: JSONRPCNotification): Promise<void> {
return this.server.dispatchNotification(notification);
}

/**
* Closes the connection.
*/
Expand Down
Loading
Loading