From 07643ed97596fce817447bedd65a739e58b62b56 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 11:51:15 -0400 Subject: [PATCH 1/6] feat: build_pathway over MCP, and a richer widget-to-agent channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pathways reach MCP hosts two ways now, matching how agents actually work: - build_pathway: the full pipeline as one tool call (~30s, maxDuration raised) — plans 4-6 sequenced activities against a verified standard, persists the session under a minted learner id, and returns the /learn/{sessionId} student link plus a structured plan summary. With no persistent storage it says so honestly instead of inventing a link. - The connector instructions now teach the other way explicitly: in a live conversation the agent IS the sequencer — show_widget, read the evidence, choose the next activity. The evidence channel back to the agent gets two upgrades: - Completion reports now carry a structured widget_result block (kind, standard, correct, attempts, hints, score, detail) alongside the prose sentence — the shape the SDK's universal WidgetResult converges on — via updateModelContext growing an optional detail rider (host bridge and the in-widget reportToConversation both). - One early struggle signal: after three wrong checks without completion, the conversation hears about stuck work once, so an agent can help before the finish line instead of only after it. Shell rebuilt (public/widget-shell.html). Co-Authored-By: Claude Fable 5 --- mcp/host-bridge.ts | 9 +++- mcp/report-to-host.ts | 45 ++++++++++++++++++- src/app/api/mcp/route.ts | 96 +++++++++++++++++++++++++++++++++++++++- src/lib/mcp/report.ts | 10 ++++- src/lib/pathway/run.ts | 41 +++++++++++++++++ 5 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 src/lib/pathway/run.ts diff --git a/mcp/host-bridge.ts b/mcp/host-bridge.ts index b3a9693f..eb28b4ba 100644 --- a/mcp/host-bridge.ts +++ b/mcp/host-bridge.ts @@ -133,7 +133,12 @@ export class HostBridge { } /** Say something back into the conversation the widget is sitting in. */ - updateModelContext(text: string) { - return this.request('ui/update-model-context', { content: [{ type: 'text', text }] }); + updateModelContext(text: string, detail?: Record) { + // Prose first — hosts feed this to a model, and the sentence is the + // message. The structured block rides along so the model (or the host's + // tooling) can read exact fields instead of parsing English. + const content: { type: 'text'; text: string }[] = [{ type: 'text', text }]; + if (detail) content.push({ type: 'text', text: '```json\n' + JSON.stringify(detail) + '\n```' }); + return this.request('ui/update-model-context', { content }); } } diff --git a/mcp/report-to-host.ts b/mcp/report-to-host.ts index 6f9f238f..7cfa233d 100644 --- a/mcp/report-to-host.ts +++ b/mcp/report-to-host.ts @@ -46,19 +46,62 @@ function describe(event: Event, attempts: number): string { return `The student worked through the ${kind}${standard} ${outcome}.${struggle}${score}`; } +/** How many failed checks before the conversation hears about it early. */ +const STRUGGLE_AFTER_ATTEMPTS = 3; + export function reportCompletionToHost(bridge: HostBridge) { let attempts = 0; + let hints = 0; let reported = false; + let struggleReported = false; return { track(event: Event) { // Every attempt at an answer counts, whatever the widget calls it. if (event.eventType === 'answer_checked' || event.eventType === 'attempt') attempts += 1; + if (event.eventType === 'hint_requested') hints += 1; + + // One early signal, before the finish line: an agent that only hears + // about completed work can never help with stuck work. Sent once, only + // for repeated *wrong* checks — a slow careful student is not stuck. + if ( + !reported && + !struggleReported && + event.eventType === 'answer_checked' && + event.correct === false && + attempts >= STRUGGLE_AFTER_ATTEMPTS + ) { + struggleReported = true; + void bridge.updateModelContext( + `The student is still working through the ${event.widgetKind ?? 'activity'} and has checked ${attempts} answers without getting it yet. They have not asked for help.`, + { + type: 'widget_progress', + kind: event.widgetKind ?? null, + standardCode: event.standardCode ?? null, + attempts, + hintsUsed: hints, + completed: false, + }, + ); + } if (event.eventType !== 'widget_completed' || reported) return; reported = true; - void bridge.updateModelContext(describe(event, attempts)); + // Prose for the model to respond to, plus the structured result — the + // same shape the SDK's universal WidgetResult is converging on — so + // exact fields survive without parsing English. + void bridge.updateModelContext(describe(event, attempts), { + type: 'widget_result', + kind: event.widgetKind ?? null, + standardCode: event.standardCode ?? null, + completed: true, + correct: event.correct ?? null, + attempts, + hintsUsed: hints, + score: typeof event.payload?.score === 'number' ? event.payload.score : undefined, + detail: event.payload ?? undefined, + }); }, trackHesitation() {}, flush() {}, diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index ca630b22..5987634a 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -1,9 +1,13 @@ 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'; -export const maxDuration = 60; +// Long enough for `build_pathway` (~30s across five model calls), not just +// the single-call `show_widget`. +export const maxDuration = 120; /** * The MCP server, deployed with the app. @@ -112,6 +116,13 @@ const INSTRUCTIONS = [ '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 TOOL_DESCRIPTION = [ @@ -212,6 +223,32 @@ function tools(origin: string) { }, _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".', + }, + gradeHint: { type: 'string', description: 'Optional, e.g. "5th grade".' }, + }, + required: ['topic'], + }, + }, { name: 'score_draft', title: 'Score a draft', @@ -380,6 +417,63 @@ async function handle(message: RpcRequest, request: Request) { } } + if (message.params?.name === 'build_pathway') { + const args = (message.params?.arguments ?? {}) as { topic?: string; gradeHint?: 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.gradeHint?.trim() || undefined); + + // The session needs an owner the storage layer knows. MCP callers + // have no browser learner id, so one is minted per pathway — the + // share link is the artifact; the id is bookkeeping. + const studentId = await storageAdapter().createStudent(); + const sessionId = studentId + ? await storageAdapter().persistSession({ + studentId, + topic, + gradeHint: args.gradeHint?.trim() || null, + anchor: run.anchor, + plan: run.plan, + stepWidgets: run.stepWidgets, + rejectedCodes: run.rejected, + }) + : null; + + const steps = run.plan.steps.map((step, i) => `${i + 1}. [${step.purpose}] ${step.title}`); + 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 — this instance has no persistent storage, so there is no share link. The pathway can still be rebuilt any time.', + 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, + standard: { code: run.anchor.standard.code, verified: run.anchor.standard.verified }, + steps: run.plan.steps.map((step) => ({ title: step.title, purpose: step.purpose, widgetKind: step.widgetKind ?? null })), + 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)}`); } diff --git a/src/lib/mcp/report.ts b/src/lib/mcp/report.ts index 84d4ed0a..10553a23 100644 --- a/src/lib/mcp/report.ts +++ b/src/lib/mcp/report.ts @@ -47,18 +47,24 @@ function isEmbedded(): boolean { * Repeats are dropped: a widget that re-renders, or a student who checks the * same answer twice, should not narrate itself twice into the transcript. */ -export function reportToConversation(text: string): void { +export function reportToConversation(text: string, detail?: Record): void { const message = text.trim(); if (!message || message === lastSent || !isEmbedded()) return; lastSent = message; + // Prose leads — it is what the model responds to. The optional structured + // block rides along for exact fields (the same convention the shell's + // completion reporter uses), so richer widget reports can carry both. + const content: { type: 'text'; text: string }[] = [{ type: 'text', text: message }]; + if (detail) content.push({ type: 'text', text: '```json\n' + JSON.stringify(detail) + '\n```' }); + window.parent?.postMessage( { jsonrpc: '2.0', id: nextId++, method: 'ui/update-model-context', - params: { content: [{ type: 'text', text: message }] }, + params: { content }, }, '*', ); diff --git a/src/lib/pathway/run.ts b/src/lib/pathway/run.ts new file mode 100644 index 00000000..216b0c53 --- /dev/null +++ b/src/lib/pathway/run.ts @@ -0,0 +1,41 @@ +import type { Anchor } from '@/lib/pathway/events'; +import { streamPathway } from '@/lib/pathway/generate'; +import type { PathwayPlan } from '@/lib/pathway/schema'; + +export type PathwayRun = { + anchor: Anchor; + plan: PathwayPlan; + stepWidgets: Record; + rejected: string[]; +}; + +/** + * The pipeline as a single promise instead of a stream. + * + * `streamPathway` is the real thing; the app consumes it event by event so + * the in-progress state can be the design. A protocol caller (the MCP + * `build_pathway` tool) has nowhere to put intermediate events — a tool call + * returns once — so this collects the same stream into the same shape the + * NDJSON route accumulates, and throws where the route would have emitted an + * error event. + */ +export async function runPathway(topic: string, gradeHint?: string): Promise { + let anchor: Anchor | null = null; + let plan: PathwayPlan | null = null; + const stepWidgets: Record = {}; + const rejected: string[] = []; + + for await (const event of streamPathway(topic, gradeHint, null)) { + if (event.type === 'anchor') anchor = event.anchor; + if (event.type === 'plan') plan = event.plan; + if (event.type === 'step-widget') stepWidgets[event.stepIndex] = event.widget; + if (event.type === 'verdict' && !event.resolved) rejected.push(event.code); + if (event.type === 'error') throw new Error(event.message); + } + + if (!anchor || !plan) { + throw new Error('The run did not produce a pathway — no anchor or plan came back.'); + } + + return { anchor, plan, stepWidgets, rejected }; +} From 4cafd4d4e650feec0389fa586cea64c19446a034 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 13:23:52 -0400 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20review=20findings=20=E2=80=94=20hone?= =?UTF-8?q?st=20saves,=20safe=20reporting,=20per-widget=20counters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the pre-merge review pass: - build_pathway: persistence is separately guarded so a storage failure returns the built pathway with the real reason instead of discarding five model calls behind a wrong 'no persistent storage' guess; the minted ownerId is returned (the edit endpoint gates on it — discarding it made MCP-built pathways permanently uneditable); per-step widgetKind now reports the kind actually built, with the planned kind and the substitution note carried when generation fell back. - runPathway: passes through teacherNote/lessonPlanExcerpt so the assign route's third copy of the collect loop can converge on it; collects step notes; drops the dead in-band error branch (the generator throws). - Struggle signal: consecutive-wrong streak with a progress heuristic (solved/placed/matched counters reset it), so a student steadily solving a crossword is not reported as stuck; counters reset when the host hands the frame a new spec, so widget B never inherits widget A's attempts or its already-reported silence. - Model-context detail blocks: size-capped and fenced with more backticks than the content contains, so payload text can't break into prose; the dedupe key includes the detail, not just the sentence. Co-Authored-By: Claude Fable 5 --- mcp/host-bridge.ts | 13 +++++- mcp/report-to-host.ts | 89 ++++++++++++++++++++++++++++++---------- mcp/shell.tsx | 9 ++++ src/app/api/mcp/route.ts | 64 +++++++++++++++++++++++------ src/lib/mcp/report.ts | 23 ++++++++--- src/lib/pathway/run.ts | 33 +++++++++++---- 6 files changed, 183 insertions(+), 48 deletions(-) diff --git a/mcp/host-bridge.ts b/mcp/host-bridge.ts index eb28b4ba..6a5eabab 100644 --- a/mcp/host-bridge.ts +++ b/mcp/host-bridge.ts @@ -136,9 +136,18 @@ export class HostBridge { updateModelContext(text: string, detail?: Record) { // Prose first — hosts feed this to a model, and the sentence is the // message. The structured block rides along so the model (or the host's - // tooling) can read exact fields instead of parsing English. + // tooling) can read exact fields instead of parsing English. The block + // is capped (payloads carry model-generated text of unbounded size) and + // fenced with more backticks than the content contains, so a payload + // string with ``` in it cannot break out of the fence into prose. const content: { type: 'text'; text: string }[] = [{ type: 'text', text }]; - if (detail) content.push({ type: 'text', text: '```json\n' + JSON.stringify(detail) + '\n```' }); + if (detail) { + let json = JSON.stringify(detail); + if (json.length > 4000) json = `${json.slice(0, 4000)}… (truncated)`; + const longestRun = json.match(/`+/g)?.reduce((max, run) => Math.max(max, run.length), 0) ?? 0; + const fence = '`'.repeat(Math.max(3, longestRun + 1)); + content.push({ type: 'text', text: `${fence}json\n${json}\n${fence}` }); + } return this.request('ui/update-model-context', { content }); } } diff --git a/mcp/report-to-host.ts b/mcp/report-to-host.ts index 7cfa233d..9d27620f 100644 --- a/mcp/report-to-host.ts +++ b/mcp/report-to-host.ts @@ -12,10 +12,9 @@ import type { HostBridge } from './host-bridge'; * go in here, since the shell provides no telemetry sink. So this is not new * instrumentation, only a destination for what was always being reported. * - * Deliberately quiet: one message when the activity is finished, and nothing - * else. Narrating every keystroke into the transcript would bury the - * conversation in a student's typing, which is the same mistake the meter - * itself was tuned away from. + * Deliberately quiet: one message when the activity is finished, at most one + * struggle signal before it, and nothing else. Narrating every keystroke + * into the transcript would bury the conversation in a student's typing. */ type Event = { @@ -46,42 +45,77 @@ function describe(event: Event, attempts: number): string { return `The student worked through the ${kind}${standard} ${outcome}.${struggle}${score}`; } -/** How many failed checks before the conversation hears about it early. */ -const STRUGGLE_AFTER_ATTEMPTS = 3; +/** How many consecutive wrong checks before the conversation hears about it early. */ +const STRUGGLE_AFTER_WRONG = 3; + +/** + * Payload fields that mean "the student is advancing through a multi-part + * widget". Crossword and friends emit `answer_checked` with `correct: false` + * meaning "not finished yet" while these counters climb — that is progress, + * not struggle, and must not trigger the early signal. Heuristic on purpose; + * the durable fix is completion semantics on the registry entry, tracked in + * the registry-owned-semantics refactor. + */ +const PROGRESS_FIELDS = ['solved', 'placed', 'matched', 'correctCount', 'revealed'] as const; + +function progressReading(payload: Record | undefined): number { + if (!payload) return 0; + let total = 0; + for (const field of PROGRESS_FIELDS) { + const value = payload[field]; + if (typeof value === 'number') total += value; + } + return total; +} export function reportCompletionToHost(bridge: HostBridge) { let attempts = 0; let hints = 0; + let wrongStreak = 0; + let lastProgress = 0; let reported = false; let struggleReported = false; + /** Shared base for both report shapes, so they cannot drift apart. */ + const resultBase = (event: Event) => ({ + kind: event.widgetKind ?? null, + standardCode: event.standardCode ?? null, + attempts, + hintsUsed: hints, + }); + return { track(event: Event) { // Every attempt at an answer counts, whatever the widget calls it. - if (event.eventType === 'answer_checked' || event.eventType === 'attempt') attempts += 1; + if (event.eventType === 'answer_checked' || event.eventType === 'attempt') { + attempts += 1; + + const progress = progressReading(event.payload); + if (event.correct === true || progress > lastProgress) { + wrongStreak = 0; + } else if (event.correct === false) { + wrongStreak += 1; + } + lastProgress = Math.max(lastProgress, progress); + } if (event.eventType === 'hint_requested') hints += 1; // One early signal, before the finish line: an agent that only hears - // about completed work can never help with stuck work. Sent once, only - // for repeated *wrong* checks — a slow careful student is not stuck. + // about completed work can never help with stuck work. Sent once, and + // only for consecutive wrong checks with no visible progress — a slow + // careful student, or one steadily solving a multi-part widget, is not + // stuck. if ( !reported && !struggleReported && event.eventType === 'answer_checked' && event.correct === false && - attempts >= STRUGGLE_AFTER_ATTEMPTS + wrongStreak >= STRUGGLE_AFTER_WRONG ) { struggleReported = true; void bridge.updateModelContext( `The student is still working through the ${event.widgetKind ?? 'activity'} and has checked ${attempts} answers without getting it yet. They have not asked for help.`, - { - type: 'widget_progress', - kind: event.widgetKind ?? null, - standardCode: event.standardCode ?? null, - attempts, - hintsUsed: hints, - completed: false, - }, + { type: 'widget_progress', ...resultBase(event), completed: false }, ); } @@ -93,17 +127,28 @@ export function reportCompletionToHost(bridge: HostBridge) { // exact fields survive without parsing English. void bridge.updateModelContext(describe(event, attempts), { type: 'widget_result', - kind: event.widgetKind ?? null, - standardCode: event.standardCode ?? null, + ...resultBase(event), completed: true, correct: event.correct ?? null, - attempts, - hintsUsed: hints, score: typeof event.payload?.score === 'number' ? event.payload.score : undefined, detail: event.payload ?? undefined, }); }, trackHesitation() {}, flush() {}, + /** + * A host can hand the same frame a new widget (a second tool result). + * Counters describe one activity, so the shell calls this when the spec + * changes — widget B must not inherit widget A's attempts, or its + * already-reported silence. + */ + reset() { + attempts = 0; + hints = 0; + wrongStreak = 0; + lastProgress = 0; + reported = false; + struggleReported = false; + }, }; } diff --git a/mcp/shell.tsx b/mcp/shell.tsx index dd8aee57..6c4385c5 100644 --- a/mcp/shell.tsx +++ b/mcp/shell.tsx @@ -174,6 +174,15 @@ function Shell() { const spec = hostSpec ?? window.__WIDGET_SPEC__; + // A host can re-use this frame for a second tool result. The completion + // tracker's counters describe one activity — reset them whenever the spec + // object changes so widget B doesn't inherit widget A's attempt count or + // its already-reported silence. (A re-delivered identical spec also resets: + // over-counting a restart beats permanently silencing the frame.) + useEffect(() => { + telemetry.reset(); + }, [spec]); + if (!spec) { return (

diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 5987634a..c36556b0 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -427,23 +427,47 @@ async function handle(message: RpcRequest, request: Request) { try { const run = await runPathway(topic, args.gradeHint?.trim() || undefined); - // The session needs an owner the storage layer knows. MCP callers - // have no browser learner id, so one is minted per pathway — the - // share link is the artifact; the id is bookkeeping. - const studentId = await storageAdapter().createStudent(); - const sessionId = studentId - ? await storageAdapter().persistSession({ - studentId, + // 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.gradeHint?.trim() || null, anchor: run.anchor, plan: run.plan, stepWidgets: run.stepWidgets, rejectedCodes: run.rejected, - }) - : null; + }); + if (!sessionId) saveReason = 'storage is not accepting writes'; + } + } catch (saveError) { + saveReason = saveError instanceof Error ? saveError.message : 'unknown storage error'; + } - const steps = run.plan.steps.map((step, i) => `${i + 1}. [${step.purpose}] ${step.title}`); + 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'; @@ -452,7 +476,7 @@ async function handle(message: RpcRequest, request: Request) { ...steps, sessionId ? `Student link: ${origin}/learn/${sessionId}` - : 'Not saved — this instance has no persistent storage, so there is no share link. The pathway can still be rebuilt any time.', + : `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) @@ -463,8 +487,24 @@ async function handle(message: RpcRequest, request: Request) { 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) => ({ title: step.title, purpose: step.purpose, widgetKind: step.widgetKind ?? null })), + // 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, }, }); diff --git a/src/lib/mcp/report.ts b/src/lib/mcp/report.ts index 10553a23..fb5347c0 100644 --- a/src/lib/mcp/report.ts +++ b/src/lib/mcp/report.ts @@ -49,15 +49,28 @@ function isEmbedded(): boolean { */ export function reportToConversation(text: string, detail?: Record): void { const message = text.trim(); - if (!message || message === lastSent || !isEmbedded()) return; - - lastSent = message; + if (!message || !isEmbedded()) return; // Prose leads — it is what the model responds to. The optional structured // block rides along for exact fields (the same convention the shell's - // completion reporter uses), so richer widget reports can carry both. + // completion reporter uses; the cap and over-long fence are mirrored there + // too, since the two files deliberately share a wire format, not code). const content: { type: 'text'; text: string }[] = [{ type: 'text', text: message }]; - if (detail) content.push({ type: 'text', text: '```json\n' + JSON.stringify(detail) + '\n```' }); + let detailBlock: string | null = null; + if (detail) { + let json = JSON.stringify(detail); + if (json.length > 4000) json = `${json.slice(0, 4000)}… (truncated)`; + const longestRun = json.match(/`+/g)?.reduce((max, run) => Math.max(max, run.length), 0) ?? 0; + const fence = '`'.repeat(Math.max(3, longestRun + 1)); + detailBlock = `${fence}json\n${json}\n${fence}`; + content.push({ type: 'text', text: detailBlock }); + } + + // Dedupe on everything sent, not just the prose — identical sentences with + // different structured detail are different reports. + const dedupeKey = detailBlock ? `${message}\n${detailBlock}` : message; + if (dedupeKey === lastSent) return; + lastSent = dedupeKey; window.parent?.postMessage( { diff --git a/src/lib/pathway/run.ts b/src/lib/pathway/run.ts index 216b0c53..11e96d5e 100644 --- a/src/lib/pathway/run.ts +++ b/src/lib/pathway/run.ts @@ -6,6 +6,8 @@ export type PathwayRun = { anchor: Anchor; plan: PathwayPlan; stepWidgets: Record; + /** Per-step degradation notes — e.g. "draft-meter didn't fit, built a fallback". */ + stepWidgetNotes: Record; rejected: string[]; }; @@ -16,26 +18,43 @@ export type PathwayRun = { * the in-progress state can be the design. A protocol caller (the MCP * `build_pathway` tool) has nowhere to put intermediate events — a tool call * returns once — so this collects the same stream into the same shape the - * NDJSON route accumulates, and throws where the route would have emitted an - * error event. + * NDJSON route accumulates. Generator failures throw and propagate out of + * the `for await` — there is no in-band error event to handle. + * + * The pass-through options mirror `streamPathway`'s remaining parameters so + * other collect-the-stream callers (the assign route carries a third copy of + * this loop) can converge on this helper instead of forking it. */ -export async function runPathway(topic: string, gradeHint?: string): Promise { +export async function runPathway( + topic: string, + gradeHint?: string, + extras?: { teacherNote?: string; lessonPlanExcerpt?: string }, +): Promise { let anchor: Anchor | null = null; let plan: PathwayPlan | null = null; const stepWidgets: Record = {}; + const stepWidgetNotes: Record = {}; const rejected: string[] = []; - for await (const event of streamPathway(topic, gradeHint, null)) { + for await (const event of streamPathway( + topic, + gradeHint, + null, + extras?.teacherNote, + extras?.lessonPlanExcerpt, + )) { if (event.type === 'anchor') anchor = event.anchor; if (event.type === 'plan') plan = event.plan; - if (event.type === 'step-widget') stepWidgets[event.stepIndex] = event.widget; + if (event.type === 'step-widget') { + stepWidgets[event.stepIndex] = event.widget; + if (event.note) stepWidgetNotes[event.stepIndex] = event.note; + } if (event.type === 'verdict' && !event.resolved) rejected.push(event.code); - if (event.type === 'error') throw new Error(event.message); } if (!anchor || !plan) { throw new Error('The run did not produce a pathway — no anchor or plan came back.'); } - return { anchor, plan, stepWidgets, rejected }; + return { anchor, plan, stepWidgets, stepWidgetNotes, rejected }; } From c57e5a5663e8618cae0df6717710c4b9c50ab5bf Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 15:13:09 -0400 Subject: [PATCH 3/6] test: the reporting state machine, the collector, and the protocol envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - report-to-host: completion reports once with the structured result; struggle fires exactly once after three consecutive wrongs; steady multi-part progress (the crossword case that review caught) never triggers it; a right answer resets the streak; reset() gives a reused frame fresh counters. The fence logic moves to a shared pure helper (src/lib/mcp/fence.ts) used by both reporters — tested for escape resistance and the size cap. - runPathway: collects anchor/plan/widgets/notes/rejections from a mocked pipeline, throws on a hollow run, passes teacher context through — the collector contract the MCP handler leans on. - /api/mcp protocol envelope: initialize, the four-tool listing with MCP Apps metadata, resource listing, unknown method/tool codes, parse errors, and build_pathway's topic guard — pinned with real Requests. ('server-only' stubbed in vitest resolve; it has no runtime outside Next.) Co-Authored-By: Claude Fable 5 --- mcp/host-bridge.ts | 10 +--- src/app/api/mcp/route.test.ts | 75 +++++++++++++++++++++++ src/lib/mcp/fence.ts | 21 +++++++ src/lib/mcp/report-to-host.test.ts | 96 ++++++++++++++++++++++++++++++ src/lib/mcp/report.ts | 13 ++-- src/lib/pathway/run.test.ts | 61 +++++++++++++++++++ test/stubs/server-only.ts | 1 + vitest.config.mts | 3 + 8 files changed, 264 insertions(+), 16 deletions(-) create mode 100644 src/app/api/mcp/route.test.ts create mode 100644 src/lib/mcp/fence.ts create mode 100644 src/lib/mcp/report-to-host.test.ts create mode 100644 src/lib/pathway/run.test.ts create mode 100644 test/stubs/server-only.ts diff --git a/mcp/host-bridge.ts b/mcp/host-bridge.ts index 6a5eabab..3fdc2c21 100644 --- a/mcp/host-bridge.ts +++ b/mcp/host-bridge.ts @@ -1,3 +1,5 @@ +import { fencedDetailBlock } from '../src/lib/mcp/fence'; + /** * JSON-RPC over postMessage, the way an MCP App talks to its host. * @@ -141,13 +143,7 @@ export class HostBridge { // fenced with more backticks than the content contains, so a payload // string with ``` in it cannot break out of the fence into prose. const content: { type: 'text'; text: string }[] = [{ type: 'text', text }]; - if (detail) { - let json = JSON.stringify(detail); - if (json.length > 4000) json = `${json.slice(0, 4000)}… (truncated)`; - const longestRun = json.match(/`+/g)?.reduce((max, run) => Math.max(max, run.length), 0) ?? 0; - const fence = '`'.repeat(Math.max(3, longestRun + 1)); - content.push({ type: 'text', text: `${fence}json\n${json}\n${fence}` }); - } + if (detail) content.push({ type: 'text', text: fencedDetailBlock(detail) }); return this.request('ui/update-model-context', { content }); } } diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts new file mode 100644 index 00000000..2d01fd63 --- /dev/null +++ b/src/app/api/mcp/route.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { POST } from './route'; + +/** + * Protocol-level integration: drive the deployed endpoint's handler with real + * Requests and assert the wire contract — the part a host depends on before + * any model call happens. Tool *execution* is covered by unit tests + * (find.test.ts, run.test.ts) and the eval-harness roadmap; this file pins + * the envelope. + */ +function rpc(body: unknown) { + return POST( + new Request('https://example.test/api/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ); +} + +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(); + 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 names = result.tools.map((tool: { name: string }) => tool.name); + expect(names).toEqual( + expect.arrayContaining(['show_widget', 'find_activity', 'build_pathway', 'score_draft']), + ); + const show = result.tools.find((tool: { name: string }) => tool.name === 'show_widget'); + expect(show._meta.ui.resourceUri).toMatch(/^ui:\/\//); + }); + + it('serves the shell resource listing', async () => { + const res = await rpc({ jsonrpc: '2.0', id: 3, method: 'resources/list' }); + const { result } = await res.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); + + 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); + }); + + 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' }), + ); + 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({ + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { name: 'build_pathway', arguments: {} }, + }); + const { result } = await res.json(); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/topic/i); + }); +}); diff --git a/src/lib/mcp/fence.ts b/src/lib/mcp/fence.ts new file mode 100644 index 00000000..86389074 --- /dev/null +++ b/src/lib/mcp/fence.ts @@ -0,0 +1,21 @@ +/** + * The one safe way to put a structured detail block into model context. + * + * Payloads carry model-generated text of unbounded size that can legitimately + * contain ``` — a fence the content can close is a fence the content can + * escape, spilling data into the transcript as prose. So: cap the size, then + * fence with more backticks than the longest run inside. + * + * Shared by the widget-side reporter (src/lib/mcp/report.ts) and the shell's + * host bridge (mcp/host-bridge.ts) — the two deliberately share this pure + * helper and nothing else, so neither drags the other's runtime along. + */ +const MAX_DETAIL_CHARS = 4000; + +export function fencedDetailBlock(detail: Record): string { + let json = JSON.stringify(detail); + if (json.length > MAX_DETAIL_CHARS) json = `${json.slice(0, MAX_DETAIL_CHARS)}… (truncated)`; + const longestRun = json.match(/`+/g)?.reduce((max, run) => Math.max(max, run.length), 0) ?? 0; + const fence = '`'.repeat(Math.max(3, longestRun + 1)); + return `${fence}json\n${json}\n${fence}`; +} diff --git a/src/lib/mcp/report-to-host.test.ts b/src/lib/mcp/report-to-host.test.ts new file mode 100644 index 00000000..82784843 --- /dev/null +++ b/src/lib/mcp/report-to-host.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import { fencedDetailBlock } from '@/lib/mcp/fence'; +import { reportCompletionToHost } from '../../../mcp/report-to-host'; + +type Sent = { text: string; detail?: Record }; + +function harness() { + const sent: Sent[] = []; + const bridge = { + updateModelContext(text: string, detail?: Record) { + sent.push({ text, detail }); + return Promise.resolve(undefined); + }, + }; + // The tracker only calls updateModelContext; the rest of HostBridge is irrelevant here. + return { sent, tracker: reportCompletionToHost(bridge as never) }; +} + +const wrong = (kind = 'drag-sort', payload?: Record) => ({ + eventType: 'answer_checked', + widgetKind: kind, + correct: false, + payload, +}); + +describe('reportCompletionToHost', () => { + it('reports completion once, with the structured result riding along', () => { + const { sent, tracker } = harness(); + tracker.track(wrong()); + tracker.track({ eventType: 'widget_completed', widgetKind: 'drag-sort', correct: true, payload: { score: 80 } }); + tracker.track({ eventType: 'widget_completed', widgetKind: 'drag-sort', correct: true }); + + expect(sent).toHaveLength(1); + expect(sent[0].detail).toMatchObject({ type: 'widget_result', completed: true, correct: true, attempts: 1, score: 80 }); + }); + + it('fires the struggle signal once after three consecutive wrong checks', () => { + const { sent, tracker } = harness(); + tracker.track(wrong()); + tracker.track(wrong()); + expect(sent).toHaveLength(0); + tracker.track(wrong()); + expect(sent).toHaveLength(1); + expect(sent[0].detail).toMatchObject({ type: 'widget_progress', completed: false, attempts: 3 }); + tracker.track(wrong()); + expect(sent).toHaveLength(1); + }); + + it('does not read steady multi-part progress as struggle (the crossword case)', () => { + const { sent, tracker } = harness(); + // correct:false means "not finished yet" while solved climbs — progress, not stuckness. + tracker.track(wrong('crossword', { solved: 1 })); + tracker.track(wrong('crossword', { solved: 2 })); + tracker.track(wrong('crossword', { solved: 3 })); + tracker.track(wrong('crossword', { solved: 4 })); + expect(sent).toHaveLength(0); + }); + + it('a right answer resets the wrong streak', () => { + const { sent, tracker } = harness(); + tracker.track(wrong()); + tracker.track(wrong()); + tracker.track({ eventType: 'answer_checked', widgetKind: 'drag-sort', correct: true }); + tracker.track(wrong()); + tracker.track(wrong()); + expect(sent).toHaveLength(0); + }); + + it('reset() gives a new widget fresh counters and a fresh voice', () => { + const { sent, tracker } = harness(); + tracker.track({ eventType: 'widget_completed', widgetKind: 'flashcard', correct: null }); + expect(sent).toHaveLength(1); + tracker.reset(); + tracker.track({ eventType: 'widget_completed', widgetKind: 'crossword', correct: true }); + expect(sent).toHaveLength(2); + expect(sent[1].detail).toMatchObject({ kind: 'crossword', attempts: 0 }); + }); +}); + +describe('fencedDetailBlock', () => { + it('fences with more backticks than the content contains', () => { + const block = fencedDetailBlock({ note: 'code: ```js\nalert(1)\n``` end' }); + const opening = block.split('json')[0]; + expect(opening.length).toBeGreaterThanOrEqual(4); + // The fence must not appear inside the body it wraps. + const body = block.slice(block.indexOf('\n') + 1, block.lastIndexOf('\n')); + expect(body.includes(opening)).toBe(false); + }); + + it('caps unbounded payloads', () => { + const block = fencedDetailBlock({ text: 'x'.repeat(10_000) }); + expect(block.length).toBeLessThan(4200); + expect(block).toContain('(truncated)'); + }); +}); diff --git a/src/lib/mcp/report.ts b/src/lib/mcp/report.ts index fb5347c0..258b2299 100644 --- a/src/lib/mcp/report.ts +++ b/src/lib/mcp/report.ts @@ -1,3 +1,5 @@ +import { fencedDetailBlock } from '@/lib/mcp/fence'; + /** * What the widget says back into the conversation it is sitting in. * @@ -56,15 +58,8 @@ export function reportToConversation(text: string, detail?: Record 4000) json = `${json.slice(0, 4000)}… (truncated)`; - const longestRun = json.match(/`+/g)?.reduce((max, run) => Math.max(max, run.length), 0) ?? 0; - const fence = '`'.repeat(Math.max(3, longestRun + 1)); - detailBlock = `${fence}json\n${json}\n${fence}`; - content.push({ type: 'text', text: detailBlock }); - } + const detailBlock = detail ? fencedDetailBlock(detail) : null; + if (detailBlock) content.push({ type: 'text', text: detailBlock }); // Dedupe on everything sent, not just the prose — identical sentences with // different structured detail are different reports. diff --git a/src/lib/pathway/run.test.ts b/src/lib/pathway/run.test.ts new file mode 100644 index 00000000..e9b27d3d --- /dev/null +++ b/src/lib/pathway/run.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { PathwayEvent } from '@/lib/pathway/events'; + +vi.mock('@/lib/pathway/generate', () => ({ + streamPathway: vi.fn(), +})); + +import { streamPathway } from '@/lib/pathway/generate'; +import { runPathway } from '@/lib/pathway/run'; + +const anchor = { standard: { code: 'MATH.4.NF.EQUIV' } } as never; +const plan = { bigIdea: 'x', steps: [{ title: 'a' }, { title: 'b' }] } as never; + +function feed(events: Partial[]) { + vi.mocked(streamPathway).mockImplementation(async function* () { + for (const event of events) yield event as PathwayEvent; + }); +} + +describe('runPathway', () => { + it('collects the stream into the same shape the NDJSON route accumulates', async () => { + feed([ + { type: 'verdict', code: 'FAKE.1', resolved: false }, + { type: 'verdict', code: 'MATH.4.NF.EQUIV', resolved: true }, + { type: 'anchor', anchor } as never, + { type: 'plan', plan } as never, + { type: 'step-widget', stepIndex: 0, widget: { kind: 'flashcard' } } as never, + { type: 'step-widget', stepIndex: 1, widget: { kind: 'drag-sort' }, note: 'fell back' } as never, + { type: 'done' } as never, + ]); + + const run = await runPathway('topic'); + expect(run.anchor).toBe(anchor); + expect(run.plan).toBe(plan); + expect(run.rejected).toEqual(['FAKE.1']); + expect(run.stepWidgets[1]).toMatchObject({ kind: 'drag-sort' }); + // Substitution notes survive collection — the caller reports them, never hides them. + expect(run.stepWidgetNotes).toEqual({ 1: 'fell back' }); + }); + + it('throws when the run produces no pathway instead of returning a hollow result', async () => { + feed([{ type: 'done' } as never]); + await expect(runPathway('topic')).rejects.toThrow(/did not produce a pathway/); + }); + + it('passes teacher context through to the pipeline', async () => { + feed([ + { type: 'anchor', anchor } as never, + { type: 'plan', plan } as never, + ]); + await runPathway('topic', '4', { teacherNote: 'they confuse thirds and fourths' }); + expect(vi.mocked(streamPathway)).toHaveBeenCalledWith( + 'topic', + '4', + null, + 'they confuse thirds and fourths', + undefined, + ); + }); +}); diff --git a/test/stubs/server-only.ts b/test/stubs/server-only.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/test/stubs/server-only.ts @@ -0,0 +1 @@ +export {}; diff --git a/vitest.config.mts b/vitest.config.mts index 769ffd1f..50cc46cf 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -6,6 +6,9 @@ export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), + // Next's build-time marker package has no runtime — outside Next it + // must resolve to nothing rather than fail module resolution. + 'server-only': fileURLToPath(new URL('./test/stubs/server-only.ts', import.meta.url)), }, }, test: { From fec421fee3c8fad9c6402f1200cc926f172271d4 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 15:51:09 -0400 Subject: [PATCH 4/6] feat: the MCP tools take an audience, not a grade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gradeHint` forces every caller to describe a learner as a K-12 student. A deployment against ABET criteria, a cert blueprint or an internal competency model has nothing honest to put there, and the argument is free text steering standard selection — nothing about it needs to be a grade. `find_activity` and `build_pathway` are new in this stack, so they are named right at birth and never carry `gradeHint`. `show_widget` ships on main, so it gains `audience` as the documented argument and keeps `gradeHint` as an accepted alias — removing it would break callers written against the deployed tool for no benefit. The boundary stays honest in both directions: `audience` here is free text and explicitly a hint, while the scheme-scoped `audience` on an emitted manifest comes from the graph. The comment at the find_activity call site says so, because the two fields now share a name and only one is verified. Tested through tools/list with real Requests, per the wire-surface rule: the three tools expose `audience`, the two new ones do not carry the old name, and show_widget's alias is pinned so a later cleanup cannot silently drop it. No mcp:build: a server route is not reachable from widget components. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/api/mcp/route.test.ts | 22 ++++++++++++++++++ src/app/api/mcp/route.ts | 44 +++++++++++++++++++++++++++-------- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 2d01fd63..8feb7a0d 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -39,6 +39,28 @@ 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 () => { + const res = await rpc({ jsonrpc: '2.0', id: 21, method: 'tools/list' }); + const { result } = await res.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. + for (const name of ['show_widget', 'find_activity', 'build_pathway']) { + expect(props(name).audience?.type).toBe('string'); + } + + // These two are new here, so they never carried the old name. + expect(props('find_activity')).not.toHaveProperty('gradeHint'); + expect(props('build_pathway')).not.toHaveProperty('gradeHint'); + + // show_widget shipped with `gradeHint`; removing it would break callers + // written against the deployed tool, so it stays accepted. + expect(props('show_widget')).toHaveProperty('gradeHint'); + }); + it('serves the shell resource listing', async () => { const res = await rpc({ jsonrpc: '2.0', id: 3, method: 'resources/list' }); const { result } = await res.json(); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index c36556b0..46caf099 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -188,7 +188,10 @@ function tools(origin: string) { type: 'string', description: 'Optional. A Common Core or NGSS code if known; verified against the standards graph.', }, - gradeHint: { type: 'string', description: 'Optional, e.g. "4th grade".' }, + audience: { + type: 'string', + description: 'Optional. Who this is for, in plain words — "4th grade", "undergraduate intro stats", "new hires". Steers standard selection; a hint, not a verified claim.', + }, need: { type: 'string', description: 'Optional preference in plain words — "a game", "something they write", "a quick check".', @@ -208,7 +211,14 @@ function tools(origin: string) { type: 'string', description: 'What the activity should be about, in plain words — "the Industrial Revolution", "comparing fractions". Enough on its own.', }, - gradeHint: { type: 'string', description: 'Optional, e.g. "8th grade", "high school".' }, + audience: { + type: 'string', + description: 'Optional. Who this is for, in plain words — "4th grade", "undergraduate intro stats", "new hires". Steers standard selection; a hint, not a verified claim.', + }, + gradeHint: { + type: 'string', + description: 'Deprecated alias for `audience`, 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.', @@ -244,7 +254,10 @@ function tools(origin: string) { type: 'string', description: 'What the pathway teaches, in plain words — "comparing fractions", "the water cycle".', }, - gradeHint: { type: 'string', description: 'Optional, e.g. "5th grade".' }, + audience: { + type: 'string', + description: 'Optional. Who this is for, in plain words — "4th grade", "undergraduate intro stats", "new hires". Steers standard selection; a hint, not a verified claim.', + }, }, required: ['topic'], }, @@ -388,12 +401,20 @@ async function handle(message: RpcRequest, request: Request) { const args = (message.params?.arguments ?? {}) as { topic?: string; standardCode?: string; - gradeHint?: string; + audience?: string; need?: string; }; try { - const found = await findActivities(args); + // `audience` is free text at this boundary — a hint that steers + // standard selection. The scheme-scoped `audience` on the emitted + // manifest comes from the graph, never from this string. + const found = await findActivities({ + topic: args.topic, + standardCode: args.standardCode, + need: args.need, + gradeHint: args.audience, + }); const header = found.standard ? `${found.activities.length} activities for ${found.standard.code} — ${found.standard.description}` + @@ -418,14 +439,14 @@ async function handle(message: RpcRequest, request: Request) { } if (message.params?.name === 'build_pathway') { - const args = (message.params?.arguments ?? {}) as { topic?: string; gradeHint?: string }; + const args = (message.params?.arguments ?? {}) as { topic?: string; audience?: 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.gradeHint?.trim() || undefined); + const run = await runPathway(topic, args.audience?.trim() || undefined); // Persistence is best-effort and separately guarded: a storage // failure must not discard a pathway that took five model calls to @@ -447,7 +468,7 @@ async function handle(message: RpcRequest, request: Request) { sessionId = await adapter.persistSession({ studentId: ownerId, topic, - gradeHint: args.gradeHint?.trim() || null, + gradeHint: args.audience?.trim() || null, anchor: run.anchor, plan: run.plan, stepWidgets: run.stepWidgets, @@ -520,15 +541,18 @@ async function handle(message: RpcRequest, request: Request) { const args = (message.params?.arguments ?? {}) as { topic?: string; + audience?: string; + /** Deprecated alias for `audience`; this tool shipped with it. */ gradeHint?: string; standardCode?: string; kind?: string; }; + const audience = args.audience ?? args.gradeHint; try { const built = await buildWidget({ topic: args.topic, - gradeHint: args.gradeHint, + gradeHint: audience, standardCode: args.standardCode, kind: args.kind, }); @@ -559,7 +583,7 @@ async function handle(message: RpcRequest, request: Request) { if (topic && (args.standardCode || args.kind)) { try { - const retry = await buildWidget({ topic, gradeHint: args.gradeHint }); + const retry = await buildWidget({ topic, gradeHint: audience }); return ok(message.id, { content: [ { From 731f0024062ea69b7dd6cb050991b36f4c5ae4d0 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 16:17:50 -0400 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20audienceHint=20at=20the=20tool=20bou?= =?UTF-8?q?ndary=20=E2=80=94=20one=20word,=20one=20meaning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naming the tool input `audience` reintroduced exactly the ambiguity the rename set out to kill. On a manifest, `audience` is scheme-scoped and graph-derived — a verified statement about the activity. As a tool input it is unverified caller text that steers standard proposal. Same word, two meanings, one surface: a consumer reading both cannot tell which guarantee it is holding. The old `gradeHint` was right about this and I dropped the wrong half of it. The `…Hint` suffix carries the epistemics; only "grade" was the K-12 assumption. So: `audienceHint`. `show_widget` still accepts `gradeHint` as the alias it shipped with, now pointing at `audienceHint`. The tools/list test asserts the new name *and* that no tool exposes a bare `audience`, so the collision cannot come back by someone reaching for the shorter word. No behavior change: same argument, same plumbing, different name. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/api/mcp/route.test.ts | 7 ++++-- src/app/api/mcp/route.ts | 41 ++++++++++++++++++----------------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 8feb7a0d..1b54543b 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -39,7 +39,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 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(); const props = (name: string) => @@ -49,7 +49,10 @@ 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).audience?.type).toBe('string'); + 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'); } // These two are new here, so they never carried the old name. diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 46caf099..da963aeb 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -188,9 +188,9 @@ function tools(origin: string) { type: 'string', description: 'Optional. A Common Core or NGSS code if known; verified against the standards graph.', }, - audience: { + audienceHint: { type: 'string', - description: 'Optional. Who this is for, in plain words — "4th grade", "undergraduate intro stats", "new hires". Steers standard selection; a hint, not a verified claim.', + 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', @@ -211,13 +211,13 @@ function tools(origin: string) { type: 'string', description: 'What the activity should be about, in plain words — "the Industrial Revolution", "comparing fractions". Enough on its own.', }, - audience: { + audienceHint: { type: 'string', - description: 'Optional. Who this is for, in plain words — "4th grade", "undergraduate intro stats", "new hires". Steers standard selection; a hint, not a verified claim.', + 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 `audience`, still accepted so callers written against the shipped tool keep working.', + description: 'Deprecated alias for `audienceHint`, still accepted so callers written against the shipped tool keep working.', }, standardCode: { type: 'string', @@ -254,9 +254,9 @@ function tools(origin: string) { type: 'string', description: 'What the pathway teaches, in plain words — "comparing fractions", "the water cycle".', }, - audience: { + audienceHint: { type: 'string', - description: 'Optional. Who this is for, in plain words — "4th grade", "undergraduate intro stats", "new hires". Steers standard selection; a hint, not a verified claim.', + 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'], @@ -401,19 +401,20 @@ async function handle(message: RpcRequest, request: Request) { const args = (message.params?.arguments ?? {}) as { topic?: string; standardCode?: string; - audience?: string; + audienceHint?: string; need?: string; }; try { - // `audience` is free text at this boundary — a hint that steers - // standard selection. The scheme-scoped `audience` on the emitted - // manifest comes from the graph, never from this string. + // 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.audience, + gradeHint: args.audienceHint, }); const header = found.standard @@ -439,14 +440,14 @@ async function handle(message: RpcRequest, request: Request) { } if (message.params?.name === 'build_pathway') { - const args = (message.params?.arguments ?? {}) as { topic?: string; audience?: string }; + 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.audience?.trim() || undefined); + 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 @@ -468,7 +469,7 @@ async function handle(message: RpcRequest, request: Request) { sessionId = await adapter.persistSession({ studentId: ownerId, topic, - gradeHint: args.audience?.trim() || null, + gradeHint: args.audienceHint?.trim() || null, anchor: run.anchor, plan: run.plan, stepWidgets: run.stepWidgets, @@ -541,18 +542,18 @@ async function handle(message: RpcRequest, request: Request) { const args = (message.params?.arguments ?? {}) as { topic?: string; - audience?: string; - /** Deprecated alias for `audience`; this tool shipped with it. */ + audienceHint?: string; + /** Deprecated alias for `audienceHint`; this tool shipped with it. */ gradeHint?: string; standardCode?: string; kind?: string; }; - const audience = args.audience ?? args.gradeHint; + const audienceHint = args.audienceHint ?? args.gradeHint; try { const built = await buildWidget({ topic: args.topic, - gradeHint: audience, + gradeHint: audienceHint, standardCode: args.standardCode, kind: args.kind, }); @@ -583,7 +584,7 @@ async function handle(message: RpcRequest, request: Request) { if (topic && (args.standardCode || args.kind)) { try { - const retry = await buildWidget({ topic, gradeHint: audience }); + const retry = await buildWidget({ topic, gradeHint: audienceHint }); return ok(message.id, { content: [ { From 3c39179ca322c8b882c34c7a7c1e2a461e068ee6 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 20:38:19 -0400 Subject: [PATCH 6/6] docs: the live loop keeps the pedagogical arc as its spine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam's point: freestyle sequencing throws away exactly what the planner provides — the activate → model → practice → check arc against a verified standard is the pedagogy, not an artifact of the handoff form. The connector guidance now says so: in conversation the agent delivers that arc through show_widget, letting evidence set pacing inside it (and may call build_pathway first to use its plan as the spine); build_pathway remains the handoff form for a link. Co-Authored-By: Claude Fable 5 --- src/app/api/mcp/route.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index da963aeb..9ed4b10c 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -117,12 +117,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 TOOL_DESCRIPTION = [