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
58 changes: 58 additions & 0 deletions openspec/changes/add-odf-comments/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Design notes — ODF `add_comment` + `get_comments`

## Markup: `office:annotation` (inline in `content.xml`)
ODF comments are inline annotations, not a separate part. A whole-paragraph comment brackets the
paragraph's inline content; a ranged comment brackets a substring:
```xml
<text:p>
<office:annotation office:name="__Annot__1">
<dc:creator>Jane Doe</dc:creator>
<dc:date>2026-06-06T00:00:00</dc:date>
<text:p>The comment body.</text:p>
</office:annotation>Hello world<office:annotation-end office:name="__Annot__1"/>
</text:p>
```
The basic markup (annotation parent + `dc:creator`/`dc:date`/`text:p` body, paired
`office:annotation-end` by `office:name`) is conformant under the ODF 1.2/1.3 reference and is what
LibreOffice writes/reads.

## B1 — annotation body must not leak into the paragraph stream (peer-review BLOCKER)
`OdfDocument.collectBlocks` and `buildSegments` recurse into every child. An `office:annotation`
contains a `<text:p>` body, so without a guard that body both (a) inflates the anchor paragraph's
visible text and (b) registers as a phantom `pN` block. Reproduced on built `dist`:
`Hello <annotation>…<text:p>Comment body</text:p></annotation>world` →
`[{p0,"Hello A…Comment bodyworld"},{p1,"Comment body"}]`. Fix: both traversals skip
`office:annotation` / `office:annotation-end` subtrees via a shared `isAnnotationSubtree` guard.
This is covered by an explicit no-leak regression test.

## B2 — two insertion paths, not one (peer-review BLOCKER)
The Phase-1 `replaceTextById` single-`#text`-node contract is right for `anchor_text` but wrong for
whole-paragraph anchoring (it would fail on paragraphs with `text:span`/`text:s`/`text:tab`/multiple
text nodes). So:
- **Whole-paragraph** (`addWholeBlockAnnotation`): purely structural — annotation as the block's
first inline child, `office:annotation-end` after its last inline child. Independent of text
segmentation. Empty paragraph → a single point annotation (no end).
- **Ranged** (`addRangedAnnotation`): resolve the host `#text` node via `buildSegments`; cross-node
match → `MATCH_SPANS_MULTIPLE_NODES`. Split the host at `end` then at `start`; insert the
annotation before the middle text node and `office:annotation-end` after it.

## `office:name` / comment ids
Generated names are allocated by scanning ALL existing `office:name` values: pick the smallest `N`
where `__Annot__N` collides with none and `N` exceeds every numeric suffix present. `commentId = N`.
`get_comments` parses `__Annot__<n>` for the numeric id; annotations whose names don't match are
assigned ids sequentially after the max parsed value, deterministically by document order (a
documented limitation — real `.odt`s from LibreOffice use the `__Annot__N` convention).

## Replies deferred
ODF has no first-class reply graph (DOCX links replies via `commentsExtended.xml`). Inventing a
thread convention now would be fragile, so `parent_comment_id` on a `.odt` returns
`UNSUPPORTED_FOR_ODF`; `get_comments` returns `replies: []` for every ODF comment. Threading is a
later phase.

## Parity & dispatch
Handlers mirror the DOCX `add_comment`/`get_comments` param + response shapes (`author` stays
required; comment body param is `text`; `get_comments` returns the `McpComment` shape). Inserting an
annotation does NOT shift positional paragraph IDs (annotations are inline children and are skipped
by `collectBlocks`), so no `invalidates_paragraph_ids_after` field is emitted. `isOdfRequest` keys on
the `.odt` extension and is checked after the gdocs branch; the `resolveSessionForTool` chokepoint
still returns `UNSUPPORTED_FOR_ODF` for any tool not in the ODF set before a DocxSession cast.
44 changes: 44 additions & 0 deletions openspec/changes/add-odf-comments/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Change: Extend the ODF (.odt) lane with `add_comment` and `get_comments`

