From 1a1c9009bf3946d88422efeaba65fc01d42500be Mon Sep 17 00:00:00 2001 From: Steven Obiajulu Date: Fri, 26 Jun 2026 01:44:07 -0400 Subject: [PATCH 1/4] feat(docx-mcp): add read-only get_document_outline tool Agents working long documents (e.g. a 60-page MSA) currently have to read_file/grep through the whole body to locate a section, burning context window and risking edits to the wrong clause. get_document_outline projects the existing document-view heading detection into a compact structural map: one entry per heading carrying text, outline level, source, and the stable _bk_* paragraph_id, so an agent can read the cheap outline first and then scope a targeted read_file/replace_text to the right region. Style-based (Word HeadingN) headings only by default to keep the map low-noise; heuristic titles/run-in headers are opt-in via include_heuristic_headings. Output is JSON by default with an optional markdown rendering. DOCX sessions only (ODF has no equivalent heading projection yet). Read-only. Ref: #493 --- .../add-document-outline-tool/proposal.md | 29 ++++ .../specs/mcp-server/spec.md | 19 +++ .../add-document-outline-tool/tasks.md | 29 ++++ .../docx-mcp/docs/tool-reference.generated.md | 13 ++ packages/docx-mcp/src/server.ts | 3 + packages/docx-mcp/src/tool_catalog.ts | 17 ++ .../src/tools/get_document_outline.test.ts | 149 ++++++++++++++++++ .../src/tools/get_document_outline.ts | 89 +++++++++++ packages/safe-docx-mcpb/manifest.json | 4 + 9 files changed, 352 insertions(+) create mode 100644 openspec/changes/add-document-outline-tool/proposal.md create mode 100644 openspec/changes/add-document-outline-tool/specs/mcp-server/spec.md create mode 100644 openspec/changes/add-document-outline-tool/tasks.md create mode 100644 packages/docx-mcp/src/tools/get_document_outline.test.ts create mode 100644 packages/docx-mcp/src/tools/get_document_outline.ts diff --git a/openspec/changes/add-document-outline-tool/proposal.md b/openspec/changes/add-document-outline-tool/proposal.md new file mode 100644 index 00000000..a672dc85 --- /dev/null +++ b/openspec/changes/add-document-outline-tool/proposal.md @@ -0,0 +1,29 @@ +# Change: Add get_document_outline MCP Tool + +## Why + +Agents working on long documents (e.g. a 60-page MSA) currently have to `read_file`/`grep` their way through the whole body to locate a section. That burns context window and raises the risk of acting on the wrong clause — the agent has no lightweight map of *where things are* before it starts reading prose. + +`docx-core` already detects headings (Word heading styles plus heuristic title/run-in detection) and exposes a stable `_bk_*` id per paragraph through the document view. A cheap structural projection over that data lets an agent read the outline first (hundreds of tokens), see that §4.2 holds the indemnity clause, then `read_file`/`replace_text` only the paragraphs under it. Map first, then targeted read. + +## What Changes + +- Add a read-only `get_document_outline` MCP tool (DOCX sessions) that returns a compact structural map of the document's headings instead of full text. +- Each outline entry carries the heading `text`, outline `level` (for Word heading styles), heading `source`, and the stable `paragraph_id` (`_bk_*`) so the agent can follow up with a targeted `read_file` / `replace_text` scoped to that region. +- Output defaults to JSON; a `format: "markdown"` option renders an indented Markdown outline for cheap human/agent skimming. +- Heading detection is style-based by default (Word `HeadingN` styles). Heuristic headings (title/run-in/centered-caps) are opt-in via `include_heuristic_headings` so the default outline stays low-noise. + +## Impact + +- Affected specs: `mcp-server` +- Affected code: + - `packages/docx-mcp/src/tools/get_document_outline.ts` (new projection over `buildDocumentView`) + - `packages/docx-mcp/src/tool_catalog.ts` (catalog entry + input schema) + - `packages/docx-mcp/src/server.ts` (dispatch wiring) + +## Out of scope + +- Full text extraction (that is `read_file`). +- Semantic classification of clauses (that is the agent's job). +- Section-break and table/figure anchors (follow-up; this change ships headings only). +- ODT / Google Doc sessions (ODF has no equivalent heading projection yet; follow-up). diff --git a/openspec/changes/add-document-outline-tool/specs/mcp-server/spec.md b/openspec/changes/add-document-outline-tool/specs/mcp-server/spec.md new file mode 100644 index 00000000..7ba8c3e5 --- /dev/null +++ b/openspec/changes/add-document-outline-tool/specs/mcp-server/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: Document Outline Tool +The Safe-Docx MCP server SHALL provide a read-only `get_document_outline` tool that returns a compact structural map of a document's headings, each carrying the stable paragraph id so an agent can follow up with a targeted read or edit. The tool SHALL operate on DOCX sessions. + +#### Scenario: word-style headings are projected with level and paragraph id +- **WHEN** `get_document_outline` is called on a document containing Word `HeadingN`-styled paragraphs +- **THEN** the response SHALL include an `outline` array with one entry per heading paragraph +- **AND** each entry SHALL include the heading `text`, the outline `level`, the heading `source`, and the stable `paragraph_id` + +#### Scenario: heuristic headings are excluded by default and included on opt-in +- **WHEN** `get_document_outline` is called on a document whose only headings are heuristic (manual title / run-in / centered-caps, not Word styles) +- **THEN** the default response SHALL omit those headings from `outline` +- **AND** WHEN the same call sets `include_heuristic_headings=true` the response SHALL include those headings with their heuristic `source` + +#### Scenario: markdown format renders an indented outline +- **WHEN** `get_document_outline` is called with `format="markdown"` +- **THEN** the response SHALL return a `content` string rendering the headings as an indented Markdown outline +- **AND** the depth of each heading SHALL reflect its outline level diff --git a/openspec/changes/add-document-outline-tool/tasks.md b/openspec/changes/add-document-outline-tool/tasks.md new file mode 100644 index 00000000..23e59278 --- /dev/null +++ b/openspec/changes/add-document-outline-tool/tasks.md @@ -0,0 +1,29 @@ +# Tasks + +## Phase 1: Tool Implementation + +- [x] 1.1 Implement `get_document_outline` projection + - Build the document view, project paragraphs whose `heading` is set into outline entries + - Each entry: `paragraph_id` (`_bk_*`), `text`, `level`, `source` + - Default to Word-style headings only; include heuristic headings only when `include_heuristic_headings=true` + - File: `packages/docx-mcp/src/tools/get_document_outline.ts` + +- [x] 1.2 Support `format` output + - `json` (default): structured `outline` array plus `total_headings` / `total_paragraphs` + - `markdown`: indented Markdown outline string under `content` + +## Phase 2: Wiring + +- [x] 2.1 Add `get_document_outline` to the tool catalog with a read-only annotation and input schema + - File: `packages/docx-mcp/src/tool_catalog.ts` + +- [x] 2.2 Dispatch `get_document_outline` in the server (DOCX direct; ODT via the shared session lane) + - File: `packages/docx-mcp/src/server.ts` + +## Phase 3: Tests + +- [x] 3.1 Add traceability tests covering each scenario + - Word-style headings are projected with level and `_bk_*` id + - Heuristic headings are excluded by default and included on opt-in + - Markdown format renders an indented outline + - File: `packages/docx-mcp/src/tools/get_document_outline.test.ts` diff --git a/packages/docx-mcp/docs/tool-reference.generated.md b/packages/docx-mcp/docs/tool-reference.generated.md index 92cee775..577d6f1e 100644 --- a/packages/docx-mcp/docs/tool-reference.generated.md +++ b/packages/docx-mcp/docs/tool-reference.generated.md @@ -25,6 +25,19 @@ Read document content (DOCX, ODT, or Google Doc). Output is token-limited (~14k | `include_fingerprint` | `boolean` | no | When true and format="json", include a portable content_fingerprint ("sha256:nfkc:<32hex>") on each paragraph. Read-only metadata derived from the paragraph's normalized visible text; NOT an edit anchor. Edit tools accept only `_bk_*` IDs. No effect on TOON/simple output. Ignored for Google Docs and ODT. | | `include_footnotes` | `boolean` | no | When true and format="json", attach a `footnotes` array ({id, display_number, text}) to each paragraph node for the footnotes anchored to it. Windowed to the returned slice (a paginated walk returns each footnote exactly once) and counted toward the read token budget. Footnotes with an empty body or no anchored paragraph are excluded — use get_footnotes for the authoritative full enumeration. No effect on TOON/simple output. Ignored for Google Docs and ODT. Default: false. | +## `get_document_outline` + +Get a compact structural map of a document's headings (DOCX only). Returns one entry per heading paragraph with its text, outline level, source, and stable `_bk_*` paragraph_id — so an agent can read the cheap outline first, then scope a targeted read_file/replace_text to the right section instead of scanning the whole body. Style-based (Word HeadingN) headings only by default; set include_heuristic_headings=true to also include heuristic titles/run-in headers. Read-only. + +- readOnly: `true` +- destructive: `false` + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `file_path` | `string` | no | Path to the DOCX or ODT file. | +| `format` | `enum("json", "markdown")` | no | Output format: 'json' (default, structured outline array) or 'markdown' (indented ATX outline under `content`). | +| `include_heuristic_headings` | `boolean` | no | When true, also include heuristically-detected headings (manual title / run-in / centered-caps) alongside Word HeadingN styles. Default: false (style-based only). | + ## `grep` Search paragraphs with regex. Use file_path for session-based search, file_paths for stateless multi-file search, or google_doc_id for Google Docs. ODT supported via file_path (single-file) only. diff --git a/packages/docx-mcp/src/server.ts b/packages/docx-mcp/src/server.ts index a86640c5..907a97d3 100644 --- a/packages/docx-mcp/src/server.ts +++ b/packages/docx-mcp/src/server.ts @@ -7,6 +7,7 @@ import { SessionManager, type GDocsSession, type OdfSession } from './session/ma import { SAFE_DOCX_MCP_TOOLS } from './tool_catalog.js'; import { readFile } from './tools/read_file.js'; import { grep } from './tools/grep.js'; +import { getDocumentOutline } from './tools/get_document_outline.js'; import { replaceText } from './tools/replace_text.js'; import { insertParagraph } from './tools/insert_paragraph.js'; import { batchEdit } from './tools/batch_edit.js'; @@ -159,6 +160,8 @@ export async function dispatchToolCall( if (isGDocsRequest(args)) return await dispatchGDocs(sessions, args, 'grep'); if (isOdfRequest(args)) return await dispatchOdf(sessions, args, 'grep'); return await grep(sessions, args as Parameters[1]); + case 'get_document_outline': + return await getDocumentOutline(sessions, args as Parameters[1]); case 'batch_edit': return await batchEdit(sessions, args as Parameters[1]); case 'replace_text': diff --git a/packages/docx-mcp/src/tool_catalog.ts b/packages/docx-mcp/src/tool_catalog.ts index 4d9eb187..2a2590f3 100644 --- a/packages/docx-mcp/src/tool_catalog.ts +++ b/packages/docx-mcp/src/tool_catalog.ts @@ -65,6 +65,23 @@ export const SAFE_DOCX_TOOL_CATALOG = [ }), annotations: { readOnlyHint: true, destructiveHint: false }, }, + { + name: 'get_document_outline', + description: + 'Get a compact structural map of a document\'s headings (DOCX only). Returns one entry per heading paragraph with its text, outline level, source, and stable `_bk_*` paragraph_id — so an agent can read the cheap outline first, then scope a targeted read_file/replace_text to the right section instead of scanning the whole body. Style-based (Word HeadingN) headings only by default; set include_heuristic_headings=true to also include heuristic titles/run-in headers. Read-only.', + input: z.object({ + ...FILE_FIELD_OPTIONAL, + format: z + .enum(['json', 'markdown']) + .optional() + .describe("Output format: 'json' (default, structured outline array) or 'markdown' (indented ATX outline under `content`)."), + include_heuristic_headings: z + .boolean() + .optional() + .describe('When true, also include heuristically-detected headings (manual title / run-in / centered-caps) alongside Word HeadingN styles. Default: false (style-based only).'), + }), + annotations: { readOnlyHint: true, destructiveHint: false }, + }, { name: 'grep', description: 'Search paragraphs with regex. Use file_path for session-based search, file_paths for stateless multi-file search, or google_doc_id for Google Docs. ODT supported via file_path (single-file) only.', diff --git a/packages/docx-mcp/src/tools/get_document_outline.test.ts b/packages/docx-mcp/src/tools/get_document_outline.test.ts new file mode 100644 index 00000000..fb4dba89 --- /dev/null +++ b/packages/docx-mcp/src/tools/get_document_outline.test.ts @@ -0,0 +1,149 @@ +import { describe, expect } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { getDocumentOutline } from './get_document_outline.js'; +import { makeDocxWithDocumentXml } from '../testing/docx_test_utils.js'; +import { testAllure, type AllureBddContext } from '../testing/allure-test.js'; +import { + assertSuccess, + registerCleanup, + createTestSessionManager, + createTrackedTempDir, +} from '../testing/session-test-utils.js'; + +const TEST_FEATURE = 'add-document-outline-tool'; +const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; + +type OutlineEntry = { + paragraph_id: string; + text: string; + level: number | null; + source: string; +}; + +function outlineEntries(value: unknown): OutlineEntry[] { + return ((value as { outline?: OutlineEntry[] }).outline ?? []); +} + +/** A long lowercase body sentence that triggers no heuristic heading detection. */ +const BODY_PROSE = + 'This agreement sets forth the terms and conditions governing the relationship between the parties hereto.'; + +/** Document with two Word HeadingN-styled paragraphs and one body paragraph. */ +const STYLED_HEADINGS_XML = + `` + + `` + + `` + + `Introduction` + + `${BODY_PROSE}` + + `Scope of Services` + + ``; + +/** Document whose only heading is heuristic (a short bare title, no Word style). */ +const HEURISTIC_HEADING_XML = + `` + + `` + + `` + + `Indemnification` + + `${BODY_PROSE}` + + ``; + +async function writeTestDocx(tmpDir: string, xml: string, filename = 'input.docx'): Promise { + const inputPath = path.join(tmpDir, filename); + const buf = await makeDocxWithDocumentXml(xml); + await fs.writeFile(inputPath, new Uint8Array(buf)); + return inputPath; +} + +describe('Traceability: Document Outline Tool', () => { + const test = testAllure.epic('Document Editing').withLabels({ feature: TEST_FEATURE }); + const humanReadableTest = test.allure({ + tags: ['human-readable'], + parameters: { audience: 'non-technical' }, + }); + registerCleanup(); + + // ── ADDED: Document Outline Tool ──────────────────────────────────── + + humanReadableTest.openspec('word-style headings are projected with level and paragraph id')('Scenario: word-style headings are projected with level and paragraph id', async ({ given, when, then, attachPrettyJson }: AllureBddContext) => { + const mgr = createTestSessionManager(); + const tmpDir = await createTrackedTempDir('outline-styled-'); + let inputPath = ''; + await given('a document with Word HeadingN-styled paragraphs', async () => { + inputPath = await writeTestDocx(tmpDir, STYLED_HEADINGS_XML); + }); + + const result = await when('get_document_outline is called', async () => { + const r = await getDocumentOutline(mgr, { file_path: inputPath }); + assertSuccess(r, 'get_document_outline'); + await attachPrettyJson('get_document_outline response', r); + return r; + }); + + await then('the outline SHALL include one entry per heading with text, level, source, and paragraph_id', () => { + const outline = outlineEntries(result); + expect(outline).toHaveLength(2); + const [first, second] = outline; + expect(first!.text).toBe('Introduction'); + expect(first!.level).toBe(1); + expect(first!.source).toBe('word_style'); + expect(first!.paragraph_id.startsWith('_bk_')).toBe(true); + expect(second!.text).toBe('Scope of Services'); + expect(second!.level).toBe(2); + }); + }); + + humanReadableTest.openspec('heuristic headings are excluded by default and included on opt-in')('Scenario: heuristic headings are excluded by default and included on opt-in', async ({ given, when, then, attachPrettyJson }: AllureBddContext) => { + const mgr = createTestSessionManager(); + const tmpDir = await createTrackedTempDir('outline-heuristic-'); + let inputPath = ''; + await given('a document whose only heading is heuristic (a bare title, not a Word style)', async () => { + inputPath = await writeTestDocx(tmpDir, HEURISTIC_HEADING_XML); + }); + + const byDefault = await when('get_document_outline is called without include_heuristic_headings', async () => { + const r = await getDocumentOutline(mgr, { file_path: inputPath }); + assertSuccess(r, 'get_document_outline'); + await attachPrettyJson('default response', r); + return r; + }); + + const optedIn = await when('get_document_outline is called with include_heuristic_headings=true', async () => { + const r = await getDocumentOutline(mgr, { file_path: inputPath, include_heuristic_headings: true }); + assertSuccess(r, 'get_document_outline'); + await attachPrettyJson('opt-in response', r); + return r; + }); + + await then('the default outline SHALL omit the heuristic heading but the opt-in outline SHALL include it', () => { + expect(outlineEntries(byDefault)).toHaveLength(0); + const optedInOutline = outlineEntries(optedIn); + expect(optedInOutline).toHaveLength(1); + expect(optedInOutline[0]!.text).toBe('Indemnification'); + expect(optedInOutline[0]!.source).not.toBe('word_style'); + }); + }); + + humanReadableTest.openspec('markdown format renders an indented outline')('Scenario: markdown format renders an indented outline', async ({ given, when, then, attachPrettyJson }: AllureBddContext) => { + const mgr = createTestSessionManager(); + const tmpDir = await createTrackedTempDir('outline-markdown-'); + let inputPath = ''; + await given('a document with Heading1 and Heading2 paragraphs', async () => { + inputPath = await writeTestDocx(tmpDir, STYLED_HEADINGS_XML); + }); + + const result = await when('get_document_outline is called with format="markdown"', async () => { + const r = await getDocumentOutline(mgr, { file_path: inputPath, format: 'markdown' }); + assertSuccess(r, 'get_document_outline'); + await attachPrettyJson('markdown response', r); + return r; + }); + + await then('the content SHALL render headings as an indented Markdown outline reflecting level', () => { + const content = (result as { content?: string }).content ?? ''; + expect(content).toContain('# Introduction'); + expect(content).toContain('## Scope of Services'); + }); + }); +}); diff --git a/packages/docx-mcp/src/tools/get_document_outline.ts b/packages/docx-mcp/src/tools/get_document_outline.ts new file mode 100644 index 00000000..ca486d54 --- /dev/null +++ b/packages/docx-mcp/src/tools/get_document_outline.ts @@ -0,0 +1,89 @@ +import { SessionManager } from '../session/manager.js'; +import { errorMessage } from '../error_utils.js'; +import { ok, err, type ToolResponse } from './types.js'; +import { resolveSessionForTool, mergeSessionResolutionMetadata } from './session_resolution.js'; +import type { DocumentViewNode, HeadingSource } from '@usejunior/docx-core'; + +type OutlineEntry = { + paragraph_id: string; + text: string; + level: number | null; + source: HeadingSource; +}; + +/** + * Projects the document view into outline entries. Word `HeadingN` styles carry + * a numeric `level`; heuristic sources (title/run-in/centered-caps) have a null + * level and are only emitted when explicitly requested, so the default outline + * stays low-noise on documents that mix manual emphasis with real structure. + */ +export function projectOutline( + nodes: readonly DocumentViewNode[], + includeHeuristic: boolean, +): OutlineEntry[] { + const entries: OutlineEntry[] = []; + for (const node of nodes) { + const heading = node.heading; + if (!heading) continue; + if (!includeHeuristic && heading.source !== 'word_style') continue; + entries.push({ + paragraph_id: node.id, + text: heading.text, + level: heading.level, + source: heading.source, + }); + } + return entries; +} + +/** + * Renders outline entries as an indented Markdown ATX outline. The heading depth + * reflects the outline `level`; heuristic headings without a level render at + * depth 1. Depth is clamped to the ATX 1..6 range. + */ +export function renderOutlineMarkdown(entries: readonly OutlineEntry[]): string { + return entries + .map((entry) => { + const depth = Math.min(6, Math.max(1, entry.level ?? 1)); + return `${'#'.repeat(depth)} ${entry.text}`; + }) + .join('\n'); +} + +export async function getDocumentOutline( + manager: SessionManager, + params: { + file_path?: string; + format?: string; + include_heuristic_headings?: boolean; + }, +): Promise { + const resolved = await resolveSessionForTool(manager, params, { toolName: 'get_document_outline' }); + if (!resolved.ok) return resolved.response; + const { session, metadata } = resolved; + + const format = (params.format ?? 'json').toLowerCase(); + if (format !== 'json' && format !== 'markdown') { + return err('INVALID_FORMAT', `Invalid format: ${params.format}`, "Use 'json' (default) or 'markdown'."); + } + + try { + const { nodes } = session.doc.buildDocumentView({ showFormatting: false }); + const includeHeuristic = params.include_heuristic_headings ?? false; + const outline = projectOutline(nodes, includeHeuristic); + + const base = { + file_path: manager.normalizePath(session.originalPath), + total_paragraphs: nodes.length, + total_headings: outline.length, + }; + + if (format === 'markdown') { + return ok(mergeSessionResolutionMetadata({ ...base, content: renderOutlineMarkdown(outline) }, metadata)); + } + + return ok(mergeSessionResolutionMetadata({ ...base, outline }, metadata)); + } catch (e: unknown) { + return err('OUTLINE_ERROR', errorMessage(e), 'Check session status and try again.'); + } +} diff --git a/packages/safe-docx-mcpb/manifest.json b/packages/safe-docx-mcpb/manifest.json index cf3186ee..fc263b80 100644 --- a/packages/safe-docx-mcpb/manifest.json +++ b/packages/safe-docx-mcpb/manifest.json @@ -28,6 +28,10 @@ "name": "grep", "description": "Search for text patterns in the document." }, + { + "name": "get_document_outline", + "description": "Get a compact structural map of the document's headings with stable paragraph IDs." + }, { "name": "batch_edit", "description": "Validate, conflict-check, and apply multiple edit steps in one call." From 678eb7919bc8a2d2919aaf91304add67d8bb4ca0 Mon Sep 17 00:00:00 2001 From: Steven Obiajulu Date: Tue, 30 Jun 2026 12:51:44 -0400 Subject: [PATCH 2/4] test(docx-mcp): assert exact outline heading lines in get_document_outline Replace the lower-bound toContain checks in the markdown-format scenario with an exact line-by-line assertion (split on '\n', expect the precise ATX heading lines at depth 1/2 and no body-paragraph leak). Strengthens the heading-depth coverage the toContain assertions only bounded. Ref: #493 --- packages/docx-mcp/src/tools/get_document_outline.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/docx-mcp/src/tools/get_document_outline.test.ts b/packages/docx-mcp/src/tools/get_document_outline.test.ts index fb4dba89..7baf9def 100644 --- a/packages/docx-mcp/src/tools/get_document_outline.test.ts +++ b/packages/docx-mcp/src/tools/get_document_outline.test.ts @@ -142,8 +142,10 @@ describe('Traceability: Document Outline Tool', () => { await then('the content SHALL render headings as an indented Markdown outline reflecting level', () => { const content = (result as { content?: string }).content ?? ''; - expect(content).toContain('# Introduction'); - expect(content).toContain('## Scope of Services'); + const lines = content.split('\n'); + // Exact lines: Heading1 renders at depth 1, Heading2 at depth 2, and the + // body prose paragraph (not a heading) contributes no line. + expect(lines).toEqual(['# Introduction', '## Scope of Services']); }); }); }); From 5266c9bbaf222a919a7d3bba3547b715eec5e1dc Mon Sep 17 00:00:00 2001 From: Steven Obiajulu Date: Tue, 30 Jun 2026 12:51:52 -0400 Subject: [PATCH 3/4] docs(docx-mcp): scope get_document_outline file_path to DOCX-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool rejects .odt paths with UNSUPPORTED_FOR_ODF, so its file_path description must not advertise ODT. Add a DOCX-only optional file field (keeping the shared FILE_FIELD_OPTIONAL generic for ODT-supporting tools) and use it for get_document_outline. Also correct tasks.md item 2.2, which inaccurately described ODT going "via the shared session lane" — ODT is rejected, not routed. Ref: #493 --- openspec/changes/add-document-outline-tool/tasks.md | 2 +- packages/docx-mcp/src/tool_catalog.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/openspec/changes/add-document-outline-tool/tasks.md b/openspec/changes/add-document-outline-tool/tasks.md index 23e59278..df27fa5d 100644 --- a/openspec/changes/add-document-outline-tool/tasks.md +++ b/openspec/changes/add-document-outline-tool/tasks.md @@ -17,7 +17,7 @@ - [x] 2.1 Add `get_document_outline` to the tool catalog with a read-only annotation and input schema - File: `packages/docx-mcp/src/tool_catalog.ts` -- [x] 2.2 Dispatch `get_document_outline` in the server (DOCX direct; ODT via the shared session lane) +- [x] 2.2 Dispatch `get_document_outline` in the server (DOCX only; `.odt` paths are rejected with `UNSUPPORTED_FOR_ODF`) - File: `packages/docx-mcp/src/server.ts` ## Phase 3: Tests diff --git a/packages/docx-mcp/src/tool_catalog.ts b/packages/docx-mcp/src/tool_catalog.ts index 2a2590f3..d68bb82c 100644 --- a/packages/docx-mcp/src/tool_catalog.ts +++ b/packages/docx-mcp/src/tool_catalog.ts @@ -20,6 +20,12 @@ const FILE_FIELD_OPTIONAL = { file_path: z.string().optional().describe('Path to the DOCX or ODT file.'), }; +// DOCX-only tools reject `.odt` paths with UNSUPPORTED_FOR_ODF, so their +// file_path description must not advertise ODT support. +const FILE_FIELD_OPTIONAL_DOCX_ONLY = { + file_path: z.string().optional().describe('Path to the DOCX file.'), +}; + const GOOGLE_DOC_ID_FIELD = { google_doc_id: z.string().optional().describe( 'Google Doc ID or URL (alternative to file_path). ' + @@ -70,7 +76,7 @@ export const SAFE_DOCX_TOOL_CATALOG = [ description: 'Get a compact structural map of a document\'s headings (DOCX only). Returns one entry per heading paragraph with its text, outline level, source, and stable `_bk_*` paragraph_id — so an agent can read the cheap outline first, then scope a targeted read_file/replace_text to the right section instead of scanning the whole body. Style-based (Word HeadingN) headings only by default; set include_heuristic_headings=true to also include heuristic titles/run-in headers. Read-only.', input: z.object({ - ...FILE_FIELD_OPTIONAL, + ...FILE_FIELD_OPTIONAL_DOCX_ONLY, format: z .enum(['json', 'markdown']) .optional() From ce322cc9881a9be3915eba76df5ad94bc1a74a16 Mon Sep 17 00:00:00 2001 From: Steven Obiajulu Date: Tue, 30 Jun 2026 12:55:16 -0400 Subject: [PATCH 4/4] docs(docx-mcp): regenerate tool-reference for DOCX-only get_document_outline file_path Ref: #493 --- packages/docx-mcp/docs/tool-reference.generated.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docx-mcp/docs/tool-reference.generated.md b/packages/docx-mcp/docs/tool-reference.generated.md index 577d6f1e..3c37e01c 100644 --- a/packages/docx-mcp/docs/tool-reference.generated.md +++ b/packages/docx-mcp/docs/tool-reference.generated.md @@ -34,7 +34,7 @@ Get a compact structural map of a document's headings (DOCX only). Returns one e | Field | Type | Required | Notes | | --- | --- | --- | --- | -| `file_path` | `string` | no | Path to the DOCX or ODT file. | +| `file_path` | `string` | no | Path to the DOCX file. | | `format` | `enum("json", "markdown")` | no | Output format: 'json' (default, structured outline array) or 'markdown' (indented ATX outline under `content`). | | `include_heuristic_headings` | `boolean` | no | When true, also include heuristically-detected headings (manual title / run-in / centered-caps) alongside Word HeadingN styles. Default: false (style-based only). |