diff --git a/.changeset/mcp-json-code-parts.md b/.changeset/mcp-json-code-parts.md new file mode 100644 index 0000000..13283dd --- /dev/null +++ b/.changeset/mcp-json-code-parts.md @@ -0,0 +1,18 @@ +--- +"sideshow": patch +--- + +The `json` and `code` surface kinds (publishable over the CLI and REST since +they were added) are now advertised by the MCP tools too. Both the streamable +HTTP (`/mcp`) and stdio MCP transports list `json` and `code` in their +`publish_post`/`update_post` (and the deprecated `publish_surface`/ +`update_surface` aliases) `kind` enums and document their fields (`data` for +json; `code`/`language`/`title`/`lineStart` for code), so an MCP agent can +publish a collapsible JSON tree or a syntax-highlighted code block — not just +CLI/REST callers. + +To stop the surface-kind list from drifting between tiers again, all three +surfaces now derive from one canonical `SURFACE_KINDS` list in +`server/types.ts`: the `SurfaceKind` type, both MCP `kind` enums, and a new +`test/mcpSpec.test.ts` guard that fails if any kind is missing from the MCP +schemas or the runtime validator. diff --git a/server/mcpSpec.ts b/server/mcpSpec.ts index 488f075..e14b652 100644 --- a/server/mcpSpec.ts +++ b/server/mcpSpec.ts @@ -1,15 +1,22 @@ import { z } from "zod"; import { KIT_IDS } from "./kits.ts"; +import { SURFACE_KINDS, type SurfaceKind } from "./types.ts"; export const MCP_SERVER_INFO = { name: "sideshow", version: "0.1.0" }; +// The `kind` enum both MCP transports advertise — derived from the one canonical +// list (types.ts) so the MCP tier can never again fall behind what REST/CLI +// accept. Non-empty tuple cast so z.enum keeps the literal union. +const PART_KIND_ENUM = [...SURFACE_KINDS] as [SurfaceKind, ...SurfaceKind[]]; + export const MCP_INSTRUCTIONS = "sideshow is a live visual surface the user watches in a browser. Publish posts to illustrate " + "concepts, sketch UI ideas, visualize data, or show a code review while you work. A post is an " + "ordered list of surfaces: an `html` surface is markup you write (a body fragment), a `markdown` surface is " + "prose the viewer renders with consistent typography, a `mermaid` surface is diagram source the viewer " + "renders to an SVG (flowchart, sequence, ERD, …), a `diff` surface is a patch the viewer renders as " + - "a syntax-highlighted split/unified diff. Combine them — e.g. a markdown rationale above a diff surface — " + + "a syntax-highlighted split/unified diff, a `json` surface is any JSON value rendered as a collapsible " + + "tree, and a `code` surface is source the viewer syntax-highlights. Combine them — e.g. a markdown rationale above a diff surface — " + "in one card. publish_post is the general tool; publish_snippet is " + "sugar for a single html surface. Call get_design_guide once before your first publish. On your first " + 'publish, also pass sessionTitle to name the session after the task (e.g. "Auth refactor"). The ' + @@ -57,6 +64,13 @@ const d = { traceTs: "ISO timestamp", terminalText: "terminal surface: raw output (ANSI SGR color escapes are rendered)", terminalCols: "terminal surface: optional render width in columns", + surfaceData: + "json surface: any JSON value (object, array, string, number, boolean, null) — the viewer renders it as a collapsible tree", + surfaceCode: "code surface: source code the viewer syntax-highlights and shows with line numbers", + surfaceLanguage: + "code surface: language id (ts, js, python, go, rust, …); omit or use 'text' for plain monospace", + surfaceLineStart: + "code surface: 1-based starting line number for an excerpt (the viewer numbers from here instead of 1)", }; const MCP_SURFACES_DESCRIPTION = @@ -73,7 +87,9 @@ const MCP_SURFACES_DESCRIPTION = "{kind:'trace', steps:[{label, kind?, detail?, ts?}]} renders a step timeline, and/or " + "{kind:'trace', assetId} for an uploaded trace file (downloadable). terminal: {kind:'terminal', " + "text:'', cols?, title?} renders monospace terminal output (ANSI SGR colors supported; " + - "cursor-addressing TUIs are not resolved). Optional diff layout " + + "cursor-addressing TUIs are not resolved). json: {kind:'json', data:} renders a " + + "collapsible tree. code: {kind:'code', code:'', language?, title?, lineStart?} renders " + + "syntax-highlighted source with line numbers. Optional diff layout " + "'unified'|'split'. Combine freely, e.g. [{kind:'html',...},{kind:'image',assetId},{kind:'trace',steps}]."; const MCP_SURFACE_JSON_SCHEMA = { @@ -81,7 +97,7 @@ const MCP_SURFACE_JSON_SCHEMA = { properties: { kind: { type: "string", - enum: ["html", "markdown", "mermaid", "diff", "image", "trace", "terminal"], + enum: PART_KIND_ENUM, }, html: { type: "string", description: d.surfaceHtml }, kits: { type: "array", items: { type: "string" }, description: d.surfaceKits }, @@ -109,6 +125,10 @@ const MCP_SURFACE_JSON_SCHEMA = { title: { type: "string", description: d.traceTitle }, text: { type: "string", description: d.terminalText }, cols: { type: "number", description: d.terminalCols }, + data: { description: d.surfaceData }, + code: { type: "string", description: d.surfaceCode }, + language: { type: "string", description: d.surfaceLanguage }, + lineStart: { type: "number", description: d.surfaceLineStart }, steps: { type: "array", description: d.traceSteps, @@ -135,17 +155,17 @@ const MCP_SURFACES_JSON_SCHEMA = { export const MCP_TOOL_DESCRIPTIONS = { publishPostHttp: - "Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace). Returns the post id, view URL, and sessionId — pass sessionId as `session` on later calls. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", + "Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace, terminal, json, code). Returns the post id, view URL, and sessionId — pass sessionId as `session` on later calls. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", publishPostStdio: - "Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace). Returns the post id and view URL. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", + "Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace, terminal, json, code). Returns the post id and view URL. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", updatePost: "Revise a post in place (same card, new version). Prefer this over publishing a near-duplicate. Pass the full replacement surfaces array. If the result includes userFeedback, read it.", listPostsHttp: "List posts — pass a session id to scope, or omit for all sessions.", listPostsStdio: "List posts in this conversation's session.", publishSurfaceHttp: - "Deprecated alias of publish_post — Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace). Returns the post id, view URL, and sessionId — pass sessionId as `session` on later calls. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", + "Deprecated alias of publish_post — Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace, terminal, json, code). Returns the post id, view URL, and sessionId — pass sessionId as `session` on later calls. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", publishSurfaceStdio: - "Deprecated alias of publish_post — Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace). Returns the post id and view URL. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", + "Deprecated alias of publish_post — Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace, terminal, json, code). Returns the post id and view URL. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", updateSurface: "Deprecated alias of update_post — Revise a post in place (same card, new version). Prefer this over publishing a near-duplicate. Pass the full replacement surfaces array. If the result includes userFeedback, read it.", publishSnippet: @@ -342,7 +362,7 @@ const traceStepSchema = z.object({ const mcpPartSchema = z .object({ - kind: z.enum(["html", "markdown", "mermaid", "diff", "image", "trace", "terminal"]), + kind: z.enum(PART_KIND_ENUM), html: z.string().optional().describe(d.surfaceHtml), kits: z.array(z.string()).optional().describe(d.surfaceKits), markdown: z.string().optional().describe(d.surfaceMarkdown), @@ -357,12 +377,17 @@ const mcpPartSchema = z steps: z.array(traceStepSchema).optional().describe(d.traceSteps), text: z.string().optional().describe(d.terminalText), cols: z.number().optional().describe(d.terminalCols), + data: z.unknown().optional().describe(d.surfaceData), + code: z.string().optional().describe(d.surfaceCode), + language: z.string().optional().describe(d.surfaceLanguage), + lineStart: z.number().int().min(1).optional().describe(d.surfaceLineStart), }) .describe( "A surface: html {kind:'html',html}; markdown {kind:'markdown',markdown} (prose); mermaid " + "{kind:'mermaid',mermaid} (diagram source → SVG); diff {kind:'diff',patch}; image " + "{kind:'image',assetId} (from upload_asset); trace {kind:'trace',steps} and/or {kind:'trace',assetId}; " + - "terminal {kind:'terminal',text} (monospace output; ANSI SGR colors rendered)", + "terminal {kind:'terminal',text} (monospace output; ANSI SGR colors rendered); json {kind:'json',data} " + + "(any JSON value → collapsible tree); code {kind:'code',code,language?,lineStart?} (highlighted source)", ); export const STDIO_MCP_INPUT_SCHEMAS = { diff --git a/server/types.ts b/server/types.ts index 4622abe..fd31046 100644 --- a/server/types.ts +++ b/server/types.ts @@ -18,16 +18,26 @@ export interface Session { // `markdown`, `terminal`, and `mermaid` parts are structured data rendered by // the trusted viewer. A snippet is just a surface with one html part; a // diagram-with-its-diff is `[html, diff]`. -export type SurfaceKind = - | "html" - | "diff" - | "image" - | "trace" - | "markdown" - | "terminal" - | "mermaid" - | "json" - | "code"; +// The canonical, ordered list of every surface kind — the single source of +// truth. `SurfaceKind` derives from it, and the MCP tool schemas (mcpSpec.ts) +// build their `kind` enums from it, so a kind can't be added to the model +// without the MCP tier advertising it too (the gap that left `json`/`code` +// publishable over CLI/REST but invisible to MCP). The per-kind FIELD schemas +// in surfaceParts.ts and mcpSpec.ts are still hand-written; test/mcpSpec.test.ts +// guards that every kind here round-trips through both the MCP schema and the +// validator with its fields, so neither half can silently fall behind. +export const SURFACE_KINDS = [ + "html", + "diff", + "image", + "trace", + "markdown", + "terminal", + "mermaid", + "json", + "code", +] as const; +export type SurfaceKind = (typeof SURFACE_KINDS)[number]; export interface HtmlSurface { kind: "html"; diff --git a/test/mcpSpec.test.ts b/test/mcpSpec.test.ts new file mode 100644 index 0000000..6043827 --- /dev/null +++ b/test/mcpSpec.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { z } from "zod"; +import { HTTP_MCP_TOOLS, STDIO_MCP_INPUT_SCHEMAS } from "../server/mcpSpec.ts"; +import { validateSurfaceParts } from "../server/surfaceParts.ts"; +import { SURFACE_KINDS, type Surface } from "../server/types.ts"; + +// This suite is the guard against the regression where `json` and `code` +// surfaces shipped to CLI/REST but were never added to the MCP tool schemas — +// leaving them publishable on two tiers and invisible on a third. It pins all +// three surfaces to the one canonical list (SURFACE_KINDS): the HTTP JSON-Schema +// enum, the stdio zod enum, and the runtime validator. Add a kind to types.ts +// without teaching MCP about it (or the validator) and one of these fails. + +// The exact `kind` enum a client receives for the canonical publish tool from +// the HTTP `tools/list` response. +const httpKindEnum = (() => { + const tool = HTTP_MCP_TOOLS.find((t) => t.name === "publish_post"); + assert.ok(tool, "publish_post tool must exist"); + // inputSchema.properties.surfaces.items.properties.kind.enum — the wire path. + const enumValues = (tool as any).inputSchema.properties.surfaces.items.properties.kind.enum; + assert.ok(Array.isArray(enumValues), "surfaces.items.kind.enum must be an array"); + return enumValues as string[]; +})(); + +// A minimal valid example per kind, used to prove the schema + validator accept +// each one. Kept deliberately minimal so a field going missing from the MCP +// schema surfaces here. +const EXAMPLES: Record<(typeof SURFACE_KINDS)[number], Surface> = { + html: { kind: "html", html: "

