typescript-sdk/examples/standalone-get/server.ts
Felix Weinberger 1823aae891
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>
2026-06-26 14:01:10 +01:00

99 lines
4 KiB
TypeScript

/**
* 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`);
});