Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions mcp/host-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { fencedDetailBlock } from '../src/lib/mcp/fence';

/**
* JSON-RPC over postMessage, the way an MCP App talks to its host.
*
Expand Down Expand Up @@ -133,7 +135,15 @@ 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<string, unknown>) {
// 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. 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: fencedDetailBlock(detail) });
return this.request('ui/update-model-context', { content });
}
}
100 changes: 94 additions & 6 deletions mcp/report-to-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -46,21 +45,110 @@ function describe(event: Event, attempts: number): string {
return `The student worked through the ${kind}${standard} ${outcome}.${struggle}${score}`;
}

/** 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<string, unknown> | 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, 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 &&
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', ...resultBase(event), 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',
...resultBase(event),
completed: true,
correct: event.correct ?? null,
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;
},
};
}
9 changes: 9 additions & 0 deletions mcp/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<p className="p-6 text-sm text-muted-foreground">
Expand Down
100 changes: 100 additions & 0 deletions src/app/api/mcp/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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('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) =>
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).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.
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();
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);
});
});
Loading
Loading