typescript-sdk/examples/repl/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

289 lines
13 KiB
TypeScript

/**
* Fully-featured **sessionful** HTTP playground server for the interactive
* REPL client.
*
* Exposes every primitive the REPL client (`./client.ts`) can drive: tools
* (typed input/output schemas + annotations + form elicitation +
* `ResourceLink`s), prompts (with `completable()` argument completion),
* resources (direct + `ResourceTemplate`), `notifications/message` logging,
* and `notifications/resources/list_changed`.
*
* HTTP-only and sessionful by design: hosted on
* `NodeStreamableHTTPServerTransport` with an in-memory `eventStore` so the
* REPL client's `reconnect`, `terminate-session`, and
* `run-notifications-tool-with-resumability` commands actually replay missed
* events on reconnect with `Last-Event-ID`. The canonical
* `serveStdio` / `createMcpHandler` arms cannot express that, and the REPL
* client uses stdin for the readline command loop.
*
* Pair with `pnpm run client` in a second terminal.
*/
import { randomUUID } from 'node:crypto';
import { parseExampleArgs } from '@mcp-examples/shared';
import { InMemoryEventStore } from '@mcp-examples/shared/auth';
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import type { CallToolResult, PrimitiveSchemaDefinition, ReadResourceResult, ResourceLink } from '@modelcontextprotocol/server';
import { completable, isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
import type { Request, Response } from 'express';
import * as z from 'zod/v4';
/** Dynamic resources added via the `add-resource` tool (shared across sessions). */
const dynamicResources = new Map<string, string>();
function buildServer(): McpServer {
const server = new McpServer(
{
name: 'repl-playground-server',
version: '1.0.0',
icons: [{ src: 'https://modelcontextprotocol.io/favicon.svg', sizes: ['any'], mimeType: 'image/svg+xml' }],
websiteUrl: 'https://github.com/modelcontextprotocol/typescript-sdk'
},
{ capabilities: { logging: {}, resources: { listChanged: true } } }
);
// --- Tools -------------------------------------------------------------
// Typed input + inferred structured output + read-only annotation.
server.registerTool(
'greet',
{
title: 'Greeting Tool',
description: 'Returns a greeting for the named subject',
inputSchema: z.object({ name: z.string().describe('Name to greet') }),
outputSchema: z.object({ greeting: z.string() }),
annotations: { readOnlyHint: true, idempotentHint: true }
},
async ({ name }) => {
const structuredContent = { greeting: `Hello, ${name}!` };
return { content: [{ type: 'text', text: structuredContent.greeting }], structuredContent };
}
);
// Sends `notifications/message` log lines while it runs (drive with `multi-greet`).
server.registerTool(
'multi-greet',
{
description: 'Sends several greetings with a delay between each, emitting log notifications as it goes',
inputSchema: z.object({ name: z.string().describe('Name to greet') }),
annotations: { title: 'Multiple Greeting Tool', readOnlyHint: true, openWorldHint: false }
},
async ({ name }, ctx): Promise<CallToolResult> => {
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
await ctx.mcpReq.log('debug', `Starting multi-greet for ${name}`);
await sleep(500);
await ctx.mcpReq.log('info', `Sending first greeting to ${name}`);
await sleep(500);
await ctx.mcpReq.log('info', `Sending second greeting to ${name}`);
return { content: [{ type: 'text', text: `Good morning, ${name}!` }] };
}
);
// Form-mode elicitation (drive with the REPL's `collect-info` command).
server.registerTool(
'collect-user-info',
{
description: 'Collects user information through form elicitation',
inputSchema: z.object({
infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect')
})
},
async ({ infoType }, ctx): Promise<CallToolResult> => {
const schemas: Record<
string,
{ message: string; schema: { type: 'object'; properties: Record<string, PrimitiveSchemaDefinition>; required?: string[] } }
> = {
contact: {
message: 'Please provide your contact information',
schema: {
type: 'object',
properties: {
name: { type: 'string', title: 'Full Name' },
email: { type: 'string', title: 'Email Address', format: 'email' }
},
required: ['name', 'email']
}
},
preferences: {
message: 'Please set your preferences',
schema: {
type: 'object',
properties: {
theme: { type: 'string', title: 'Theme', enum: ['light', 'dark', 'auto'] },
notifications: { type: 'boolean', title: 'Enable Notifications', default: true }
},
required: ['theme']
}
},
feedback: {
message: 'Please provide your feedback',
schema: {
type: 'object',
properties: {
rating: { type: 'integer', title: 'Rating', minimum: 1, maximum: 5 },
comments: { type: 'string', title: 'Comments', maxLength: 500 }
},
required: ['rating']
}
}
};
const picked = schemas[infoType]!;
const result = await ctx.mcpReq.send({
method: 'elicitation/create',
params: { mode: 'form', message: picked.message, requestedSchema: picked.schema }
});
if (result.action === 'accept') {
return { content: [{ type: 'text', text: `Collected ${infoType}: ${JSON.stringify(result.content, null, 2)}` }] };
}
return {
content: [{ type: 'text', text: `User ${result.action === 'decline' ? 'declined' : 'cancelled'} the ${infoType} request.` }]
};
}
);
// Periodic notifications for testing resumability (`start-notifications` in the REPL).
server.registerTool(
'start-notification-stream',
{
description: 'Sends periodic log notifications for testing resumability',
inputSchema: z.object({
interval: z.number().describe('Interval in ms between notifications').default(1000),
count: z.number().describe('Number of notifications to send').default(5)
})
},
async ({ interval, count }, ctx): Promise<CallToolResult> => {
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
for (let i = 1; i <= count; i++) {
await ctx.mcpReq.log('info', `Periodic notification #${i} at ${new Date().toISOString()}`);
await sleep(interval);
}
return { content: [{ type: 'text', text: `Sent ${count} notifications at ${interval}ms intervals` }] };
}
);
// Mutates the resource set and publishes `resources/list_changed` on this
// session's standalone SSE stream.
server.registerTool(
'add-resource',
{
description: 'Add a dynamic note resource and publish resources/list_changed',
inputSchema: z.object({ name: z.string(), text: z.string() }),
annotations: { destructiveHint: false }
},
async ({ name, text }): Promise<CallToolResult> => {
dynamicResources.set(name, text);
server.sendResourceListChanged();
return { content: [{ type: 'text', text: `Added note://${name}` }] };
}
);
// Returns ResourceLinks (drive with `call-tool list-files`, then `read-resource <uri>`).
server.registerTool(
'list-files',
{
title: 'List Files with ResourceLinks',
description: 'Returns a list of files as ResourceLinks without embedding their content',
inputSchema: z.object({})
},
async (): Promise<CallToolResult> => {
const links: ResourceLink[] = [
{ type: 'resource_link', uri: 'config://app', name: 'App config', mimeType: 'application/json' },
...[...dynamicResources.keys()].map(
(name): ResourceLink => ({ type: 'resource_link', uri: `note://${name}`, name, mimeType: 'text/plain' })
)
];
return { content: [{ type: 'text', text: 'Available files:' }, ...links] };
}
);
// --- Prompts (with argument completion) --------------------------------
const LANGUAGES = ['python', 'typescript', 'rust', 'go'];
server.registerPrompt(
'greeting-template',
{
title: 'Greeting Template',
description: 'A simple greeting prompt template',
argsSchema: z.object({
name: z.string().describe('Name to include in greeting'),
language: completable(z.string().describe('Language'), value => LANGUAGES.filter(l => l.startsWith(value)))
})
},
async ({ name, language }) => ({
messages: [{ role: 'user', content: { type: 'text', text: `Please greet ${name} in ${language}.` } }]
})
);
// --- Resources (direct + template + dynamic) ---------------------------
server.registerResource(
'app-config',
'config://app',
{ title: 'App config', mimeType: 'application/json', description: 'Static application config' },
async (uri): Promise<ReadResourceResult> => ({
contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"feature":true}' }]
})
);
server.registerResource(
'greeting',
new ResourceTemplate('greeting://{name}', { list: undefined }),
{ description: 'A greeting for the named subject' },
async (uri, vars): Promise<ReadResourceResult> => ({ contents: [{ uri: uri.href, text: `Hello, ${vars.name}!` }] })
);
server.registerResource(
'note',
new ResourceTemplate('note://{name}', {
list: () => ({
resources: [...dynamicResources.keys()].map(name => ({ uri: `note://${name}`, name, mimeType: 'text/plain' }))
})
}),
{ description: 'A dynamic note added via add-resource', mimeType: 'text/plain' },
async (uri, vars): Promise<ReadResourceResult> => {
const text = dynamicResources.get(String(vars.name)) ?? '(no such note)';
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text }] };
}
);
return server;
}
const { port } = parseExampleArgs();
// Sessionful 2025-era hosting with an in-memory event store so the REPL
// client's resumability commands work (reconnect with `Last-Event-ID` replays
// missed `notifications/message` events).
const sessions = new Map<string, NodeStreamableHTTPServerTransport>();
const eventStore = new InMemoryEventStore();
const app = createMcpExpressApp();
app.all('/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(),
eventStore, // resumability — events are persisted for replay on GET reconnect
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 });
}
});
app.listen(port, () => console.error(`[server] REPL playground listening on http://127.0.0.1:${port}/mcp`));
process.on('SIGINT', async () => {
for (const t of sessions.values()) await t.close();
process.exit(0);
});