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
8
examples/standalone-get/README.md
Normal file
8
examples/standalone-get/README.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# standalone-get
|
||||
|
||||
Server-initiated `notifications/resources/list_changed` over the **standalone GET** SSE stream (sessionful 2025). The `add_resource` tool registers a new resource on the session's instance, which emits the notification over the GET stream the client opened via
|
||||
`ClientOptions.listChanged`; the client calls the tool and asserts the notification arrived.
|
||||
|
||||
The original timer-driven unsolicited push (server emits on its own schedule) was traded for this tool-triggered approach for CI determinism — the `list_changed`-over-standalone-GET behaviour is still demonstrated; "server pushes on its own schedule" is no longer shown.
|
||||
|
||||
**HTTP-only**, sessionful 2025 by definition.
|
||||
40
examples/standalone-get/client.ts
Normal file
40
examples/standalone-get/client.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Connects (2025-era), opens the standalone GET stream by registering a
|
||||
* `listChanged` handler, calls `add_resource` to trigger a
|
||||
* `notifications/resources/list_changed` over that stream, and asserts it
|
||||
* arrived.
|
||||
*/
|
||||
import { check, parseExampleArgs } from '@mcp-examples/shared';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const { url } = parseExampleArgs();
|
||||
|
||||
let received = 0;
|
||||
const client = new Client(
|
||||
{ name: 'standalone-get-client', version: '1.0.0' },
|
||||
{
|
||||
// Explicitly the 2025 `initialize` handshake — the standalone GET
|
||||
// stream is a sessionful-2025 transport feature, so this story is
|
||||
// legacy-only by design (was reaching 2025 by fallback; pin it).
|
||||
versionNegotiation: { mode: 'legacy' },
|
||||
listChanged: { resources: { autoRefresh: false, debounceMs: 0, onChanged: () => void received++ } }
|
||||
}
|
||||
);
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(url)));
|
||||
|
||||
const before = await client.listResources();
|
||||
check.ok(before.resources.length > 0);
|
||||
|
||||
// Mutate on demand → server emits list_changed over the standalone GET stream.
|
||||
await client.callTool({ name: 'add_resource', arguments: { content: 'hello' } });
|
||||
const deadline = Date.now() + 5000;
|
||||
while (received < 1) {
|
||||
if (Date.now() > deadline) throw new Error('no listChanged within 5s');
|
||||
await new Promise(r => setTimeout(r, 25));
|
||||
}
|
||||
check.ok(received >= 1);
|
||||
|
||||
const after = await client.listResources();
|
||||
check.ok(after.resources.length > before.resources.length);
|
||||
|
||||
await client.close();
|
||||
29
examples/standalone-get/package.json
Normal file
29
examples/standalone-get/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "@mcp-examples/standalone-get",
|
||||
"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:*",
|
||||
"express": "catalog:runtimeServerOnly",
|
||||
"zod": "catalog:runtimeShared"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:devTools"
|
||||
},
|
||||
"example": {
|
||||
"transports": [
|
||||
"http"
|
||||
],
|
||||
"era": "legacy",
|
||||
"path": "/mcp",
|
||||
"//": "The standalone GET stream is a sessionful-2025 transport feature; the client is era-blind so dual would duplicate."
|
||||
}
|
||||
}
|
||||
99
examples/standalone-get/server.ts
Normal file
99
examples/standalone-get/server.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Standalone GET stream + `notifications/resources/list_changed` (sessionful
|
||||
* 2025).
|
||||
*
|
||||
* One `NodeStreamableHTTPServerTransport` + `McpServer` per session, the way
|
||||
* you would deploy a sessionful 2025 server. The `add_resource` tool registers
|
||||
* a new resource on the session's instance — `McpServer.registerResource` emits
|
||||
* `notifications/resources/list_changed`, which on a sessionful transport
|
||||
* travels over the **standalone GET** SSE stream the client opened. The client
|
||||
* decides when to mutate (no timer race with the runner).
|
||||
*
|
||||
* **HTTP-only**, sessionful 2025 by definition — so the canonical
|
||||
* `serveStdio` / `createMcpHandler` shape does not apply (per-request stateless
|
||||
* has no GET stream).
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { parseExampleArgs } from '@mcp-examples/shared';
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server';
|
||||
import type { Request, Response } from 'express';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
function buildServer(): McpServer {
|
||||
const server = new McpServer(
|
||||
{ name: 'standalone-get-example', version: '1.0.0' },
|
||||
{ capabilities: { resources: { listChanged: true } } }
|
||||
);
|
||||
let nextId = 1;
|
||||
const register = (name: string, content: string) =>
|
||||
server.registerResource(
|
||||
name,
|
||||
`https://mcp-example.com/dynamic/${encodeURIComponent(name)}`,
|
||||
{ mimeType: 'text/plain' },
|
||||
async uri => ({
|
||||
contents: [{ uri: uri.href, mimeType: 'text/plain', text: content }]
|
||||
})
|
||||
);
|
||||
register('initial', 'Initial content');
|
||||
|
||||
server.registerTool(
|
||||
'add_resource',
|
||||
{
|
||||
description:
|
||||
'Register a new resource on this session — emits notifications/resources/list_changed over the standalone GET stream.',
|
||||
inputSchema: z.object({ content: z.string() })
|
||||
},
|
||||
async ({ content }) => {
|
||||
const name = `note-${nextId++}`;
|
||||
register(name, content);
|
||||
return { content: [{ type: 'text', text: `registered ${name}` }] };
|
||||
}
|
||||
);
|
||||
return server;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, NodeStreamableHTTPServerTransport>();
|
||||
const app = createMcpExpressApp();
|
||||
|
||||
app.post('/mcp', 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().connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} else if (sid) {
|
||||
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 standalone GET stream (the point of this story) and DELETE (explicit
|
||||
// session termination per the MCP spec) route to the session's transport.
|
||||
const sessionVerb = async (req: Request, res: Response) => {
|
||||
const sid = req.headers['mcp-session-id'] as string | undefined;
|
||||
const t = sid ? sessions.get(sid) : undefined;
|
||||
if (!t) {
|
||||
res.status(sid ? 404 : 400).send(sid ? 'Session not found' : 'Missing session ID');
|
||||
return;
|
||||
}
|
||||
await t.handleRequest(req, res);
|
||||
};
|
||||
app.get('/mcp', sessionVerb);
|
||||
app.delete('/mcp', sessionVerb);
|
||||
|
||||
const { port } = parseExampleArgs();
|
||||
app.listen(port, () => {
|
||||
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
|
||||
});
|
||||
Loading…
Reference in a new issue