## Why
Phases 1/2a (`add-odf-core`, `add-odf-grep-insert`) wired a provider-aware ODF lane so an agent
can `open → read_file → grep → replace_text → insert_paragraph → save` a real `.odt`. The next
capability an editing/reviewing agent reaches for is comments. ODF comments are a real
simplification versus DOCX: they live **inline in `content.xml`** as `office:annotation`
elements — there is no separate `comments.xml` part, no rels, and no `commentsExtended.xml`. This
makes comments the lighter of the two remaining Phase-2 tools (the heavier `compare_documents`
tracked-changes atomizer stays deferred to a later phase).

## What Changes
- `@usejunior/odf-core`:
- Extract the private `buildSegments` / `Segment` from `document.ts` into a shared
`shared/odf/text_segments.ts` so comments reuse the visible-text↔node mapping without an
import cycle. The extracted walk and `collectBlocks` SHALL skip `office:annotation` /
`office:annotation-end` subtrees so an annotation's body `text:p` never leaks into the anchor
paragraph's visible text nor registers as a phantom block.
- New `comments.ts`: read all `office:annotation`s into a structured list; insert an annotation
either over a whole paragraph (structural: first inline child … last inline child) or over a
`anchor_text` substring (single-`#text`-node split). Cross-node ranged matches return
`MATCH_SPANS_MULTIPLE_NODES`.
- `OdfDocument.addComment(...)` / `OdfDocument.getComments()` delegating to `comments.ts`;
export `OdfComment`.
- `@usejunior/docx-mcp`: add `add_comment` and `get_comments` to the ODF supported-tool set; add
`tools/odf/{add_comment,get_comments}.ts` handlers mirroring the DOCX param/response shapes; add
`isOdfRequest` dispatch branches; update `tool_catalog.ts` provider text + regenerated docs.
- Comment **replies** (`parent_comment_id`) on a `.odt` return `UNSUPPORTED_FOR_ODF` — ODF has no
first-class reply graph and a thread convention is deliberately deferred.

## Impact
- Affected specs: `mcp-server` (ADDED: ODF comment support, OPCM-01..05); `odf-core` (ADDED: ODF
annotations read/write, OANN-01..05).
- Affected code: `packages/odf-core/src/{document,index}.ts`,
`packages/odf-core/src/shared/odf/{namespaces,text_segments}.ts`,
`packages/odf-core/src/comments.ts`; `packages/docx-mcp/src/tools/{provider_guard,session_resolution}.ts`,
`packages/docx-mcp/src/tools/odf/{add_comment,get_comments}.ts`,
`packages/docx-mcp/src/server.ts`, `tool_catalog.ts` + regenerated tool docs. DOCX and Google
Docs paths unchanged.
- Amends the sibling active `add-odf-grep-insert` spec wording (drops `add_comment` from its
"still-unsupported" example, since it becomes supported here).
- `odf-core` stays `private: true` (optional-lazy provider, not a published dependency of docx-mcp).
- Out of scope: `compare_documents`, comment replies/threads, `delete_comment` for ODF, durable
injected `xml:id` anchors, `.ods`/`.odp`.
41 changes: 41 additions & 0 deletions openspec/changes/add-odf-comments/specs/mcp-server/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## ADDED Requirements

### Requirement: ODF Comment Support (`add_comment`, `get_comments`)

The MCP server SHALL service `add_comment` and `get_comments` for ODF sessions via the
provider-aware `.odt` lane, in addition to the existing ODF tools. Both SHALL route to the ODF
handler when the `file_path` ends in `.odt` (or resolves to an existing `OdfSession`); the DOCX and
Google Docs paths SHALL remain unchanged, and every still-unsupported tool SHALL continue to return
`UNSUPPORTED_FOR_ODF`.

ODF `add_comment` SHALL insert an `office:annotation` carrying `dc:creator` (the required `author`),
`dc:date`, and a `text:p` comment body. When `anchor_text` is omitted the annotation SHALL bracket
the whole anchor paragraph; when `anchor_text` is provided the annotation SHALL bracket the matched
substring, returning `TEXT_NOT_FOUND` / `MULTIPLE_MATCHES` when the substring is absent or ambiguous
and `MATCH_SPANS_MULTIPLE_NODES` when the match crosses inline node boundaries. Inserting an
annotation SHALL NOT alter the document's positional paragraph IDs.

