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:
Felix Weinberger 2026-06-26 14:01:10 +01:00 • committed by GitHub
commit 1823aae891
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 69131 additions and 9001 deletions

View 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.

View 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();

View 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."
}
}

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