hi

" }, + diff: { kind: "diff", files: [{ filename: "a.ts", before: "a", after: "b" }] }, + image: { kind: "image", assetId: "asset123" }, + trace: { kind: "trace", steps: [{ label: "step one" }] }, + markdown: { kind: "markdown", markdown: "# heading" }, + terminal: { kind: "terminal", text: "$ ls\nfile.txt" }, + mermaid: { kind: "mermaid", mermaid: "flowchart TD\nA-->B" }, + json: { kind: "json", data: { ok: true, items: [1, 2, 3] } }, + code: { kind: "code", code: "const x = 1;", language: "ts" }, +}; + +test("HTTP publish_post advertises exactly the canonical kind set", () => { + assert.deepEqual([...httpKindEnum].sort(), [...SURFACE_KINDS].sort()); +}); + +test("every canonical kind has a worked example (no kind left untested)", () => { + for (const kind of SURFACE_KINDS) { + assert.ok(EXAMPLES[kind], `missing test example for kind "${kind}"`); + } +}); + +test("the stdio publish schema accepts a minimal example of every kind", () => { + // The stdio schema object is z.object(STDIO_MCP_INPUT_SCHEMAS.publishPost); + // its `surfaces` field is the array schema the MCP SDK enforces. + const publishSchema = z.object(STDIO_MCP_INPUT_SCHEMAS.publishPost); + for (const kind of SURFACE_KINDS) { + const result = publishSchema.safeParse({ title: "t", surfaces: [EXAMPLES[kind]] }); + assert.ok( + result.success, + `stdio schema rejected kind "${kind}": ${result.success ? "" : result.error}`, + ); + } +}); + +test("the stdio publish schema rejects an unknown kind", () => { + const publishSchema = z.object(STDIO_MCP_INPUT_SCHEMAS.publishPost); + const result = publishSchema.safeParse({ title: "t", surfaces: [{ kind: "bogus", html: "x" }] }); + assert.equal(result.success, false); +}); + +test("the runtime validator accepts a minimal example of every kind", async () => { + for (const kind of SURFACE_KINDS) { + const result = await validateSurfaceParts([EXAMPLES[kind]]); + assert.ok(result.ok, `validator rejected kind "${kind}": ${result.ok ? "" : result.error}`); + } +});