ODF `get_comments` SHALL return every annotation in document order in the same shape as the DOCX
tool (`id`, `author`, `date`, `initials`, `text`, `anchored_paragraph_id`, `replies`), with
`replies` always empty for ODF. Comment **replies** are not supported for ODF: an `add_comment`
invocation carrying `parent_comment_id` against a `.odt` SHALL return `UNSUPPORTED_FOR_ODF`.

#### Scenario: [OPCM-01] `add_comment` annotates a whole ODF paragraph
- **WHEN** `add_comment` is invoked with a `.odt` `file_path`, a `target_paragraph_id`, an `author`, and `text` (no `anchor_text`)
- **THEN** the ODF handler inserts an `office:annotation` bracketing that paragraph and returns `mode: 'root'` with the anchor paragraph id, and the DOCX/gdocs handlers are not invoked

#### Scenario: [OPCM-02] `add_comment` annotates a substring via `anchor_text`
- **WHEN** `add_comment` is invoked with a `.odt` `file_path`, `target_paragraph_id`, `anchor_text`, `author`, and `text`
- **THEN** the annotation brackets the matched substring (`office:annotation` … `office:annotation-end`) and the response echoes the `anchor_text`

#### Scenario: [OPCM-03] `get_comments` returns ODF annotations
- **WHEN** `get_comments` is invoked with a `.odt` `file_path` after a comment has been added
- **THEN** the response lists the comment with its author, date, body text, and anchored paragraph id, and `replies` is empty

#### Scenario: [OPCM-04] Replies are unsupported for ODF
- **WHEN** `add_comment` is invoked with a `.odt` `file_path` and a `parent_comment_id`
- **THEN** an `UNSUPPORTED_FOR_ODF` error is returned and no DOCX logic runs

#### Scenario: [OPCM-05] Missing or ambiguous `anchor_text` is rejected
- **WHEN** `add_comment` is invoked with an `anchor_text` that is absent from (or matches multiple times in) the target paragraph
- **THEN** a `TEXT_NOT_FOUND` or `MULTIPLE_MATCHES` error is returned and no annotation is inserted
43 changes: 43 additions & 0 deletions openspec/changes/add-odf-comments/specs/odf-core/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
## ADDED Requirements

### Requirement: ODF Annotations (read/write)

`OdfDocument` SHALL provide `addComment(params)` and `getComments()` backed by ODF
`office:annotation` markup inline in `content.xml`.

`addComment` SHALL insert an `office:annotation` carrying `dc:creator`, `dc:date`, and a `text:p`
body, with an `office:name` allocated so it collides with no existing annotation name. When a
visible `start`/`end` range is omitted, the annotation SHALL bracket the whole anchor paragraph by
structural insertion (annotation as the first inline child, `office:annotation-end` after the last
inline child) independent of text segmentation; an empty paragraph SHALL receive a single point
annotation. When a range is given, the annotation SHALL bracket exactly that range by splitting the
host `#text` node, and a range that crosses inline node boundaries SHALL return a
`MATCH_SPANS_MULTIPLE_NODES` result rather than throwing.

`getComments` SHALL return every annotation in document order with its id (parsed from
`office:name`), author (`dc:creator`), date (`dc:date` or null), body text, and the positional id of
the anchor paragraph.

An annotation's body SHALL NOT appear in `getParagraphs()` visible text and SHALL NOT register as a
paragraph block: both `collectBlocks` and the visible-text walk SHALL skip `office:annotation` /
`office:annotation-end` subtrees.

#### Scenario: [OANN-01] addComment brackets a range
- **WHEN** `addComment` is called with a visible `start`/`end` range inside a single text node
- **THEN** an `office:annotation` is inserted at `start` and an `office:annotation-end` with the same `office:name` at `end`

#### Scenario: [OANN-02] getComments reads annotation metadata
- **WHEN** `getComments` is called on a document containing an annotation
- **THEN** the returned record carries the `dc:creator` author, the `dc:date` date, the body text, and the anchor paragraph's positional id

