Skip to content
Open
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
4 changes: 2 additions & 2 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ let package = Package(
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.0"),
.package(url: "https://github.com/PsychQuant/ooxml-swift.git", from: "0.21.0"),
.package(url: "https://github.com/PsychQuant/ooxml-swift.git", from: "0.22.0"),
.package(url: "https://github.com/PsychQuant/markdown-swift.git", from: "0.2.0"),
.package(url: "https://github.com/PsychQuant/word-to-md-swift.git", from: "0.6.1"),
.package(url: "https://github.com/PsychQuant/latex-math-swift.git", from: "0.1.0"),
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ A Swift-native MCP (Model Context Protocol) server for Microsoft Word document (
- **Theme + Header/Footer/Watermark CRUD (v3.3.0+)**: `word/theme/theme1.xml` editing, header/footer enumeration + deletion, watermark VML detection. NTPU thesis Chinese font fix path: `update_theme_fonts({ minor: { ea: "DFKai-SB" } })`.
- **Comment Threads + People + Notes Update + Web Settings (v3.4.0+)**: 13 tools for collaborative comment metadata, `people.xml` author records (dual identity: GUID + legacy author), in-place endnote/footnote editing (preserves IDs), `webSettings.xml` configuration.
- **Full LaTeX Subset for `insert_equation` (v3.2.0+)**: Delegated to [`latex-math-swift`](https://github.com/PsychQuant/latex-math-swift). Supports `\frac`, `\sqrt`, `\hat`/`\bar`/`\tilde` accents, `\left/\right` delimiters, `\sum`/`\int`/`\prod` n-ary with bounds, function names, limits, `\text{}`, all Greek letters (including `\varepsilon` variants), and common operators.
- **Text-Anchor Insertion**: Insert captions / images relative to matched text (`after_text` / `before_text`), no pre-search call required
- **Text-Anchor Insertion**: Insert captions / images relative to matched text (`after_text` / `before_text`), no pre-search call required. Optional `match_options.math_script_insensitive` lets anchors typed as `H₀` match flattened math text such as `H0` without changing exact matching by default.
- **Batch Operations**: `replace_text_batch` / `search_text_batch` collapse N round-trips into one
- **Session State API**: SHA256 + mtime-based disk drift detection, `revert_to_disk` / `reload_from_disk` / `check_disk_drift`
- **Structural Readback**: `list_captions` / `list_equations` / `update_all_fields` (F9-equivalent) for manuscript review workflows
Expand Down Expand Up @@ -278,7 +278,7 @@ for the per-tool inventory before reusing an index across tools.
| `get_text` | Get plain text content |
| `get_paragraphs` | Get all paragraphs with formatting |
| `estimate_paragraph_for_page` | **v3.18.0+** — estimate a Word UI page number to a `get_paragraphs` candidate range (heuristic JSON with confidence + warning) |
| `insert_paragraph` | Insert a new paragraph |
| `insert_paragraph` | Insert a new paragraph; text anchors support `match_options.math_script_insensitive` for `H₀` / `H0` matching |
| `update_paragraph` | Update paragraph content |
| `delete_paragraph` | Delete a paragraph |
| `replace_text` | Cross-run-safe find & replace with `scope` (body\|all) + `regex` + `$1..$N` backreferences |
Expand Down Expand Up @@ -429,7 +429,7 @@ Even/odd + section linkage (4, **v3.11.0**):
| Tool | Description |
|------|-------------|
| `insert_image` | Insert inline image (PNG, JPEG) |
| `insert_image_from_path` | **v2.0.0+** — width/height optional (auto-aspect via `ImageDimensions.detect`), supports `into_table_cell` + `after_text` / `before_text` anchors |
| `insert_image_from_path` | **v2.0.0+** — width/height optional (auto-aspect via `ImageDimensions.detect`), supports `into_table_cell` + `after_text` / `before_text` anchors; `match_options.math_script_insensitive` can match math-script variants such as `H₀` / `H0` |
| `insert_floating_image` | Insert floating image with text wrap |
| `update_image` | Update image properties |
| `delete_image` | Delete image |
Expand All @@ -440,7 +440,7 @@ Even/odd + section linkage (4, **v3.11.0**):

| Tool | Description |
|------|-------------|
| `insert_caption` | **v2.0.0+** — real OOXML SEQ field (not literal text). Accepts English + Chinese labels (`Figure`/`Table`/`Equation`/`圖`/`表`/`公式`), 5-way anchor (`paragraph_index` / `after_image_id` / `after_table_index` / `after_text` / `before_text`), optional `STYLEREF` chapter number prefix |
| `insert_caption` | **v2.0.0+** — real OOXML SEQ field (not literal text). Accepts English + Chinese labels (`Figure`/`Table`/`Equation`/`圖`/`表`/`公式`), 5-way anchor (`paragraph_index` / `after_image_id` / `after_table_index` / `after_text` / `before_text`), optional `STYLEREF` chapter number prefix; `match_options.math_script_insensitive` can match math-script variants such as `H₀` / `H0` |
| `list_captions` | **v3.1.0** — enumerate caption paragraphs with label / sequence_number / caption_text / paragraph_index |
| `get_caption` | **v3.1.0** — detailed single caption info including optional `chapter_number` from STYLEREF |
| `update_caption` | **v3.1.0** — modify caption text or label without breaking the SEQ field structure |
Expand All @@ -450,7 +450,7 @@ Even/odd + section linkage (4, **v3.11.0**):

| Tool | Description |
|------|-------------|
| `insert_equation` | **v2.0.0+** — emits structurally correct OMML via `MathComponent` AST (9 types). Primary: `components:` tree; fallback: `latex:` subset (`\frac`, `\sqrt`, `x^{y}`, Greek, ∑/∫/∏) |
| `insert_equation` | **v2.0.0+** — emits structurally correct OMML via `MathComponent` AST (9 types). Primary: `components:` tree; fallback: `latex:` subset (`\frac`, `\sqrt`, `x^{y}`, Greek, ∑/∫/∏). Display-mode text anchors support `match_options.math_script_insensitive` for `H₀` / `H0` matching |
| `list_equations` | **v3.1.0** — enumerate `<m:oMath>` runs with display_mode flag |
| `get_equation` | **v3.1.0** — detailed single equation info with component summary |
| `update_equation` | **v3.1.0** — replace target equation's components tree |
Expand Down
90 changes: 82 additions & 8 deletions Sources/CheWordMCP/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,28 @@ actor WordMCPServer {
return detectPresentAnchors(args, anchors: anchors)
}

/// Parse text-anchor matching options shared by insertion tools.
/// Omitted `match_options` and omitted `math_script_insensitive` keep
/// exact matching. Wrong types are rejected instead of silently enabling or
/// disabling a caller-provided option.
static func parseAnchorLookupOptions(_ args: [String: Value], tool: String) -> (AnchorLookupOptions, String?) {
guard let rawOptions = args["match_options"] else {
return (.exact, nil)
}
guard let optionsDict = rawOptions.objectValue else {
return (.exact, "Error: \(tool): match_options must be an object")
}

var options = AnchorLookupOptions.exact
if let rawMathScript = optionsDict["math_script_insensitive"] {
guard let enabled = rawMathScript.boolValue else {
return (.exact, "Error: \(tool): match_options.math_script_insensitive must be boolean")
}
options.mathScriptInsensitive = enabled
}
return (options, nil)
}

/// One emitted log event. `event` is the dotted name (e.g. `storeDocument.entry`),
/// `keyValues` is the structured payload.
struct DebugLogEvent: Sendable, Equatable {
Expand Down Expand Up @@ -471,6 +493,10 @@ actor WordMCPServer {
}
}

func toolInputSchemaForTesting(name: String) -> Value? {
allTools.first { $0.name == name }?.inputSchema
}

func isDocumentDirtyForTesting(_ docId: String) -> Bool {
isDirty(docId: docId)
}
Expand Down Expand Up @@ -820,6 +846,16 @@ actor WordMCPServer {
"text_instance": .object([
"type": .string("integer"),
"description": .string("after_text / before_text 的第 N 次匹配(1-based,預設 1)")
]),
"match_options": .object([
"type": .string("object"),
"description": .string("after_text / before_text 的比對選項。預設 exact matching;設定 { math_script_insensitive: true } 時,H₀/H0、xᵢ/xi 等數學上下標變體會雙向匹配。"),
"properties": .object([
"math_script_insensitive": .object([
"type": .string("boolean"),
"description": .string("是否啟用數學上下標變體匹配(預設 false)。true 時會把 H₀/H0、xᵢ/xi 等視為等價 anchor。")
])
])
])
]),
"required": .array([.string("doc_id"), .string("text")])
Expand Down Expand Up @@ -1761,6 +1797,16 @@ actor WordMCPServer {
"type": .string("integer"),
"description": .string("after_text / before_text 的第 N 次匹配(1-based,預設 1)")
]),
"match_options": .object([
"type": .string("object"),
"description": .string("after_text / before_text 的比對選項。預設 exact matching;設定 { math_script_insensitive: true } 時,H₀/H0、xᵢ/xi 等數學上下標變體會雙向匹配。"),
"properties": .object([
"math_script_insensitive": .object([
"type": .string("boolean"),
"description": .string("是否啟用數學上下標變體匹配(預設 false)。true 時會把 H₀/H0、xᵢ/xi 等視為等價 anchor。")
])
])
]),
"name": .object([
"type": .string("string"),
"description": .string("圖片名稱(可選,用於替代文字)")
Expand Down Expand Up @@ -2593,6 +2639,16 @@ actor WordMCPServer {
"text_instance": .object([
"type": .string("integer"),
"description": .string("after_text / before_text 的第 N 次匹配(1-based,預設 1)")
]),
"match_options": .object([
"type": .string("object"),
"description": .string("after_text / before_text 的比對選項。預設 exact matching;設定 { math_script_insensitive: true } 時,H₀/H0、xᵢ/xi 等數學上下標變體會雙向匹配。"),
"properties": .object([
"math_script_insensitive": .object([
"type": .string("boolean"),
"description": .string("是否啟用數學上下標變體匹配(預設 false)。true 時會把 H₀/H0、xᵢ/xi 等視為等價 anchor。")
])
])
])
]),
"required": .array([.string("doc_id")])
Expand Down Expand Up @@ -5041,6 +5097,16 @@ actor WordMCPServer {
"type": .string("integer"),
"description": .string("after_text / before_text 的第 N 次匹配(1-based,預設 1)")
]),
"match_options": .object([
"type": .string("object"),
"description": .string("after_text / before_text 的比對選項。預設 exact matching;設定 { math_script_insensitive: true } 時,H₀/H0、xᵢ/xi 等數學上下標變體會雙向匹配。"),
"properties": .object([
"math_script_insensitive": .object([
"type": .string("boolean"),
"description": .string("是否啟用數學上下標變體匹配(預設 false)。true 時會把 H₀/H0、xᵢ/xi 等視為等價 anchor。")
])
])
]),
"position": .object([
"type": .string("string"),
"description": .string("搭配 paragraph_index 使用:above(上方)、below(下方,預設)")
Expand Down Expand Up @@ -6902,6 +6968,8 @@ actor WordMCPServer {
if let explicit = args["text_instance"]?.intValue, explicit < 1 {
return "Error: insert_paragraph: text_instance must be ≥ 1, got \(explicit)."
}
let (anchorLookupOptions, matchOptionsError) = WordMCPServer.parseAnchorLookupOptions(args, tool: "insert_paragraph")
if let matchOptionsError { return matchOptionsError }
let resultMessage: String

if let cellDict = args["into_table_cell"]?.objectValue {
Expand Down Expand Up @@ -6929,14 +6997,14 @@ actor WordMCPServer {
}
} else if let afterText = args["after_text"]?.stringValue {
do {
try doc.insertParagraph(para, at: .afterText(afterText, instance: textInstance))
try doc.insertParagraph(para, at: .afterText(afterText, instance: textInstance, options: anchorLookupOptions))
resultMessage = "Inserted paragraph after text '\(afterText)' (instance \(textInstance))"
} catch let InsertLocationError.textNotFound(searchText, instance) {
return "Error: insert_paragraph: text '\(searchText)' not found (instance \(instance))"
}
} else if let beforeText = args["before_text"]?.stringValue {
do {
try doc.insertParagraph(para, at: .beforeText(beforeText, instance: textInstance))
try doc.insertParagraph(para, at: .beforeText(beforeText, instance: textInstance, options: anchorLookupOptions))
resultMessage = "Inserted paragraph before text '\(beforeText)' (instance \(textInstance))"
} catch let InsertLocationError.textNotFound(searchText, instance) {
return "Error: insert_paragraph: text '\(searchText)' not found (instance \(instance))"
Expand Down Expand Up @@ -8179,6 +8247,8 @@ actor WordMCPServer {
if let explicit = args["text_instance"]?.intValue, explicit < 1 {
return "Error: insert_image_from_path: text_instance must be ≥ 1, got \(explicit)."
}
let (anchorLookupOptions, matchOptionsError) = WordMCPServer.parseAnchorLookupOptions(args, tool: "insert_image_from_path")
if let matchOptionsError { return matchOptionsError }
if let cellDict = args["into_table_cell"]?.objectValue {
// F5 (v3.15.1): malformed partial dict returns structured error instead of silent fallthrough.
guard let tableIdx = cellDict["table_index"]?.intValue,
Expand Down Expand Up @@ -8220,7 +8290,7 @@ actor WordMCPServer {
path: path,
widthPx: width,
heightPx: height,
at: .afterText(afterText, instance: textInstance),
at: .afterText(afterText, instance: textInstance, options: anchorLookupOptions),
name: name,
description: description
)
Expand All @@ -8233,7 +8303,7 @@ actor WordMCPServer {
path: path,
widthPx: width,
heightPx: height,
at: .beforeText(beforeText, instance: textInstance),
at: .beforeText(beforeText, instance: textInstance, options: anchorLookupOptions),
name: name,
description: description
)
Expand Down Expand Up @@ -9192,6 +9262,8 @@ actor WordMCPServer {
if let explicit = args["text_instance"]?.intValue, explicit < 1 {
return "Error: insert_equation: text_instance must be ≥ 1, got \(explicit)."
}
let (anchorLookupOptions, matchOptionsError) = WordMCPServer.parseAnchorLookupOptions(args, tool: "insert_equation")
if let matchOptionsError { return matchOptionsError }

// Anchors only meaningful in display mode (block-level new paragraph).
// Inline mode appends an OMML run into an existing paragraph; anchor
Expand Down Expand Up @@ -9240,10 +9312,10 @@ actor WordMCPServer {
location = .afterImageId(afterImageId)
anchorInfo = "after image '\(afterImageId)'"
} else if displayMode, let afterText = afterText {
location = .afterText(afterText, instance: textInstance)
location = .afterText(afterText, instance: textInstance, options: anchorLookupOptions)
anchorInfo = "after text '\(afterText)' (instance \(textInstance))"
} else if displayMode, let beforeText = beforeText {
location = .beforeText(beforeText, instance: textInstance)
location = .beforeText(beforeText, instance: textInstance, options: anchorLookupOptions)
anchorInfo = "before text '\(beforeText)' (instance \(textInstance))"
} else {
// Display mode with no explicit anchor appends at end by passing
Expand Down Expand Up @@ -12979,6 +13051,8 @@ actor WordMCPServer {
if let explicit = args["text_instance"]?.intValue, explicit < 1 {
return "Error: insert_caption: text_instance must be ≥ 1, got \(explicit)."
}
let (anchorLookupOptions, matchOptionsError) = WordMCPServer.parseAnchorLookupOptions(args, tool: "insert_caption")
if let matchOptionsError { return matchOptionsError }

// Build caption paragraph: label text + optional chapter STYLEREF + SEQ field + optional caption text
var runs: [Run] = [Run(text: "\(label) ")]
Expand Down Expand Up @@ -13016,9 +13090,9 @@ actor WordMCPServer {
} else if let tableIdx = afterTableIndexArg {
location = .afterTableIndex(tableIdx)
} else if let afterText = afterTextArg {
location = .afterText(afterText, instance: textInstance)
location = .afterText(afterText, instance: textInstance, options: anchorLookupOptions)
} else {
location = .beforeText(beforeTextArg!, instance: textInstance)
location = .beforeText(beforeTextArg!, instance: textInstance, options: anchorLookupOptions)
}

do {
Expand Down
Loading