Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
e4227d135c
commit
1823aae891
4 changed files with 69131 additions and 9001 deletions
26
examples/legacy-routing/README.md
Normal file
26
examples/legacy-routing/README.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# legacy-routing
|
||||
|
||||
`isLegacyRequest` routing: keep an **existing** sessionful 1.x Streamable HTTP deployment serving 2025-era clients, add a strict `createMcpHandler({ legacy: 'reject' })` for 2026-07-28 traffic, on the **same port**. The predicate decides per request which arm handles it.
|
||||
|
||||
`server.ts` also shows the browser-client CORS `exposedHeaders` recipe and explicit `GET` (standalone SSE stream) / `DELETE` (session termination per the MCP spec) routes for the sessionful arm.
|
||||
|
||||
**HTTP-only** by definition; see also `dual-era/` for the simple case where you don't have a sessionful deployment to keep.
|
||||
|
||||
## Direct transport construction (without `createMcpHandler`)
|
||||
|
||||
If you need full control over the per-request transport on a web-standards runtime (Hono, Cloudflare Workers, …) instead of `createMcpHandler`, construct `WebStandardStreamableHTTPServerTransport` directly:
|
||||
|
||||
```ts
|
||||
import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
||||
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID()
|
||||
});
|
||||
const server = new McpServer({ name: 'direct-transport', version: '1.0.0' });
|
||||
await server.connect(transport);
|
||||
|
||||
// Any Request/Response runtime (fetch handler, Hono `c.req.raw`, …):
|
||||
export default { fetch: (request: Request) => transport.handleRequest(request) };
|
||||
```
|
||||
|
||||
`NodeStreamableHTTPServerTransport` (used in this story's legacy arm) is the Node.js `IncomingMessage`/`ServerResponse` equivalent.
|
||||
24
examples/legacy-routing/client.ts
Normal file
24
examples/legacy-routing/client.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Connects to the routing fork as both a plain 2025 client (lands on the
|
||||
* existing sessionful transport, `era=legacy`) and a 2026-capable client
|
||||
* (lands on the strict modern entry, `era=modern`).
|
||||
*/
|
||||
import { check, parseExampleArgs } from '@mcp-examples/shared';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const { url } = parseExampleArgs();
|
||||
|
||||
// 2025 client → routed to the existing sessionful deployment.
|
||||
const legacy = new Client({ name: 'legacy-routing-client', version: '1.0.0' });
|
||||
await legacy.connect(new StreamableHTTPClientTransport(new URL(url)));
|
||||
const lr = await legacy.callTool({ name: 'greet', arguments: { name: 'A' } });
|
||||
check.match(lr.content?.[0]?.type === 'text' ? lr.content[0].text : '', /era=legacy/);
|
||||
await legacy.close();
|
||||
|
||||
// 2026 client → routed to the strict modern entry.
|
||||
const modern = new Client({ name: 'legacy-routing-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await modern.connect(new StreamableHTTPClientTransport(new URL(url)));
|
||||
check.equal(modern.getNegotiatedProtocolVersion(), '2026-07-28');
|
||||
const mr = await modern.callTool({ name: 'greet', arguments: { name: 'B' } });
|
||||
check.match(mr.content?.[0]?.type === 'text' ? mr.content[0].text : '', /era=modern/);
|
||||
await modern.close();
|
||||
30
examples/legacy-routing/package.json
Normal file
30
examples/legacy-routing/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@mcp-examples/legacy-routing",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"server": "tsx server.ts",
|
||||
"client": "tsx client.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mcp-examples/shared": "workspace:*",
|
||||
"@modelcontextprotocol/client": "workspace:*",
|
||||
"@modelcontextprotocol/express": "workspace:*",
|
||||
"@modelcontextprotocol/node": "workspace:*",
|
||||
"@modelcontextprotocol/server": "workspace:*",
|
||||
"cors": "catalog:runtimeServerOnly",
|
||||
"express": "catalog:runtimeServerOnly",
|
||||
"zod": "catalog:runtimeShared"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "catalog:devTools",
|
||||
"tsx": "catalog:devTools"
|
||||
},
|
||||
"example": {
|
||||
"transports": [
|
||||
"http"
|
||||
],
|
||||
"era": "modern",
|
||||
"//": "The story body drives BOTH eras itself (one legacy + one modern client against the same port); pinned so the runner invokes it once."
|
||||
}
|
||||
}
|
||||
93
examples/legacy-routing/server.ts
Normal file
93
examples/legacy-routing/server.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* `isLegacyRequest` routing in front of an existing sessionful 1.x deployment,
|
||||
* with a strict modern entry on the SAME port.
|
||||
*
|
||||
* This is the v2 answer to "I already have a sessionful Streamable HTTP
|
||||
* deployment and want to add 2026-07-28 serving without disturbing it":
|
||||
* route in user land — `await isLegacyRequest(req)` decides per request,
|
||||
* legacy traffic goes to your existing transport, modern traffic to a strict
|
||||
* `createMcpHandler(factory, { legacy: 'reject' })`.
|
||||
*
|
||||
* HTTP-only by definition.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { parseExampleArgs } from '@mcp-examples/shared';
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node';
|
||||
import type { McpRequestContext } from '@modelcontextprotocol/server';
|
||||
import { createMcpHandler, isInitializeRequest, isLegacyRequest, McpServer } from '@modelcontextprotocol/server';
|
||||
import cors from 'cors';
|
||||
import type { Request, Response } from 'express';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// One factory for both legs.
|
||||
const buildServer = (era: 'legacy' | 'modern') => {
|
||||
const server = new McpServer({ name: 'legacy-routing-example', version: '1.0.0' });
|
||||
server.registerTool('greet', { description: 'Greets the caller', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({
|
||||
content: [{ type: 'text', text: `Hello, ${name}! (era=${era})` }]
|
||||
}));
|
||||
return server;
|
||||
};
|
||||
|
||||
// --- the existing sessionful 2025 deployment, unchanged ---
|
||||
const sessions = new Map<string, NodeStreamableHTTPServerTransport>();
|
||||
const handleLegacy = async (req: Request, res: Response) => {
|
||||
const sid = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sid && sessions.has(sid)) {
|
||||
await sessions.get(sid)!.handleRequest(req, res, req.body);
|
||||
} else if (!sid && isInitializeRequest(req.body)) {
|
||||
const transport = new NodeStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: id => {
|
||||
sessions.set(id, transport);
|
||||
}
|
||||
});
|
||||
transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId);
|
||||
await buildServer('legacy').connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} else if (sid) {
|
||||
// Unknown session ID → 404 so the client knows to start a new session.
|
||||
res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null });
|
||||
} else {
|
||||
res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null });
|
||||
}
|
||||
};
|
||||
|
||||
// --- the strict modern entry alongside it ---
|
||||
const modern = createMcpHandler((ctx: McpRequestContext) => buildServer(ctx.era), { legacy: 'reject' });
|
||||
const modernNode = toNodeHandler(modern);
|
||||
|
||||
const app = createMcpExpressApp();
|
||||
// Browser-client CORS recipe: expose the response headers a browser-based MCP
|
||||
// client must be able to read (`Mcp-Session-Id` for session correlation,
|
||||
// `WWW-Authenticate` for the auth challenge, `Last-Event-Id` for resumability,
|
||||
// `Mcp-Protocol-Version` for negotiation). DEMO ONLY — restrict `origin` in
|
||||
// production.
|
||||
app.use(
|
||||
cors({
|
||||
origin: '*',
|
||||
exposedHeaders: ['Mcp-Session-Id', 'WWW-Authenticate', 'Last-Event-Id', 'Mcp-Protocol-Version']
|
||||
})
|
||||
);
|
||||
|
||||
app.post('/mcp', async (req: Request, res: Response) => {
|
||||
// The predicate inspects the same headers + body the entry does. Express
|
||||
// has parsed the JSON body; pass it as `parsedBody` so the predicate need
|
||||
// not re-read the stream.
|
||||
const probe = new globalThis.Request(`http://localhost${req.url}`, {
|
||||
method: req.method,
|
||||
headers: req.headers as Record<string, string>
|
||||
});
|
||||
await ((await isLegacyRequest(probe, req.body)) ? handleLegacy(req, res) : modernNode(req, res, req.body));
|
||||
});
|
||||
// GET (standalone SSE stream / reconnect with Last-Event-ID) and DELETE
|
||||
// (explicit session termination per the MCP spec) are sessionful-2025-only —
|
||||
// route them straight to the legacy arm; the transport handles each verb.
|
||||
app.get('/mcp', (req, res) => void handleLegacy(req, res));
|
||||
app.delete('/mcp', (req, res) => void handleLegacy(req, res));
|
||||
|
||||
const { port } = parseExampleArgs();
|
||||
app.listen(port, () => {
|
||||
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
|
||||
});
|
||||
Loading…
Reference in a new issue