#### Scenario: [OANN-03] Whole-paragraph anchoring survives spans and spaces
- **WHEN** `addComment` is called with no range on a paragraph containing `text:span` / `text:s` content
- **THEN** the annotation brackets the entire paragraph via structural insertion without a `MATCH_SPANS_MULTIPLE_NODES` failure

#### Scenario: [OANN-04] Cross-node ranged match is rejected
- **WHEN** `addComment` is called with a range that crosses inline node boundaries
- **THEN** an `{ ok: false, code: 'MATCH_SPANS_MULTIPLE_NODES' }` result is returned (no throw)

#### Scenario: [OANN-05] Annotation body does not leak into the paragraph stream
- **WHEN** a paragraph carries an `office:annotation` whose body is a `text:p`
- **THEN** `getParagraphs()` returns only the host paragraph's visible text (without the comment body) and creates no phantom block for the annotation body
22 changes: 22 additions & 0 deletions openspec/changes/add-odf-comments/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Tasks

## odf-core
- [x] Add `DC` namespace to `ODF_NS` (`http://purl.org/dc/elements/1.1/`)
- [x] Extract `Segment` + `buildSegments` into `shared/odf/text_segments.ts`; skip `office:annotation` / `office:annotation-end` subtrees (B1)
- [x] `collectBlocks` in `document.ts` skips annotation subtrees (no phantom blocks) (B1)
- [x] New `comments.ts`: `addWholeBlockAnnotation` (structural) + `addRangedAnnotation` (single-text-node split, B2); `readAnnotations`; `office:name` id allocation scanning all existing names
- [x] `OdfDocument.addComment` / `getComments` delegating to `comments.ts`; export `OdfComment`
- [x] odf-core `comments.test.ts`: whole-block (incl. spans), ranged, point/empty, id allocation, round-trip read, MATCH_SPANS_MULTIPLE_NODES, B1 no-leak regression

## docx-mcp
- [x] `tools/odf/add_comment.ts` (root + ranged; replies → UNSUPPORTED_FOR_ODF; `author` required; `text` param)
- [x] `tools/odf/get_comments.ts` (maps to McpComment shape; `replies: []`)
- [x] Add `add_comment`, `get_comments` to `ODF_SUPPORTED_TOOLS`; update guard hint + both `session_resolution.ts` hints
- [x] Register handlers in `loadOdfHandlers`; add `isOdfRequest` dispatch branches
- [x] Update `tool_catalog.ts` provider text + regenerate `tool-reference.generated.md`
- [x] Switch `odf_grep_insert.test.ts` OPLR-08 + "two unsupported tools" cases to `compare_documents`; amend `add-odf-grep-insert` spec wording (drop `add_comment` from the unsupported example)

## Tests & verification
- [x] docx-mcp `odf_comments.test.ts`: OPCM-01..05 scenarios + branch tests; `TEST_FEATURE='add-odf-comments'`
- [x] Full CI gate locally + document-shaped `.odt` smoke (add_comment + get_comments on real NVCA .odt, reopen in LibreOffice)
- [x] Coverage ratchet not regressed
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,5 @@ re-reads before its next edit.
- **THEN** a new paragraph is inserted before/after the anchor, the response returns the new positional paragraph ID(s) and ID-invalidation fields, and re-reading reflects the inserted text

#### Scenario: [OPLR-08] Still-unsupported tools remain guarded
- **WHEN** a tool outside the ODF supported set (e.g. `compare_documents`, `add_comment`) is invoked against a `.odt` path or ODF session
- **WHEN** a tool outside the ODF supported set (e.g. `compare_documents`) is invoked against a `.odt` path or ODF session
- **THEN** an `UNSUPPORTED_FOR_ODF` error is returned and no DOCX logic runs
4 changes: 2 additions & 2 deletions packages/docx-mcp/docs/tool-reference.generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ Close an open file session, or close all sessions with explicit confirmation. Su

## `add_comment`

Add a comment or threaded reply to a document. Provide target_paragraph_id + anchor_text for root comments, or parent_comment_id for replies.
Add a comment or threaded reply to a document. Provide target_paragraph_id + anchor_text for root comments, or parent_comment_id for replies. Supports DOCX and ODT (ODT backs comments with office:annotation; threaded replies are DOCX-only).

