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
23 changes: 23 additions & 0 deletions .changeset/post-surface-wire-vocab.md
Original file line number Diff line number Diff line change
@@ -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/<id>` 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).
45 changes: 43 additions & 2 deletions mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,47 @@ async function ensureSession(title?: string): Promise<string> {

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",
{
Expand Down Expand Up @@ -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);
Expand Down
51 changes: 36 additions & 15 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)));
Expand Down Expand Up @@ -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 ---
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -839,11 +850,17 @@ 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.
// 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 (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 (hasBlocks) {
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") {
Expand All @@ -862,6 +879,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) => {
Expand All @@ -872,6 +890,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 ---
Expand Down Expand Up @@ -943,10 +962,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 / <img> / 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");
Expand Down Expand Up @@ -1023,7 +1042,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) ---

Expand Down
36 changes: 27 additions & 9 deletions server/mcpHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>; the new post tools
// emit the canonical /p/<id>. 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,
Expand All @@ -69,13 +75,22 @@ export function registerMcp(app: Hono, deps: McpDeps) {

async function callTool(name: string, args: any, origin: string): Promise<string> {
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(
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,
Expand All @@ -84,8 +99,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 } = {
Expand All @@ -94,12 +110,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({
Expand Down Expand Up @@ -137,7 +154,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);
Expand All @@ -147,6 +164,7 @@ export function registerMcp(app: Hono, deps: McpDeps) {
2,
);
}
case "list_posts":
case "list_surfaces":
case "list_snippets": {
const surfaces = await deps.store.listPosts(
Expand Down
Loading
Loading