From abab484a874343764316a78b553e2592b8321b63 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 12 Aug 2026 16:40:57 -0300 Subject: [PATCH 1/3] feat(mcp): add preset shape guide lookup Co-authored-by: shbernal --- .changeset/small-trees-smile.md | 5 + README.md | 3 +- apps/mcp-server/README.md | 11 +- apps/mcp-server/src/index.ts | 4 +- apps/mcp-server/src/mcp.ts | 2 +- apps/mcp-server/src/ooxml-tools.ts | 52 ++++- apps/mcp-server/src/preset-shape-guides.ts | 203 +++++++++++++++++++ data/sources.json | 9 + tests/mcp-server/mcp-auth.test.ts | 1 + tests/mcp-server/preset-shape-guides.test.ts | 28 +++ tests/mcp-server/tools-list.test.ts | 2 + 11 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 .changeset/small-trees-smile.md create mode 100644 apps/mcp-server/src/preset-shape-guides.ts create mode 100644 tests/mcp-server/preset-shape-guides.test.ts diff --git a/.changeset/small-trees-smile.md b/.changeset/small-trees-smile.md new file mode 100644 index 0000000..eaebf66 --- /dev/null +++ b/.changeset/small-trees-smile.md @@ -0,0 +1,5 @@ +--- +"@ooxml-dev/mcp-server": minor +--- + +Add a tool for looking up DrawingML preset-shape adjust guides. diff --git a/README.md b/README.md index e8d87f9..e85df94 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,12 @@ url = "https://api.ooxml.dev/mcp" } ``` -Three tool families share one server: +Four tool families share one server: - **Prose search** (over the spec PDFs): `ooxml_search`, `ooxml_section`, `ooxml_parts` - **Schema lookup** (over the parsed XSDs): `ooxml_element`, `ooxml_type`, `ooxml_children`, `ooxml_attributes`, `ooxml_enum`, `ooxml_namespace` - **Package metadata** (curated from Part 1 §11.3.x / §12.3.x / §13.3.x / §15.x): `ooxml_package_part` +- **Preset shapes** (curated from Part 1 Annex D): `ooxml_preset_shape` ### Authentication diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 1d546fe..65bd762 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -1,10 +1,11 @@ # OOXML Reference MCP Server -Cloudflare Worker that exposes ECMA-376 (Office Open XML) over the Model Context Protocol. Three tool families share one server: +Cloudflare Worker that exposes ECMA-376 (Office Open XML) over the Model Context Protocol. Four tool families share one server: - **Prose search** — semantic search across the four ECMA-376 part PDFs (~18,000 chunks, embedded with Voyage, queried with pgvector). - **Schema lookup** — deterministic queries over the parsed XSD graph (profiles, namespaces, symbols, content models, attributes, enums). - **Package metadata** — curated OPC part-type reference (content types, source relationship types, root namespaces, typical paths in the package). +- **Preset shapes** — adjust-guide names from the Part 1 Annex D addendum. Hosted at `https://api.ooxml.dev/mcp`. @@ -78,6 +79,14 @@ Default profile is `transitional`. Future profiles will compose Transitional wit Curated from ECMA-376 Part 1 §11.3.x / §12.3.x / §13.3.x / §15.x. Answers package-level questions the schema graph and prose corpus don't cover (e.g. "what kind of part is `/customXml/item1.xml`?"). +### Preset shapes + +| Tool | Returns | +| --- | --- | +| `ooxml_preset_shape` | Ordered adjust-guide names for a DrawingML `ST_ShapeType` value | + +Curated from the ECMA-376 Fourth Edition, Part 1 Annex D addendum. + ## Development ```bash diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index fa0ed82..1b4a24b 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -1,12 +1,14 @@ /** * OOXML Reference MCP Server * - * Cloudflare Worker exposing three tool families over MCP: + * Cloudflare Worker exposing four tool families over MCP: * - prose search over ECMA-376 PDFs (ooxml_search, ooxml_section, ooxml_parts) * - schema lookup over the parsed XSD graph (ooxml_element, ooxml_type, * ooxml_children, ooxml_attributes, ooxml_enum, ooxml_namespace) * - package metadata curated from Part 1 §11.3.x / §12.3.x / §13.3.x / §15.x * (ooxml_package_part) + * - preset shapes adjust-guide names from Part 1 Annex D + * (ooxml_preset_shape) */ import { type OAuthHelpers, OAuthProvider } from "@cloudflare/workers-oauth-provider"; diff --git a/apps/mcp-server/src/mcp.ts b/apps/mcp-server/src/mcp.ts index ea634fe..2cdbad1 100644 --- a/apps/mcp-server/src/mcp.ts +++ b/apps/mcp-server/src/mcp.ts @@ -151,7 +151,7 @@ function handleInitialize(id: number | string | null): JsonRpcResponse { version: "0.1.0", }, instructions: - "OOXML (ECMA-376 / Office Open XML) reference server. Three tool families: (1) prose search over the spec PDFs (ooxml_search, ooxml_section, ooxml_parts); (2) deterministic schema lookup over the parsed XSDs (ooxml_element, ooxml_type, ooxml_children, ooxml_attributes, ooxml_enum, ooxml_namespace); (3) OPC package metadata curated from Part 1 §11.3.x / §12.3.x / §13.3.x / §15.x (ooxml_package_part). The three corpora can disagree about URIs for the same concept (custom XML data storage is the canonical example); each tool surface notes when it keys on the XSD URI vs the spec-prose URI.", + "OOXML (ECMA-376 / Office Open XML) reference server. Four tool families: (1) prose search over the spec PDFs (ooxml_search, ooxml_section, ooxml_parts); (2) deterministic schema lookup over the parsed XSDs (ooxml_element, ooxml_type, ooxml_children, ooxml_attributes, ooxml_enum, ooxml_namespace); (3) OPC package metadata curated from Part 1 §11.3.x / §12.3.x / §13.3.x / §15.x (ooxml_package_part); (4) preset-shape adjust-guide lookup from Part 1 Annex D (ooxml_preset_shape). The schema, prose, and package corpora can disagree about URIs for the same concept (custom XML data storage is the canonical example); each tool surface notes when it keys on the XSD URI vs the spec-prose URI.", }, }; } diff --git a/apps/mcp-server/src/ooxml-tools.ts b/apps/mcp-server/src/ooxml-tools.ts index 8541132..2a0af10 100644 --- a/apps/mcp-server/src/ooxml-tools.ts +++ b/apps/mcp-server/src/ooxml-tools.ts @@ -3,7 +3,8 @@ * * Tools: * ooxml_element, ooxml_type, ooxml_children, - * ooxml_attributes, ooxml_enum, ooxml_namespace. + * ooxml_attributes, ooxml_enum, ooxml_namespace, + * ooxml_package_part, ooxml_preset_shape. * * Default profile is `transitional`. Future profiles (e.g. word-compatible-docx) * will compose Transitional with Office extension schemas. @@ -40,6 +41,7 @@ import { type OpcPart, searchParts, } from "./opc-parts"; +import { lookupPresetShapeGuides } from "./preset-shape-guides"; export const DEFAULT_PROFILE = "transitional"; @@ -166,6 +168,22 @@ export const OOXML_TOOL_DEFS: ToolDef[] = [ }, }, }, + { + name: "ooxml_preset_shape", + description: + "Return the ordered adjust-guide names for a DrawingML preset shape used in ``. " + + "The data comes from ECMA-376 Fourth Edition, Part 1 Annex D. Pass the exact `ST_ShapeType` value, such as `round2SameRect`.", + inputSchema: { + type: "object" as const, + properties: { + shape: { + type: "string", + description: 'Exact `ST_ShapeType` value from ``.', + }, + }, + required: ["shape"], + }, + }, ]; export type OoxmlToolName = @@ -175,7 +193,8 @@ export type OoxmlToolName = | "ooxml_attributes" | "ooxml_enum" | "ooxml_namespace" - | "ooxml_package_part"; + | "ooxml_package_part" + | "ooxml_preset_shape"; const OOXML_TOOL_NAMES: ReadonlySet = new Set(OOXML_TOOL_DEFS.map((t) => t.name)); @@ -423,6 +442,15 @@ export async function runOoxmlTool( }); } + case "ooxml_preset_shape": { + const shape = typeof args.shape === "string" ? args.shape.trim() : ""; + if (!shape) return formatPresetShapeNotFound(shape); + + const guides = lookupPresetShapeGuides(shape); + if (guides === null) return formatPresetShapeNotFound(shape); + return formatPresetShapeGuides(shape, guides); + } + default: { const _exhaustive: never = name; throw new Error(`Unhandled OOXML tool: ${_exhaustive}`); @@ -722,6 +750,26 @@ function formatPackagePartNotFound( return lines.join("\n"); } +function formatPresetShapeGuides(shape: string, guides: readonly string[]): string { + const lines = [`## Preset shape: ${shape}`, ""]; + if (guides.length === 0) { + lines.push("This shape has no adjust guides."); + } else { + lines.push(`Adjust guides, in order: ${guides.map((guide) => `\`${guide}\``).join(", ")}.`); + } + lines.push(""); + lines.push("Source: ECMA-376 Fourth Edition, Part 1 Annex D (`presetShapeDefinitions.xml`)."); + return lines.join("\n"); +} + +function formatPresetShapeNotFound(shape: string): string { + return [ + `## Preset shape not found${shape ? `: ${shape}` : ""}`, + "", + 'Pass an exact `ST_ShapeType` value from ``.', + ].join("\n"); +} + // --- Local element resolution ------------------------------------------ type LocalResolution = diff --git a/apps/mcp-server/src/preset-shape-guides.ts b/apps/mcp-server/src/preset-shape-guides.ts new file mode 100644 index 0000000..a2c46d7 --- /dev/null +++ b/apps/mcp-server/src/preset-shape-guides.ts @@ -0,0 +1,203 @@ +/** + * DrawingML preset shape adjust-guide names from ECMA-376 Fourth Edition, + * Part 1 Annex D (`OfficeOpenXML-DrawingMLGeometries.zip` / + * `presetShapeDefinitions.xml`). The PDF corpus does not include this addendum. + * + * Every ST_ShapeType value is included so callers can distinguish a valid + * shape with no guides from an invalid shape name. + */ +const PRESET_SHAPES = new Map([ + ["line", []], + ["lineInv", []], + ["triangle", ["adj"]], + ["rtTriangle", []], + ["rect", []], + ["diamond", []], + ["parallelogram", ["adj"]], + ["trapezoid", ["adj"]], + ["nonIsoscelesTrapezoid", ["adj1", "adj2"]], + ["pentagon", ["hf", "vf"]], + ["hexagon", ["adj", "vf"]], + ["heptagon", ["hf", "vf"]], + ["octagon", ["adj"]], + ["decagon", ["vf"]], + ["dodecagon", []], + ["star4", ["adj"]], + ["star5", ["adj", "hf", "vf"]], + ["star6", ["adj", "hf"]], + ["star7", ["adj", "hf", "vf"]], + ["star8", ["adj"]], + ["star10", ["adj", "hf"]], + ["star12", ["adj"]], + ["star16", ["adj"]], + ["star24", ["adj"]], + ["star32", ["adj"]], + ["roundRect", ["adj"]], + ["round1Rect", ["adj"]], + ["round2SameRect", ["adj1", "adj2"]], + ["round2DiagRect", ["adj1", "adj2"]], + ["snipRoundRect", ["adj1", "adj2"]], + ["snip1Rect", ["adj"]], + ["snip2SameRect", ["adj1", "adj2"]], + ["snip2DiagRect", ["adj1", "adj2"]], + ["plaque", ["adj"]], + ["ellipse", []], + ["teardrop", ["adj"]], + ["homePlate", ["adj"]], + ["chevron", ["adj"]], + ["pieWedge", []], + ["pie", ["adj1", "adj2"]], + ["blockArc", ["adj1", "adj2", "adj3"]], + ["donut", ["adj"]], + ["noSmoking", ["adj"]], + ["rightArrow", ["adj1", "adj2"]], + ["leftArrow", ["adj1", "adj2"]], + // The fourth-edition addendum duplicates upDownArrow where upArrow belongs; + // ST_ShapeType and the corresponding reference implementation confirm these guides. + ["upArrow", ["adj1", "adj2"]], + ["downArrow", ["adj1", "adj2"]], + ["stripedRightArrow", ["adj1", "adj2"]], + ["notchedRightArrow", ["adj1", "adj2"]], + ["bentUpArrow", ["adj1", "adj2", "adj3"]], + ["leftRightArrow", ["adj1", "adj2"]], + ["upDownArrow", ["adj1", "adj2"]], + ["leftUpArrow", ["adj1", "adj2", "adj3"]], + ["leftRightUpArrow", ["adj1", "adj2", "adj3"]], + ["quadArrow", ["adj1", "adj2", "adj3"]], + ["leftArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["rightArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["upArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["downArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["leftRightArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["upDownArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["quadArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["bentArrow", ["adj1", "adj2", "adj3", "adj4"]], + ["uturnArrow", ["adj1", "adj2", "adj3", "adj4", "adj5"]], + ["circularArrow", ["adj1", "adj2", "adj3", "adj4", "adj5"]], + ["leftCircularArrow", ["adj1", "adj2", "adj3", "adj4", "adj5"]], + ["leftRightCircularArrow", ["adj1", "adj2", "adj3", "adj4", "adj5"]], + ["curvedRightArrow", ["adj1", "adj2", "adj3"]], + ["curvedLeftArrow", ["adj1", "adj2", "adj3"]], + ["curvedUpArrow", ["adj1", "adj2", "adj3"]], + ["curvedDownArrow", ["adj1", "adj2", "adj3"]], + ["swooshArrow", ["adj1", "adj2"]], + ["cube", ["adj"]], + ["can", ["adj"]], + ["lightningBolt", []], + ["heart", []], + ["sun", ["adj"]], + ["moon", ["adj"]], + ["smileyFace", ["adj"]], + ["irregularSeal1", []], + ["irregularSeal2", []], + ["foldedCorner", ["adj"]], + ["bevel", ["adj"]], + ["frame", ["adj1"]], + ["halfFrame", ["adj1", "adj2"]], + ["corner", ["adj1", "adj2"]], + ["diagStripe", ["adj"]], + ["chord", ["adj1", "adj2"]], + ["arc", ["adj1", "adj2"]], + ["leftBracket", ["adj"]], + ["rightBracket", ["adj"]], + ["leftBrace", ["adj1", "adj2"]], + ["rightBrace", ["adj1", "adj2"]], + ["bracketPair", ["adj"]], + ["bracePair", ["adj"]], + ["straightConnector1", []], + ["bentConnector2", []], + ["bentConnector3", ["adj1"]], + ["bentConnector4", ["adj1", "adj2"]], + ["bentConnector5", ["adj1", "adj2", "adj3"]], + ["curvedConnector2", []], + ["curvedConnector3", ["adj1"]], + ["curvedConnector4", ["adj1", "adj2"]], + ["curvedConnector5", ["adj1", "adj2", "adj3"]], + ["callout1", ["adj1", "adj2", "adj3", "adj4"]], + ["callout2", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6"]], + ["callout3", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6", "adj7", "adj8"]], + ["accentCallout1", ["adj1", "adj2", "adj3", "adj4"]], + ["accentCallout2", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6"]], + ["accentCallout3", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6", "adj7", "adj8"]], + ["borderCallout1", ["adj1", "adj2", "adj3", "adj4"]], + ["borderCallout2", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6"]], + ["borderCallout3", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6", "adj7", "adj8"]], + ["accentBorderCallout1", ["adj1", "adj2", "adj3", "adj4"]], + ["accentBorderCallout2", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6"]], + ["accentBorderCallout3", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6", "adj7", "adj8"]], + ["wedgeRectCallout", ["adj1", "adj2"]], + ["wedgeRoundRectCallout", ["adj1", "adj2", "adj3"]], + ["wedgeEllipseCallout", ["adj1", "adj2"]], + ["cloudCallout", ["adj1", "adj2"]], + ["cloud", []], + ["ribbon", ["adj1", "adj2"]], + ["ribbon2", ["adj1", "adj2"]], + ["ellipseRibbon", ["adj1", "adj2", "adj3"]], + ["ellipseRibbon2", ["adj1", "adj2", "adj3"]], + ["leftRightRibbon", ["adj1", "adj2", "adj3"]], + ["verticalScroll", ["adj"]], + ["horizontalScroll", ["adj"]], + ["wave", ["adj1", "adj2"]], + ["doubleWave", ["adj1", "adj2"]], + ["plus", ["adj"]], + ["flowChartProcess", []], + ["flowChartDecision", []], + ["flowChartInputOutput", []], + ["flowChartPredefinedProcess", []], + ["flowChartInternalStorage", []], + ["flowChartDocument", []], + ["flowChartMultidocument", []], + ["flowChartTerminator", []], + ["flowChartPreparation", []], + ["flowChartManualInput", []], + ["flowChartManualOperation", []], + ["flowChartConnector", []], + ["flowChartPunchedCard", []], + ["flowChartPunchedTape", []], + ["flowChartSummingJunction", []], + ["flowChartOr", []], + ["flowChartCollate", []], + ["flowChartSort", []], + ["flowChartExtract", []], + ["flowChartMerge", []], + ["flowChartOfflineStorage", []], + ["flowChartOnlineStorage", []], + ["flowChartMagneticTape", []], + ["flowChartMagneticDisk", []], + ["flowChartMagneticDrum", []], + ["flowChartDisplay", []], + ["flowChartDelay", []], + ["flowChartAlternateProcess", []], + ["flowChartOffpageConnector", []], + ["actionButtonBlank", []], + ["actionButtonHome", []], + ["actionButtonHelp", []], + ["actionButtonInformation", []], + ["actionButtonForwardNext", []], + ["actionButtonBackPrevious", []], + ["actionButtonEnd", []], + ["actionButtonBeginning", []], + ["actionButtonReturn", []], + ["actionButtonDocument", []], + ["actionButtonSound", []], + ["actionButtonMovie", []], + ["gear6", ["adj1", "adj2"]], + ["gear9", ["adj1", "adj2"]], + ["funnel", []], + ["mathPlus", ["adj1"]], + ["mathMinus", ["adj1"]], + ["mathMultiply", ["adj1"]], + ["mathDivide", ["adj1", "adj2", "adj3"]], + ["mathEqual", ["adj1", "adj2"]], + ["mathNotEqual", ["adj1", "adj2", "adj3"]], + ["cornerTabs", []], + ["squareTabs", []], + ["plaqueTabs", []], + ["chartX", []], + ["chartStar", []], + ["chartPlus", []], +]); + +export function lookupPresetShapeGuides(shape: string): readonly string[] | null { + return PRESET_SHAPES.get(shape) ?? null; +} diff --git a/data/sources.json b/data/sources.json index 4710df8..db3ab69 100644 --- a/data/sources.json +++ b/data/sources.json @@ -45,6 +45,15 @@ "url": "https://ecma-international.org/wp-content/uploads/ECMA-376-4_5th_edition_december_2016.zip", "license_note": "Published by Ecma International. See the ECMA-376 publications page for the current download and licensing terms before redistribution.", "sha256": "bd25da1109f73762356596918bf5ff8b74a1331642dba5f1c1d1dfc6bed34ecd" + }, + { + "name": "ecma-376-annex-d-drawingml-geometries", + "kind": "curated_static", + "edition": "4th", + "version": "2012-12", + "url": "https://ecma-international.org/wp-content/uploads/ECMA-376_4th_edition_december_2012.zip", + "license_note": "Adjust-guide names are derived from the published Part 1 Annex D presetShapeDefinitions.xml addendum.", + "sha256": "cc7e6cead58205025a0e05e6d339137b99f3add55c435bf98c88987ae85c7f4f" } ] } diff --git a/tests/mcp-server/mcp-auth.test.ts b/tests/mcp-server/mcp-auth.test.ts index 5606de1..581aff5 100644 --- a/tests/mcp-server/mcp-auth.test.ts +++ b/tests/mcp-server/mcp-auth.test.ts @@ -21,6 +21,7 @@ const EXPECTED_TOOL_NAMES = [ "ooxml_enum", "ooxml_namespace", "ooxml_package_part", + "ooxml_preset_shape", ]; const identity: McpAuthorizationProps = { diff --git a/tests/mcp-server/preset-shape-guides.test.ts b/tests/mcp-server/preset-shape-guides.test.ts new file mode 100644 index 0000000..c6cfbec --- /dev/null +++ b/tests/mcp-server/preset-shape-guides.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { runOoxmlTool } from "../../apps/mcp-server/src/ooxml-tools.ts"; + +const sqlStub = (() => { + throw new Error("SQL should not be called by ooxml_preset_shape"); +}) as unknown as Parameters[2]; + +test("returns the two guide names required by round2SameRect", async () => { + const output = await runOoxmlTool("ooxml_preset_shape", { shape: "round2SameRect" }, sqlStub); + + expect(output).toContain("`adj1`, `adj2`"); + expect(output).toContain("Fourth Edition"); +}); + +test("distinguishes shapes without guides from invalid shape names", async () => { + const noGuides = await runOoxmlTool("ooxml_preset_shape", { shape: "rect" }, sqlStub); + const invalid = await runOoxmlTool("ooxml_preset_shape", { shape: "notARealShape" }, sqlStub); + + expect(noGuides).toContain("has no adjust guides"); + expect(invalid).toContain("Preset shape not found"); + expect(invalid).not.toContain("has no adjust guides"); +}); + +test("does not accept preset text-warp names as preset shapes", async () => { + const output = await runOoxmlTool("ooxml_preset_shape", { shape: "textArchDown" }, sqlStub); + + expect(output).toContain("Preset shape not found"); +}); diff --git a/tests/mcp-server/tools-list.test.ts b/tests/mcp-server/tools-list.test.ts index ea679a7..9b1c072 100644 --- a/tests/mcp-server/tools-list.test.ts +++ b/tests/mcp-server/tools-list.test.ts @@ -23,6 +23,8 @@ const EXPECTED_TOOL_NAMES = [ "ooxml_namespace", // OPC package metadata (over the curated opc-parts dataset) "ooxml_package_part", + // DrawingML preset shape guide names (from Part 1 Annex D) + "ooxml_preset_shape", ] as const; interface JsonRpcResponse { From 2876bc7edf3cd64a3355d4ab475ef4a8d97f0f33 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 12 Aug 2026 17:15:17 -0300 Subject: [PATCH 2/3] refactor(mcp): generate guides from Annex D --- README.md | 2 +- apps/mcp-server/README.md | 6 +- apps/mcp-server/src/ooxml-tools.ts | 10 +- ...es.ts => preset-shape-guides.generated.ts} | 335 +++++++++--------- data/sources.json | 4 +- package.json | 1 + scripts/generate-preset-shape-guides.ts | 169 +++++++++ tests/mcp-server/preset-shape-guides.test.ts | 24 ++ 8 files changed, 371 insertions(+), 180 deletions(-) rename apps/mcp-server/src/{preset-shape-guides.ts => preset-shape-guides.generated.ts} (92%) create mode 100644 scripts/generate-preset-shape-guides.ts diff --git a/README.md b/README.md index e85df94..ffb48ca 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Four tool families share one server: - **Prose search** (over the spec PDFs): `ooxml_search`, `ooxml_section`, `ooxml_parts` - **Schema lookup** (over the parsed XSDs): `ooxml_element`, `ooxml_type`, `ooxml_children`, `ooxml_attributes`, `ooxml_enum`, `ooxml_namespace` - **Package metadata** (curated from Part 1 §11.3.x / §12.3.x / §13.3.x / §15.x): `ooxml_package_part` -- **Preset shapes** (curated from Part 1 Annex D): `ooxml_preset_shape` +- **Preset shapes** (generated from Part 1 Annex D): `ooxml_preset_shape` ### Authentication diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 65bd762..2bea70a 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -5,7 +5,7 @@ Cloudflare Worker that exposes ECMA-376 (Office Open XML) over the Model Context - **Prose search** — semantic search across the four ECMA-376 part PDFs (~18,000 chunks, embedded with Voyage, queried with pgvector). - **Schema lookup** — deterministic queries over the parsed XSD graph (profiles, namespaces, symbols, content models, attributes, enums). - **Package metadata** — curated OPC part-type reference (content types, source relationship types, root namespaces, typical paths in the package). -- **Preset shapes** — adjust-guide names from the Part 1 Annex D addendum. +- **Preset shapes** — adjust-guide names extracted from the Part 1 Annex D addendum. Hosted at `https://api.ooxml.dev/mcp`. @@ -85,7 +85,9 @@ Curated from ECMA-376 Part 1 §11.3.x / §12.3.x / §13.3.x / §15.x. Answers pa | --- | --- | | `ooxml_preset_shape` | Ordered adjust-guide names for a DrawingML `ST_ShapeType` value | -Curated from the ECMA-376 Fourth Edition, Part 1 Annex D addendum. +Generated from the ECMA-376 Fourth Edition, Part 1 Annex D addendum. + +Regenerate the bundled lookup from the pinned Annex D XML with `bun run mcp:preset-shapes:generate`. ## Development diff --git a/apps/mcp-server/src/ooxml-tools.ts b/apps/mcp-server/src/ooxml-tools.ts index 2a0af10..67fd7b2 100644 --- a/apps/mcp-server/src/ooxml-tools.ts +++ b/apps/mcp-server/src/ooxml-tools.ts @@ -41,7 +41,7 @@ import { type OpcPart, searchParts, } from "./opc-parts"; -import { lookupPresetShapeGuides } from "./preset-shape-guides"; +import { lookupPresetShapeGuides } from "./preset-shape-guides.generated"; export const DEFAULT_PROFILE = "transitional"; @@ -172,13 +172,13 @@ export const OOXML_TOOL_DEFS: ToolDef[] = [ name: "ooxml_preset_shape", description: "Return the ordered adjust-guide names for a DrawingML preset shape used in ``. " + - "The data comes from ECMA-376 Fourth Edition, Part 1 Annex D. Pass the exact `ST_ShapeType` value, such as `round2SameRect`.", + "The data is extracted from ECMA-376 Fourth Edition, Part 1 Annex D. Pass the exact preset shape name, such as `round2SameRect`.", inputSchema: { type: "object" as const, properties: { shape: { type: "string", - description: 'Exact `ST_ShapeType` value from ``.', + description: 'Exact preset shape name from ``.', }, }, required: ["shape"], @@ -764,9 +764,9 @@ function formatPresetShapeGuides(shape: string, guides: readonly string[]): stri function formatPresetShapeNotFound(shape: string): string { return [ - `## Preset shape not found${shape ? `: ${shape}` : ""}`, + `## Preset shape not found in Annex D${shape ? `: ${shape}` : ""}`, "", - 'Pass an exact `ST_ShapeType` value from ``.', + 'Check the exact name from ``.', ].join("\n"); } diff --git a/apps/mcp-server/src/preset-shape-guides.ts b/apps/mcp-server/src/preset-shape-guides.generated.ts similarity index 92% rename from apps/mcp-server/src/preset-shape-guides.ts rename to apps/mcp-server/src/preset-shape-guides.generated.ts index a2c46d7..bf2610c 100644 --- a/apps/mcp-server/src/preset-shape-guides.ts +++ b/apps/mcp-server/src/preset-shape-guides.generated.ts @@ -1,201 +1,196 @@ /** - * DrawingML preset shape adjust-guide names from ECMA-376 Fourth Edition, - * Part 1 Annex D (`OfficeOpenXML-DrawingMLGeometries.zip` / - * `presetShapeDefinitions.xml`). The PDF corpus does not include this addendum. - * - * Every ST_ShapeType value is included so callers can distinguish a valid - * shape with no guides from an invalid shape name. + * Generated by scripts/generate-preset-shape-guides.ts. + * Source: ECMA-376 Fourth Edition, Part 1 Annex D, presetShapeDefinitions.xml + * Source XML SHA-256: 188fffc3d3c526c8b48bc88c087ffebe6495a93a3944727b6af6d51a6194ad7f + * Do not edit by hand. */ const PRESET_SHAPES = new Map([ - ["line", []], - ["lineInv", []], - ["triangle", ["adj"]], - ["rtTriangle", []], - ["rect", []], - ["diamond", []], - ["parallelogram", ["adj"]], - ["trapezoid", ["adj"]], - ["nonIsoscelesTrapezoid", ["adj1", "adj2"]], - ["pentagon", ["hf", "vf"]], - ["hexagon", ["adj", "vf"]], - ["heptagon", ["hf", "vf"]], - ["octagon", ["adj"]], - ["decagon", ["vf"]], - ["dodecagon", []], - ["star4", ["adj"]], - ["star5", ["adj", "hf", "vf"]], - ["star6", ["adj", "hf"]], - ["star7", ["adj", "hf", "vf"]], - ["star8", ["adj"]], - ["star10", ["adj", "hf"]], - ["star12", ["adj"]], - ["star16", ["adj"]], - ["star24", ["adj"]], - ["star32", ["adj"]], - ["roundRect", ["adj"]], - ["round1Rect", ["adj"]], - ["round2SameRect", ["adj1", "adj2"]], - ["round2DiagRect", ["adj1", "adj2"]], - ["snipRoundRect", ["adj1", "adj2"]], - ["snip1Rect", ["adj"]], - ["snip2SameRect", ["adj1", "adj2"]], - ["snip2DiagRect", ["adj1", "adj2"]], - ["plaque", ["adj"]], - ["ellipse", []], - ["teardrop", ["adj"]], - ["homePlate", ["adj"]], - ["chevron", ["adj"]], - ["pieWedge", []], - ["pie", ["adj1", "adj2"]], - ["blockArc", ["adj1", "adj2", "adj3"]], - ["donut", ["adj"]], - ["noSmoking", ["adj"]], - ["rightArrow", ["adj1", "adj2"]], - ["leftArrow", ["adj1", "adj2"]], - // The fourth-edition addendum duplicates upDownArrow where upArrow belongs; - // ST_ShapeType and the corresponding reference implementation confirm these guides. - ["upArrow", ["adj1", "adj2"]], - ["downArrow", ["adj1", "adj2"]], - ["stripedRightArrow", ["adj1", "adj2"]], - ["notchedRightArrow", ["adj1", "adj2"]], - ["bentUpArrow", ["adj1", "adj2", "adj3"]], - ["leftRightArrow", ["adj1", "adj2"]], - ["upDownArrow", ["adj1", "adj2"]], - ["leftUpArrow", ["adj1", "adj2", "adj3"]], - ["leftRightUpArrow", ["adj1", "adj2", "adj3"]], - ["quadArrow", ["adj1", "adj2", "adj3"]], - ["leftArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], - ["rightArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], - ["upArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], - ["downArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], - ["leftRightArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], - ["upDownArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], - ["quadArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], - ["bentArrow", ["adj1", "adj2", "adj3", "adj4"]], - ["uturnArrow", ["adj1", "adj2", "adj3", "adj4", "adj5"]], - ["circularArrow", ["adj1", "adj2", "adj3", "adj4", "adj5"]], - ["leftCircularArrow", ["adj1", "adj2", "adj3", "adj4", "adj5"]], - ["leftRightCircularArrow", ["adj1", "adj2", "adj3", "adj4", "adj5"]], - ["curvedRightArrow", ["adj1", "adj2", "adj3"]], - ["curvedLeftArrow", ["adj1", "adj2", "adj3"]], - ["curvedUpArrow", ["adj1", "adj2", "adj3"]], - ["curvedDownArrow", ["adj1", "adj2", "adj3"]], - ["swooshArrow", ["adj1", "adj2"]], - ["cube", ["adj"]], - ["can", ["adj"]], - ["lightningBolt", []], - ["heart", []], - ["sun", ["adj"]], - ["moon", ["adj"]], - ["smileyFace", ["adj"]], - ["irregularSeal1", []], - ["irregularSeal2", []], - ["foldedCorner", ["adj"]], - ["bevel", ["adj"]], - ["frame", ["adj1"]], - ["halfFrame", ["adj1", "adj2"]], - ["corner", ["adj1", "adj2"]], - ["diagStripe", ["adj"]], - ["chord", ["adj1", "adj2"]], + ["accentBorderCallout1", ["adj1", "adj2", "adj3", "adj4"]], + ["accentBorderCallout2", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6"]], + ["accentBorderCallout3", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6", "adj7", "adj8"]], + ["accentCallout1", ["adj1", "adj2", "adj3", "adj4"]], + ["accentCallout2", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6"]], + ["accentCallout3", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6", "adj7", "adj8"]], + ["actionButtonBackPrevious", []], + ["actionButtonBeginning", []], + ["actionButtonBlank", []], + ["actionButtonDocument", []], + ["actionButtonEnd", []], + ["actionButtonForwardNext", []], + ["actionButtonHelp", []], + ["actionButtonHome", []], + ["actionButtonInformation", []], + ["actionButtonMovie", []], + ["actionButtonReturn", []], + ["actionButtonSound", []], ["arc", ["adj1", "adj2"]], - ["leftBracket", ["adj"]], - ["rightBracket", ["adj"]], - ["leftBrace", ["adj1", "adj2"]], - ["rightBrace", ["adj1", "adj2"]], - ["bracketPair", ["adj"]], - ["bracePair", ["adj"]], - ["straightConnector1", []], + ["bentArrow", ["adj1", "adj2", "adj3", "adj4"]], ["bentConnector2", []], ["bentConnector3", ["adj1"]], ["bentConnector4", ["adj1", "adj2"]], ["bentConnector5", ["adj1", "adj2", "adj3"]], - ["curvedConnector2", []], - ["curvedConnector3", ["adj1"]], - ["curvedConnector4", ["adj1", "adj2"]], - ["curvedConnector5", ["adj1", "adj2", "adj3"]], - ["callout1", ["adj1", "adj2", "adj3", "adj4"]], - ["callout2", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6"]], - ["callout3", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6", "adj7", "adj8"]], - ["accentCallout1", ["adj1", "adj2", "adj3", "adj4"]], - ["accentCallout2", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6"]], - ["accentCallout3", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6", "adj7", "adj8"]], + ["bentUpArrow", ["adj1", "adj2", "adj3"]], + ["bevel", ["adj"]], + ["blockArc", ["adj1", "adj2", "adj3"]], ["borderCallout1", ["adj1", "adj2", "adj3", "adj4"]], ["borderCallout2", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6"]], ["borderCallout3", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6", "adj7", "adj8"]], - ["accentBorderCallout1", ["adj1", "adj2", "adj3", "adj4"]], - ["accentBorderCallout2", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6"]], - ["accentBorderCallout3", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6", "adj7", "adj8"]], - ["wedgeRectCallout", ["adj1", "adj2"]], - ["wedgeRoundRectCallout", ["adj1", "adj2", "adj3"]], - ["wedgeEllipseCallout", ["adj1", "adj2"]], - ["cloudCallout", ["adj1", "adj2"]], + ["bracePair", ["adj"]], + ["bracketPair", ["adj"]], + ["callout1", ["adj1", "adj2", "adj3", "adj4"]], + ["callout2", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6"]], + ["callout3", ["adj1", "adj2", "adj3", "adj4", "adj5", "adj6", "adj7", "adj8"]], + ["can", ["adj"]], + ["chartPlus", []], + ["chartStar", []], + ["chartX", []], + ["chevron", ["adj"]], + ["chord", ["adj1", "adj2"]], + ["circularArrow", ["adj1", "adj2", "adj3", "adj4", "adj5"]], ["cloud", []], - ["ribbon", ["adj1", "adj2"]], - ["ribbon2", ["adj1", "adj2"]], + ["cloudCallout", ["adj1", "adj2"]], + ["corner", ["adj1", "adj2"]], + ["cornerTabs", []], + ["cube", ["adj"]], + ["curvedConnector2", []], + ["curvedConnector3", ["adj1"]], + ["curvedConnector4", ["adj1", "adj2"]], + ["curvedConnector5", ["adj1", "adj2", "adj3"]], + ["curvedDownArrow", ["adj1", "adj2", "adj3"]], + ["curvedLeftArrow", ["adj1", "adj2", "adj3"]], + ["curvedRightArrow", ["adj1", "adj2", "adj3"]], + ["curvedUpArrow", ["adj1", "adj2", "adj3"]], + ["decagon", ["vf"]], + ["diagStripe", ["adj"]], + ["diamond", []], + ["dodecagon", []], + ["donut", ["adj"]], + ["doubleWave", ["adj1", "adj2"]], + ["downArrow", ["adj1", "adj2"]], + ["downArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["ellipse", []], ["ellipseRibbon", ["adj1", "adj2", "adj3"]], ["ellipseRibbon2", ["adj1", "adj2", "adj3"]], - ["leftRightRibbon", ["adj1", "adj2", "adj3"]], - ["verticalScroll", ["adj"]], - ["horizontalScroll", ["adj"]], - ["wave", ["adj1", "adj2"]], - ["doubleWave", ["adj1", "adj2"]], - ["plus", ["adj"]], - ["flowChartProcess", []], + ["flowChartAlternateProcess", []], + ["flowChartCollate", []], + ["flowChartConnector", []], ["flowChartDecision", []], + ["flowChartDelay", []], + ["flowChartDisplay", []], + ["flowChartDocument", []], + ["flowChartExtract", []], ["flowChartInputOutput", []], - ["flowChartPredefinedProcess", []], ["flowChartInternalStorage", []], - ["flowChartDocument", []], - ["flowChartMultidocument", []], - ["flowChartTerminator", []], - ["flowChartPreparation", []], + ["flowChartMagneticDisk", []], + ["flowChartMagneticDrum", []], + ["flowChartMagneticTape", []], ["flowChartManualInput", []], ["flowChartManualOperation", []], - ["flowChartConnector", []], - ["flowChartPunchedCard", []], - ["flowChartPunchedTape", []], - ["flowChartSummingJunction", []], - ["flowChartOr", []], - ["flowChartCollate", []], - ["flowChartSort", []], - ["flowChartExtract", []], ["flowChartMerge", []], + ["flowChartMultidocument", []], ["flowChartOfflineStorage", []], - ["flowChartOnlineStorage", []], - ["flowChartMagneticTape", []], - ["flowChartMagneticDisk", []], - ["flowChartMagneticDrum", []], - ["flowChartDisplay", []], - ["flowChartDelay", []], - ["flowChartAlternateProcess", []], ["flowChartOffpageConnector", []], - ["actionButtonBlank", []], - ["actionButtonHome", []], - ["actionButtonHelp", []], - ["actionButtonInformation", []], - ["actionButtonForwardNext", []], - ["actionButtonBackPrevious", []], - ["actionButtonEnd", []], - ["actionButtonBeginning", []], - ["actionButtonReturn", []], - ["actionButtonDocument", []], - ["actionButtonSound", []], - ["actionButtonMovie", []], + ["flowChartOnlineStorage", []], + ["flowChartOr", []], + ["flowChartPredefinedProcess", []], + ["flowChartPreparation", []], + ["flowChartProcess", []], + ["flowChartPunchedCard", []], + ["flowChartPunchedTape", []], + ["flowChartSort", []], + ["flowChartSummingJunction", []], + ["flowChartTerminator", []], + ["foldedCorner", ["adj"]], + ["frame", ["adj1"]], + ["funnel", []], ["gear6", ["adj1", "adj2"]], ["gear9", ["adj1", "adj2"]], - ["funnel", []], - ["mathPlus", ["adj1"]], - ["mathMinus", ["adj1"]], - ["mathMultiply", ["adj1"]], + ["halfFrame", ["adj1", "adj2"]], + ["heart", []], + ["heptagon", ["hf", "vf"]], + ["hexagon", ["adj", "vf"]], + ["homePlate", ["adj"]], + ["horizontalScroll", ["adj"]], + ["irregularSeal1", []], + ["irregularSeal2", []], + ["leftArrow", ["adj1", "adj2"]], + ["leftArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["leftBrace", ["adj1", "adj2"]], + ["leftBracket", ["adj"]], + ["leftCircularArrow", ["adj1", "adj2", "adj3", "adj4", "adj5"]], + ["leftRightArrow", ["adj1", "adj2"]], + ["leftRightArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["leftRightCircularArrow", ["adj1", "adj2", "adj3", "adj4", "adj5"]], + ["leftRightRibbon", ["adj1", "adj2", "adj3"]], + ["leftRightUpArrow", ["adj1", "adj2", "adj3"]], + ["leftUpArrow", ["adj1", "adj2", "adj3"]], + ["lightningBolt", []], + ["line", []], + ["lineInv", []], ["mathDivide", ["adj1", "adj2", "adj3"]], ["mathEqual", ["adj1", "adj2"]], + ["mathMinus", ["adj1"]], + ["mathMultiply", ["adj1"]], ["mathNotEqual", ["adj1", "adj2", "adj3"]], - ["cornerTabs", []], - ["squareTabs", []], + ["mathPlus", ["adj1"]], + ["moon", ["adj"]], + ["nonIsoscelesTrapezoid", ["adj1", "adj2"]], + ["noSmoking", ["adj"]], + ["notchedRightArrow", ["adj1", "adj2"]], + ["octagon", ["adj"]], + ["parallelogram", ["adj"]], + ["pentagon", ["hf", "vf"]], + ["pie", ["adj1", "adj2"]], + ["pieWedge", []], + ["plaque", ["adj"]], ["plaqueTabs", []], - ["chartX", []], - ["chartStar", []], - ["chartPlus", []], + ["plus", ["adj"]], + ["quadArrow", ["adj1", "adj2", "adj3"]], + ["quadArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["rect", []], + ["ribbon", ["adj1", "adj2"]], + ["ribbon2", ["adj1", "adj2"]], + ["rightArrow", ["adj1", "adj2"]], + ["rightArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["rightBrace", ["adj1", "adj2"]], + ["rightBracket", ["adj"]], + ["round1Rect", ["adj"]], + ["round2DiagRect", ["adj1", "adj2"]], + ["round2SameRect", ["adj1", "adj2"]], + ["roundRect", ["adj"]], + ["rtTriangle", []], + ["smileyFace", ["adj"]], + ["snip1Rect", ["adj"]], + ["snip2DiagRect", ["adj1", "adj2"]], + ["snip2SameRect", ["adj1", "adj2"]], + ["snipRoundRect", ["adj1", "adj2"]], + ["squareTabs", []], + ["star10", ["adj", "hf"]], + ["star12", ["adj"]], + ["star16", ["adj"]], + ["star24", ["adj"]], + ["star32", ["adj"]], + ["star4", ["adj"]], + ["star5", ["adj", "hf", "vf"]], + ["star6", ["adj", "hf"]], + ["star7", ["adj", "hf", "vf"]], + ["star8", ["adj"]], + ["straightConnector1", []], + ["stripedRightArrow", ["adj1", "adj2"]], + ["sun", ["adj"]], + ["swooshArrow", ["adj1", "adj2"]], + ["teardrop", ["adj"]], + ["trapezoid", ["adj"]], + ["triangle", ["adj"]], + ["upArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["upDownArrow", ["adj1", "adj2"]], + ["upDownArrowCallout", ["adj1", "adj2", "adj3", "adj4"]], + ["uturnArrow", ["adj1", "adj2", "adj3", "adj4", "adj5"]], + ["verticalScroll", ["adj"]], + ["wave", ["adj1", "adj2"]], + ["wedgeEllipseCallout", ["adj1", "adj2"]], + ["wedgeRectCallout", ["adj1", "adj2"]], + ["wedgeRoundRectCallout", ["adj1", "adj2", "adj3"]], ]); export function lookupPresetShapeGuides(shape: string): readonly string[] | null { diff --git a/data/sources.json b/data/sources.json index db3ab69..2d95b0d 100644 --- a/data/sources.json +++ b/data/sources.json @@ -48,11 +48,11 @@ }, { "name": "ecma-376-annex-d-drawingml-geometries", - "kind": "curated_static", + "kind": "electronic_addendum", "edition": "4th", "version": "2012-12", "url": "https://ecma-international.org/wp-content/uploads/ECMA-376_4th_edition_december_2012.zip", - "license_note": "Adjust-guide names are derived from the published Part 1 Annex D presetShapeDefinitions.xml addendum.", + "license_note": "The preset-shape lookup is generated from the published Part 1 Annex D presetShapeDefinitions.xml addendum.", "sha256": "cc7e6cead58205025a0e05e6d339137b99f3add55c435bf98c88987ae85c7f4f" } ] diff --git a/package.json b/package.json index f5187fe..c78bdd8 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "db:shell": "docker compose exec db psql -U postgres -d ecma_spec", "db:migrate": "bun scripts/db-migrate.ts", "mcp:users": "bun --env-file=.env apps/mcp-server/scripts/users.ts", + "mcp:preset-shapes:generate": "bun scripts/generate-preset-shape-guides.ts", "sources:sync": "bun scripts/sources-sync.ts", "pdf:ingest": "bun scripts/ingest-pdf/pipeline.ts", "pdf:chunk": "bun scripts/ingest-pdf/chunk.ts", diff --git a/scripts/generate-preset-shape-guides.ts b/scripts/generate-preset-shape-guides.ts new file mode 100644 index 0000000..8c58da7 --- /dev/null +++ b/scripts/generate-preset-shape-guides.ts @@ -0,0 +1,169 @@ +/** + * Generate the MCP preset-shape lookup from ECMA-376 Part 1 Annex D. + * + * By default, the script downloads the pinned fourth-edition archive from + * data/sources.json and extracts presetShapeDefinitions.xml from its nested + * ZIP files. Pass --source to regenerate from an already extracted XML file. + */ + +import { createHash } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { XMLParser } from "fast-xml-parser"; + +const SOURCE_NAME = "ecma-376-annex-d-drawingml-geometries"; +const OUTPUT_PATH = "apps/mcp-server/src/preset-shape-guides.generated.ts"; +const PART_1_ZIP = + "ECMA-376, Fourth Edition, Part 1 - Fundamentals And Markup Language Reference.zip"; +const GEOMETRIES_ZIP = "OfficeOpenXML-DrawingMLGeometries.zip"; +const SHAPES_XML = "presetShapeDefinitions.xml"; + +interface SourceEntry { + name: string; + url: string; + sha256: string; +} + +interface SourceManifest { + sources: SourceEntry[]; +} + +type PreserveOrderNode = Record; + +export interface PresetShapeGuides { + name: string; + guides: string[]; +} + +export function extractPresetShapeGuides(xml: string): PresetShapeGuides[] { + const parsed = new XMLParser({ preserveOrder: true, ignoreAttributes: false }).parse( + xml, + ) as PreserveOrderNode[]; + const documentRoot = parsed.find((node) => Array.isArray(node.presetShapeDefinitons)); + const shapeNodes = documentRoot?.presetShapeDefinitons as PreserveOrderNode[] | undefined; + if (!shapeNodes) throw new Error("presetShapeDefinitons root not found"); + + const shapes = new Map(); + for (const shapeNode of shapeNodes) { + const name = Object.keys(shapeNode).find((key) => key !== ":@"); + if (!name) continue; + + const contents = shapeNode[name] as PreserveOrderNode[]; + const avList = contents.find((node) => Array.isArray(node.avLst))?.avLst as + | PreserveOrderNode[] + | undefined; + const guides = (avList ?? []) + .filter((node) => Array.isArray(node.gd)) + .map((node) => (node[":@"] as Record | undefined)?.["@_name"]) + .filter((guide): guide is string => typeof guide === "string"); + + const existing = shapes.get(name); + if (existing) { + if (existing.join("\0") !== guides.join("\0")) { + throw new Error(`Conflicting Annex D definitions for ${name}`); + } + continue; + } + shapes.set(name, guides); + } + + if (shapes.size === 0) throw new Error("No preset shapes found in Annex D"); + return [...shapes].map(([name, guides]) => ({ name, guides })); +} + +function sha256(data: ArrayBuffer | Uint8Array | string): string { + return createHash("sha256").update(data).digest("hex"); +} + +async function extractZipEntry( + zipPath: string, + entry: string, + destination: string, +): Promise { + const process = Bun.spawn(["unzip", "-j", "-o", "-q", zipPath, entry, "-d", destination], { + stdout: "inherit", + stderr: "pipe", + }); + const [error, code] = await Promise.all([new Response(process.stderr).text(), process.exited]); + if (code !== 0) throw new Error(`Could not extract ${entry}: ${error.trim()}`); + return join(destination, basename(entry)); +} + +async function downloadAnnexXml(): Promise { + const manifest = (await Bun.file("data/sources.json").json()) as SourceManifest; + const source = manifest.sources.find((entry) => entry.name === SOURCE_NAME); + if (!source) throw new Error(`Missing ${SOURCE_NAME} in data/sources.json`); + + const response = await fetch(source.url); + if (!response.ok) throw new Error(`Could not download Annex D: ${response.status}`); + const outerZip = new Uint8Array(await response.arrayBuffer()); + const actualHash = sha256(outerZip); + if (actualHash !== source.sha256) { + throw new Error(`Annex D source hash mismatch: expected ${source.sha256}, got ${actualHash}`); + } + + const tempDirectory = await mkdtemp(join(tmpdir(), "ooxml-annex-d-")); + try { + const outerPath = join(tempDirectory, "edition4.zip"); + await Bun.write(outerPath, outerZip); + const part1Path = await extractZipEntry(outerPath, PART_1_ZIP, tempDirectory); + const geometriesPath = await extractZipEntry(part1Path, GEOMETRIES_ZIP, tempDirectory); + const shapesPath = await extractZipEntry(geometriesPath, SHAPES_XML, tempDirectory); + return new Uint8Array(await Bun.file(shapesPath).arrayBuffer()); + } finally { + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +function renderLookup(shapes: PresetShapeGuides[], sourceHash: string): string { + const entries = shapes + .map(({ name, guides }) => `\t[${JSON.stringify(name)}, ${JSON.stringify(guides)}],`) + .join("\n"); + return `/** + * Generated by scripts/generate-preset-shape-guides.ts. + * Source: ECMA-376 Fourth Edition, Part 1 Annex D, presetShapeDefinitions.xml + * Source XML SHA-256: ${sourceHash} + * Do not edit by hand. + */ +const PRESET_SHAPES = new Map([ +${entries} +]); + +export function lookupPresetShapeGuides(shape: string): readonly string[] | null { +\treturn PRESET_SHAPES.get(shape) ?? null; +} +`; +} + +function sourceArgument(): string | null { + const index = process.argv.indexOf("--source"); + return index === -1 ? null : (process.argv[index + 1] ?? null); +} + +async function main() { + const source = sourceArgument(); + if (process.argv.includes("--source") && !source) + throw new Error("--source requires an XML path"); + + const xmlBytes = source + ? new Uint8Array(await Bun.file(source).arrayBuffer()) + : await downloadAnnexXml(); + const xml = new TextDecoder().decode(xmlBytes); + const shapes = extractPresetShapeGuides(xml); + await Bun.write(OUTPUT_PATH, renderLookup(shapes, sha256(xmlBytes))); + + const format = Bun.spawn(["bunx", "biome", "format", "--write", OUTPUT_PATH], { + stdout: "inherit", + stderr: "inherit", + }); + if ((await format.exited) !== 0) throw new Error("Could not format generated lookup"); + console.log(`Generated ${OUTPUT_PATH} from ${shapes.length} Annex D shapes.`); +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/tests/mcp-server/preset-shape-guides.test.ts b/tests/mcp-server/preset-shape-guides.test.ts index c6cfbec..5430042 100644 --- a/tests/mcp-server/preset-shape-guides.test.ts +++ b/tests/mcp-server/preset-shape-guides.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { runOoxmlTool } from "../../apps/mcp-server/src/ooxml-tools.ts"; +import { extractPresetShapeGuides } from "../../scripts/generate-preset-shape-guides.ts"; const sqlStub = (() => { throw new Error("SQL should not be called by ooxml_preset_shape"); @@ -26,3 +27,26 @@ test("does not accept preset text-warp names as preset shapes", async () => { expect(output).toContain("Preset shape not found"); }); + +test("extracts guide names from Annex D XML and deduplicates identical definitions", () => { + const xml = ` + + + + + `; + + expect(extractPresetShapeGuides(xml)).toEqual([ + { name: "round2SameRect", guides: ["adj1", "adj2"] }, + { name: "rect", guides: [] }, + ]); +}); + +test("rejects conflicting duplicate definitions in Annex D XML", () => { + const xml = ` + + + `; + + expect(() => extractPresetShapeGuides(xml)).toThrow("Conflicting Annex D definitions for rect"); +}); From d3fc37cda79909d589b249965372cfba420a0469 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 12 Aug 2026 17:55:07 -0300 Subject: [PATCH 3/3] test(mcp): verify Annex guide delivery --- .github/workflows/ci.yml | 3 + apps/mcp-server/src/ooxml-tools.ts | 1 + package.json | 1 + scripts/generate-preset-shape-guides.ts | 63 +------------ scripts/lib/source-artifact.ts | 94 ++++++++++++++++++++ tests/mcp-server/mcp-auth.test.ts | 26 ++++++ tests/mcp-server/preset-shape-guides.test.ts | 8 +- tests/scripts/source-artifact.test.ts | 51 +++++++++++ 8 files changed, 184 insertions(+), 63 deletions(-) create mode 100644 scripts/lib/source-artifact.ts create mode 100644 tests/scripts/source-artifact.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ab5afb..c2452d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,8 @@ jobs: - name: PDF ingest tests run: bun run pdf:test + - name: MCP tests + run: bun run mcp:test + - name: Build run: bun run build diff --git a/apps/mcp-server/src/ooxml-tools.ts b/apps/mcp-server/src/ooxml-tools.ts index 67fd7b2..24477bb 100644 --- a/apps/mcp-server/src/ooxml-tools.ts +++ b/apps/mcp-server/src/ooxml-tools.ts @@ -215,6 +215,7 @@ export async function callOoxmlTool( args: Record, env: OoxmlEnv, ): Promise { + if (name === "ooxml_preset_shape") return runOoxmlTool(name, args, null); const sql = neon(env.DATABASE_URL); return runOoxmlTool(name, args, sql); } diff --git a/package.json b/package.json index c78bdd8..3654277 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "db:migrate": "bun scripts/db-migrate.ts", "mcp:users": "bun --env-file=.env apps/mcp-server/scripts/users.ts", "mcp:preset-shapes:generate": "bun scripts/generate-preset-shape-guides.ts", + "mcp:test": "bun test tests/scripts/source-artifact.test.ts tests/mcp-server/tools-list.test.ts tests/mcp-server/opc-parts.test.ts tests/mcp-server/mcp-auth.test.ts tests/mcp-server/oauth-authorization.test.ts tests/mcp-server/preset-shape-guides.test.ts", "sources:sync": "bun scripts/sources-sync.ts", "pdf:ingest": "bun scripts/ingest-pdf/pipeline.ts", "pdf:chunk": "bun scripts/ingest-pdf/chunk.ts", diff --git a/scripts/generate-preset-shape-guides.ts b/scripts/generate-preset-shape-guides.ts index 8c58da7..1b1dff2 100644 --- a/scripts/generate-preset-shape-guides.ts +++ b/scripts/generate-preset-shape-guides.ts @@ -6,11 +6,8 @@ * ZIP files. Pass --source to regenerate from an already extracted XML file. */ -import { createHash } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; import { XMLParser } from "fast-xml-parser"; +import { readPinnedNestedZipEntry, sha256 } from "./lib/source-artifact"; const SOURCE_NAME = "ecma-376-annex-d-drawingml-geometries"; const OUTPUT_PATH = "apps/mcp-server/src/preset-shape-guides.generated.ts"; @@ -19,16 +16,6 @@ const PART_1_ZIP = const GEOMETRIES_ZIP = "OfficeOpenXML-DrawingMLGeometries.zip"; const SHAPES_XML = "presetShapeDefinitions.xml"; -interface SourceEntry { - name: string; - url: string; - sha256: string; -} - -interface SourceManifest { - sources: SourceEntry[]; -} - type PreserveOrderNode = Record; export interface PresetShapeGuides { @@ -60,6 +47,8 @@ export function extractPresetShapeGuides(xml: string): PresetShapeGuides[] { const existing = shapes.get(name); if (existing) { + // The published fourth-edition XML repeats upDownArrow. Keep an + // identical duplicate, but fail if repeated definitions disagree. if (existing.join("\0") !== guides.join("\0")) { throw new Error(`Conflicting Annex D definitions for ${name}`); } @@ -72,50 +61,6 @@ export function extractPresetShapeGuides(xml: string): PresetShapeGuides[] { return [...shapes].map(([name, guides]) => ({ name, guides })); } -function sha256(data: ArrayBuffer | Uint8Array | string): string { - return createHash("sha256").update(data).digest("hex"); -} - -async function extractZipEntry( - zipPath: string, - entry: string, - destination: string, -): Promise { - const process = Bun.spawn(["unzip", "-j", "-o", "-q", zipPath, entry, "-d", destination], { - stdout: "inherit", - stderr: "pipe", - }); - const [error, code] = await Promise.all([new Response(process.stderr).text(), process.exited]); - if (code !== 0) throw new Error(`Could not extract ${entry}: ${error.trim()}`); - return join(destination, basename(entry)); -} - -async function downloadAnnexXml(): Promise { - const manifest = (await Bun.file("data/sources.json").json()) as SourceManifest; - const source = manifest.sources.find((entry) => entry.name === SOURCE_NAME); - if (!source) throw new Error(`Missing ${SOURCE_NAME} in data/sources.json`); - - const response = await fetch(source.url); - if (!response.ok) throw new Error(`Could not download Annex D: ${response.status}`); - const outerZip = new Uint8Array(await response.arrayBuffer()); - const actualHash = sha256(outerZip); - if (actualHash !== source.sha256) { - throw new Error(`Annex D source hash mismatch: expected ${source.sha256}, got ${actualHash}`); - } - - const tempDirectory = await mkdtemp(join(tmpdir(), "ooxml-annex-d-")); - try { - const outerPath = join(tempDirectory, "edition4.zip"); - await Bun.write(outerPath, outerZip); - const part1Path = await extractZipEntry(outerPath, PART_1_ZIP, tempDirectory); - const geometriesPath = await extractZipEntry(part1Path, GEOMETRIES_ZIP, tempDirectory); - const shapesPath = await extractZipEntry(geometriesPath, SHAPES_XML, tempDirectory); - return new Uint8Array(await Bun.file(shapesPath).arrayBuffer()); - } finally { - await rm(tempDirectory, { recursive: true, force: true }); - } -} - function renderLookup(shapes: PresetShapeGuides[], sourceHash: string): string { const entries = shapes .map(({ name, guides }) => `\t[${JSON.stringify(name)}, ${JSON.stringify(guides)}],`) @@ -148,7 +93,7 @@ async function main() { const xmlBytes = source ? new Uint8Array(await Bun.file(source).arrayBuffer()) - : await downloadAnnexXml(); + : await readPinnedNestedZipEntry(SOURCE_NAME, [PART_1_ZIP, GEOMETRIES_ZIP, SHAPES_XML]); const xml = new TextDecoder().decode(xmlBytes); const shapes = extractPresetShapeGuides(xml); await Bun.write(OUTPUT_PATH, renderLookup(shapes, sha256(xmlBytes))); diff --git a/scripts/lib/source-artifact.ts b/scripts/lib/source-artifact.ts new file mode 100644 index 0000000..3a1ab77 --- /dev/null +++ b/scripts/lib/source-artifact.ts @@ -0,0 +1,94 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; + +export interface SourceEntry { + name: string; + url: string; + sha256: string; +} + +interface SourceManifest { + sources: SourceEntry[]; +} + +export function sha256(data: ArrayBuffer | Uint8Array | string): string { + return createHash("sha256").update(data).digest("hex"); +} + +export async function loadSourceEntry( + name: string, + manifestPath = "data/sources.json", +): Promise { + const manifest = (await Bun.file(manifestPath).json()) as SourceManifest; + const source = manifest.sources.find((entry) => entry.name === name); + if (!source) throw new Error(`Missing ${name} in ${manifestPath}`); + if (!source.url || !source.sha256) throw new Error(`Source ${name} must include url and sha256`); + return source; +} + +export async function downloadVerifiedSource( + source: SourceEntry, + fetchSource: typeof fetch = fetch, +): Promise { + const response = await fetchSource(source.url); + if (!response.ok) throw new Error(`Could not download ${source.name}: ${response.status}`); + + const bytes = new Uint8Array(await response.arrayBuffer()); + const actualHash = sha256(bytes); + if (actualHash !== source.sha256) { + throw new Error(`${source.name} hash mismatch: expected ${source.sha256}, got ${actualHash}`); + } + return bytes; +} + +async function extractZipEntry( + zipPath: string, + entry: string, + destination: string, +): Promise { + const process = Bun.spawn(["unzip", "-j", "-o", "-q", zipPath, entry, "-d", destination], { + stdout: "inherit", + stderr: "pipe", + }); + const [error, code] = await Promise.all([new Response(process.stderr).text(), process.exited]); + if (code !== 0) throw new Error(`Could not extract ${entry}: ${error.trim()}`); + return join(destination, basename(entry)); +} + +/** + * Read one file through a chain of nested ZIP entries. + * + * Each entry except the last must itself be a ZIP. For example: + * outer.zip -> part1.zip -> geometries.zip -> presetShapeDefinitions.xml + */ +export async function readNestedZipEntry( + archive: Uint8Array, + entries: readonly string[], +): Promise { + if (entries.length === 0) throw new Error("At least one ZIP entry is required"); + + const tempDirectory = await mkdtemp(join(tmpdir(), "ooxml-source-")); + try { + let archivePath = join(tempDirectory, "source.zip"); + await Bun.write(archivePath, archive); + + for (const [index, entry] of entries.entries()) { + const stageDirectory = join(tempDirectory, String(index)); + await mkdir(stageDirectory); + archivePath = await extractZipEntry(archivePath, entry, stageDirectory); + } + return new Uint8Array(await Bun.file(archivePath).arrayBuffer()); + } finally { + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +export async function readPinnedNestedZipEntry( + sourceName: string, + entries: readonly string[], +): Promise { + const source = await loadSourceEntry(sourceName); + return readNestedZipEntry(await downloadVerifiedSource(source), entries); +} diff --git a/tests/mcp-server/mcp-auth.test.ts b/tests/mcp-server/mcp-auth.test.ts index 581aff5..97f084a 100644 --- a/tests/mcp-server/mcp-auth.test.ts +++ b/tests/mcp-server/mcp-auth.test.ts @@ -7,6 +7,7 @@ import { type McpAuthorizationProps, type UsageEvent, } from "../../apps/mcp-server/src/mcp-auth.ts"; +import { executeMcpTool } from "../../apps/mcp-server/src/mcp.ts"; const USER_ID = "user_test_mcp"; const CLIENT_ID = "dynamic_client_test"; @@ -135,6 +136,31 @@ test("authenticated tools/list exposes only the public OOXML tools", async () => expect(names).toEqual(EXPECTED_TOOL_NAMES); }); +test("authenticated MCP returns preset-shape guides from Annex D", async () => { + const events: UsageEvent[] = []; + const handler = createAuthenticatedMcpHandler({ + usageRecorder: { record: (event) => events.push(event) }, + toolExecutor: (name, args) => + executeMcpTool(name, args, { DATABASE_URL: "", VOYAGE_API_KEY: "" } as never), + now: () => new Date("2026-08-12T18:00:00.000Z"), + }); + + const response = await handler( + modernRequest("tools/call", { + name: "ooxml_preset_shape", + arguments: { shape: "round2SameRect" }, + }), + identity, + ); + const body = (await response.json()) as { + result?: { content?: Array<{ text?: string }> }; + }; + + expect(response.status).toBe(200); + expect(body.result?.content?.[0]?.text).toContain("`adj1`, `adj2`"); + expect(events[0]?.tool).toBe("ooxml_preset_shape"); +}); + test("usage recording failures do not discard a successful tool result", async () => { const usageErrors: unknown[] = []; const backgroundTasks: Promise[] = []; diff --git a/tests/mcp-server/preset-shape-guides.test.ts b/tests/mcp-server/preset-shape-guides.test.ts index 5430042..11cd9a2 100644 --- a/tests/mcp-server/preset-shape-guides.test.ts +++ b/tests/mcp-server/preset-shape-guides.test.ts @@ -13,13 +13,13 @@ test("returns the two guide names required by round2SameRect", async () => { expect(output).toContain("Fourth Edition"); }); -test("distinguishes shapes without guides from invalid shape names", async () => { +test("distinguishes shapes without guides from names missing in Annex D", async () => { const noGuides = await runOoxmlTool("ooxml_preset_shape", { shape: "rect" }, sqlStub); - const invalid = await runOoxmlTool("ooxml_preset_shape", { shape: "notARealShape" }, sqlStub); + const missing = await runOoxmlTool("ooxml_preset_shape", { shape: "notARealShape" }, sqlStub); expect(noGuides).toContain("has no adjust guides"); - expect(invalid).toContain("Preset shape not found"); - expect(invalid).not.toContain("has no adjust guides"); + expect(missing).toContain("Preset shape not found in Annex D"); + expect(missing).not.toContain("has no adjust guides"); }); test("does not accept preset text-warp names as preset shapes", async () => { diff --git a/tests/scripts/source-artifact.test.ts b/tests/scripts/source-artifact.test.ts new file mode 100644 index 0000000..0a967aa --- /dev/null +++ b/tests/scripts/source-artifact.test.ts @@ -0,0 +1,51 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "bun:test"; +import { + downloadVerifiedSource, + readNestedZipEntry, + sha256, +} from "../../scripts/lib/source-artifact.ts"; + +async function zip(output: string, input: string): Promise { + const process = Bun.spawn(["zip", "-j", "-q", output, input], { + stdout: "inherit", + stderr: "inherit", + }); + if ((await process.exited) !== 0) throw new Error(`Could not create ${output}`); +} + +test("verifies a pinned source before returning its bytes", async () => { + const bytes = new TextEncoder().encode("source artifact"); + const source = { + name: "fixture", + url: "https://example.test/source.zip", + sha256: sha256(bytes), + }; + const fetchSource = async () => new Response(bytes); + + expect(await downloadVerifiedSource(source, fetchSource)).toEqual(bytes); + await expect( + downloadVerifiedSource({ ...source, sha256: "0".repeat(64) }, fetchSource), + ).rejects.toThrow("fixture hash mismatch"); +}); + +test("reads a file through nested ZIP entries", async () => { + const directory = await mkdtemp(join(tmpdir(), "ooxml-source-test-")); + try { + const xmlPath = join(directory, "shapes.xml"); + const innerZip = join(directory, "inner.zip"); + const outerZip = join(directory, "outer.zip"); + await Bun.write(xmlPath, ""); + await zip(innerZip, xmlPath); + await zip(outerZip, innerZip); + + const archive = new Uint8Array(await Bun.file(outerZip).arrayBuffer()); + const xml = await readNestedZipEntry(archive, ["inner.zip", "shapes.xml"]); + + expect(new TextDecoder().decode(xml)).toBe(""); + } finally { + await rm(directory, { recursive: true, force: true }); + } +});