- readOnly: `false`
- destructive: `true`
Expand All @@ -237,7 +237,7 @@ Add a comment or threaded reply to a document. Provide target_paragraph_id + anc

## `get_comments`

Get all comments from the document with IDs, authors, dates, text, and anchored paragraph IDs. Includes threaded replies. Read-only.
Get all comments from the document with IDs, authors, dates, text, and anchored paragraph IDs. Includes threaded replies (DOCX). Supports DOCX and ODT. Read-only.

- readOnly: `true`
- destructive: `false`
Expand Down
8 changes: 7 additions & 1 deletion packages/docx-mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,13 @@ async function loadGDocsHandlers(): Promise<typeof gdocsHandlers> {

async function loadOdfHandlers(): Promise<typeof odfHandlers> {
if (odfHandlers) return odfHandlers;
const [readFile, replaceText, grep, insertParagraph, save, getFileStatus, closeFile] = await Promise.all([
const [readFile, replaceText, grep, insertParagraph, addComment, getComments, save, getFileStatus, closeFile] = await Promise.all([
import('./tools/odf/read_file.js'),
import('./tools/odf/replace_text.js'),
import('./tools/odf/grep.js'),
import('./tools/odf/insert_paragraph.js'),
import('./tools/odf/add_comment.js'),
import('./tools/odf/get_comments.js'),
import('./tools/odf/save.js'),
import('./tools/odf/get_file_status.js'),
import('./tools/odf/close_file.js'),
Expand All @@ -103,6 +105,8 @@ async function loadOdfHandlers(): Promise<typeof odfHandlers> {
replace_text: replaceText.odfReplaceText,
grep: grep.odfGrep,
insert_paragraph: insertParagraph.odfInsertParagraph,
add_comment: addComment.odfAddComment,
get_comments: getComments.odfGetComments,
save: save.odfSave,
get_file_status: getFileStatus.odfGetFileStatus,
close_file: closeFile.odfCloseFile,
Expand Down Expand Up @@ -190,8 +194,10 @@ export async function dispatchToolCall(
if (isOdfRequest(args)) return await dispatchOdf(sessions, args, 'close_file');
return await closeFile(sessions, args as Parameters<typeof closeFile>[1]);
case 'add_comment':
if (isOdfRequest(args)) return await dispatchOdf(sessions, args, 'add_comment');
return await addComment(sessions, args as Parameters<typeof addComment>[1]);
case 'get_comments':
if (isOdfRequest(args)) return await dispatchOdf(sessions, args, 'get_comments');
return await getComments(sessions, args as Parameters<typeof getComments>[1]);
case 'delete_comment':
return await deleteComment(sessions, args as Parameters<typeof deleteComment>[1]);
Expand Down
4 changes: 2 additions & 2 deletions packages/docx-mcp/src/tool_catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ export const SAFE_DOCX_TOOL_CATALOG = [
{
name: 'add_comment',
description:
'Add a comment or threaded reply to a document. Provide target_paragraph_id + anchor_text for root comments, or parent_comment_id for replies.',
'Add a comment or threaded reply to a document. Provide target_paragraph_id + anchor_text for root comments, or parent_comment_id for replies. Supports DOCX and ODT (ODT backs comments with office:annotation; threaded replies are DOCX-only).',
input: z.object({
...FILE_FIELD,
target_paragraph_id: z.string().optional().describe('Paragraph ID to anchor the comment to (for root comments).'),
Expand All @@ -293,7 +293,7 @@ export const SAFE_DOCX_TOOL_CATALOG = [
{
name: 'get_comments',
description:
'Get all comments from the document with IDs, authors, dates, text, and anchored paragraph IDs. Includes threaded replies. Read-only.',
'Get all comments from the document with IDs, authors, dates, text, and anchored paragraph IDs. Includes threaded replies (DOCX). Supports DOCX and ODT. Read-only.',
input: z.object({
...FILE_FIELD,
}),
Expand Down
Loading
Loading