92 lines
4.2 KiB
TypeScript
92 lines
4.2 KiB
TypeScript
/**
|
|
* `subscriptions/listen` change notifications (protocol revision 2026-07-28).
|
|
*
|
|
* One factory, either transport — but the publish surface differs by entry:
|
|
*
|
|
* - **HTTP** (`createMcpHandler`): the handler exposes `.notify`
|
|
* ({@link ServerNotifier}) over its cross-request {@link ServerEventBus};
|
|
* `handler.notify.toolsChanged()` reaches every open `subscriptions/listen`
|
|
* stream that opted in to `toolsListChanged`.
|
|
* - **stdio** (`serveStdio`): one `McpServer` instance is pinned for the
|
|
* connection; toggling a `RegisteredTool` (`.enable()/.disable()`) emits the
|
|
* instance's own `notifications/tools/list_changed`, which the stdio entry's
|
|
* listen router fans onto every open subscription.
|
|
*
|
|
* The `flip_tools` tool toggles the `farewell` tool and publishes the change,
|
|
* so the client decides when to mutate (no timer race with the runner). The
|
|
* canonical-shape transport branch below assigns `publish` per entry.
|
|
*/
|
|
import { serve } from '@hono/node-server';
|
|
import { parseExampleArgs } from '@mcp-examples/shared';
|
|
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
|
|
import type { RegisteredTool, ServerEventBus, ServerNotifier } from '@modelcontextprotocol/server';
|
|
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
|
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
import * as z from 'zod/v4';
|
|
|
|
let extraToolEnabled = false;
|
|
/**
|
|
* Publishes `tools/list_changed` to every open subscription. Assigned by the
|
|
* transport branch below: `handler.notify.toolsChanged()` over HTTP; toggling
|
|
* the pinned instance's `RegisteredTool` over stdio.
|
|
*/
|
|
let publish: () => void = () => {};
|
|
|
|
function buildServer(): McpServer {
|
|
const server = new McpServer(
|
|
{ name: 'subscriptions-listen-example', version: '1.0.0' },
|
|
{ capabilities: { tools: { listChanged: true } } }
|
|
);
|
|
|
|
server.registerTool('greet', { description: 'Returns a greeting', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({
|
|
content: [{ type: 'text', text: `hello, ${name}` }]
|
|
}));
|
|
const farewell: RegisteredTool = server.registerTool(
|
|
'farewell',
|
|
{ description: 'Returns a farewell', inputSchema: z.object({ name: z.string() }) },
|
|
async ({ name }) => ({ content: [{ type: 'text', text: `goodbye, ${name}` }] })
|
|
);
|
|
if (!extraToolEnabled) farewell.disable();
|
|
|
|
server.registerTool(
|
|
'flip_tools',
|
|
{ description: 'Toggle the farewell tool and publish tools/list_changed to every open subscription' },
|
|
async () => {
|
|
extraToolEnabled = !extraToolEnabled;
|
|
// Over stdio this `update` IS the publish (the entry's listen
|
|
// router fans the instance's outbound list_changed onto every open
|
|
// subscription); over HTTP it just keeps this per-request instance
|
|
// consistent and `publish()` reaches the cross-request bus.
|
|
farewell.update({ enabled: extraToolEnabled });
|
|
publish();
|
|
return { content: [{ type: 'text', text: `farewell ${extraToolEnabled ? 'enabled' : 'disabled'}` }] };
|
|
}
|
|
);
|
|
|
|
return server;
|
|
}
|
|
|
|
const { transport, port } = parseExampleArgs();
|
|
|
|
if (transport === 'stdio') {
|
|
// Over stdio the per-instance `farewell.update` inside `flip_tools` IS the
|
|
// publish, so `publish` stays a no-op here.
|
|
void serveStdio(buildServer);
|
|
console.error('[server] serving over stdio');
|
|
} else {
|
|
// Host with the per-request HTTP entry on its default posture. The handler
|
|
// creates an in-process bus by default; supply your own `bus` for
|
|
// multi-process deployments.
|
|
const handler = createMcpHandler(buildServer);
|
|
const bus: ServerEventBus = handler.bus;
|
|
const notify: ServerNotifier = handler.notify;
|
|
void bus; // (the typed publish facade `notify` wraps `bus.publish`)
|
|
publish = () => notify.toolsChanged();
|
|
// `createMcpHonoApp()` arms localhost host/origin validation by default;
|
|
// bind loopback explicitly to match.
|
|
const app = createMcpHonoApp();
|
|
app.all('/mcp', c => handler.fetch(c.req.raw));
|
|
serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
|
|
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
|
|
});
|
|
}
|