Skip to content

Commit b9f60e0

Browse files
seanhancaseanhancaclaude
authored
mcp: glyph_seal — standalone seal companion (0.3.0 push, PR 2/5) (#110)
Tier-2 of the 0.3.0 push. `glyph_verify` already existed. The missing companion is `glyph_seal`: emit the provenance block as JSON, no SVG, no embedding. Use cases: - Store the seal next to an audit log entry; verify later without the SVG - Sign with an external key (regulated-industry workflow) - Pipelines where SVG renders downstream — the seal travels separately Same hash inputs as the seal embedded in `renderSvg`'s SVG `<metadata>` block, so a standalone-sealed JSON object verifies cleanly against the SVG that `glyph_render` produces from the same `(spec, rows, schema)` tuple. 190/190 MCP tests pass (4 new). Biome clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: seanhanca <infraservice@livepeer.org> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 40ccaf1 commit b9f60e0

2 files changed

Lines changed: 157 additions & 0 deletions

File tree

packages/mcp/src/server.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ describe("Glyph MCP server", () => {
116116
"glyph_query",
117117
"glyph_regression",
118118
"glyph_render",
119+
"glyph_seal",
119120
"glyph_spec_diff",
120121
"glyph_spec_patch",
121122
"glyph_story",
@@ -180,6 +181,7 @@ describe("Glyph MCP server", () => {
180181
"glyph_query",
181182
"glyph_regression",
182183
"glyph_render",
184+
"glyph_seal",
183185
"glyph_spec_diff",
184186
"glyph_spec_patch",
185187
"glyph_story",
@@ -2318,6 +2320,79 @@ describe("Glyph MCP server", () => {
23182320
});
23192321
});
23202322

2323+
describe("glyph_seal (0.3.0 — standalone seal companion to verify)", () => {
2324+
const spec = {
2325+
data: { source: "inline" },
2326+
layers: [{ mark: "bar", encoding: { x: "a", y: "b" } }],
2327+
} as const;
2328+
const schema = [
2329+
{ name: "a", type: "VARCHAR" },
2330+
{ name: "b", type: "INTEGER" },
2331+
];
2332+
const rows: ReadonlyArray<ReadonlyArray<unknown>> = [
2333+
["hi", 1],
2334+
["bye", 2],
2335+
];
2336+
2337+
it("returns the provenance block with deterministic hashes", async () => {
2338+
const r1 = await callText(client, "glyph_seal", { spec, rows, schema });
2339+
expect(r1.isError).toBeFalsy();
2340+
const seal = JSON.parse(r1.text);
2341+
expect(seal.format).toBeDefined();
2342+
expect(seal.specHash).toMatch(/^[0-9a-f]{64}$/);
2343+
expect(seal.dataHash).toMatch(/^[0-9a-f]{64}$/);
2344+
expect(seal.rowCount).toBe(2);
2345+
// Second call with the same inputs produces the same hashes.
2346+
const r2 = await callText(client, "glyph_seal", { spec, rows, schema });
2347+
expect(JSON.parse(r2.text)).toEqual(seal);
2348+
});
2349+
2350+
it("seal matches the one embedded in the rendered SVG (round-trip with glyph_verify)", async () => {
2351+
const sealResp = await callText(client, "glyph_seal", { spec, rows, schema });
2352+
const seal = JSON.parse(sealResp.text);
2353+
const { compileSpec, parseSpec, renderSvg } = await import("@glyph/core");
2354+
const svg = renderSvg(compileSpec({ spec: parseSpec(spec), rows, schema }));
2355+
// The embedded seal in the SVG must match the standalone seal byte-for-byte.
2356+
const embeddedMatch = svg.match(
2357+
/<metadata id="glyph-provenance"[^>]*>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/metadata>/,
2358+
);
2359+
expect(embeddedMatch).not.toBeNull();
2360+
const embedded = JSON.parse(embeddedMatch![1]!);
2361+
expect(embedded.specHash).toBe(seal.specHash);
2362+
expect(embedded.dataHash).toBe(seal.dataHash);
2363+
expect(embedded.scaleDigest).toBe(seal.scaleDigest);
2364+
});
2365+
2366+
it("rejects an invalid spec", async () => {
2367+
const r = await callText(client, "glyph_seal", {
2368+
spec: { layers: [{ mark: "not-a-real-mark", encoding: {} }] },
2369+
rows: [],
2370+
schema: [],
2371+
});
2372+
expect(r.isError).toBe(true);
2373+
expect(r.text).toContain("spec invalid");
2374+
});
2375+
2376+
it("works for self-contained chart specs with empty rows (e.g. hierarchy / function data)", async () => {
2377+
// A chart-spec with self-contained data shape — no rows/schema needed.
2378+
const hierarchySpec = {
2379+
data: {
2380+
hierarchy: { name: "root", children: [{ name: "a", value: 1 }] },
2381+
},
2382+
layers: [{ mark: "treemap", encoding: {} }],
2383+
};
2384+
const r = await callText(client, "glyph_seal", {
2385+
spec: hierarchySpec,
2386+
rows: [],
2387+
schema: [],
2388+
});
2389+
expect(r.isError).toBeFalsy();
2390+
const seal = JSON.parse(r.text);
2391+
expect(seal.specHash).toMatch(/^[0-9a-f]{64}$/);
2392+
expect(seal.rowCount).toBe(0);
2393+
});
2394+
});
2395+
23212396
describe("multi-modal sync (PR73 / PLAN 2.1)", () => {
23222397
it("glyph_render with modalities=['chart','table'] returns rows-sample bundle", async () => {
23232398
const r = await callText(client, "glyph_render", {

packages/mcp/src/server.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ const MCP_TOOLS = [
192192
// a verify verb. Without it, the seal is opaque — agents can't ask
193193
// "is this SVG genuinely from this spec + data?" through MCP.
194194
{ name: "glyph_verify", since: "0.0.21" },
195+
// ---- 0.3.0 — `glyph_seal` standalone-seal companion to glyph_verify --
196+
{ name: "glyph_seal", since: "0.3.0" },
195197
// ---- Joy of Math PR E5 — natural-language story composer -----------
196198
// The "bar-raiser" agent-facing endpoint. Same `(intent, audience,
197199
// theme, duration_ms)` → same JSON; no LLM call. See the
@@ -3393,6 +3395,86 @@ export function createServer(state: ServerState = new ServerState()): {
33933395
}),
33943396
);
33953397

3398+
// ----- glyph_seal (0.3.0) ------------------------------------------------
3399+
// Emit the provenance seal as a standalone JSON object — without
3400+
// requiring (or producing) an SVG. The companion to glyph_verify:
3401+
// store the seal next to your audit log, then later prove the SVG you
3402+
// received matches by re-computing the seal from (spec, rows, schema)
3403+
// and comparing. Useful in pipelines where the SVG is rendered
3404+
// downstream (e.g. by a separate worker) and the seal must travel
3405+
// independently for compliance / audit-trail purposes.
3406+
//
3407+
// Pure function — same (spec, rows, schema) → byte-identical seal.
3408+
// Same hash inputs as the seal embedded in renderSvg's output, so a
3409+
// standalone-sealed JSON object verifies cleanly against the SVG
3410+
// returned by glyph_render against the same inputs.
3411+
server.registerTool(
3412+
"glyph_seal",
3413+
{
3414+
title: "Compute the cryptographic provenance seal for a chart",
3415+
description:
3416+
"0.3.0 — emit the provenance seal for a (spec, rows, schema) tuple as JSON, without producing an SVG. Returns `{ format, specHash, dataHash, libraryVersion, rowCount, scaleDigest }` — the same block embedded in `glyph_render`'s SVG `<metadata>`. Use this when you want to store the seal alongside an audit log, sign it with an external key, or send it through a pipeline where the SVG is rendered downstream.",
3417+
inputSchema: {
3418+
spec: z.unknown().describe("The Glyph spec to seal."),
3419+
rows: z
3420+
.array(z.array(z.unknown()))
3421+
.default([])
3422+
.describe(
3423+
"Positional rows matching `schema`. Pass an empty array for self-contained specs (compose, function-data, hierarchy, …) where the spec carries its own inputs.",
3424+
),
3425+
schema: z
3426+
.array(z.object({ name: z.string(), type: z.string() }))
3427+
.default([])
3428+
.describe(
3429+
"Column schema (name + DuckDB type) for the rows. Empty for self-contained specs.",
3430+
),
3431+
},
3432+
},
3433+
async ({ spec, rows, schema }) =>
3434+
state.serial(async () => {
3435+
const parsed = safeParseSpec(spec);
3436+
if (!parsed.ok) {
3437+
return {
3438+
isError: true,
3439+
content: [
3440+
{
3441+
type: "text" as const,
3442+
text: `glyph_seal: spec invalid: ${parsed.error.message}`,
3443+
},
3444+
],
3445+
};
3446+
}
3447+
try {
3448+
const scene = compileSpec({ spec: parsed.spec, rows, schema });
3449+
const prov = scene.provenance;
3450+
if (!prov) {
3451+
return {
3452+
isError: true,
3453+
content: [
3454+
{
3455+
type: "text" as const,
3456+
text: "glyph_seal: compiler produced a scene without provenance (regression)",
3457+
},
3458+
],
3459+
};
3460+
}
3461+
return {
3462+
content: [{ type: "text" as const, text: JSON.stringify(prov, null, 2) }],
3463+
};
3464+
} catch (err) {
3465+
return {
3466+
isError: true,
3467+
content: [
3468+
{
3469+
type: "text" as const,
3470+
text: `glyph_seal: compile failed: ${(err as Error).message ?? String(err)}`,
3471+
},
3472+
],
3473+
};
3474+
}
3475+
}),
3476+
);
3477+
33963478
// ----- glyph_verify (Moat PR1) -------------------------------------------
33973479
// Cryptographic provenance verification. Closes the loop on the seal
33983480
// every renderSvg attaches: an agent passes a spec + the data it

0 commit comments

Comments
 (0)