Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/small-trees-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ooxml-dev/mcp-server": minor
---

Add a tool for looking up DrawingML preset-shape adjust guides.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion apps/mcp-server/README.md
Original file line number Diff line number Diff line change
@@ -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`.

Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion apps/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp-server/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
};
}
Expand Down
53 changes: 51 additions & 2 deletions apps/mcp-server/src/ooxml-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -40,6 +41,7 @@ import {
type OpcPart,
searchParts,
} from "./opc-parts";
import { lookupPresetShapeGuides } from "./preset-shape-guides.generated";

export const DEFAULT_PROFILE = "transitional";

Expand Down Expand Up @@ -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 `<a:prstGeom>`. " +
"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 `<a:prstGeom prst="...">`.',
},
},
required: ["shape"],
},
},
];

export type OoxmlToolName =
Expand All @@ -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<string> = new Set(OOXML_TOOL_DEFS.map((t) => t.name));

Expand All @@ -196,6 +215,7 @@ export async function callOoxmlTool(
args: Record<string, unknown>,
env: OoxmlEnv,
): Promise<string> {
if (name === "ooxml_preset_shape") return runOoxmlTool(name, args, null);
const sql = neon(env.DATABASE_URL);
return runOoxmlTool(name, args, sql);
}
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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 `<a:prstGeom prst="...">`.',
].join("\n");
}

// --- Local element resolution ------------------------------------------

type LocalResolution =
Expand Down
198 changes: 198 additions & 0 deletions apps/mcp-server/src/preset-shape-guides.generated.ts
Original file line number Diff line number Diff line change
@@ -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<string, readonly string[]>([
["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;
}
Loading