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..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 @@ -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,19 +62,19 @@ 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 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) => result.tools.find((tool: { name: string }) => tool.name === name).inputSchema.properties; // 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).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'); } @@ -65,36 +88,73 @@ 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); + }); +}); + +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 9ed4b10c..9a5017d1 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,33 +15,43 @@ 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 }); } -/** 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', @@ -56,589 +64,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.'); + throw new Error('The widget shell is missing — run `pnpm mcp:build` and redeploy.'); } + return shell.text(); + }, + }); - /** - * 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 }); - } - } - - 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..1cac2e09 --- /dev/null +++ b/src/lib/mcp/server.ts @@ -0,0 +1,445 @@ +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 — 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 = [ + '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; +}