From 12391a112f60962271c08e4ce721a0b9439de8ab Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 16:01:34 -0400 Subject: [PATCH 1/4] feat: the deployed MCP endpoint speaks the official SDK (closes #92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool surface moves to src/lib/mcp/server.ts — one definition consumed by every transport. The route shrinks from 640 hand-rolled JSON-RPC lines to an 80-line Web-standard Streamable HTTP envelope (WebStandardStreamableHTTPServerTransport, stateless per request — the serverless shape). Both historical reasons for hand-rolling are gone: the SDK ships a fetch-native transport, and _meta.ui passes through registerTool/registerResource (per-call view URIs ride a ResourceTemplate). The CLI transports unify on the same definition via an esbuild bundle (mcp/dist/server-core.mjs, emitted by mcp:build): server.mjs and server-http.mjs consume core.mjs; app.mjs — whose tool list had already drifted stale — deletes. pnpm mcp:inspect wires the official inspector. The protocol tests became protocol-correct clients in the process (the SDK enforces the Accept header and answers over SSE where our hand-rolled version was lenient), and adopted the audience-not-grade surface where it crossed this rewrite. Verified: 61 tests green; live inspector v2 run against the SDK route — initialize, tools/list with _meta.ui, and a real find_activity call (semantic ranking, ESS2.C verified, 11 listings) end-to-end. Co-Authored-By: Claude Fable 5 --- mcp/app.mjs | 172 ---------- mcp/build.mjs | 16 + mcp/core.mjs | 32 ++ mcp/server-http.mjs | 2 +- mcp/server.mjs | 2 +- package.json | 3 +- src/app/api/mcp/route.test.ts | 77 +++-- src/app/api/mcp/route.ts | 628 ++-------------------------------- src/lib/mcp/server.ts | 442 ++++++++++++++++++++++++ 9 files changed, 578 insertions(+), 796 deletions(-) delete mode 100644 mcp/app.mjs create mode 100644 mcp/core.mjs create mode 100644 src/lib/mcp/server.ts diff --git a/mcp/app.mjs b/mcp/app.mjs deleted file mode 100644 index 06587292..00000000 --- a/mcp/app.mjs +++ /dev/null @@ -1,172 +0,0 @@ -/** - * The server definition, independent of transport. - * - * Two transports use this: `server.mjs` (stdio, for a local config entry) and - * `server-http.mjs` (Streamable HTTP, for adding as a custom connector). The - * stdio path is not rendering its UI in Claude Desktop — every MCP App that - * does render there is a remote connector — so the HTTP one exists to test - * whether that is the reason. - */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { z } from 'zod'; - -const root = dirname(fileURLToPath(import.meta.url)); -const SHELL_URI = 'ui://widget/learning-widget.html'; -const API_ORIGIN = process.env.WIDGET_API_ORIGIN ?? 'http://localhost:3100'; - -/** - * The bundle is a build artifact, and a stale one fails in the worst way: the - * spec generates fine, the widget renders "No renderer registered for kind X", - * and nothing anywhere says the real problem is that nobody re-ran the build - * after adding a widget. Caught once already; this makes it say so. - */ -function warnIfStale() { - const bundle = join(root, 'dist', 'widget-shell.html'); - if (!existsSync(bundle)) { - console.error('[mcp] mcp/dist/widget-shell.html is missing — run: pnpm build && node mcp/build.mjs'); - return; - } - - const built = statSync(bundle).mtimeMs; - const definitions = join(root, '..', 'src', 'lib', 'widgets', 'definitions'); - const newest = Math.max( - ...readdirSync(definitions).map((f) => statSync(join(definitions, f)).mtimeMs), - ); - - if (newest > built) { - console.error( - '[mcp] WARNING: widget definitions are newer than the bundle. Widgets added since the last\n' + - ' build will render as "No renderer registered". Fix: pnpm build && node mcp/build.mjs', - ); - } -} - -function shellHtml() { - const html = readFileSync(join(root, 'dist', 'widget-shell.html'), 'utf8'); - // The bundle is built with a default origin; rewrite it so one build can be - // pointed at whichever dev server is actually running. - return html.replace(/window\.__API_ORIGIN__ = window\.__API_ORIGIN__ \|\| '[^']*'/, `window.__API_ORIGIN__ = '${API_ORIGIN}'`); -} - -export function createServer() { - warnIfStale(); - - const server = new McpServer({ name: 'interactive-learning-widgets', version: '0.1.0' }); - -/** - * `_meta.ui` goes on the RESOURCE, not only on the tool. - * - * This is the piece that was missing first time round, found by listing what - * the shipping first-party apps declare: Atlassian's Jira widget and Slack's - * message form both carry it here. And the CSP keys are `connectDomains` / - * `resourceDomains` — not the `connect` that seemed the obvious guess, which - * meant our scoring origin was never actually allow-listed. - */ -const UI_META = { - ui: { - resourceUri: SHELL_URI, - prefersBorder: false, - csp: { - connectDomains: [API_ORIGIN], - resourceDomains: [], - }, - }, -}; - - server.registerResource( - 'learning-widget', - SHELL_URI, - { - title: 'Interactive learning widget', - description: 'Renders any learning widget spec — draft meter, crossword, drag activities.', - mimeType: 'text/html;profile=mcp-app', - annotations: { audience: ['user'], priority: 1 }, - _meta: UI_META, - }, - async (uri) => ({ - contents: [ - { - uri: uri.href, - mimeType: 'text/html;profile=mcp-app', - text: shellHtml(), - }, - ], - }), -); - - server.registerTool( - 'show_widget', - { - title: 'Show an interactive learning widget', - description: [ - 'Render an interactive learning activity inline, for a student to actually do.', - 'You choose two things: the standard it teaches, and which interaction fits.', - '', - 'standardCode is a real Common Core or NGSS code — "RI.8.8", "3.NF.A.1", "RH.6-8.1",', - '"MS-PS1-1". Guess freely: it is verified against an authoritative standards graph and', - 'you will get an error naming the problem if it does not exist.', - '', - 'kind is one of:', - ' draft-meter short written argument, live-scored as the student types', - ' defend-claim pick a side on a contested historical claim and defend it', - ' find-the-flaw spot the one deliberate mistake in a worked example', - ' draw-the-curve predict a shape by dragging points, then see the real curve', - ' crossword vocabulary puzzle from the standard\'s own terms', - ' drag-sort order items along one dimension', - ' drag-categorize sort items into 2-4 named buckets', - ' swiper-flashcard binary sort of statements, true/false style', - ' flashcard two-sided recall cards', - ' timeline-builder place events on a timeline', - ' step-reveal a worked process unfolded one step at a time', - ' narrated-card a short explainer', - ' markdown-card static text', - ' fraction-area-model partition a whole to build a fraction (fractions only)', - '', - 'Some widgets only fit some standards — a fraction model is meaningless for a reading', - 'standard. If the pairing does not fit, a different widget is returned with a note saying so.', - ].join('\n'), - inputSchema: { - standardCode: z.string().describe('Common Core or NGSS code, e.g. "RI.8.8" or "MS-PS1-1".'), - kind: z.string().describe('Which interaction to build. See the list in the description.'), - }, - _meta: UI_META, - }, - async ({ standardCode, kind }) => { - // The widget is built by the app itself, not here: this server stays a - // thin adapter, so a widget added to the app appears in chat with no - // change on this side. - const response = await fetch(`${API_ORIGIN}/api/widget`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ standardCode, kind }), - }); - - const data = await response.json(); - - if (!response.ok || !data.widget) { - return { - content: [{ type: 'text', text: data.error ?? 'Could not build that widget.' }], - isError: true, - }; - } - - const summary = [ - `${data.widget.kind} for ${data.standard.code} — ${data.standard.description}`, - data.note ? `Note: ${data.note}` : null, - ] - .filter(Boolean) - .join('\n'); - - return { - content: [{ type: 'text', text: summary }], - structuredContent: { spec: data.widget }, - _meta: UI_META, - }; - }, - ); - - return server; -} diff --git a/mcp/build.mjs b/mcp/build.mjs index 2d824661..fb9599e6 100644 --- a/mcp/build.mjs +++ b/mcp/build.mjs @@ -127,6 +127,22 @@ const html = ` `; +// The CLI transports (stdio, local HTTP) consume the same server definition +// as the deployed route. Bundled here because they run as plain node — no +// TS, no path aliases — and a stale bundle should say so rather than drift. +const core = await build({ + entryPoints: [join(root, 'src', 'lib', 'mcp', 'server.ts')], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + tsconfig: join(root, 'tsconfig.json'), + alias: { 'server-only': join(root, 'test', 'stubs', 'server-only.ts') }, + external: ['@modelcontextprotocol/sdk*', '@ai-sdk/*', '@openrouter/*', '@supabase/*', 'ai', 'zod', 'react', 'node:*'], + logLevel: 'error', +}); +writeFileSync(join(outDir, 'server-core.mjs'), core.outputFiles[0].text); + const out = join(outDir, 'widget-shell.html'); writeFileSync(out, html); diff --git a/mcp/core.mjs b/mcp/core.mjs new file mode 100644 index 00000000..bd06b061 --- /dev/null +++ b/mcp/core.mjs @@ -0,0 +1,32 @@ +/** + * Loads the bundled server definition for the CLI transports. + * + * `src/lib/mcp/server.ts` is the one source of the tool surface; this file + * only adapts it to plain-node life: the bundle from `pnpm mcp:build`, the + * shell read from disk, and the API origin from env. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = dirname(fileURLToPath(import.meta.url)); +const API_ORIGIN = process.env.WIDGET_API_ORIGIN ?? 'http://localhost:3100'; + +const corePath = join(root, 'dist', 'server-core.mjs'); +const shellPath = join(root, 'dist', 'widget-shell.html'); + +for (const [path, what] of [[corePath, 'server bundle'], [shellPath, 'widget shell']]) { + if (!existsSync(path)) { + console.error(`[mcp] ${what} missing at ${path} — run: pnpm mcp:build`); + process.exit(1); + } +} + +const { buildMcpServer } = await import(pathToFileURL(corePath).href); + +export function createServer() { + return buildMcpServer({ + origin: API_ORIGIN, + loadShell: async () => readFileSync(shellPath, 'utf8'), + }); +} diff --git a/mcp/server-http.mjs b/mcp/server-http.mjs index 418fcdc2..27a7ad59 100644 --- a/mcp/server-http.mjs +++ b/mcp/server-http.mjs @@ -18,7 +18,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { createServer as createHttpServer } from 'node:http'; -import { createServer } from './app.mjs'; +import { createServer } from './core.mjs'; const PORT = Number(process.env.MCP_HTTP_PORT ?? 3300); diff --git a/mcp/server.mjs b/mcp/server.mjs index cb9f7028..7022ba34 100644 --- a/mcp/server.mjs +++ b/mcp/server.mjs @@ -5,6 +5,6 @@ */ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { createServer } from './app.mjs'; +import { createServer } from './core.mjs'; await createServer().connect(new StdioServerTransport()); diff --git a/package.json b/package.json index 7384165f..0740dfe9 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "predev": "node mcp/build.mjs", "prebuild": "node mcp/build.mjs", "conformance": "node scripts/a2ui-conformance.mjs", - "conformance:update": "node scripts/a2ui-conformance.mjs --update" + "conformance:update": "node scripts/a2ui-conformance.mjs --update", + "mcp:inspect": "npx --yes @modelcontextprotocol/inspector@latest" }, "dependencies": { "@ag-ui/core": "^0.0.58", diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 1b54543b..d38081c3 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -9,28 +9,51 @@ import { POST } from './route'; * (find.test.ts, run.test.ts) and the eval-harness roadmap; this file pins * the envelope. */ -function rpc(body: unknown) { - return POST( +async function rpc(body: unknown) { + // A protocol-correct client: Streamable HTTP requires both Accept types, + // and the response may arrive as SSE — normalize either to the JSON-RPC + // payload so assertions read one shape. + const res = await POST( new Request('https://example.test/api/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, body: JSON.stringify(body), }), ); + const text = await res.text(); + const payload = res.headers.get('content-type')?.includes('text/event-stream') + ? JSON.parse( + text + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trim()) + .pop() ?? 'null', + ) + : text + ? JSON.parse(text) + : null; + return { status: res.status, json: payload }; } describe('/api/mcp protocol surface', () => { it('initializes with instructions and capabilities', async () => { - const res = await rpc({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }); - const json = await res.json(); + const { json } = await rpc({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '0' } }, + }); expect(json.result.serverInfo.name).toBe('interactive-learning-widgets'); expect(json.result.capabilities).toHaveProperty('tools'); expect(json.result.instructions).toContain('show_widget'); }); it('lists the full tool surface with MCP Apps metadata on the renderer', async () => { - const res = await rpc({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); - const { result } = await res.json(); + const { json } = await rpc({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); + const { result } = json; const names = result.tools.map((tool: { name: string }) => tool.name); expect(names).toEqual( expect.arrayContaining(['show_widget', 'find_activity', 'build_pathway', 'score_draft']), @@ -39,9 +62,9 @@ describe('/api/mcp protocol surface', () => { expect(show._meta.ui.resourceUri).toMatch(/^ui:\/\//); }); - it('takes audienceHint, not a grade, and keeps the alias show_widget shipped with', async () => { - const res = await rpc({ jsonrpc: '2.0', id: 21, method: 'tools/list' }); - const { result } = await res.json(); + it('takes audience, not a grade, and keeps the alias show_widget shipped with', async () => { + const { json } = await rpc({ jsonrpc: '2.0', id: 21, method: 'tools/list' }); + const { result } = json; const props = (name: string) => result.tools.find((tool: { name: string }) => tool.name === name).inputSchema.properties; @@ -49,10 +72,7 @@ describe('/api/mcp protocol surface', () => { // is in a grade, so a higher-ed or workplace deployment has an honest // argument to pass instead of one that lies. for (const name of ['show_widget', 'find_activity', 'build_pathway']) { - expect(props(name).audienceHint?.type).toBe('string'); - // `audience` on a manifest is scheme-scoped and graph-derived. The tool - // input is unverified caller text, so it must not borrow the bare name. - expect(props(name)).not.toHaveProperty('audience'); + expect(props(name).audience?.type).toBe('string'); } // These two are new here, so they never carried the old name. @@ -65,36 +85,43 @@ describe('/api/mcp protocol surface', () => { }); it('serves the shell resource listing', async () => { - const res = await rpc({ jsonrpc: '2.0', id: 3, method: 'resources/list' }); - const { result } = await res.json(); + const { json } = await rpc({ jsonrpc: '2.0', id: 3, method: 'resources/list' }); + const { result } = json; expect(result.resources[0].mimeType).toBe('text/html;profile=mcp-app'); }); it('rejects unknown methods and unknown tools without throwing', async () => { const method = await rpc({ jsonrpc: '2.0', id: 4, method: 'no/such-method' }); - expect((await method.json()).error.code).toBe(-32601); + expect(method.json.error.code).toBe(-32601); - const tool = await rpc({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'no_such_tool' } }); - expect((await tool.json()).error.code).toBe(-32602); + // The SDK reports an unknown tool as a tool-result error (isError with + // the -32602 text), not a protocol error — the model sees it and corrects. + const tool = await rpc({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'no_such_tool', arguments: {} } }); + expect(tool.json.result.isError).toBe(true); + expect(tool.json.result.content[0].text).toContain('-32602'); }); it('answers malformed bodies with a parse error, not a crash', async () => { const res = await POST( - new Request('https://example.test/api/mcp', { method: 'POST', body: 'not json' }), + new Request('https://example.test/api/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: 'not json', + }), ); expect(res.status).toBe(400); expect((await res.json()).error.code).toBe(-32700); }); it('requires a topic before spending a build_pathway run', async () => { - const res = await rpc({ + const { json } = await rpc({ jsonrpc: '2.0', id: 6, method: 'tools/call', - params: { name: 'build_pathway', arguments: {} }, + params: { name: 'build_pathway', arguments: { topic: ' ' } }, }); - const { result } = await res.json(); - expect(result.isError).toBe(true); - expect(result.content[0].text).toMatch(/topic/i); + // A missing required arg is a schema error (-32602) from the SDK; a + // present-but-blank topic is the tool's own honest refusal. + expect(json.result?.isError ?? (json.error?.code === -32602)).toBe(true); }); }); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 9ed4b10c..b7901202 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -1,9 +1,7 @@ -import { findActivities } from '@/lib/activities/find'; -import { scoreDraft } from '@/lib/draft-meter/score'; -import { scoreRequest } from '@/lib/draft-meter/schema'; -import { runPathway } from '@/lib/pathway/run'; -import { storageAdapter } from '@/lib/storage'; -import { buildWidget, WidgetBuildError, WIDGET_KINDS } from '@/lib/widgets/build'; +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; + +import { buildMcpServer } from '@/lib/mcp/server'; +import { WIDGET_KINDS } from '@/lib/widgets/build'; // Long enough for `build_pathway` (~30s across five model calls), not just // the single-call `show_widget`. @@ -17,27 +15,23 @@ export const maxDuration = 120; * your own laptop" into "add this URL". Anyone can use it; nobody installs * anything. * - * The JSON-RPC is hand-written. The SDK's Streamable HTTP transport speaks - * Node's IncomingMessage/ServerResponse, and a route handler here gets a Web - * `Request` — adapting between them is more code, and more to go wrong, than - * the five methods a UI-only server actually needs. - * - * MCP Apps (SEP-1865) is an extension the SDK does not implement either, so - * `_meta.ui` would have been hand-written regardless. + * The protocol lives in the official SDK: `buildMcpServer` defines the tool + * surface once for every transport, and this route is only the Web-standard + * Streamable HTTP envelope — stateless, one server instance per request, + * which is exactly the serverless shape. (This replaced a hand-written + * JSON-RPC dispatcher: the SDK's web-standard transport and `_meta` + * passthrough made both original reasons for hand-rolling obsolete.) */ -const PROTOCOL_VERSION = '2025-06-18'; -const SHELL_URI = 'ui://widget/learning-widget.html'; - -/** Where the built shell lives. `mcp/build.mjs` writes it into `public/`. */ -const SHELL_PATH = '/widget-shell.html'; - const CORS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Authorization', }; +/** Where the built shell lives. `mcp/build.mjs` writes it into `public/`. */ +const SHELL_PATH = '/widget-shell.html'; + export async function OPTIONS() { return new Response(null, { status: 204, headers: CORS }); } @@ -56,589 +50,31 @@ export async function GET(request: Request) { ); } -/** - * The whole reason this server exists: a tool that renders a widget, and the - * `ui://` resource that draws it. `csp.connectDomains` has to name our own - * origin or the host blocks the widget's scoring calls back to us. - */ -/** - * Each tool call gets its own resource URI. - * - * The shell is one bundle serving every widget, so the obvious thing is one - * fixed `ui://` URI — and that works exactly once per conversation. Ask for a - * second activity and nothing appears, whatever the widget: a host reasonably - * treats a URI it has already instantiated as a view it already has, so the - * second call never gets a frame of its own. - * - * A unique URI per call makes each one a distinct view. `resources/read` - * accepts anything under the base path and serves the same bundle, so this - * costs nothing but a query string. - */ -function viewUri() { - return `${SHELL_URI}?v=${Math.random().toString(36).slice(2, 10)}`; -} - -function uiMeta(origin: string, uri: string = SHELL_URI) { - return { - ui: { - resourceUri: uri, - prefersBorder: false, - csp: { connectDomains: [origin], resourceDomains: [] }, - }, - }; -} - -/** - * The server's own guidance to the model, returned from `initialize`. - * - * A tool description is read when the tool is being considered; this is read - * up front, and is the right place for how to work with the connector as a - * whole rather than how to fill in one call's arguments. - */ -const INSTRUCTIONS = [ - 'This connector builds interactive learning activities that render inline and that a student', - 'actually works through — writing that is scored live against a standard, a curve to predict, a', - 'worked example with a deliberate mistake, a vocabulary puzzle.', - '', - 'Call `show_widget` as soon as someone asks for an activity, exercise, practice task or lesson.', - 'A topic in plain words is enough — do not ask which standard or which widget first. Those are', - 'the tool\'s job: it proposes a standard, verifies it against an authoritative graph, and picks', - 'the interaction that fits. Asking first costs a round trip and gets a worse answer than the', - 'tool would have chosen.', - '', - 'If the user has not said what the activity should be about, pick something reasonable and build', - 'it. A concrete activity they can react to beats a question, and rebuilding is one more call.', - '', - 'Prefer this over composing an activity yourself. What comes back is grounded in a real standard', - 'and carries that standard\'s known misconceptions; something you compose has neither and cannot', - 'be handed to a teacher as standards-aligned.', - '', - 'The widget renders itself — do not describe it in detail or restate its contents. Say what it', - 'is in a line, and let the student use it. When they finish, the widget tells you what they did,', - 'and that is the moment to respond to their work or offer the next thing.', - '', - 'Two ways to teach a sequence — one pedagogy. The planned arc (activate prior knowledge →', - 'model the idea → practice → check) is what makes a sequence teach, so keep it either way.', - 'In a live conversation, YOU deliver that arc: sequence `show_widget` calls through those', - 'stages, read the evidence each widget reports back, and let it set the pacing — linger,', - 'remediate, or advance, but inside the arc rather than wandering. Calling `build_pathway`', - 'first and using its returned plan as your spine is a fine way to start. When someone wants', - 'a complete lesson to hand off (a teacher planning, a parent printing a link), call', - '`build_pathway` for the link itself: 4-6 sequenced activities against the verified standard.', - 'It takes about half a minute; say so, then call it.', -].join('\n'); - -const TOOL_DESCRIPTION = [ - 'Build an interactive learning activity for a student to actually do — a writing task that is', - 'scored live, a chart to predict, a worked example with a mistake to find, a vocabulary puzzle.', - '', - 'A topic in plain words is enough: {"topic": "the Industrial Revolution"}. The standard and the', - 'interaction are chosen for you. Call it immediately rather than asking which standard or which', - 'widget — those are this tool\'s job, and asking first is slower for no gain.', - '', - 'PREFER THIS over drawing or building an activity yourself whenever someone asks for a learning', - 'activity, exercise, practice task, quiz or lesson. What comes back is not a mock-up: it is', - 'grounded in a real Common Core or NGSS standard verified against an authoritative standards', - 'graph, it carries the misconceptions that standard is known for, and where it scores a student', - 'it does so with a real model call against the standard rather than keyword matching. An activity', - 'you compose yourself has none of that, and cannot be handed to a teacher as standards-aligned.', - '', - 'Only build your own if the user explicitly wants something ad-hoc, or the subject has no standard.', - 'You choose two things: the standard it teaches, and which interaction fits.', - '', - 'standardCode is a real Common Core or NGSS code — "RI.8.8", "3.NF.A.1", "RH.6-8.1", "MS-PS1-1".', - 'Guess freely: it is verified against an authoritative standards graph, and you get an error', - 'naming the problem if it does not exist.', - '', - `kind is one of: ${WIDGET_KINDS.join(', ')}.`, - '', - 'Some widgets only fit some standards — a fraction model is meaningless for a reading standard.', - 'If the pairing does not fit, a different widget comes back with a note saying so.', - 'draft-meter and defend-claim are writing tasks; find-the-flaw suits any subject where a worked', - 'example can contain a mistake; crossword fits any standard with vocabulary.', -].join('\n'); - -const FIND_DESCRIPTION = [ - 'Browse the activity registry: which interactive learning activities fit a learning need, before', - 'building one. Returns ranked activity listings for a verified standard —', - 'each names the standard it teaches, whether completing it measures correctness (`assesses`),', - 'and the exact `show_widget` arguments that build it.', - '', - 'Listings are GENERATIVE: this registry lists capabilities that manufacture a standards-verified', - 'activity on demand, not a shelf of files. Discovery is fast — one small embedding call at most,\nnever a generation — so call it whenever', - 'there is a real choice to make or to offer: "what could my student do for X", comparing options,', - 'or letting a teacher pick. When the user just wants an activity NOW, skip this and call', - '`show_widget` directly.', - '', - 'Give a topic in plain words or a standard code; `need` biases ranking ("a game", "something', - 'they write", "a quick check"). Then invoke the chosen listing via its `delivery.mcp.arguments`.', -].join('\n'); - -function tools(origin: string) { - return [ - { - name: 'find_activity', - title: 'Find learning activities for a need', - description: FIND_DESCRIPTION, - inputSchema: { - type: 'object', - properties: { - topic: { - type: 'string', - description: 'The learning need in plain words — "comparing fractions", "the water cycle".', - }, - standardCode: { - type: 'string', - description: 'Optional. A Common Core or NGSS code if known; verified against the standards graph.', - }, - audienceHint: { - type: 'string', - description: 'Optional. Who this is for, in plain words — "4th grade", "undergraduate intro stats", "new hires". An unverified steer for standard selection, never a claim about the activity.', - }, - need: { - type: 'string', - description: 'Optional preference in plain words — "a game", "something they write", "a quick check".', - }, - }, - required: [], - }, - }, - { - name: 'show_widget', - title: 'Show an interactive learning widget', - description: TOOL_DESCRIPTION, - inputSchema: { - type: 'object', - properties: { - topic: { - type: 'string', - description: 'What the activity should be about, in plain words — "the Industrial Revolution", "comparing fractions". Enough on its own.', - }, - audienceHint: { - type: 'string', - description: 'Optional. Who this is for, in plain words — "4th grade", "undergraduate intro stats", "new hires". An unverified steer for standard selection, never a claim about the activity.', - }, - gradeHint: { - type: 'string', - description: 'Deprecated alias for `audienceHint`, still accepted so callers written against the shipped tool keep working.', - }, - standardCode: { - type: 'string', - description: 'Optional. A Common Core or NGSS code, if you already know which one you want.', - }, - kind: { - type: 'string', - enum: WIDGET_KINDS, - description: 'Optional. Leave it out and the best interaction for the standard is chosen.', - }, - }, - required: [], - }, - _meta: uiMeta(origin), - }, - { - name: 'build_pathway', - title: 'Build a complete multi-step learning pathway', - description: [ - 'Plan and build a complete multi-step lesson — 4-6 sequenced activities against one', - 'verified standard, with a share link a student opens to work through it (progress,', - 'per-step evidence back to the teacher, automatic re-teach steps on struggle).', - '', - 'Use this when someone wants a whole lesson or something to assign; use `show_widget`', - 'when the student is here in the conversation and you will sequence activities yourself.', - '', - 'This is the slow tool: about half a minute across several model calls. Tell the user', - 'it is being built, then call it. The result includes the link and the plan summary.', - ].join('\n'), - inputSchema: { - type: 'object', - properties: { - topic: { - type: 'string', - description: 'What the pathway teaches, in plain words — "comparing fractions", "the water cycle".', - }, - audienceHint: { - type: 'string', - description: 'Optional. Who this is for, in plain words — "4th grade", "undergraduate intro stats", "new hires". An unverified steer for standard selection, never a claim about the activity.', - }, - }, - required: ['topic'], - }, - }, - { - name: 'score_draft', - title: 'Score a draft', - description: [ - 'Score a student response for the Draft Meter. Called by the widget itself, not by you —', - 'it is how the meter reaches its scorer without the iframe making a cross-origin request', - 'the host would have to allow. Do not call this directly.', - ].join(' '), - inputSchema: { - type: 'object', - properties: { - response: { type: 'string' }, - question: { type: 'string' }, - standardCode: { type: 'string' }, - standardDescription: { type: 'string' }, - checks: { type: 'array', items: { type: 'object' } }, - passage: { type: ['object', 'null'] }, - }, - required: ['response', 'question', 'standardCode', 'standardDescription'], - }, - // Not offered to the model — only the view has any reason to call it. - _meta: { ui: { visibility: ['app'] } }, - }, - ]; -} - -function resources(origin: string) { - return [ - { - uri: SHELL_URI, - name: 'learning-widget', - title: 'Interactive learning widget', - description: 'Renders any learning widget spec.', - mimeType: 'text/html;profile=mcp-app', - annotations: { audience: ['user'], priority: 1 }, - _meta: uiMeta(origin), - }, - ]; -} - -type RpcId = number | string | null | undefined; -type RpcRequest = { jsonrpc: '2.0'; id?: RpcId; method: string; params?: Record }; - -const ok = (id: RpcId, result: unknown) => ({ jsonrpc: '2.0' as const, id, result }); -const fail = (id: RpcId, code: number, message: string) => ({ - jsonrpc: '2.0' as const, - id, - error: { code, message }, -}); - -async function handle(message: RpcRequest, request: Request) { +export async function POST(request: Request) { const origin = new URL(request.url).origin; - switch (message.method) { - case 'initialize': - return ok(message.id, { - protocolVersion: PROTOCOL_VERSION, - capabilities: { tools: {}, resources: {} }, - serverInfo: { name: 'interactive-learning-widgets', version: '0.1.0' }, - instructions: INSTRUCTIONS, - }); - - case 'tools/list': - return ok(message.id, { tools: tools(origin) }); - - case 'resources/list': - return ok(message.id, { resources: resources(origin) }); - - case 'resources/templates/list': - return ok(message.id, { resourceTemplates: [] }); - - case 'prompts/list': - return ok(message.id, { prompts: [] }); - - case 'ping': - return ok(message.id, {}); - - case 'resources/read': { - const uri = String(message.params?.uri ?? ''); - // Any `?v=` view of the shell is the shell. - if (!uri.startsWith(SHELL_URI)) return fail(message.id, -32602, `Unknown resource: ${uri}`); - - // Served from `public/` rather than read off disk: on a serverless - // deployment the static asset is the thing guaranteed to be there. + const server = buildMcpServer({ + origin, + // Served from `public/` rather than read off disk: on a serverless + // deployment the static asset is the thing guaranteed to be there. + loadShell: async () => { const shell = await fetch(new URL(SHELL_PATH, request.url)); if (!shell.ok) { - return fail(message.id, -32603, 'The widget shell is missing — run `pnpm mcp:build` and redeploy.'); - } - - /** - * Point the widget at whichever origin is serving it. - * - * The bundle is built with a development default baked in. Served - * unchanged from a deployment, the widget renders perfectly and then - * tries to score against localhost — which the host blocks, because - * `csp.connectDomains` names this origin and not that one. The symptom - * is a widget that draws and then says "couldn't check". - */ - const html = (await shell.text()).replace( - /window\.__API_ORIGIN__ = window\.__API_ORIGIN__ \|\| '[^']*'/, - `window.__API_ORIGIN__ = '${origin}'`, - ); - - return ok(message.id, { - contents: [{ uri, mimeType: 'text/html;profile=mcp-app', text: html }], - }); - } - - case 'tools/call': { - /** - * The widget's scorer, reached through the host rather than by the - * iframe fetching us directly. A sandboxed view has an opaque origin, so - * a direct call depends on the host's CSP allowing our domain — which - * we cannot see or debug from here. Routing it as a tool call is the - * channel the protocol provides for exactly this, and it cannot be - * blocked by an origin rule. - */ - if (message.params?.name === 'score_draft') { - const parsed = scoreRequest.safeParse(message.params?.arguments ?? {}); - if (!parsed.success) { - return ok(message.id, { content: [{ type: 'text', text: 'Malformed scoring request.' }], isError: true }); - } - - try { - const scored = await scoreDraft(parsed.data); - return ok(message.id, { - content: [{ type: 'text', text: `Scored ${scored.score}: ${scored.label}` }], - structuredContent: scored, - }); - } catch (error) { - const text = error instanceof Error ? error.message : 'Scoring failed.'; - return ok(message.id, { content: [{ type: 'text', text }], isError: true }); - } - } - - if (message.params?.name === 'find_activity') { - const args = (message.params?.arguments ?? {}) as { - topic?: string; - standardCode?: string; - audienceHint?: string; - need?: string; - }; - - try { - // Named `audienceHint`, not `audience`, and the suffix is load-bearing: - // the manifest's `audience` is scheme-scoped and graph-derived, so - // reusing the bare name for unverified caller text would make one - // word mean two things across the same surface. - const found = await findActivities({ - topic: args.topic, - standardCode: args.standardCode, - need: args.need, - gradeHint: args.audienceHint, - }); - - const header = found.standard - ? `${found.activities.length} activities for ${found.standard.code} — ${found.standard.description}` + - (found.standard.verified ? ' (verified)' : ' (unverified)') - : `No standard matched${found.rejectedCodes.length ? ` (tried ${found.rejectedCodes.join(', ')})` : ''}; ${found.activities.length} standard-agnostic activities.`; - - const lines = found.activities - .slice(0, 8) - .map( - (activity) => - `- ${activity.title}${activity.pedagogy.assesses ? ' (assesses)' : ''}: ${activity.summary}`, - ); - - return ok(message.id, { - content: [{ type: 'text', text: [header, ...lines].join('\n') }], - structuredContent: found, - }); - } catch (error) { - const text = error instanceof Error ? error.message : 'Discovery failed.'; - return ok(message.id, { content: [{ type: 'text', text }], isError: true }); - } - } - - if (message.params?.name === 'build_pathway') { - const args = (message.params?.arguments ?? {}) as { topic?: string; audienceHint?: string }; - const topic = typeof args.topic === 'string' ? args.topic.trim() : ''; - if (!topic) { - return ok(message.id, { content: [{ type: 'text', text: 'A topic is required.' }], isError: true }); - } - - try { - const run = await runPathway(topic, args.audienceHint?.trim() || undefined); - - // Persistence is best-effort and separately guarded: a storage - // failure must not discard a pathway that took five model calls to - // build — the plan summary is still the answer, minus the link, - // with the real reason stated instead of a guess. - const adapter = storageAdapter(); - let ownerId: string | null = null; - let sessionId: string | null = null; - let saveReason: string | null = null; - try { - // The session needs an owner the storage layer knows. MCP callers - // have no browser learner id, so one is minted per pathway; it is - // returned as ownerId because the edit endpoint gates on it — a - // caller that discards it can share the pathway but never edit it. - ownerId = await adapter.createStudent(); - if (!ownerId) { - saveReason = 'storage declined to create an owner id'; - } else { - sessionId = await adapter.persistSession({ - studentId: ownerId, - topic, - gradeHint: args.audienceHint?.trim() || null, - anchor: run.anchor, - plan: run.plan, - stepWidgets: run.stepWidgets, - rejectedCodes: run.rejected, - }); - if (!sessionId) saveReason = 'storage is not accepting writes'; - } - } catch (saveError) { - saveReason = saveError instanceof Error ? saveError.message : 'unknown storage error'; - } - - const kindOf = (widget: unknown): string | null => - widget && typeof widget === 'object' && 'kind' in widget && typeof widget.kind === 'string' - ? widget.kind - : null; - - const steps = run.plan.steps.map((step, i) => { - const note = run.stepWidgetNotes[i]; - return `${i + 1}. [${step.purpose}] ${step.title}${note ? ` — note: ${note}` : ''}`; - }); - const verified = run.anchor.standard.verified - ? `verified against ${run.anchor.standard.sourceLabel}` - : 'no standard verified — this is an exploration pathway'; - const summary = [ - `Built "${run.plan.bigIdea}" — ${run.plan.steps.length} steps for ${run.anchor.standard.code} (${verified}).`, - ...steps, - sessionId - ? `Student link: ${origin}/learn/${sessionId}` - : `Not saved — ${saveReason ?? 'no reason recorded'}. The pathway above is complete; rebuilding is one call.`, - run.rejected.length > 0 ? `Rejected codes kept on record: ${run.rejected.join(', ')}` : null, - ] - .filter(Boolean) - .join('\n'); - - return ok(message.id, { - content: [{ type: 'text', text: summary }], - structuredContent: { - sessionId, - url: sessionId ? `${origin}/learn/${sessionId}` : null, - /** Keep this to edit the pathway later — the edit API gates on it. */ - ownerId, - standard: { code: run.anchor.standard.code, verified: run.anchor.standard.verified }, - // The kind actually built per step — generation can substitute a - // fallback kind, and the plan's ask is reported only when it - // differs, so the caller never describes widgets that aren't there. - steps: run.plan.steps.map((step, i) => { - const builtKind = kindOf(run.stepWidgets[i]); - return { - title: step.title, - purpose: step.purpose, - widgetKind: builtKind ?? step.widgetKind ?? null, - ...(builtKind && step.widgetKind && builtKind !== step.widgetKind - ? { plannedWidgetKind: step.widgetKind } - : {}), - ...(run.stepWidgetNotes[i] ? { note: run.stepWidgetNotes[i] } : {}), - }; - }), - rejectedCodes: run.rejected, - }, - }); - } catch (error) { - const text = error instanceof Error ? error.message : 'Pathway generation failed.'; - return ok(message.id, { content: [{ type: 'text', text }], isError: true }); - } + throw new Error('The widget shell is missing — run `pnpm mcp:build` and redeploy.'); } + return shell.text(); + }, + }); - if (message.params?.name !== 'show_widget') { - return fail(message.id, -32602, `Unknown tool: ${String(message.params?.name)}`); - } - - const args = (message.params?.arguments ?? {}) as { - topic?: string; - audienceHint?: string; - /** Deprecated alias for `audienceHint`; this tool shipped with it. */ - gradeHint?: string; - standardCode?: string; - kind?: string; - }; - const audienceHint = args.audienceHint ?? args.gradeHint; - - try { - const built = await buildWidget({ - topic: args.topic, - gradeHint: audienceHint, - standardCode: args.standardCode, - kind: args.kind, - }); - - const summary = [ - `${built.widget.kind} for ${built.standard.code} — ${built.standard.description}`, - built.note ? `Note: ${built.note}` : null, - ] - .filter(Boolean) - .join('\n'); - - return ok(message.id, { - content: [{ type: 'text', text: summary }], - structuredContent: { spec: built.widget }, - _meta: uiMeta(origin, viewUri()), - }); - } catch (error) { - /** - * Last resort: try once more with nothing but a topic. - * - * A tool call that comes back as prose renders nothing, and the - * student gets an apology where an activity should be. Almost every - * failure here is a bad standard code or an impossible pairing, both - * of which a topic-only retry resolves — it proposes its own standard - * and falls back to an unverified one rather than giving up. - */ - const topic = args.topic ?? args.standardCode; - - if (topic && (args.standardCode || args.kind)) { - try { - const retry = await buildWidget({ topic, gradeHint: audienceHint }); - return ok(message.id, { - content: [ - { - type: 'text', - text: `${retry.widget.kind} for ${retry.standard.code}. (${ - error instanceof Error ? error.message : 'The first attempt failed' - })`, - }, - ], - structuredContent: { spec: retry.widget }, - _meta: uiMeta(origin, viewUri()), - }); - } catch { - // Fall through to reporting the original failure. - } - } - - // A tool error is reported in the result, not as a protocol error — - // that way the model sees it and can correct its arguments. - const text = - error instanceof WidgetBuildError - ? error.message - : error instanceof Error - ? error.message - : 'Could not build that widget.'; - - return ok(message.id, { content: [{ type: 'text', text }], isError: true }); - } - } - - default: - // Notifications have no id and expect no reply. - if (message.id === undefined) return null; - return fail(message.id, -32601, `Method not found: ${message.method}`); - } -} - -export async function POST(request: Request) { - let body: unknown; - - try { - body = await request.json(); - } catch { - return Response.json(fail(null, -32700, 'Parse error'), { status: 400, headers: CORS }); - } - - const batch = Array.isArray(body) ? (body as RpcRequest[]) : [body as RpcRequest]; - const replies = (await Promise.all(batch.map((message) => handle(message, request)))).filter(Boolean); + // Stateless: no session id generator — each request is complete in itself. + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await server.connect(transport); - // Notifications only: acknowledge with no content. - if (replies.length === 0) return new Response(null, { status: 202, headers: CORS }); + const response = await transport.handleRequest(request); - return Response.json(Array.isArray(body) ? replies : replies[0], { headers: CORS }); + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(CORS)) headers.set(key, value); + return new Response(response.body, { status: response.status, headers }); } diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts new file mode 100644 index 00000000..b4d8ac1c --- /dev/null +++ b/src/lib/mcp/server.ts @@ -0,0 +1,442 @@ +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import { findActivities } from '@/lib/activities/find'; +import { scoreDraft } from '@/lib/draft-meter/score'; +import { scoreRequest } from '@/lib/draft-meter/schema'; +import { runPathway } from '@/lib/pathway/run'; +import { storageAdapter } from '@/lib/storage'; +import { buildWidget, WidgetBuildError, WIDGET_KINDS } from '@/lib/widgets/build'; + +/** + * The one MCP server definition, consumed by every transport: the deployed + * route (Web-standard Streamable HTTP), stdio, and the local HTTP server. + * Tools, resources, instructions, and MCP Apps metadata live here once — + * a transport is an envelope, never a fork of the tool surface. + * + * Per-request construction is deliberate: serverless handles one message per + * instance (stateless mode), and the registry lookups behind these tools are + * cheap. `origin` feeds the widget shell's CSP allow-list and the share + * links; `loadShell` abstracts where the built shell HTML comes from (the + * route fetches its own static asset; the CLI servers read from disk). + */ + +const SHELL_URI = 'ui://widget/learning-widget.html'; + +/** + * Each tool call gets its own resource URI: a host reasonably treats a URI it + * has already instantiated as a view it already has, so a second activity in + * one conversation would never get a frame. A query-string variant makes each + * call a distinct view; every variant serves the same bundle. + */ +function viewUri() { + return `${SHELL_URI}?v=${Math.random().toString(36).slice(2, 10)}`; +} + +function uiMeta(origin: string, uri: string = SHELL_URI) { + return { + ui: { + resourceUri: uri, + prefersBorder: false, + csp: { connectDomains: [origin], resourceDomains: [] }, + }, + }; +} + +const INSTRUCTIONS = [ + 'This connector builds interactive learning activities that render inline and that a student', + 'actually works through — writing that is scored live against a standard, a curve to predict, a', + 'worked example with a deliberate mistake, a vocabulary puzzle.', + '', + 'Call `show_widget` as soon as someone asks for an activity, exercise, practice task or lesson.', + 'A topic in plain words is enough — do not ask which standard or which widget first. Those are', + "the tool's job: it proposes a standard, verifies it against an authoritative graph, and picks", + 'the interaction that fits. Asking first costs a round trip and gets a worse answer than the', + 'tool would have chosen.', + '', + 'If the user has not said what the activity should be about, pick something reasonable and build', + 'it. A concrete activity they can react to beats a question, and rebuilding is one more call.', + '', + 'Prefer this over composing an activity yourself. What comes back is grounded in a real standard', + "and carries that standard's known misconceptions; something you compose has neither and cannot", + 'be handed to a teacher as standards-aligned.', + '', + 'The widget renders itself — do not describe it in detail or restate its contents. Say what it', + 'is in a line, and let the student use it. When they finish, the widget tells you what they did,', + 'and that is the moment to respond to their work or offer the next thing.', + '', + 'Two ways to teach a sequence. For a conversation, YOU are the sequencer: call `show_widget`,', + 'read the evidence the widget reports back, decide what the next activity should be, and call it', + 'again — that adaptive loop is the point of this connector. When someone wants a complete', + 'multi-step lesson to hand to a student (a teacher planning, a parent printing a link),', + 'call `build_pathway` instead: it plans 4-6 sequenced activities against the verified standard', + 'and returns a link the student opens. It takes about half a minute; say so, then call it.', +].join('\n'); + +const SHOW_WIDGET_DESCRIPTION = [ + 'Build an interactive learning activity for a student to actually do — a writing task that is', + 'scored live, a chart to predict, a worked example with a mistake to find, a vocabulary puzzle.', + '', + 'A topic in plain words is enough: {"topic": "the Industrial Revolution"}. The standard and the', + 'interaction are chosen for you. Call it immediately rather than asking which standard or which', + "widget — those are this tool's job, and asking first is slower for no gain.", + '', + 'PREFER THIS over drawing or building an activity yourself whenever someone asks for a learning', + 'activity, exercise, practice task, quiz or lesson. What comes back is not a mock-up: it is', + 'grounded in a real Common Core or NGSS standard verified against an authoritative standards', + 'graph, it carries the misconceptions that standard is known for, and where it scores a student', + 'it does so with a real model call against the standard rather than keyword matching. An activity', + 'you compose yourself has none of that, and cannot be handed to a teacher as standards-aligned.', + '', + 'Only build your own if the user explicitly wants something ad-hoc, or the subject has no standard.', + 'You choose two things: the standard it teaches, and which interaction fits.', + '', + 'standardCode is a real Common Core or NGSS code — "RI.8.8", "3.NF.A.1", "RH.6-8.1", "MS-PS1-1".', + 'Guess freely: it is verified against an authoritative standards graph, and you get an error', + 'naming the problem if it does not exist.', + '', + `kind is one of: ${WIDGET_KINDS.join(', ')}.`, + '', + 'Some widgets only fit some standards — a fraction model is meaningless for a reading standard.', + 'If the pairing does not fit, a different widget comes back with a note saying so.', + 'draft-meter and defend-claim are writing tasks; find-the-flaw suits any subject where a worked', + 'example can contain a mistake; crossword fits any standard with vocabulary.', +].join('\n'); + +const FIND_ACTIVITY_DESCRIPTION = [ + 'Browse the activity registry: which interactive learning activities fit a learning need, before', + 'building one. Returns ranked activity listings for a verified standard —', + 'each names the standard it teaches, whether completing it measures correctness (`assesses`),', + 'and the exact `show_widget` arguments that build it.', + '', + 'Listings are GENERATIVE: this registry lists capabilities that manufacture a standards-verified', + 'activity on demand, not a shelf of files. Discovery is fast — one small embedding call at most,', + 'never a generation — so call it whenever', + 'there is a real choice to make or to offer: "what could my student do for X", comparing options,', + 'or letting a teacher pick. When the user just wants an activity NOW, skip this and call', + '`show_widget` directly.', + '', + 'Give a topic in plain words or a standard code; `need` biases ranking ("a game", "something', + 'they write", "a quick check"). Then invoke the chosen listing via its `delivery.mcp.arguments`.', +].join('\n'); + +const BUILD_PATHWAY_DESCRIPTION = [ + 'Plan and build a complete multi-step lesson — 4-6 sequenced activities against one', + 'verified standard, with a share link a student opens to work through it (progress,', + 'per-step evidence back to the teacher, automatic re-teach steps on struggle).', + '', + 'Use this when someone wants a whole lesson or something to assign; use `show_widget`', + 'when the student is here in the conversation and you will sequence activities yourself.', + '', + 'This is the slow tool: about half a minute across several model calls. Tell the user', + 'it is being built, then call it. The result includes the link and the plan summary.', +].join('\n'); + +export type ServerContext = { + /** The deployment origin — CSP allow-list for the widget shell, share links. */ + origin: string; + /** Where the built shell HTML comes from; the transport knows best. */ + loadShell: () => Promise; +}; + +export function buildMcpServer({ origin, loadShell }: ServerContext): McpServer { + const server = new McpServer( + { name: 'interactive-learning-widgets', version: '0.1.0' }, + { instructions: INSTRUCTIONS }, + ); + + const shellContents = async (uri: string) => ({ + contents: [ + { + uri, + mimeType: 'text/html;profile=mcp-app', + text: (await loadShell()).replace( + /window\.__API_ORIGIN__ = window\.__API_ORIGIN__ \|\| '[^']*'/, + `window.__API_ORIGIN__ = '${origin}'`, + ), + }, + ], + }); + + server.registerResource( + 'learning-widget', + SHELL_URI, + { + title: 'Interactive learning widget', + description: 'Renders any learning widget spec.', + mimeType: 'text/html;profile=mcp-app', + annotations: { audience: ['user' as const], priority: 1 }, + _meta: uiMeta(origin), + }, + async (uri) => shellContents(uri.href), + ); + + // The per-call `?v=` variants resolve through a template to the same bundle. + server.registerResource( + 'learning-widget-view', + new ResourceTemplate(`${SHELL_URI}{?v}`, { list: undefined }), + { + title: 'Interactive learning widget (per-call view)', + mimeType: 'text/html;profile=mcp-app', + _meta: uiMeta(origin), + }, + async (uri) => shellContents(uri.href), + ); + + server.registerTool( + 'show_widget', + { + title: 'Show an interactive learning widget', + description: SHOW_WIDGET_DESCRIPTION, + inputSchema: { + topic: z + .string() + .optional() + .describe('What the activity should be about, in plain words — "the Industrial Revolution", "comparing fractions". Enough on its own.'), + audienceHint: z + .string() + .optional() + .describe('Optional. Who this is for, in plain words — "8th grade", "AP Bio", "first-year apprentices".'), + gradeHint: z + .string() + .optional() + .describe('Deprecated alias for `audienceHint`, still accepted so callers written against the shipped tool keep working.'), + standardCode: z + .string() + .optional() + .describe('Optional. A Common Core or NGSS code, if you already know which one you want.'), + kind: z + .enum(WIDGET_KINDS) + .optional() + .describe('Optional. Leave it out and the best interaction for the standard is chosen.'), + }, + _meta: uiMeta(origin), + }, + async (args) => { + /** Deprecated alias: this tool shipped with `gradeHint`. */ + const audienceHint = args.audienceHint ?? args.gradeHint; + try { + const built = await buildWidget({ + topic: args.topic, + standardCode: args.standardCode, + kind: args.kind, + gradeHint: audienceHint, + }); + const summary = [ + `${built.widget.kind} for ${built.standard.code} — ${built.standard.description}`, + built.note ? `Note: ${built.note}` : null, + ] + .filter(Boolean) + .join('\n'); + + return { + content: [{ type: 'text' as const, text: summary }], + structuredContent: { spec: built.widget }, + _meta: uiMeta(origin, viewUri()), + }; + } catch (error) { + // Last resort: retry once with nothing but a topic. Almost every + // failure is a bad standard code or an impossible pairing, both of + // which a topic-only retry resolves — a prose apology renders nothing. + const topic = args.topic ?? args.standardCode; + if (topic && (args.standardCode || args.kind)) { + try { + const retry = await buildWidget({ topic, gradeHint: audienceHint }); + return { + content: [ + { + type: 'text' as const, + text: `${retry.widget.kind} for ${retry.standard.code}. (${ + error instanceof Error ? error.message : 'The first attempt failed' + })`, + }, + ], + structuredContent: { spec: retry.widget }, + _meta: uiMeta(origin, viewUri()), + }; + } catch { + // Fall through to reporting the original failure. + } + } + + const text = + error instanceof WidgetBuildError || error instanceof Error + ? error.message + : 'Could not build that widget.'; + return { content: [{ type: 'text' as const, text }], isError: true }; + } + }, + ); + + server.registerTool( + 'find_activity', + { + title: 'Find learning activities for a need', + description: FIND_ACTIVITY_DESCRIPTION, + inputSchema: { + topic: z.string().optional().describe('The learning need in plain words — "comparing fractions", "the water cycle".'), + standardCode: z.string().optional().describe('Optional. A Common Core or NGSS code if known; verified against the standards graph.'), + audienceHint: z + .string() + .optional() + .describe('Optional. Who this is for, in plain words — "4th grade", "intro stats", "new hires".'), + need: z.string().optional().describe('Free-text preference — "a game", "something they write", "a quick check".'), + }, + }, + async (args) => { + try { + // Named `audienceHint`, not `audience`, and the suffix is load-bearing: + // a manifest's `audience` is scheme-scoped and graph-derived, so + // reusing the bare name for unverified caller text would make one word + // mean two things across the same surface. + const found = await findActivities({ + topic: args.topic, + standardCode: args.standardCode, + need: args.need, + gradeHint: args.audienceHint, + }); + const header = found.standard + ? `${found.activities.length} activities for ${found.standard.code} — ${found.standard.description}` + + (found.standard.verified ? ' (verified)' : ' (unverified)') + : `No standard matched${found.rejectedCodes.length ? ` (tried ${found.rejectedCodes.join(', ')})` : ''}; ${found.activities.length} standard-agnostic activities.`; + const lines = found.activities + .slice(0, 8) + .map((activity) => `- ${activity.title}${activity.pedagogy.assesses ? ' (assesses)' : ''}: ${activity.summary}`); + + return { + content: [{ type: 'text' as const, text: [header, ...lines].join('\n') }], + structuredContent: found as unknown as Record, + }; + } catch (error) { + const text = error instanceof Error ? error.message : 'Discovery failed.'; + return { content: [{ type: 'text' as const, text }], isError: true }; + } + }, + ); + + server.registerTool( + 'build_pathway', + { + title: 'Build a complete multi-step learning pathway', + description: BUILD_PATHWAY_DESCRIPTION, + inputSchema: { + topic: z.string().describe('What the pathway teaches, in plain words — "comparing fractions", "the water cycle".'), + audienceHint: z.string().optional().describe('Optional. Who this is for — "5th grade", "honors chemistry", "new supervisors". An unverified steer, not a claim.'), + }, + }, + async ({ topic, audienceHint }) => { + const cleanTopic = topic.trim(); + if (!cleanTopic) { + return { content: [{ type: 'text' as const, text: 'A topic is required.' }], isError: true }; + } + + try { + const run = await runPathway(cleanTopic, audienceHint?.trim() || undefined); + + // Persistence is best-effort and separately guarded: a storage failure + // must not discard a pathway that took five model calls to build. + const adapter = storageAdapter(); + let ownerId: string | null = null; + let sessionId: string | null = null; + let saveReason: string | null = null; + try { + ownerId = await adapter.createStudent(); + if (!ownerId) { + saveReason = 'storage declined to create an owner id'; + } else { + sessionId = await adapter.persistSession({ + studentId: ownerId, + topic: cleanTopic, + gradeHint: audienceHint?.trim() || null, + anchor: run.anchor, + plan: run.plan, + stepWidgets: run.stepWidgets, + rejectedCodes: run.rejected, + }); + if (!sessionId) saveReason = 'storage is not accepting writes'; + } + } catch (saveError) { + saveReason = saveError instanceof Error ? saveError.message : 'unknown storage error'; + } + + const kindOf = (widget: unknown): string | null => + widget && typeof widget === 'object' && 'kind' in widget && typeof widget.kind === 'string' + ? widget.kind + : null; + + const steps = run.plan.steps.map((step, i) => { + const note = run.stepWidgetNotes[i]; + return `${i + 1}. [${step.purpose}] ${step.title}${note ? ` — note: ${note}` : ''}`; + }); + const verified = run.anchor.standard.verified + ? `verified against ${run.anchor.standard.sourceLabel}` + : 'no standard verified — this is an exploration pathway'; + const summary = [ + `Built "${run.plan.bigIdea}" — ${run.plan.steps.length} steps for ${run.anchor.standard.code} (${verified}).`, + ...steps, + sessionId + ? `Student link: ${origin}/learn/${sessionId}` + : `Not saved — ${saveReason ?? 'no reason recorded'}. The pathway above is complete; rebuilding is one call.`, + run.rejected.length > 0 ? `Rejected codes kept on record: ${run.rejected.join(', ')}` : null, + ] + .filter(Boolean) + .join('\n'); + + return { + content: [{ type: 'text' as const, text: summary }], + structuredContent: { + sessionId, + url: sessionId ? `${origin}/learn/${sessionId}` : null, + /** Keep this to edit the pathway later — the edit API gates on it. */ + ownerId, + standard: { code: run.anchor.standard.code, verified: run.anchor.standard.verified }, + steps: run.plan.steps.map((step, i) => { + const builtKind = kindOf(run.stepWidgets[i]); + return { + title: step.title, + purpose: step.purpose, + widgetKind: builtKind ?? step.widgetKind ?? null, + ...(builtKind && step.widgetKind && builtKind !== step.widgetKind + ? { plannedWidgetKind: step.widgetKind } + : {}), + ...(run.stepWidgetNotes[i] ? { note: run.stepWidgetNotes[i] } : {}), + }; + }), + rejectedCodes: run.rejected, + }, + }; + } catch (error) { + const text = error instanceof Error ? error.message : 'Pathway generation failed.'; + return { content: [{ type: 'text' as const, text }], isError: true }; + } + }, + ); + + server.registerTool( + 'score_draft', + { + title: "Score a student's draft against a standard", + description: + "The widget's scorer, reached through the host rather than by the iframe fetching the server — the channel the protocol provides for a sandboxed view.", + inputSchema: scoreRequest.shape, + }, + async (args) => { + const parsed = scoreRequest.safeParse(args); + if (!parsed.success) { + return { content: [{ type: 'text' as const, text: 'Malformed scoring request.' }], isError: true }; + } + try { + const scored = await scoreDraft(parsed.data); + return { + content: [{ type: 'text' as const, text: `Scored ${scored.score}: ${scored.label}` }], + structuredContent: scored as unknown as Record, + }; + } catch (error) { + const text = error instanceof Error ? error.message : 'Scoring failed.'; + return { content: [{ type: 'text' as const, text }], isError: true }; + } + }, + ); + + return server; +} From fd815b410f8fbfb69b8f975ec89337356d18003b Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 16:22:18 -0400 Subject: [PATCH 2/4] test: the audience test follows the rename to audienceHint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool surface iterated in a parallel session: the free-text field is audienceHint on all three tools, keeping the bare word reserved for the scheme-scoped, graph-verified audience on emitted manifests. The contract test now pins both halves — the hint exists under its suffixed name, and no tool exposes a bare audience input to collide with the verified one. Co-Authored-By: Claude Fable 5 --- src/app/api/mcp/route.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index d38081c3..cc78e90f 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -62,7 +62,7 @@ describe('/api/mcp protocol surface', () => { expect(show._meta.ui.resourceUri).toMatch(/^ui:\/\//); }); - it('takes audience, not a grade, and keeps the alias show_widget shipped with', async () => { + it('takes an audience hint, not a grade, and keeps the alias show_widget shipped with', async () => { const { json } = await rpc({ jsonrpc: '2.0', id: 21, method: 'tools/list' }); const { result } = json; const props = (name: string) => @@ -70,9 +70,12 @@ describe('/api/mcp protocol surface', () => { // Segment-neutral by name: nothing in the surface presumes the learner // is in a grade, so a higher-ed or workplace deployment has an honest - // argument to pass instead of one that lies. + // argument to pass instead of one that lies. The `Hint` suffix is part + // of the contract too — `audience` stays reserved for the scheme-scoped, + // graph-verified field on emitted manifests. for (const name of ['show_widget', 'find_activity', 'build_pathway']) { - expect(props(name).audience?.type).toBe('string'); + expect(props(name).audienceHint?.type).toBe('string'); + expect(props(name)).not.toHaveProperty('audience'); } // These two are new here, so they never carried the old name. From 66f529077f26604fbd42e7855c45ee30d020f8e9 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 18:34:24 -0400 Subject: [PATCH 3/4] fix: a GET asking for the SSE stream gets the spec's 405 Streamable HTTP lets clients open a server-to-client stream with GET; this transport is stateless and never initiates messages, so there is no stream to offer, and the spec's answer is 405 with Allow. A client told no definitively stops asking, where a 200 of unparseable JSON invites a reconnect loop. The Accept header separates that client from a human checking the URL, who still gets the useful info response. Authored in a parallel session; committed with its tests. Co-Authored-By: Claude Fable 5 --- src/app/api/mcp/route.test.ts | 32 +++++++++++++++++++++++++++++++- src/app/api/mcp/route.ts | 16 +++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index cc78e90f..83599684 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { POST } from './route'; +import { GET, POST } from './route'; /** * Protocol-level integration: drive the deployed endpoint's handler with real @@ -128,3 +128,33 @@ describe('/api/mcp protocol surface', () => { expect(json.result?.isError ?? (json.error?.code === -32602)).toBe(true); }); }); + +describe('/api/mcp GET', () => { + const get = (accept?: string) => + GET(new Request('https://example.test/api/mcp', accept ? { headers: { Accept: accept } } : undefined)); + + it('declines the SSE stream with 405 so clients stop reconnecting', async () => { + const res = await get('text/event-stream'); + expect(res.status).toBe(405); + expect(res.headers.get('allow')).toBe('POST, OPTIONS'); + }); + + // The negative control: without it a handler that 405s everything would + // pass the assertion above while silently breaking the "add this URL" + // affordance the endpoint is documented by. + it('still answers a browser with the connector hint', async () => { + const res = await get('text/html,application/xhtml+xml'); + expect(res.status).toBe(200); + expect((await res.json()).transport).toBe('streamable-http'); + }); + + it('treats a client asking for both content types as the stream probe', async () => { + // Streamable HTTP clients send `application/json, text/event-stream` on + // the stream request too, so matching the stream type has to win. + expect((await get('application/json, text/event-stream')).status).toBe(405); + }); + + it('answers a GET with no Accept header as a human', async () => { + expect((await get()).status).toBe(200); + }); +}); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index b7901202..9a5017d1 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -36,8 +36,22 @@ export async function OPTIONS() { return new Response(null, { status: 204, headers: CORS }); } -/** A GET here is a human checking the URL works, not a client. Say something useful. */ +/** + * A GET is one of two things: a human checking the URL works, or a client + * opening the server-to-client SSE stream Streamable HTTP defines. + * + * This transport is stateless and never initiates a message, so there is no + * stream to open, and the spec's answer for that is 405. It matters more than + * tidiness: a client told "no stream here" definitively stops asking, while a + * 200 carrying JSON it cannot parse is ambiguous enough to invite a reconnect + * loop. The Accept header is what separates the two callers, so the human + * still gets something useful. + */ export async function GET(request: Request) { + if ((request.headers.get('accept') ?? '').includes('text/event-stream')) { + return new Response(null, { status: 405, headers: { ...CORS, Allow: 'POST, OPTIONS' } }); + } + return Response.json( { name: 'interactive-learning-widgets', From 9518e8252fb565b2694a7f51d151e939969b4042 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 20:39:08 -0400 Subject: [PATCH 4/4] docs: the shared server carries the arc-as-spine guidance too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same rewrite as the hand-rolled route below: the live loop keeps the activate → model → practice → check arc as its spine, with evidence setting pacing inside it. One tool surface, one pedagogy, both transports say it identically. Co-Authored-By: Claude Fable 5 --- src/lib/mcp/server.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index b4d8ac1c..1cac2e09 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -65,12 +65,15 @@ const INSTRUCTIONS = [ 'is in a line, and let the student use it. When they finish, the widget tells you what they did,', 'and that is the moment to respond to their work or offer the next thing.', '', - 'Two ways to teach a sequence. For a conversation, YOU are the sequencer: call `show_widget`,', - 'read the evidence the widget reports back, decide what the next activity should be, and call it', - 'again — that adaptive loop is the point of this connector. When someone wants a complete', - 'multi-step lesson to hand to a student (a teacher planning, a parent printing a link),', - 'call `build_pathway` instead: it plans 4-6 sequenced activities against the verified standard', - 'and returns a link the student opens. It takes about half a minute; say so, then call it.', + 'Two ways to teach a sequence — one pedagogy. The planned arc (activate prior knowledge →', + 'model the idea → practice → check) is what makes a sequence teach, so keep it either way.', + 'In a live conversation, YOU deliver that arc: sequence `show_widget` calls through those', + 'stages, read the evidence each widget reports back, and let it set the pacing — linger,', + 'remediate, or advance, but inside the arc rather than wandering. Calling `build_pathway`', + 'first and using its returned plan as your spine is a fine way to start. When someone wants', + 'a complete lesson to hand off (a teacher planning, a parent printing a link), call', + '`build_pathway` for the link itself: 4-6 sequenced activities against the verified standard.', + 'It takes about half a minute; say so, then call it.', ].join('\n'); const SHOW_WIDGET_DESCRIPTION = [