From ff4d7bd7d48ae13c17468b37d237311446a28b99 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 25 Jun 2026 12:18:57 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(api):=20post/surface=20wire=20vocabula?= =?UTF-8?q?ry=20=E2=80=94=20/api/posts=20routes=20+=20publish=5Fpost=20MCP?= =?UTF-8?q?=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps A3 (HTTP routes) + A4 (MCP) of the workspace/post/surface rename. A prior PR renamed the data model + store (Post, Surface block, getPost/createPost/…); this updates the WIRE layer to match, while keeping every old route, tool, param, and response field working as a deprecated alias. Fully additive — nothing removed. A3 — routes (server/app.ts), all reusing the existing handlers: - New: GET/POST/PUT/DELETE /api/posts[/:id], GET /p/:id, GET /session/:id/p/:postId, GET /api/sessions/:id/posts. - Publish/revise accept `surfaces` (preferred) or legacy `parts`; the rich-page handler reads ?surface=N or legacy ?part=N. isPublicReadAllowed gains /p/ and /api/posts/. Old /s/, /api/surfaces, /api/snippets, /session/:id/s/ all kept. A4 — MCP (server/mcpSpec.ts, server/mcpHttp.ts, mcp/server.ts): - New tools publish_post / update_post / list_posts on HTTP + stdio, delegating to the same handlers; they advertise `surfaces` and emit /p/ URLs. - reply_to_user now takes `postId` (preferred) with `surfaceId` as a deprecated alias. - Old tools kept, descriptions prefixed "Deprecated alias of …". Instructions + descriptions + schemas rewritten to post/surface/workspace vocabulary. guide/*.md (A6) and bin/sideshow.js (A7) intentionally untouched; the CLI keeps working via the retained legacy routes. Tests: +11 in test/api.test.ts (new routes ≡ old, surfaces+parts bodies, new MCP tools, reply_to_user postId + legacy surfaceId). 255/255 pass, typecheck/lint/ format clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/post-surface-wire-vocab.md | 23 +++ mcp/server.ts | 45 ++++- server/app.ts | 48 +++-- server/mcpHttp.ts | 30 ++- server/mcpSpec.ts | 198 +++++++++++++------- test/api.test.ts | 257 ++++++++++++++++++++++++++ 6 files changed, 506 insertions(+), 95 deletions(-) create mode 100644 .changeset/post-surface-wire-vocab.md diff --git a/.changeset/post-surface-wire-vocab.md b/.changeset/post-surface-wire-vocab.md new file mode 100644 index 0000000..de4f914 --- /dev/null +++ b/.changeset/post-surface-wire-vocab.md @@ -0,0 +1,23 @@ +--- +"sideshow": minor +--- + +Bring the new **post / surface** vocabulary to the HTTP and MCP wire layers, +additively. The canonical hierarchy is now **workspace ▸ session ▸ post ▸ +surface** (a post is an ordered list of surfaces); the older spellings keep +working as deprecated aliases — nothing is removed. + +New HTTP routes mirror the existing surface routes, sharing the same handlers: +`GET/POST/PUT/DELETE /api/posts(/:id)`, `GET /p/:id` (with `?surface=N`), +`GET /session/:id/p/:postId`, and `GET /api/sessions/:id/posts`. The publish +and revise handlers now accept a `surfaces` body (falling back to the legacy +`parts`), so both `/api/posts` and `/api/surfaces` take either field; `/p/:id` +and `/s/:id` accept `?surface=N` as well as `?part=N`. + +New MCP tools `publish_post`, `update_post`, and `list_posts` are advertised on +both transports, advertising a `surfaces` argument and emitting `/p/` view +URLs. The legacy `publish_surface` / `update_surface` / `list_surfaces` tools +remain (now described as deprecated aliases) and still accept `parts`. +`reply_to_user` additionally accepts a `postId` argument. Tool prose and schemas +are rewritten in the new vocabulary (surface→post, part→surface, +board→workspace). diff --git a/mcp/server.ts b/mcp/server.ts index 4eb20fd..1782265 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -58,6 +58,47 @@ async function ensureSession(title?: string): Promise { const server = new McpServer(MCP_SERVER_INFO, { instructions: MCP_INSTRUCTIONS }); +server.registerTool( + "publish_post", + { + description: MCP_TOOL_DESCRIPTIONS.publishPostStdio, + inputSchema: STDIO_MCP_INPUT_SCHEMAS.publishPost, + }, + async ({ title, surfaces, sessionTitle }) => { + const session = await ensureSession(sessionTitle); + const created = JSON.parse( + await api("/api/posts", { + method: "POST", + body: JSON.stringify({ title, surfaces, session }), + }), + ); + return text({ ...created, url: `${API}/p/${created.id}` }); + }, +); + +server.registerTool( + "update_post", + { + description: MCP_TOOL_DESCRIPTIONS.updatePost, + inputSchema: STDIO_MCP_INPUT_SCHEMAS.updatePost, + }, + async ({ id, surfaces, title }) => { + const updated = JSON.parse( + await api(`/api/posts/${id}`, { method: "PUT", body: JSON.stringify({ surfaces, title }) }), + ); + return text({ ...updated, url: `${API}/p/${updated.id}` }); + }, +); + +server.registerTool( + "list_posts", + { description: MCP_TOOL_DESCRIPTIONS.listPostsStdio, inputSchema: {} }, + async () => { + if (!sessionId) return text([]); + return text(JSON.parse(await api(`/api/sessions/${sessionId}/posts`))); + }, +); + server.registerTool( "publish_surface", { @@ -157,11 +198,11 @@ server.registerTool( description: MCP_TOOL_DESCRIPTIONS.replyToUser, inputSchema: STDIO_MCP_INPUT_SCHEMAS.replyToUser, }, - async ({ surfaceId, message }) => { + async ({ postId, surfaceId, message }) => { const created = JSON.parse( await api("/api/comments", { method: "POST", - body: JSON.stringify({ surface: surfaceId, text: message, author: AGENT }), + body: JSON.stringify({ surface: postId ?? surfaceId, text: message, author: AGENT }), }), ); return text(created); diff --git a/server/app.ts b/server/app.ts index 65e897f..5d2a73d 100644 --- a/server/app.ts +++ b/server/app.ts @@ -206,9 +206,11 @@ function isPublicReadAllowed(path: string, mode: PublicReadMode): boolean { if (mode === "full") return true; if (path.startsWith("/session/")) return true; if (path.startsWith("/s/")) return true; + if (path.startsWith("/p/")) return true; if (path.startsWith("/a/")) return true; if (path.startsWith("/api/sessions/")) return true; if (path.startsWith("/api/surfaces/")) return true; + if (path.startsWith("/api/posts/")) return true; if (path.startsWith("/api/snippets/")) return true; if (path === "/api/comments") return true; if (path === "/api/events") return true; @@ -662,16 +664,19 @@ export function createApp({ } return c.html(configuredViewerHtml(c)); }); - app.get("/session/:id/s/:surfaceId", async (c) => { + const sessionSurfacePage = async (c: any) => { if (isUnauthenticatedSessionRead(c)) { const session = await store.getSession(c.req.param("id")); - const surface = await store.getPost(c.req.param("surfaceId")); + const surfaceId = c.req.param("surfaceId") ?? c.req.param("postId"); + const surface = await store.getPost(surfaceId ?? ""); if (!session || !surface || surface.sessionId !== session.id) { return c.text("Session or surface not found", 404); } } return c.html(configuredViewerHtml(c)); - }); + }; + app.get("/session/:id/s/:surfaceId", sessionSurfacePage); + app.get("/session/:id/p/:postId", sessionSurfacePage); // canonical alias app.get("/guide", (c) => c.text(withOrigin(guideMarkdown, c))); app.get("/setup", (c) => c.text(withOrigin(setupText, c))); app.get("/agent-howto", (c) => c.text(withOrigin(agentHowtoText, c))); @@ -743,6 +748,7 @@ export function createApp({ return c.json(surfaces.map(surfaceMeta)); }; app.get("/api/sessions/:id/surfaces", listSessionSurfaces); + app.get("/api/sessions/:id/posts", listSessionSurfaces); // canonical alias app.get("/api/sessions/:id/snippets", listSessionSurfaces); // legacy alias // --- session trace --- @@ -790,19 +796,24 @@ export function createApp({ return c.json(surface); }; app.get("/api/surfaces/:id", getSurface); + app.get("/api/posts/:id", getSurface); // canonical alias app.get("/api/snippets/:id", getSurface); // legacy alias // Accepts either an existing session id, or agent/cwd fields to // auto-create a session — so a bare `curl` one-liner works with no ceremony. - app.post("/api/surfaces", async (c) => { + // New clients send `surfaces`; legacy clients send `parts`. Either works. + const publishPost = async (c: any) => { const body = await c.req.json().catch(() => null); - if (!body || !Array.isArray(body.parts)) { - return c.json({ error: 'body must include a "parts" array' }, 400); + const blocks = body?.surfaces ?? body?.parts; + if (!body || !Array.isArray(blocks)) { + return c.json({ error: 'body must include a "surfaces" (or legacy "parts") array' }, 400); } - const parsed = await validateSurfaceParts(body.parts); + const parsed = await validateSurfaceParts(blocks); if (!parsed.ok) return c.json({ error: parsed.error }, 400); return publish(c, body, parsed.parts); - }); + }; + app.post("/api/posts", publishPost); // canonical + app.post("/api/surfaces", publishPost); // Legacy html-only entry — sugar for a single html part. An optional `kits` // array opts the part into style/behavior bundles; it's validated (strict) @@ -839,11 +850,14 @@ export function createApp({ const revise = async (c: any) => { const body = await c.req.json().catch(() => null); if (!body) return c.json({ error: "invalid JSON body" }, 400); - // surfaces: a `parts` array; snippets: an `html` string (single html part). + // posts: a `surfaces` array (legacy `parts`); snippets: an `html` string. + const blocks = body.surfaces ?? body.parts; let parts: Surface[] | undefined; - if (body.parts !== undefined) { - if (!Array.isArray(body.parts)) return c.json({ error: '"parts" must be an array' }, 400); - const parsed = await validateSurfaceParts(body.parts); + if (blocks !== undefined) { + if (!Array.isArray(blocks)) { + return c.json({ error: '"surfaces" (or legacy "parts") must be an array' }, 400); + } + const parsed = await validateSurfaceParts(blocks); if (!parsed.ok) return c.json({ error: parsed.error }, 400); parts = parsed.parts; } else if (typeof body.html === "string") { @@ -862,6 +876,7 @@ export function createApp({ }); }; app.put("/api/surfaces/:id", revise); + app.put("/api/posts/:id", revise); // canonical alias app.put("/api/snippets/:id", revise); // legacy alias const remove = async (c: any) => { @@ -872,6 +887,7 @@ export function createApp({ return c.json({ ok: true }); }; app.delete("/api/surfaces/:id", remove); + app.delete("/api/posts/:id", remove); // canonical alias app.delete("/api/snippets/:id", remove); // legacy alias // --- comments --- @@ -943,10 +959,10 @@ export function createApp({ // server-side; mermaid as a self-rendering CDN doc). Image/trace/json parts // are data the viewer renders natively (text nodes / / JSX), so they // never reach here. - app.get("/s/:id", async (c) => { + const renderSurfacePage = async (c: any) => { const surface = await store.getPost(c.req.param("id")); if (!surface) return c.text("Surface not found", 404); - const partParam = c.req.query("part"); + const partParam = c.req.query("surface") ?? c.req.query("part"); if (partParam == null) return c.html(configuredViewerHtml(c, surface)); const ver = c.req.query("ver"); @@ -1023,7 +1039,9 @@ export function createApp({ return renderSandboxedPart({ body: rendered.body, css: rendered.css, origin, theme, mode }); }); return c.html(doc); - }); + }; + app.get("/s/:id", renderSurfacePage); + app.get("/p/:id", renderSurfacePage); // canonical alias // --- assets (agent-uploaded images, traces, files) --- diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts index a0290ab..4fd0b46 100644 --- a/server/mcpHttp.ts +++ b/server/mcpHttp.ts @@ -54,13 +54,19 @@ export interface McpDeps { export const coerceParts = coerceSurfaceParts; export function registerMcp(app: Hono, deps: McpDeps) { - const surfaceResult = (result: { surface: Post; userFeedback?: Feedback[] }, origin: string) => + // The view URL's path segment: legacy tools emit /s/; the new post tools + // emit the canonical /p/. Both resolve to the same surface page. + const surfaceResult = ( + result: { surface: Post; userFeedback?: Feedback[] }, + origin: string, + seg: "s" | "p" = "s", + ) => JSON.stringify( { id: result.surface.id, sessionId: result.surface.sessionId, version: result.surface.version, - url: `${origin}/s/${result.surface.id}`, + url: `${origin}/${seg}/${result.surface.id}`, ...(result.userFeedback && { userFeedback: result.userFeedback }), }, null, @@ -69,13 +75,16 @@ export function registerMcp(app: Hono, deps: McpDeps) { async function callTool(name: string, args: any, origin: string): Promise { switch (name) { + case "publish_post": case "publish_surface": case "publish_snippet": { + // New tools advertise `surfaces`; legacy tools still send `parts`. + const blocks = name === "publish_post" ? (args.surfaces ?? args.parts) : args.parts; const parts = name === "publish_snippet" ? await coerceParts([htmlSurface(String(args.html ?? ""), args.kits)]) - : await coerceParts(args.parts); - if (parts.length === 0) throw new Error("a surface needs at least one part"); + : await coerceParts(blocks); + if (parts.length === 0) throw new Error("a post needs at least one surface"); const result = await deps.publishSurface({ parts, title: typeof args.title === "string" ? args.title : undefined, @@ -84,8 +93,9 @@ export function registerMcp(app: Hono, deps: McpDeps) { agent: typeof args.agent === "string" ? args.agent : undefined, }); if ("error" in result) throw new Error(result.error); - return surfaceResult(result, origin); + return surfaceResult(result, origin, name === "publish_post" ? "p" : "s"); } + case "update_post": case "update_surface": case "update_snippet": { const patch: { parts?: Surface[]; title?: string } = { @@ -94,12 +104,13 @@ export function registerMcp(app: Hono, deps: McpDeps) { if (name === "update_snippet") { if (typeof args.html === "string") patch.parts = await coerceParts([htmlSurface(args.html, args.kits)]); - } else if (args.parts !== undefined) { - patch.parts = await coerceParts(args.parts); + } else { + const blocks = name === "update_post" ? (args.surfaces ?? args.parts) : args.parts; + if (blocks !== undefined) patch.parts = await coerceParts(blocks); } const result = await deps.reviseSurface(String(args.id ?? ""), patch); if ("error" in result) throw new Error(result.error); - return surfaceResult(result, origin); + return surfaceResult(result, origin, name === "update_post" ? "p" : "s"); } case "wait_for_feedback": { const result = await deps.waitForComments({ @@ -137,7 +148,7 @@ export function registerMcp(app: Hono, deps: McpDeps) { const author = named && named !== "user" ? named : "agent"; const result = await deps.createComment({ text: String(args.message ?? ""), - surface: String(args.surfaceId ?? ""), + surface: String(args.postId ?? args.surfaceId ?? ""), author, }); if ("error" in result) throw new Error(result.error); @@ -147,6 +158,7 @@ export function registerMcp(app: Hono, deps: McpDeps) { 2, ); } + case "list_posts": case "list_surfaces": case "list_snippets": { const surfaces = await deps.store.listPosts( diff --git a/server/mcpSpec.ts b/server/mcpSpec.ts index f3d0918..10b0440 100644 --- a/server/mcpSpec.ts +++ b/server/mcpSpec.ts @@ -4,14 +4,14 @@ import { KIT_IDS } from "./kits.ts"; export const MCP_SERVER_INFO = { name: "sideshow", version: "0.1.0" }; export const MCP_INSTRUCTIONS = - "sideshow is a live visual surface the user watches in a browser. Publish surfaces to illustrate " + - "concepts, sketch UI ideas, visualize data, or show a code review while you work. A surface is an " + - "ordered list of parts: an `html` part is markup you write (a body fragment), a `markdown` part is " + - "prose the viewer renders with consistent typography, a `mermaid` part is diagram source the viewer " + - "renders to an SVG (flowchart, sequence, ERD, …), a `diff` part is a patch the viewer renders as " + - "a syntax-highlighted split/unified diff. Combine them — e.g. a markdown rationale above a diff part — " + - "in one card. publish_surface is the general tool; publish_snippet is " + - "sugar for a single html part. Call get_design_guide once before your first publish. On your first " + + "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 — " + + "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 ' + "user can comment in their browser; call wait_for_feedback after publishing something you want a " + "reaction to. Any publish/update/reply result may carry a userFeedback array — comments the user " + @@ -25,9 +25,9 @@ const d = { 'Session name shown in the sidebar — name the task, e.g. "Auth refactor". Honored only when this publish creates the session.', stdioSessionTitle: 'Session name (first publish only), e.g. "Auth refactor"', agent: "Your agent name for the session label (first publish only)", - surfaceId: "Surface id returned by publish_surface", + surfaceId: "Post id returned by publish_post", replacementTitle: "Replacement title", - replacementParts: "Replacement parts array", + replacementParts: "Replacement surfaces array", timeout: "How long to wait, 0-300", afterSeq: "explicit cursor override (default: where the agent left off)", replyMessage: "Plain-text reply", @@ -36,60 +36,61 @@ const d = { assetFilename: "Original filename (used for downloads)", assetKind: "Asset kind (inferred from contentType when omitted)", assetSession: "Session id to attach the asset to", - partHtml: "html part: body fragment (no doctype/html/head/body)", - partKits: `html part: opt into style/behavior bundles by id (${KIT_IDS.join( + surfaceHtml: "html surface: body fragment (no doctype/html/head/body)", + surfaceKits: `html surface: opt into style/behavior bundles by id (${KIT_IDS.join( " | ", )}). Each injects extra CSS/JS classes (e.g. 'issues' gives .card/.tree/.badge; 'slides' gives a stepped .deck). Omit for plain html. See get_design_guide.`, - partMarkdown: "markdown part: prose (headings, lists, tables, code, links); raw HTML is escaped", - partMermaid: - "mermaid part: diagram source (flowchart, sequence, ERD, gantt, …), rendered to SVG by the viewer", - partPatch: "diff part: a unified/git diff string — the preferred, compact form", - partFiles: "diff part: before/after pairs — heavier (full contents); prefer patch", - partAssetId: "image/trace part: id returned by upload_asset", - imageAlt: "image part: alt text", - imageCaption: "image part: caption shown under the image", - traceTitle: "trace part: heading above the timeline", - traceSteps: "trace part: ordered steps rendered as a timeline", + surfaceMarkdown: + "markdown surface: prose (headings, lists, tables, code, links); raw HTML is escaped", + surfaceMermaid: + "mermaid surface: diagram source (flowchart, sequence, ERD, gantt, …), rendered to SVG by the viewer", + surfacePatch: "diff surface: a unified/git diff string — the preferred, compact form", + surfaceFiles: "diff surface: before/after pairs — heavier (full contents); prefer patch", + surfaceAssetId: "image/trace surface: id returned by upload_asset", + imageAlt: "image surface: alt text", + imageCaption: "image surface: caption shown under the image", + traceTitle: "trace surface: heading above the timeline", + traceSteps: "trace surface: ordered steps rendered as a timeline", traceLabel: "one-line summary of the step", traceKind: "free tag, e.g. tool|thought|shell", traceDetail: "expandable body (output, args, reasoning)", traceTs: "ISO timestamp", - terminalText: "terminal part: raw output (ANSI SGR color escapes are rendered)", - terminalCols: "terminal part: optional render width in columns", + terminalText: "terminal surface: raw output (ANSI SGR color escapes are rendered)", + terminalCols: "terminal surface: optional render width in columns", }; -const MCP_PARTS_DESCRIPTION = - "Ordered parts. html: {kind:'html', html:'', kits?:['issues']} — kits opt the " + - "part into extra CSS/JS bundles (issues: .card/.tree/.badge/.bar; slides: a stepped .deck with " + +const MCP_SURFACES_DESCRIPTION = + "Ordered surfaces. html: {kind:'html', html:'', kits?:['issues']} — kits opt the " + + "surface into extra CSS/JS bundles (issues: .card/.tree/.badge/.bar; slides: a stepped .deck with " + "controls); omit for plain html. markdown: {kind:'markdown', " + "markdown:'## prose'} — for explanations, plans, tradeoff write-ups (styled text, not sandboxed; " + - "embedded raw HTML is escaped — use an html part for live markup). mermaid: {kind:'mermaid', " + + "embedded raw HTML is escaped — use an html surface for live markup). mermaid: {kind:'mermaid', " + "mermaid:'graph TD; A-->B'} — diagram source rendered to SVG (flowchart, sequence, ERD, gantt, …). " + "diff: {kind:'diff', " + "patch:''} (preferred, compact) or {kind:'diff', files:[{filename, before, " + "after}]} (heavier). image: {kind:'image', assetId:'', alt?, caption?} — " + - "renders an uploaded image; you can also embed the asset URL in an html part instead. trace: " + + "renders an uploaded image; you can also embed the asset URL in an html surface instead. trace: " + "{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 " + "'unified'|'split'. Combine freely, e.g. [{kind:'html',...},{kind:'image',assetId},{kind:'trace',steps}]."; -const MCP_PART_JSON_SCHEMA = { +const MCP_SURFACE_JSON_SCHEMA = { type: "object", properties: { kind: { type: "string", enum: ["html", "markdown", "mermaid", "diff", "image", "trace", "terminal"], }, - html: { type: "string", description: d.partHtml }, - kits: { type: "array", items: { type: "string" }, description: d.partKits }, - markdown: { type: "string", description: d.partMarkdown }, - mermaid: { type: "string", description: d.partMermaid }, - patch: { type: "string", description: d.partPatch }, + html: { type: "string", description: d.surfaceHtml }, + kits: { type: "array", items: { type: "string" }, description: d.surfaceKits }, + markdown: { type: "string", description: d.surfaceMarkdown }, + mermaid: { type: "string", description: d.surfaceMermaid }, + patch: { type: "string", description: d.surfacePatch }, files: { type: "array", - description: d.partFiles, + description: d.surfaceFiles, items: { type: "object", properties: { @@ -102,7 +103,7 @@ const MCP_PART_JSON_SCHEMA = { }, }, layout: { type: "string", enum: ["unified", "split"] }, - assetId: { type: "string", description: d.partAssetId }, + assetId: { type: "string", description: d.surfaceAssetId }, alt: { type: "string", description: d.imageAlt }, caption: { type: "string", description: d.imageCaption }, title: { type: "string", description: d.traceTitle }, @@ -126,37 +127,84 @@ const MCP_PART_JSON_SCHEMA = { required: ["kind"], } as const; -const MCP_PARTS_JSON_SCHEMA = { +const MCP_SURFACES_JSON_SCHEMA = { type: "array", - description: MCP_PARTS_DESCRIPTION, - items: MCP_PART_JSON_SCHEMA, + description: MCP_SURFACES_DESCRIPTION, + items: MCP_SURFACE_JSON_SCHEMA, } as const; 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.", + 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.", + 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: - "Publish a surface to the user's sideshow board. A surface is an ordered list of parts (html, markdown, mermaid, diff, image, trace). Returns the surface 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). 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: - "Publish a surface to the user's sideshow board. A surface is an ordered list of parts (html, markdown, mermaid, diff, image, trace). Returns the surface 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). 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: - "Revise a surface in place (same card, new version). Prefer this over publishing a near-duplicate. Pass the full replacement parts array. If the result includes userFeedback, read it.", + "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: - "Publish an HTML snippet — sugar for a surface with one html part. Send a body fragment only. Returns the id, view URL, and sessionId. Pass sessionTitle on first publish. Prefer publish_surface when you want a diff or multiple parts.", - updateSnippet: "Revise an html snippet in place — sugar for update_surface with one html part.", + "Publish an HTML snippet — sugar for a post with one html surface. Send a body fragment only. Returns the id, view URL, and sessionId. Pass sessionTitle on first publish. Prefer publish_post when you want a diff or multiple surfaces.", + updateSnippet: "Revise an html snippet in place — sugar for update_post with one html surface.", waitForFeedback: "Block until the user comments on this session in their browser (or the timeout passes). Returns new comments since the agent last received feedback on any channel. Use timeoutSeconds 0 for a non-blocking check.", replyToUser: - "Post a short reply under a surface's comment thread. Use to acknowledge feedback or explain a revision.", - listSurfacesHttp: "List surfaces — pass a session id to scope, or omit for all sessions.", - listSurfacesStdio: "List surfaces in this conversation's session.", + "Post a short reply under a post's comment thread. Use to acknowledge feedback or explain a revision.", + listSurfacesHttp: + "Deprecated alias of list_posts — List posts; pass a session id to scope, or omit for all sessions.", + listSurfacesStdio: "Deprecated alias of list_posts — List posts in this conversation's session.", uploadAsset: - "Upload a binary asset (image, trace file, any file) and get back its id and URL. base64-encode the bytes in `data` (MCP carries no binary). Then reference it: put {kind:'image', assetId} or {kind:'trace', assetId} in a surface's parts, or embed the returned url in an html part (). Pass the same session id you publish with so the asset is grouped and cleaned up with it.", + "Upload a binary asset (image, trace file, any file) and get back its id and URL. base64-encode the bytes in `data` (MCP carries no binary). Then reference it: put {kind:'image', assetId} or {kind:'trace', assetId} in a post's surfaces, or embed the returned url in an html surface (). Pass the same session id you publish with so the asset is grouped and cleaned up with it.", uploadAssetStdio: - "Upload a binary asset (image, trace file, any file) and get back its id and URL. base64-encode the bytes in `data`. Then reference it: put {kind:'image', assetId} or {kind:'trace', assetId} in a surface's parts, or embed the returned url in an html part (). Attached to this conversation's session.", + "Upload a binary asset (image, trace file, any file) and get back its id and URL. base64-encode the bytes in `data`. Then reference it: put {kind:'image', assetId} or {kind:'trace', assetId} in a post's surfaces, or embed the returned url in an html surface (). Attached to this conversation's session.", getDesignGuide: - "Fetch the design contract: surface parts, html fragment rules, theme CSS variables, CDN allowlist, and the interactivity bridge. Call once per session before publishing.", + "Fetch the design contract: post surfaces, html fragment rules, theme CSS variables, CDN allowlist, and the interactivity bridge. Call once per session before publishing.", } as const; export const HTTP_MCP_TOOLS = [ + { + name: "publish_post", + description: MCP_TOOL_DESCRIPTIONS.publishPostHttp, + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: d.title }, + surfaces: MCP_SURFACES_JSON_SCHEMA, + session: { type: "string", description: d.session }, + sessionTitle: { type: "string", description: d.sessionTitle }, + agent: { type: "string", description: d.agent }, + }, + required: ["title", "surfaces"], + }, + }, + { + name: "update_post", + description: MCP_TOOL_DESCRIPTIONS.updatePost, + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: d.surfaceId }, + surfaces: MCP_SURFACES_JSON_SCHEMA, + title: { type: "string", description: d.replacementTitle }, + }, + required: ["id"], + }, + }, + { + name: "list_posts", + description: MCP_TOOL_DESCRIPTIONS.listPostsHttp, + inputSchema: { + type: "object", + properties: { + session: { type: "string", description: "Optional session id to scope the list" }, + }, + }, + }, { name: "publish_surface", description: MCP_TOOL_DESCRIPTIONS.publishSurfaceHttp, @@ -164,7 +212,7 @@ export const HTTP_MCP_TOOLS = [ type: "object", properties: { title: { type: "string", description: d.title }, - parts: MCP_PARTS_JSON_SCHEMA, + parts: MCP_SURFACES_JSON_SCHEMA, session: { type: "string", description: d.session }, sessionTitle: { type: "string", description: d.sessionTitle }, agent: { type: "string", description: d.agent }, @@ -179,7 +227,7 @@ export const HTTP_MCP_TOOLS = [ type: "object", properties: { id: { type: "string", description: d.surfaceId }, - parts: MCP_PARTS_JSON_SCHEMA, + parts: MCP_SURFACES_JSON_SCHEMA, title: { type: "string", description: d.replacementTitle }, }, required: ["id"], @@ -193,7 +241,7 @@ export const HTTP_MCP_TOOLS = [ properties: { title: { type: "string", description: "Short human-readable title" }, html: { type: "string", description: d.html }, - kits: { type: "array", items: { type: "string" }, description: d.partKits }, + kits: { type: "array", items: { type: "string" }, description: d.surfaceKits }, session: { type: "string", description: d.session }, sessionTitle: { type: "string", description: "Session name (first publish only)" }, agent: { type: "string", description: d.agent }, @@ -209,7 +257,7 @@ export const HTTP_MCP_TOOLS = [ properties: { id: { type: "string", description: "Surface id" }, html: { type: "string", description: "Replacement HTML body fragment" }, - kits: { type: "array", items: { type: "string" }, description: d.partKits }, + kits: { type: "array", items: { type: "string" }, description: d.surfaceKits }, title: { type: "string", description: d.replacementTitle }, }, required: ["id"], @@ -234,7 +282,8 @@ export const HTTP_MCP_TOOLS = [ inputSchema: { type: "object", properties: { - surfaceId: { type: "string", description: "Surface whose thread to reply in" }, + postId: { type: "string", description: "Post whose comment thread to reply in" }, + surfaceId: { type: "string", description: "Deprecated alias of postId" }, message: { type: "string", description: d.replyMessage }, author: { type: "string", @@ -242,7 +291,7 @@ export const HTTP_MCP_TOOLS = [ 'Your agent name (default "agent"; "user" is reserved and coerced to "agent")', }, }, - required: ["surfaceId", "message"], + required: ["message"], }, }, { @@ -294,14 +343,14 @@ const traceStepSchema = z.object({ const mcpPartSchema = z .object({ kind: z.enum(["html", "markdown", "mermaid", "diff", "image", "trace", "terminal"]), - html: z.string().optional().describe(d.partHtml), - kits: z.array(z.string()).optional().describe(d.partKits), - markdown: z.string().optional().describe(d.partMarkdown), - mermaid: z.string().optional().describe(d.partMermaid), - patch: z.string().optional().describe(d.partPatch), - files: z.array(diffFileSchema).optional().describe(d.partFiles), + html: z.string().optional().describe(d.surfaceHtml), + kits: z.array(z.string()).optional().describe(d.surfaceKits), + markdown: z.string().optional().describe(d.surfaceMarkdown), + mermaid: z.string().optional().describe(d.surfaceMermaid), + patch: z.string().optional().describe(d.surfacePatch), + files: z.array(diffFileSchema).optional().describe(d.surfaceFiles), layout: z.enum(["unified", "split"]).optional(), - assetId: z.string().optional().describe(d.partAssetId), + assetId: z.string().optional().describe(d.surfaceAssetId), alt: z.string().optional().describe(d.imageAlt), caption: z.string().optional().describe(d.imageCaption), title: z.string().optional().describe(d.traceTitle), @@ -310,16 +359,26 @@ const mcpPartSchema = z cols: z.number().optional().describe(d.terminalCols), }) .describe( - "A surface part: html {kind:'html',html}; markdown {kind:'markdown',markdown} (prose); mermaid " + + "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)", ); export const STDIO_MCP_INPUT_SCHEMAS = { + publishPost: { + title: z.string().describe(d.title), + surfaces: z.array(mcpPartSchema).describe(MCP_SURFACES_DESCRIPTION), + sessionTitle: z.string().optional().describe(d.stdioSessionTitle), + }, + updatePost: { + id: z.string().describe(d.surfaceId), + surfaces: z.array(mcpPartSchema).optional().describe(d.replacementParts), + title: z.string().optional().describe(d.replacementTitle), + }, publishSurface: { title: z.string().describe(d.title), - parts: z.array(mcpPartSchema).describe(MCP_PARTS_DESCRIPTION), + parts: z.array(mcpPartSchema).describe(MCP_SURFACES_DESCRIPTION), sessionTitle: z.string().optional().describe(d.stdioSessionTitle), }, updateSurface: { @@ -330,13 +389,13 @@ export const STDIO_MCP_INPUT_SCHEMAS = { publishSnippet: { title: z.string().describe("Short human-readable title shown above the snippet"), html: z.string().describe(d.html), - kits: z.array(z.string()).optional().describe(d.partKits), + kits: z.array(z.string()).optional().describe(d.surfaceKits), sessionTitle: z.string().optional().describe("Session name (first publish only)"), }, updateSnippet: { id: z.string().describe("Surface id"), html: z.string().optional().describe("Replacement HTML body fragment"), - kits: z.array(z.string()).optional().describe(d.partKits), + kits: z.array(z.string()).optional().describe(d.surfaceKits), title: z.string().optional().describe(d.replacementTitle), }, waitForFeedback: { @@ -348,7 +407,8 @@ export const STDIO_MCP_INPUT_SCHEMAS = { .describe(`${d.timeout} (default 120, 0 = check only)`), }, replyToUser: { - surfaceId: z.string().describe("Surface whose thread to reply in"), + postId: z.string().optional().describe("Post whose comment thread to reply in"), + surfaceId: z.string().optional().describe("Deprecated alias of postId"), message: z.string().describe(d.replyMessage), }, uploadAsset: { diff --git a/test/api.test.ts b/test/api.test.ts index 375510e..fe12851 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -1716,3 +1716,260 @@ test("GET /api/comments?surface= filters to that surface, not the whole session" ).json()) as any; assert.equal(allInSession.comments.length, 2); }); + +// --- post/surface wire vocabulary (additive, backward-compatible) --- + +test("POST /api/posts accepts a surfaces body and aliases /api/surfaces reads", async () => { + const app = makeApp(); + const res = await app.request( + "/api/posts", + json({ title: "Via posts", surfaces: [{ kind: "html", html: "

post

" }] }), + ); + assert.equal(res.status, 201); + const created = (await res.json()) as any; + assert.ok(created.id && created.sessionId); + assert.deepEqual(created.kinds, ["html"]); + + // GET /api/posts/:id is identical to GET /api/surfaces/:id + const viaPosts = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + const viaSurfaces = (await (await app.request(`/api/surfaces/${created.id}`)).json()) as any; + assert.deepEqual(viaPosts, viaSurfaces); + assert.equal(viaPosts.surfaces[0].html, "

post

"); +}); + +test("POST /api/surfaces still accepts a legacy parts body", async () => { + const app = makeApp(); + const res = await app.request( + "/api/surfaces", + json({ title: "Legacy", parts: [{ kind: "html", html: "

legacy

" }] }), + ); + assert.equal(res.status, 201); + const created = (await res.json()) as any; + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[0].html, "

legacy

"); +}); + +test("POST /api/posts also accepts a legacy parts body (fallback)", async () => { + const app = makeApp(); + const res = await app.request( + "/api/posts", + json({ title: "Fallback", parts: [{ kind: "html", html: "

fb

" }] }), + ); + assert.equal(res.status, 201); + const created = (await res.json()) as any; + const full = (await (await app.request(`/api/surfaces/${created.id}`)).json()) as any; + assert.equal(full.surfaces[0].html, "

fb

"); +}); + +test("missing blocks 400 mentions surfaces", async () => { + const app = makeApp(); + const res = await app.request("/api/posts", json({ title: "Empty" })); + assert.equal(res.status, 400); + const body = (await res.json()) as any; + assert.match(body.error, /surfaces/); +}); + +test("PUT /api/posts/:id revises with a surfaces body; DELETE /api/posts/:id removes", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Rev", surfaces: [{ kind: "html", html: "

v1

" }] }), + ) + ).json()) as any; + + const put = await app.request(`/api/posts/${created.id}`, { + ...json({ surfaces: [{ kind: "html", html: "

v2

" }] }), + method: "PUT", + }); + assert.equal(put.status, 200); + const revised = (await put.json()) as any; + assert.equal(revised.version, 2); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[0].html, "

v2

"); + + const del = await app.request(`/api/posts/${created.id}`, { method: "DELETE" }); + assert.equal(del.status, 200); + assert.equal((await app.request(`/api/posts/${created.id}`)).status, 404); +}); + +test("GET /p/:id and /p/:id?surface=N mirror /s/:id", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Pages", + surfaces: [ + { kind: "html", html: "

first

" }, + { kind: "markdown", markdown: "## second" }, + ], + }), + ) + ).json()) as any; + + // shell page + const shell = await app.request(`/p/${created.id}`); + assert.equal(shell.status, 200); + assert.match(shell.headers.get("content-type") ?? "", /text\/html/); + + // ?surface=N selects a block (sandboxed document), same as ?part=N + const viaSurfaceQ = await app.request(`/p/${created.id}?surface=1`); + assert.equal(viaSurfaceQ.status, 200); + const bodyNew = await viaSurfaceQ.text(); + assert.ok(bodyNew.includes("second")); + + // legacy ?part still works on /s/:id + const viaPartQ = await app.request(`/s/${created.id}?part=1`); + assert.equal(viaPartQ.status, 200); + assert.ok((await viaPartQ.text()).includes("second")); +}); + +test("GET /api/sessions/:id/posts mirrors /surfaces", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Listed", surfaces: [{ kind: "html", html: "

x

" }] }), + ) + ).json()) as any; + const viaPosts = (await ( + await app.request(`/api/sessions/${created.sessionId}/posts`) + ).json()) as any; + const viaSurfaces = (await ( + await app.request(`/api/sessions/${created.sessionId}/surfaces`) + ).json()) as any; + assert.deepEqual(viaPosts, viaSurfaces); + assert.equal(viaPosts.length, 1); +}); + +test("GET /session/:id/p/:postId serves the viewer shell", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Nested", surfaces: [{ kind: "html", html: "

x

" }] }), + ) + ).json()) as any; + const page = await app.request(`/session/${created.sessionId}/p/${created.id}`); + assert.equal(page.status, 200); + assert.match(page.headers.get("content-type") ?? "", /text\/html/); +}); + +test("publish_post / update_post / list_posts MCP tools accept surfaces", async () => { + const app = makeApp(); + const list = (await (await app.request("/mcp", mcpCall(1, "tools/list"))).json()) as any; + const names = list.result.tools.map((t: any) => t.name); + assert.ok(names.includes("publish_post")); + assert.ok(names.includes("update_post")); + assert.ok(names.includes("list_posts")); + // old tools still advertised + assert.ok(names.includes("publish_surface")); + assert.ok(names.includes("list_surfaces")); + + const published = (await ( + await app.request( + "/mcp", + mcpCall(2, "tools/call", { + name: "publish_post", + arguments: { + title: "Post", + surfaces: [{ kind: "diff", patch: "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-x\n+y" }], + }, + }), + ) + ).json()) as any; + const payload = JSON.parse(published.result.content[0].text); + assert.ok(payload.id && payload.sessionId); + // new tools emit the canonical /p/ path + assert.ok(payload.url.includes(`/p/${payload.id}`)); + const full = (await (await app.request(`/api/posts/${payload.id}`)).json()) as any; + assert.equal(full.surfaces[0].kind, "diff"); + + // update_post with surfaces + const updated = (await ( + await app.request( + "/mcp", + mcpCall(3, "tools/call", { + name: "update_post", + arguments: { id: payload.id, surfaces: [{ kind: "html", html: "

updated

" }] }, + }), + ) + ).json()) as any; + const upPayload = JSON.parse(updated.result.content[0].text); + assert.equal(upPayload.version, 2); + assert.ok(upPayload.url.includes(`/p/${payload.id}`)); + + // list_posts scoped to the session + const listed = (await ( + await app.request( + "/mcp", + mcpCall(4, "tools/call", { + name: "list_posts", + arguments: { session: payload.sessionId }, + }), + ) + ).json()) as any; + const rows = JSON.parse(listed.result.content[0].text); + assert.equal(rows.length, 1); + assert.equal(rows[0].id, payload.id); +}); + +test("reply_to_user MCP tool accepts postId (and legacy surfaceId)", async () => { + const app = makeApp(); + const published = (await ( + await app.request( + "/mcp", + mcpCall(1, "tools/call", { + name: "publish_post", + arguments: { title: "P", surfaces: [{ kind: "html", html: "

x

" }] }, + }), + ) + ).json()) as any; + const { id } = JSON.parse(published.result.content[0].text); + + // canonical postId arg + const replied = (await ( + await app.request( + "/mcp", + mcpCall(2, "tools/call", { + name: "reply_to_user", + arguments: { postId: id, message: "ack", author: "test-agent" }, + }), + ) + ).json()) as any; + assert.ok(!replied.result.isError, replied.result.content?.[0]?.text); + const comment = JSON.parse(replied.result.content[0].text); + assert.equal(comment.text, "ack"); + assert.equal(comment.postId, id); // postId routed the reply to the right post's thread + + // legacy surfaceId arg still works + const legacy = (await ( + await app.request( + "/mcp", + mcpCall(3, "tools/call", { + name: "reply_to_user", + arguments: { surfaceId: id, message: "ack2" }, + }), + ) + ).json()) as any; + assert.ok(!legacy.result.isError, legacy.result.content?.[0]?.text); + assert.equal(JSON.parse(legacy.result.content[0].text).postId, id); +}); + +test("publish_surface MCP tool still accepts legacy parts", async () => { + const app = makeApp(); + const published = (await ( + await app.request( + "/mcp", + mcpCall(2, "tools/call", { + name: "publish_surface", + arguments: { title: "Legacy", parts: [{ kind: "html", html: "

old

" }] }, + }), + ) + ).json()) as any; + const payload = JSON.parse(published.result.content[0].text); + assert.ok(payload.url.includes(`/s/${payload.id}`)); + const full = (await (await app.request(`/api/surfaces/${payload.id}`)).json()) as any; + assert.equal(full.surfaces[0].html, "

old

"); +}); From f47f9cf928ae8990974a9001f263ab6b18d70ea8 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 25 Jun 2026 13:02:57 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(api):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?PUT=20surfaces:null=20400,=20legacy-tool=20error=20vocab,=20neu?= =?UTF-8?q?tral=20id=20desc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a Sonnet code-review pass on the post/surface wire change: - PUT /api/posts/:id (and /api/surfaces/:id): an explicit `surfaces: null` was silently treated as a title-only update (200) because `surfaces ?? parts` coalesced null→undefined and skipped validation. Gate on field *presence* so null is a 400, matching POST and the legacy `parts` path. (+regression test) - mcpHttp publish: the empty-blocks error now uses the tool's own vocabulary — "a surface needs at least one part" for the deprecated publish_surface/ publish_snippet, "a post needs at least one surface" for publish_post. - mcpSpec: neutral `id` description ("Post id returned by a publish call") so the deprecated update_surface tool no longer points agents at publish_post. - Tests: cross-matrix coverage (/s/:id?surface=, /p/:id?part=) and reply_to_user with neither postId nor surfaceId (clean error). 256/256 tests, typecheck/lint/format clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/app.ts | 5 ++++- server/mcpHttp.ts | 8 ++++++- server/mcpSpec.ts | 2 +- test/api.test.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/server/app.ts b/server/app.ts index 5d2a73d..f80a97b 100644 --- a/server/app.ts +++ b/server/app.ts @@ -851,9 +851,12 @@ export function createApp({ const body = await c.req.json().catch(() => null); if (!body) return c.json({ error: "invalid JSON body" }, 400); // posts: a `surfaces` array (legacy `parts`); snippets: an `html` string. + // Presence — not nullishness — gates validation, so an explicit + // `surfaces: null` is a 400 (like POST) rather than a silent title-only update. + const hasBlocks = body.surfaces !== undefined || body.parts !== undefined; const blocks = body.surfaces ?? body.parts; let parts: Surface[] | undefined; - if (blocks !== undefined) { + if (hasBlocks) { if (!Array.isArray(blocks)) { return c.json({ error: '"surfaces" (or legacy "parts") must be an array' }, 400); } diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts index 4fd0b46..08dee2e 100644 --- a/server/mcpHttp.ts +++ b/server/mcpHttp.ts @@ -84,7 +84,13 @@ export function registerMcp(app: Hono, deps: McpDeps) { name === "publish_snippet" ? await coerceParts([htmlSurface(String(args.html ?? ""), args.kits)]) : await coerceParts(blocks); - if (parts.length === 0) throw new Error("a post needs at least one surface"); + if (parts.length === 0) { + throw new Error( + name === "publish_post" + ? "a post needs at least one surface" + : "a surface needs at least one part", + ); + } const result = await deps.publishSurface({ parts, title: typeof args.title === "string" ? args.title : undefined, diff --git a/server/mcpSpec.ts b/server/mcpSpec.ts index 10b0440..488f075 100644 --- a/server/mcpSpec.ts +++ b/server/mcpSpec.ts @@ -25,7 +25,7 @@ const d = { 'Session name shown in the sidebar — name the task, e.g. "Auth refactor". Honored only when this publish creates the session.', stdioSessionTitle: 'Session name (first publish only), e.g. "Auth refactor"', agent: "Your agent name for the session label (first publish only)", - surfaceId: "Post id returned by publish_post", + surfaceId: "Post id returned by a publish call", replacementTitle: "Replacement title", replacementParts: "Replacement surfaces array", timeout: "How long to wait, 0-300", diff --git a/test/api.test.ts b/test/api.test.ts index fe12851..4a6deb9 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -1823,6 +1823,47 @@ test("GET /p/:id and /p/:id?surface=N mirror /s/:id", async () => { const viaPartQ = await app.request(`/s/${created.id}?part=1`); assert.equal(viaPartQ.status, 200); assert.ok((await viaPartQ.text()).includes("second")); + + // cross matrix: new param on the old route, old param on the new route + const oldRouteNewParam = await app.request(`/s/${created.id}?surface=1`); + assert.equal(oldRouteNewParam.status, 200); + assert.ok((await oldRouteNewParam.text()).includes("second")); + const newRouteOldParam = await app.request(`/p/${created.id}?part=1`); + assert.equal(newRouteOldParam.status, 200); + assert.ok((await newRouteOldParam.text()).includes("second")); +}); + +test("PUT /api/posts/:id with surfaces:null is a 400, not a silent title-only update", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Orig", surfaces: [{ kind: "html", html: "

keep

" }] }), + ) + ).json()) as any; + + // explicit null surfaces must be rejected (like POST), not ignored + const bad = await app.request(`/api/posts/${created.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ surfaces: null, title: "New" }), + }); + assert.equal(bad.status, 400); + // and the post is unchanged + const after = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(after.title, "Orig"); + + // a title-only update (no surfaces/parts field at all) still works + const ok = await app.request(`/api/posts/${created.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "Renamed" }), + }); + assert.equal(ok.status, 200); + assert.equal( + ((await (await app.request(`/api/posts/${created.id}`)).json()) as any).title, + "Renamed", + ); }); test("GET /api/sessions/:id/posts mirrors /surfaces", async () => { @@ -1955,6 +1996,18 @@ test("reply_to_user MCP tool accepts postId (and legacy surfaceId)", async () => ).json()) as any; assert.ok(!legacy.result.isError, legacy.result.content?.[0]?.text); assert.equal(JSON.parse(legacy.result.content[0].text).postId, id); + + // neither postId nor surfaceId → a clean error, not a crash + const missing = (await ( + await app.request( + "/mcp", + mcpCall(4, "tools/call", { + name: "reply_to_user", + arguments: { message: "orphan" }, + }), + ) + ).json()) as any; + assert.ok(missing.result.isError); }); test("publish_surface MCP tool still accepts legacy parts", async () => {