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/.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/README.md b/README.md index e8d87f9..ffb48ca 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** (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 1d546fe..2bea70a 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 extracted from the Part 1 Annex D addendum. Hosted at `https://api.ooxml.dev/mcp`. @@ -78,6 +79,16 @@ 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 | + +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 ```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..24477bb 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.generated"; 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 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 preset shape name 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)); @@ -196,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); } @@ -423,6 +443,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 +751,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 in Annex D${shape ? `: ${shape}` : ""}`, + "", + 'Check the exact name from ``.', + ].join("\n"); +} + // --- Local element resolution ------------------------------------------ type LocalResolution = diff --git a/apps/mcp-server/src/preset-shape-guides.generated.ts b/apps/mcp-server/src/preset-shape-guides.generated.ts new file mode 100644 index 0000000..bf2610c --- /dev/null +++ b/apps/mcp-server/src/preset-shape-guides.generated.ts @@ -0,0 +1,198 @@ +/** + * 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([ + ["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"]], + ["bentArrow", ["adj1", "adj2", "adj3", "adj4"]], + ["bentConnector2", []], + ["bentConnector3", ["adj1"]], + ["bentConnector4", ["adj1", "adj2"]], + ["bentConnector5", ["adj1", "adj2", "adj3"]], + ["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"]], + ["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", []], + ["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"]], + ["flowChartAlternateProcess", []], + ["flowChartCollate", []], + ["flowChartConnector", []], + ["flowChartDecision", []], + ["flowChartDelay", []], + ["flowChartDisplay", []], + ["flowChartDocument", []], + ["flowChartExtract", []], + ["flowChartInputOutput", []], + ["flowChartInternalStorage", []], + ["flowChartMagneticDisk", []], + ["flowChartMagneticDrum", []], + ["flowChartMagneticTape", []], + ["flowChartManualInput", []], + ["flowChartManualOperation", []], + ["flowChartMerge", []], + ["flowChartMultidocument", []], + ["flowChartOfflineStorage", []], + ["flowChartOffpageConnector", []], + ["flowChartOnlineStorage", []], + ["flowChartOr", []], + ["flowChartPredefinedProcess", []], + ["flowChartPreparation", []], + ["flowChartProcess", []], + ["flowChartPunchedCard", []], + ["flowChartPunchedTape", []], + ["flowChartSort", []], + ["flowChartSummingJunction", []], + ["flowChartTerminator", []], + ["foldedCorner", ["adj"]], + ["frame", ["adj1"]], + ["funnel", []], + ["gear6", ["adj1", "adj2"]], + ["gear9", ["adj1", "adj2"]], + ["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"]], + ["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", []], + ["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 { + return PRESET_SHAPES.get(shape) ?? null; +} diff --git a/data/sources.json b/data/sources.json index 4710df8..2d95b0d 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": "electronic_addendum", + "edition": "4th", + "version": "2012-12", + "url": "https://ecma-international.org/wp-content/uploads/ECMA-376_4th_edition_december_2012.zip", + "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..3654277 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "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", + "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 new file mode 100644 index 0000000..1b1dff2 --- /dev/null +++ b/scripts/generate-preset-shape-guides.ts @@ -0,0 +1,114 @@ +/** + * 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 { 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"; +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"; + +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) { + // 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}`); + } + 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 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 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))); + + 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/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 5606de1..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"; @@ -21,6 +22,7 @@ const EXPECTED_TOOL_NAMES = [ "ooxml_enum", "ooxml_namespace", "ooxml_package_part", + "ooxml_preset_shape", ]; const identity: McpAuthorizationProps = { @@ -134,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 new file mode 100644 index 0000000..11cd9a2 --- /dev/null +++ b/tests/mcp-server/preset-shape-guides.test.ts @@ -0,0 +1,52 @@ +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"); +}) 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 names missing in Annex D", async () => { + const noGuides = await runOoxmlTool("ooxml_preset_shape", { shape: "rect" }, sqlStub); + const missing = await runOoxmlTool("ooxml_preset_shape", { shape: "notARealShape" }, sqlStub); + + expect(noGuides).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 () => { + const output = await runOoxmlTool("ooxml_preset_shape", { shape: "textArchDown" }, sqlStub); + + 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"); +}); 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 { 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 }); + } +});