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
29 changes: 29 additions & 0 deletions openspec/changes/add-document-outline-tool/proposal.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions openspec/changes/add-document-outline-tool/tasks.md
Original file line number Diff line number Diff line change
@@ -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 only; `.odt` paths are rejected with `UNSUPPORTED_FOR_ODF`)
- 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`
13 changes: 13 additions & 0 deletions packages/docx-mcp/docs/tool-reference.generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
Expand Down
3 changes: 3 additions & 0 deletions packages/docx-mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof grep>[1]);
case 'get_document_outline':
return await getDocumentOutline(sessions, args as Parameters<typeof getDocumentOutline>[1]);
case 'batch_edit':
return await batchEdit(sessions, args as Parameters<typeof batchEdit>[1]);
case 'replace_text':
Expand Down
23 changes: 23 additions & 0 deletions packages/docx-mcp/src/tool_catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). ' +
Expand Down Expand Up @@ -65,6 +71,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_DOCX_ONLY,
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.',
Expand Down
151 changes: 151 additions & 0 deletions packages/docx-mcp/src/tools/get_document_outline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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 =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<w:document xmlns:w="${W_NS}">` +
`<w:body>` +
`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Introduction</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>${BODY_PROSE}</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="Heading2"/></w:pPr><w:r><w:t>Scope of Services</w:t></w:r></w:p>` +
`</w:body></w:document>`;

/** Document whose only heading is heuristic (a short bare title, no Word style). */
const HEURISTIC_HEADING_XML =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<w:document xmlns:w="${W_NS}">` +
`<w:body>` +
`<w:p><w:r><w:t>Indemnification</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>${BODY_PROSE}</w:t></w:r></w:p>` +
`</w:body></w:document>`;

async function writeTestDocx(tmpDir: string, xml: string, filename = 'input.docx'): Promise<string> {
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 ?? '';
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']);
});
});
});
Loading