130 lines
5.7 KiB
TypeScript
130 lines
5.7 KiB
TypeScript
/**
|
|
* "Real app" capstone — a small stateful sticky-notes board that ties
|
|
* together tools that mutate state, a resource per piece of state, listChanged
|
|
* on add/remove, and a server→client elicitation guarding a destructive action.
|
|
*
|
|
* The board is process-local (one map per server process). Over stdio one
|
|
* `McpServer` instance is pinned for the connection lifetime, so the tools
|
|
* register/unregister note resources at runtime; over the per-request HTTP
|
|
* path the factory registers a resource per live note on every request.
|
|
*
|
|
* Tools:
|
|
* - `add_note(text)` — store a note, register `note:///{id}`, returns
|
|
* `{id, uri}`.
|
|
* - `remove_note(id)` — delete one note + unregister its resource.
|
|
* - `remove_all()` — delete every note, but FIRST blocks on a form-mode
|
|
* elicitation; declining/cancelling/unchecked all leave the board.
|
|
*
|
|
* One binary, either transport.
|
|
*/
|
|
import { serve } from '@hono/node-server';
|
|
import { parseExampleArgs } from '@mcp-examples/shared';
|
|
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
|
|
import type { RegisteredResource } from '@modelcontextprotocol/server';
|
|
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
|
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
import * as z from 'zod/v4';
|
|
|
|
const notes = new Map<string, string>();
|
|
let nextId = 1;
|
|
const uriFor = (id: string) => `note:///${id}`;
|
|
|
|
function buildServer(): McpServer {
|
|
const server = new McpServer({ name: 'stickynotes-example', version: '1.0.0' }, { capabilities: { resources: { listChanged: true } } });
|
|
// Registrations on THIS instance (so the stdio leg can unregister at runtime).
|
|
const registered = new Map<string, RegisteredResource>();
|
|
const registerNote = (id: string, text: string) => {
|
|
const r = server.registerResource(
|
|
`note-${id}`,
|
|
uriFor(id),
|
|
{ mimeType: 'text/plain', description: `Sticky note #${id}` },
|
|
async uri => ({
|
|
contents: [{ uri: uri.href, mimeType: 'text/plain', text: notes.get(id) ?? text }]
|
|
})
|
|
);
|
|
registered.set(id, r);
|
|
};
|
|
// Register a resource per live note (per-request HTTP path picks up the
|
|
// current board on every factory call; stdio re-uses one instance).
|
|
for (const [id, text] of notes) registerNote(id, text);
|
|
|
|
server.registerTool(
|
|
'add_note',
|
|
{
|
|
description: 'Add a sticky note; registers a note:///{id} resource for it.',
|
|
inputSchema: z.object({ text: z.string() }),
|
|
outputSchema: z.object({ id: z.string(), uri: z.string() })
|
|
},
|
|
async ({ text }) => {
|
|
const id = String(nextId++);
|
|
notes.set(id, text);
|
|
registerNote(id, text);
|
|
const structuredContent = { id, uri: uriFor(id) };
|
|
return { content: [{ type: 'text', text: `added note #${id}` }], structuredContent };
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
'remove_note',
|
|
{
|
|
description: 'Remove one sticky note by id and unregister its resource.',
|
|
inputSchema: z.object({ id: z.string() }),
|
|
outputSchema: z.object({ removed: z.boolean(), id: z.string() })
|
|
},
|
|
async ({ id }) => {
|
|
const removed = notes.delete(id);
|
|
if (removed) registered.get(id)?.remove();
|
|
return { content: [{ type: 'text', text: removed ? `removed #${id}` : 'not found' }], structuredContent: { removed, id } };
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
'remove_all',
|
|
{
|
|
description: 'Remove ALL sticky notes after confirming via a server→client elicitation.',
|
|
outputSchema: z.object({ status: z.string(), removed: z.number() })
|
|
},
|
|
async ctx => {
|
|
if (notes.size === 0) {
|
|
return { content: [{ type: 'text', text: 'nothing to clear' }], structuredContent: { status: 'empty', removed: 0 } };
|
|
}
|
|
const count = notes.size;
|
|
const result = await ctx.mcpReq.elicitInput({
|
|
mode: 'form',
|
|
message: `Remove all ${count} sticky note(s)? This cannot be undone.`,
|
|
requestedSchema: {
|
|
type: 'object',
|
|
properties: { confirm: { type: 'boolean', title: 'Yes, permanently delete every sticky note' } },
|
|
required: ['confirm']
|
|
}
|
|
});
|
|
if (result.action === 'cancel') {
|
|
return { content: [{ type: 'text', text: 'cancelled' }], structuredContent: { status: 'cancelled', removed: 0 } };
|
|
}
|
|
if (result.action !== 'accept' || !(result.content as { confirm?: boolean } | undefined)?.confirm) {
|
|
return { content: [{ type: 'text', text: 'declined' }], structuredContent: { status: 'declined', removed: 0 } };
|
|
}
|
|
for (const id of notes.keys()) registered.get(id)?.remove();
|
|
notes.clear();
|
|
return { content: [{ type: 'text', text: `cleared ${count}` }], structuredContent: { status: 'cleared', removed: count } };
|
|
}
|
|
);
|
|
|
|
return server;
|
|
}
|
|
|
|
const { transport, port } = parseExampleArgs();
|
|
|
|
if (transport === 'stdio') {
|
|
void serveStdio(buildServer);
|
|
console.error('[server] serving over stdio');
|
|
} else {
|
|
const handler = createMcpHandler(buildServer);
|
|
// `createMcpHonoApp()` binds the endpoint behind localhost host/origin
|
|
// validation by default, matching the framework factories' defaults.
|
|
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`);
|
|
});
|
|
}
|