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
2 changes: 2 additions & 0 deletions .changeset/free-dodos-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion docs/connecting-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,6 @@ shows the same steps. The plugin lives in [`../plugin/`](../plugin/).
`/agent-howto` is the current operational playbook for agents: publishing,
feedback, CLI/MCP/curl choices, and gotchas. The contract at `/guide` is the
lower-level design reference: fragment-only HTML, theme CSS variables, dark mode
rules, and when to reach for each part kind. Agents should fetch the instructions
rules, and when to reach for each surface kind. Agents should fetch the instructions
first, then fetch the guide once before their first publish (`sideshow guide`,
`get_design_guide`, or `curl -s …/guide`).
78 changes: 37 additions & 41 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ import {
type CommentAnchor,
type DiffSurface,
htmlSurface,
isSandboxedSurfaceKind,
type MarkdownSurface,
MAX_ASSET_BYTES,
surfacesByteLength,
type Session,
type Store,
type Post,
type Surface,
SURFACE_CONTENT_FIELDS,
type TerminalSurface,
type TraceStep,
} from "./types.ts";
Expand Down Expand Up @@ -310,7 +312,7 @@ export function createApp({
// Rendered-document cache for /s/:id rich surfaces. Rendering a markdown/code/
// diff surface runs shiki / @pierre-diffs SSR, which is non-trivial (a big diff
// is tens of ms + tens of KB), so memoize the finished document string. The
// key pins everything the output depends on — post id, part index, the
// key pins everything the output depends on — post id, surface index, the
// RESOLVED version number, theme, mode — and a version's content is immutable,
// so a hit is always correct (a post edit bumps the version → a new key).
// Bounded + FIFO-evicted: a dropped entry costs a re-render, never
Expand Down Expand Up @@ -382,19 +384,6 @@ export function createApp({
return feedback.length > 0 ? feedback.map(feedbackView) : undefined;
}

// Maps a surface kind to its primary content field — used by content-only
// updates (PATCH with `content`) to slot a raw string into the right field
// while preserving extra fields (language, cols, layout, etc.).
const CONTENT_FIELD: Record<string, string> = {
html: "html",
markdown: "markdown",
mermaid: "mermaid",
diff: "patch",
terminal: "text",
code: "code",
json: "data",
};

// Find a surface's index by id (first match) or 0-based numeric index.
function findSurfaceIndex(surfaces: Surface[], target: string): number {
const byId = surfaces.findIndex((s) => s.id === target);
Expand All @@ -408,7 +397,7 @@ export function createApp({
// extra fields. Returns null if the kind has no content field or JSON parse
// fails. The caller handles error reporting.
function applyContent(surface: Surface, content: string, kits?: unknown): Surface | null {
const field = CONTENT_FIELD[surface.kind];
const field = SURFACE_CONTENT_FIELDS[surface.kind];
if (!field) return null;
let value: unknown = content;
if (surface.kind === "json") {
Expand Down Expand Up @@ -601,7 +590,7 @@ export function createApp({
// The validator strips the id field (zod schemas don't declare it), so
// re-apply the target's id after validation to preserve surface identity.
const surfaces = [...existing.surfaces];
surfaces[idx] = { ...parsed.parts[0], id: existing.surfaces[idx].id };
surfaces[idx] = { ...parsed.surfaces[0], id: existing.surfaces[idx].id };
return revisePost(id, { surfaces });
}

Expand Down Expand Up @@ -1002,7 +991,7 @@ export function createApp({
return c.json(sessions.map((s) => sessionRowView(s, counts.get(s.id) ?? 0)));
});

// --- recent surfaces (post-grained feed source) ---
// --- recent posts (post-grained feed source) ---
//
// The N most-recently-updated posts across ALL sessions, newest first — one
// row per post (post-grained), distinct from the session-grained GET
Expand Down Expand Up @@ -1128,7 +1117,7 @@ export function createApp({
}
const parsed = await validateSurfaces(blocks);
if (!parsed.ok) return c.json({ error: parsed.error }, 400);
return publish(c, body, parsed.parts);
return publish(c, body, parsed.surfaces);
};
app.post("/api/posts", publishPost); // canonical
app.post("/api/surfaces", publishPost);
Expand All @@ -1143,7 +1132,7 @@ export function createApp({
}
const parsed = await validateSurfaces([htmlSurface(body.html, body.kits)]);
if (!parsed.ok) return c.json({ error: parsed.error }, 400);
return publish(c, body, parsed.parts);
return publish(c, body, parsed.surfaces);
});

async function publish(c: any, body: any, surfaces: Surface[]) {
Expand Down Expand Up @@ -1180,11 +1169,11 @@ export function createApp({
}
const parsed = await validateSurfaces(blocks);
if (!parsed.ok) return c.json({ error: parsed.error }, 400);
surfaces = parsed.parts;
surfaces = parsed.surfaces;
} else if (typeof body.html === "string") {
const parsed = await validateSurfaces([htmlSurface(body.html, body.kits)]);
if (!parsed.ok) return c.json({ error: parsed.error }, 400);
surfaces = parsed.parts;
surfaces = parsed.surfaces;
}
const result = await revisePost(c.req.param("id"), {
surfaces,
Expand Down Expand Up @@ -1249,7 +1238,7 @@ export function createApp({
const parsed = await validateSurfaces([updated]);
if (!parsed.ok) return c.json({ error: parsed.error }, 400);
surfaces = [...existing.surfaces];
surfaces[targetIdx] = { ...parsed.parts[0], id: existing.surfaces[targetIdx].id };
surfaces[targetIdx] = { ...parsed.surfaces[0], id: existing.surfaces[targetIdx].id };
}
const result = await revisePost(c.req.param("id"), {
surfaces,
Expand All @@ -1273,7 +1262,7 @@ export function createApp({
}
const parsed = await validateSurfaces([body.surface]);
if (!parsed.ok) return c.json({ error: parsed.error }, 400);
const result = await appendPostSurface(c.req.param("id"), parsed.parts[0], {
const result = await appendPostSurface(c.req.param("id"), parsed.surfaces[0], {
before: body.before,
after: body.after,
});
Expand All @@ -1296,7 +1285,7 @@ export function createApp({
if (body.surface !== undefined) {
const parsed = await validateSurfaces([body.surface]);
if (!parsed.ok) return c.json({ error: parsed.error }, 400);
surface = parsed.parts[0];
surface = parsed.surfaces[0];
}
const result = await replacePostSurface(c.req.param("id"), c.req.param("target"), {
surface,
Expand Down Expand Up @@ -1450,8 +1439,9 @@ export function createApp({
const renderPostPage = async (c: any) => {
const post = await store.getPost(c.req.param("id"));
if (!post) return c.text("Post not found", 404);
const partParam = c.req.query("surface") ?? c.req.query("part");
if (partParam == null) return c.html(configuredViewerHtml(c, { post }));
// `part` is the legacy query key; `surface` is canonical.
const surfaceParam = c.req.query("surface") ?? c.req.query("part");
if (surfaceParam == null) return c.html(configuredViewerHtml(c, { post }));

const ver = c.req.query("ver");
let title = post.title;
Expand All @@ -1464,12 +1454,11 @@ export function createApp({
surfaces = old.surfaces;
version = old.version;
}
const idx = Number(partParam ?? 0);
const part = surfaces[idx];
const idx = Number(surfaceParam ?? 0);
const surface = surfaces[idx];
// Only the kinds that become HTML are served here. Image/trace/json render
// natively in the viewer and must not be reachable as a document.
const SANDBOXED = ["html", "markdown", "code", "diff", "terminal", "mermaid"];
if (!part || !SANDBOXED.includes(part.kind)) {
if (!surface || !isSandboxedSurfaceKind(surface.kind)) {
return c.text("No renderable surface at that index", 404);
}
c.header("X-Content-Type-Options", "nosniff");
Expand Down Expand Up @@ -1505,20 +1494,27 @@ export function createApp({
else c.header("Cache-Control", "private, no-cache");

const doc = await cachedRender(cacheKey, async () => {
if (part.kind === "html") {
return renderHtmlPage({ title, html: part.html, origin, theme, mode, kits: part.kits });
if (surface.kind === "html") {
return renderHtmlPage({
title,
html: surface.html,
origin,
theme,
mode,
kits: surface.kits,
});
}
if (part.kind === "mermaid") {
return renderMermaidPage({ mermaid: part.mermaid, origin, theme, mode });
if (surface.kind === "mermaid") {
return renderMermaidPage({ mermaid: surface.mermaid, origin, theme, mode });
}
const rendered =
part.kind === "markdown"
? await renderMarkdown(part as MarkdownSurface, { theme: themeId, mode })
: part.kind === "code"
? await renderCode(part as CodeSurface, { theme: themeId, mode })
: part.kind === "terminal"
? renderTerminal(part as TerminalSurface)
: await renderDiff(part as DiffSurface, { theme: themeId, mode }).catch((e) => ({
surface.kind === "markdown"
? await renderMarkdown(surface as MarkdownSurface, { theme: themeId, mode })
: surface.kind === "code"
? await renderCode(surface as CodeSurface, { theme: themeId, mode })
: surface.kind === "terminal"
? renderTerminal(surface as TerminalSurface)
: await renderDiff(surface as DiffSurface, { theme: themeId, mode }).catch((e) => ({
body: `<div class="rich-error">Couldn’t render diff — ${escapeHtml(
e instanceof Error ? e.message : "render error",
)}</div>`,
Expand Down
13 changes: 7 additions & 6 deletions server/mcpHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface McpDeps {
// Coerce loosely-typed tool args into a validated Surface[]. Unknown kinds
// and empty surfaces are dropped rather than rejected, so a slightly-off call
// still publishes what it can.
// Deprecated alias retained for older deep imports that used the legacy name.
export const coerceParts = coerceSurfaces;

export function registerMcp(app: Hono, deps: McpDeps) {
Expand Down Expand Up @@ -93,8 +94,8 @@ export function registerMcp(app: Hono, deps: McpDeps) {
const blocks = name === "publish_post" ? (args.surfaces ?? args.parts) : args.parts;
const surfaces =
name === "publish_snippet"
? await coerceParts([htmlSurface(String(args.html ?? ""), args.kits)])
: await coerceParts(blocks);
? await coerceSurfaces([htmlSurface(String(args.html ?? ""), args.kits)])
: await coerceSurfaces(blocks);
if (surfaces.length === 0) {
throw new Error("a post needs at least one surface");
}
Expand All @@ -116,10 +117,10 @@ export function registerMcp(app: Hono, deps: McpDeps) {
};
if (name === "update_snippet") {
if (typeof args.html === "string")
patch.surfaces = await coerceParts([htmlSurface(args.html, args.kits)]);
patch.surfaces = await coerceSurfaces([htmlSurface(args.html, args.kits)]);
} else {
const blocks = name === "update_post" ? (args.surfaces ?? args.parts) : args.parts;
if (blocks !== undefined) patch.surfaces = await coerceParts(blocks);
if (blocks !== undefined) patch.surfaces = await coerceSurfaces(blocks);
}
const result = await deps.revisePost(String(args.id ?? ""), patch);
if ("error" in result) throw new Error(result.error);
Expand Down Expand Up @@ -210,7 +211,7 @@ export function registerMcp(app: Hono, deps: McpDeps) {
case "get_design_guide":
return deps.guide;
case "add_surface": {
const surfaces = await coerceParts([args.surface]);
const surfaces = await coerceSurfaces([args.surface]);
if (surfaces.length === 0) throw new Error("invalid surface");
const result = await deps.appendPostSurface(String(args.postId ?? ""), surfaces[0], {
before: typeof args.before === "string" ? args.before : undefined,
Expand All @@ -222,7 +223,7 @@ export function registerMcp(app: Hono, deps: McpDeps) {
case "edit_surface": {
let surface: Surface | undefined;
if (args.surface !== undefined) {
const surfaces = await coerceParts([args.surface]);
const surfaces = await coerceSurfaces([args.surface]);
if (surfaces.length === 0) throw new Error("invalid surface");
surface = surfaces[0];
}
Expand Down
28 changes: 12 additions & 16 deletions server/mcpSpec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";
import { KIT_IDS } from "./kits.ts";
import { SURFACE_KINDS, type SurfaceKind } from "./types.ts";
import { SURFACE_KIND_LIST, SURFACE_KINDS, type SurfaceKind } from "./types.ts";

export const MCP_SERVER_INFO = { name: "sideshow", version: "0.1.0" };

Expand Down Expand Up @@ -155,10 +155,8 @@ const MCP_SURFACES_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, terminal, json, code). Returns the post id, view URL, sessionId, and the new surface ids (use them to target a surface for later edits without a get_post round-trip) — 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, terminal, json, code). Returns the post id, view URL, and the new surface ids (use them to target a surface for later edits without a get_post round-trip). 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.",
publishPostHttp: `Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (${SURFACE_KIND_LIST}). Returns the post id, view URL, sessionId, and the new surface ids (use them to target a surface for later edits without a get_post round-trip) — 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 (${SURFACE_KIND_LIST}). Returns the post id, view URL, and the new surface ids (use them to target a surface for later edits without a get_post round-trip). 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. Returns the new surface ids (use them to target a surface for later edits without a get_post round-trip). If the result includes userFeedback, read it.",
listPostsHttp:
Expand All @@ -167,10 +165,8 @@ export const MCP_TOOL_DESCRIPTIONS = {
"List posts in this conversation's session. Returns lean post rows with surfaces as `{id, kind, index}` metadata (no surface bodies).",
getPost:
"Fetch a single post by id — returns the full post object including surfaces (with their ids and 0-based indexes), version, and history. Use this to recover surface ids (or indexes) for per-surface operations (edit_surface, remove_surface, reorder_surfaces) after a context compaction, or to inspect a post's current state before editing.",
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, 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, 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.",
publishSurfaceHttp: `Deprecated alias of publish_post — Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (${SURFACE_KIND_LIST}). 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 (${SURFACE_KIND_LIST}). 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 @@ -445,7 +441,7 @@ const traceStepSchema = z.object({
ts: z.string().optional().describe(d.traceTs),
});

const mcpPartSchema = z
const mcpSurfaceSchema = z
.object({
kind: z.enum(PART_KIND_ENUM),
html: z.string().optional().describe(d.surfaceHtml),
Expand Down Expand Up @@ -478,25 +474,25 @@ const mcpPartSchema = z
export const STDIO_MCP_INPUT_SCHEMAS = {
publishPost: {
title: z.string().describe(d.title),
surfaces: z.array(mcpPartSchema).describe(MCP_SURFACES_DESCRIPTION),
surfaces: z.array(mcpSurfaceSchema).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),
surfaces: z.array(mcpSurfaceSchema).optional().describe(d.replacementParts),
title: z.string().optional().describe(d.replacementTitle),
},
getPost: {
id: z.string().describe(d.surfaceId),
},
publishSurface: {
title: z.string().describe(d.title),
parts: z.array(mcpPartSchema).describe(MCP_SURFACES_DESCRIPTION),
parts: z.array(mcpSurfaceSchema).describe(MCP_SURFACES_DESCRIPTION),
sessionTitle: z.string().optional().describe(d.stdioSessionTitle),
},
updateSurface: {
id: z.string().describe(d.surfaceId),
parts: z.array(mcpPartSchema).optional().describe(d.replacementParts),
parts: z.array(mcpSurfaceSchema).optional().describe(d.replacementParts),
title: z.string().optional().describe(d.replacementTitle),
},
publishSnippet: {
Expand Down Expand Up @@ -535,14 +531,14 @@ export const STDIO_MCP_INPUT_SCHEMAS = {
},
addSurface: {
postId: z.string().describe(d.surfaceId),
surface: mcpPartSchema.describe("Surface to append"),
surface: mcpSurfaceSchema.describe("Surface to append"),
before: z.string().optional().describe(d.surfaceTarget),
after: z.string().optional().describe(d.surfaceTarget),
},
editSurface: {
postId: z.string().describe(d.surfaceId),
target: z.string().describe(d.surfaceTarget),
surface: mcpPartSchema.optional().describe("Full replacement surface"),
surface: mcpSurfaceSchema.optional().describe("Full replacement surface"),
content: z
.string()
.optional()
Expand Down
Loading
Loading