Skip to content
Merged
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
18 changes: 18 additions & 0 deletions .changeset/mcp-json-code-parts.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 34 additions & 9 deletions server/mcpSpec.ts
Original file line number Diff line number Diff line change
@@ -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 ' +
Expand Down Expand Up @@ -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 =
Expand All @@ -73,15 +87,17 @@ 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:'<output>', 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:<any JSON value>} renders a " +
"collapsible tree. code: {kind:'code', code:'<source>', 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 = {
type: "object",
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 },
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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),
Expand All @@ -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 = {
Expand Down
30 changes: 20 additions & 10 deletions server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
75 changes: 75 additions & 0 deletions test/mcpSpec.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<p>hi</p>" },
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}`);
}
});
Loading