mcp-word-bridge 4.0.4 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,7 +70,6 @@ Single process. The MCP client spawns the server, which starts both the HTTPS br
70
70
  | `word_set_document_properties` | Set metadata fields |
71
71
  | `word_save` | Save document to disk |
72
72
  | `word_clear` | Clear all document body content |
73
- | `word_create_document` | Create and open a new blank document in a new Word window |
74
73
  | `word_get_word_count` | Get word, character, and paragraph counts |
75
74
  | `word_get_styles` | List available styles |
76
75
  | `word_get_coauthors` | Get co-authoring status and active authors |
@@ -290,6 +289,19 @@ src/
290
289
  └── equations.ts # LaTeX→OMML conversion pipeline
291
290
  ```
292
291
 
292
+ ## Known Limitations
293
+
294
+ Platform constraints in the Word JavaScript API that cannot be resolved in this project:
295
+
296
+ | Area | Limitation | Upstream Issue |
297
+ |------|------------|----------------|
298
+ | Tracked Changes | `word_get_tracked_changes` returns empty `text` for deletions. The `Word.TrackedChange.text` property returns `""` when `type` is `"Deleted"`. Only additions and formatting changes include text content. | [office-js#5188](https://github.com/OfficeDev/office-js/issues/5188) |
299
+ | Tracked Changes | `getTrackedChanges()` throws if the document contains "moved" tracked changes (drag-and-drop reorders). These produce paired "moved from"/"moved to" entries that the API cannot handle. | [office-js#5535](https://github.com/OfficeDev/office-js/issues/5535) |
300
+ | Highlight Colors | `highlightColor` only supports 17 named colors (Yellow, Green, Cyan, Magenta, Blue, Red, DarkBlue, DarkCyan, DarkGreen, DarkMagenta, DarkRed, DarkYellow, Gray25, Gray50, Black, White, NoHighlight). The Word API documentation claims `#RRGGBB` is accepted, but Desktop silently maps hex values to the nearest named color. This tool rejects hex to prevent silent mismatch — use `color` for arbitrary RGB. | [office-js#4638](https://github.com/OfficeDev/office-js/issues/4638) |
301
+ | Paragraph Style | TOC field entries and certain table paragraphs return `style: ""` (empty string) instead of the actual style name. Paragraphs are flagged with `isTocEntry: true` when detected as TOC entries. Use this flag rather than the style field for identification. | [office-js#5934](https://github.com/OfficeDev/office-js/issues/5934) |
302
+ | Page Info | `word_get_page_info` requires WordApiDesktop 1.2+ (Word for Windows/Mac desktop). Not available on Word for the web. | |
303
+ | Undo | The Word JavaScript API does not expose a programmatic `undo()` method. Changes made by the add-in appear in Word's undo stack (Ctrl+Z works for the user), but there is no way to trigger undo from code. | |
304
+
293
305
  ## License
294
306
 
295
307
  MIT
package/dist/server.js CHANGED
@@ -18784,16 +18784,6 @@ var clear = forwardTool(
18784
18784
  { properties: {} },
18785
18785
  "clearDocument"
18786
18786
  );
18787
- var createDocument = forwardTool(
18788
- "word_create_document",
18789
- "[Document] Create and open a new blank document in a new Word window. Optionally provide base64-encoded .docx as template.",
18790
- {
18791
- properties: {
18792
- base64: { type: "string", description: "Optional base64-encoded .docx file to use as template" }
18793
- }
18794
- },
18795
- "createNewDocument"
18796
- );
18797
18787
  var getWordCount = forwardTool(
18798
18788
  "word_get_word_count",
18799
18789
  "[Document] Get word, character, and paragraph counts.",
@@ -18857,7 +18847,6 @@ var documentTools = [
18857
18847
  setDocumentProperties,
18858
18848
  save,
18859
18849
  clear,
18860
- createDocument,
18861
18850
  getWordCount,
18862
18851
  getStyles,
18863
18852
  getCoauthors,
@@ -18874,15 +18863,6 @@ var ToolError = class extends Error {
18874
18863
  };
18875
18864
 
18876
18865
  // src/server/validation.ts
18877
- var WORD_SPECIAL_CODES = /\^(p|w|t|l|m|b|n|s|d|a|e|f|g|v|~|\^|\-|13|11|14|12|07|09|30|31|32|34|36|37|38|39|40|41|42|43|44|45|46|47|92|94|127|129|130|131|132|133|134|135|136|137|138|139|140|141|142|143|144|145|146|147|148|149|150|151|152|153|154|155|156|157|158|159|160|161|162|163|164|165|166|167|168|169|170|171|172|173|174|175|176|177|178|179|180|181|182|183|184|185|186|187|188|189|190|191|192|193|194|195|196|197|198|199|200|201|202|203|204|205|206|207|208|209|210|211|212|213|214|215|216|217|218|219|220|221|222|223|224|225|226|227|228|229|230|231|232|233|234|235|236|237|238|239|240|241|242|243|244|245|246|247|248|249|250|251|252|253|254|255)/;
18878
- function checkNoSpecialCodes(text2, paramName) {
18879
- const match = text2.match(WORD_SPECIAL_CODES);
18880
- if (match) {
18881
- throw new ToolError(
18882
- `${paramName} contains Word special code "${match[0]}" which can corrupt document structure. Use literal text only. Common special codes: ^p (paragraph mark), ^t (tab), ^w (whitespace), ^13 (paragraph mark).`
18883
- );
18884
- }
18885
- }
18886
18866
  function checkNonEmpty(value, name) {
18887
18867
  if (!value || typeof value !== "string" || value.trim() === "") {
18888
18868
  throw new ToolError(`${name} must be a non-empty string.`);
@@ -18921,6 +18901,24 @@ function checkSpacingBounds(value, name) {
18921
18901
  );
18922
18902
  }
18923
18903
  }
18904
+ function checkIndentBounds(value, name) {
18905
+ if (name === "firstLineIndent") {
18906
+ if (value < -MAX_SPACING_POINTS || value > MAX_SPACING_POINTS) {
18907
+ throw new ToolError(
18908
+ `${name} value ${value} is out of range. Valid range: -${MAX_SPACING_POINTS} to ${MAX_SPACING_POINTS} points.`
18909
+ );
18910
+ }
18911
+ } else {
18912
+ if (value < 0) {
18913
+ throw new ToolError(`${name} must be non-negative (in points).`);
18914
+ }
18915
+ if (value > MAX_SPACING_POINTS) {
18916
+ throw new ToolError(
18917
+ `${name} value ${value} exceeds maximum (${MAX_SPACING_POINTS} points = 22 inches).`
18918
+ );
18919
+ }
18920
+ }
18921
+ }
18924
18922
  function checkPropertyKeyLength(key) {
18925
18923
  if (key.length > MAX_CUSTOM_PROPERTY_KEY_LENGTH) {
18926
18924
  throw new ToolError(
@@ -18983,7 +18981,7 @@ var insertParagraphAtIndex = forwardTool(
18983
18981
  );
18984
18982
  var deleteParagraph = forwardTool(
18985
18983
  "word_delete_paragraph",
18986
- "[Paragraphs] Delete a paragraph by its 0-based index.",
18984
+ "[Paragraphs] Delete a paragraph by its 0-based index. For table cells with multiple paragraphs, extra paragraphs can be removed (the last paragraph in a cell cannot be deleted).",
18987
18985
  {
18988
18986
  properties: {
18989
18987
  index: { type: "number", description: "Paragraph index (0-based)" }
@@ -19038,14 +19036,21 @@ var setParagraphSpacing = {
19038
19036
  const spacingFields = [
19039
19037
  ["lineSpacing", args.lineSpacing],
19040
19038
  ["spaceBefore", args.spaceBefore],
19041
- ["spaceAfter", args.spaceAfter],
19039
+ ["spaceAfter", args.spaceAfter]
19040
+ ];
19041
+ for (const [name, value] of spacingFields) {
19042
+ if (value !== void 0 && typeof value === "number") {
19043
+ checkSpacingBounds(value, name);
19044
+ }
19045
+ }
19046
+ const indentFields = [
19042
19047
  ["firstLineIndent", args.firstLineIndent],
19043
19048
  ["leftIndent", args.leftIndent],
19044
19049
  ["rightIndent", args.rightIndent]
19045
19050
  ];
19046
- for (const [name, value] of spacingFields) {
19051
+ for (const [name, value] of indentFields) {
19047
19052
  if (value !== void 0 && typeof value === "number") {
19048
- checkSpacingBounds(value, name);
19053
+ checkIndentBounds(value, name);
19049
19054
  }
19050
19055
  }
19051
19056
  const result = await bridge2.send("setParagraphSpacing", args);
@@ -19078,16 +19083,36 @@ var moveParagraph = {
19078
19083
  throw new ToolError(`toIndex (${toIndex}) is inside the source range [${fromIndex}, ${fromIndex + count - 1}]. Move to a position outside the range.`);
19079
19084
  }
19080
19085
  const paraCount = await bridge2.send("getParagraphs", {});
19081
- const total = paraCount.count;
19086
+ const total = paraCount.total;
19082
19087
  checkBounds(fromIndex, total, "fromIndex");
19083
19088
  if (fromIndex + count - 1 >= total) throw new ToolError(`fromIndex + count (${fromIndex + count}) exceeds paragraph count (${total}).`);
19084
19089
  checkBounds(toIndex, total, "toIndex");
19090
+ for (const para of paraCount.paragraphs) {
19091
+ if (para.index >= fromIndex && para.index < fromIndex + count && para.inTable) {
19092
+ throw new ToolError(`Paragraph ${para.index} is inside a table cell. Use table-specific tools to modify table content.`);
19093
+ }
19094
+ }
19095
+ const lastIdx = total - 1;
19096
+ if (fromIndex + count - 1 === lastIdx) {
19097
+ const lastPara = paraCount.paragraphs.find((p) => p.index === lastIdx);
19098
+ if (lastPara && lastPara.text === "") {
19099
+ throw new ToolError(
19100
+ `Cannot move the last paragraph (index ${lastIdx}) \u2014 Word requires at least one paragraph and will auto-create a replacement, resulting in duplication. Use word_copy_paragraph instead, or exclude the trailing paragraph from the range.`
19101
+ );
19102
+ }
19103
+ }
19104
+ const adjustedTo = fromIndex < toIndex ? toIndex - count : toIndex;
19105
+ if (toIndex === fromIndex + count && location === "After") {
19106
+ return jsonResult({ success: true, warning: "No move performed \u2014 destination is equivalent to source position.", moved: null });
19107
+ }
19108
+ if (adjustedTo === fromIndex && location === "After") {
19109
+ return jsonResult({ success: true, warning: "No move performed \u2014 destination is equivalent to source position.", moved: null });
19110
+ }
19085
19111
  const ooxmlResult = await bridge2.send("getParaOoxml", { index: fromIndex, count });
19086
19112
  const savedOoxml = ooxmlResult.ooxml;
19087
19113
  for (let i = count - 1; i >= 0; i--) {
19088
19114
  await bridge2.send("deleteParagraph", { index: fromIndex + i });
19089
19115
  }
19090
- const adjustedTo = fromIndex < toIndex ? toIndex - count : toIndex;
19091
19116
  try {
19092
19117
  await bridge2.send("insertOoxmlAtIndex", { ooxml: savedOoxml, index: adjustedTo, location });
19093
19118
  } catch (insertErr) {
@@ -19123,12 +19148,19 @@ var copyParagraph = {
19123
19148
  if (!Number.isInteger(count)) throw new ToolError("count must be an integer.");
19124
19149
  if (count < 1) throw new ToolError("count must be at least 1");
19125
19150
  const paraCount = await bridge2.send("getParagraphs", {});
19126
- const total = paraCount.count;
19151
+ const total = paraCount.total;
19127
19152
  checkBounds(fromIndex, total, "fromIndex");
19128
19153
  if (fromIndex + count - 1 >= total) throw new ToolError(`fromIndex + count (${fromIndex + count}) exceeds paragraph count (${total}).`);
19129
19154
  checkBounds(toIndex, total, "toIndex");
19155
+ for (const para of paraCount.paragraphs) {
19156
+ if (para.index >= fromIndex && para.index < fromIndex + count && para.inTable) {
19157
+ throw new ToolError(`Paragraph ${para.index} is inside a table cell. Use table-specific tools to modify table content.`);
19158
+ }
19159
+ }
19130
19160
  const ooxmlResult = await bridge2.send("getParaOoxml", { index: fromIndex, count });
19131
- await bridge2.send("insertOoxmlAtIndex", { ooxml: ooxmlResult.ooxml, index: toIndex, location });
19161
+ const effectiveToIndex = toIndex >= fromIndex && toIndex < fromIndex + count ? fromIndex + count - 1 : toIndex;
19162
+ const effectiveLocation = toIndex >= fromIndex && toIndex < fromIndex + count ? "After" : location;
19163
+ await bridge2.send("insertOoxmlAtIndex", { ooxml: ooxmlResult.ooxml, index: effectiveToIndex, location: effectiveLocation });
19132
19164
  return jsonResult({ success: true, copied: { from: fromIndex, count, to: toIndex, location } });
19133
19165
  }
19134
19166
  };
@@ -19148,7 +19180,7 @@ var paragraphTools = [
19148
19180
  // src/server/tools/search.ts
19149
19181
  var search = forwardTool(
19150
19182
  "word_search",
19151
- "[Search] Find text in the document. Returns match count and up to 30 matches. Query must be \u2264255 chars.",
19183
+ '[Search] Find text in the document. Returns match count and up to 30 matches. Query must be \u2264255 chars. Note: Word search codes (^p = paragraph mark, ^t = tab) are interpreted. To search for literal "^", use "^^".',
19152
19184
  {
19153
19185
  properties: {
19154
19186
  query: { type: "string" },
@@ -19161,24 +19193,22 @@ var search = forwardTool(
19161
19193
  );
19162
19194
  var searchAndReplace = {
19163
19195
  name: "word_search_and_replace",
19164
- description: "[Search] Find and replace ALL occurrences. For single-paragraph edits, prefer word_replace_paragraph_text.",
19196
+ description: "[Search] Find and replace ALL occurrences. Supports Word search codes (^p = paragraph mark, ^t = tab). For single-paragraph edits, prefer word_replace_paragraph_text.",
19165
19197
  schema: {
19166
19198
  properties: {
19167
19199
  find: { type: "string" },
19168
19200
  replace: { type: "string" },
19169
19201
  matchCase: { type: "boolean", description: "Default: false" },
19170
- matchWholeWord: { type: "boolean" }
19202
+ matchWholeWord: { type: "boolean" },
19203
+ preserveBookmarks: { type: "boolean", description: "Re-create bookmarks on replacement text after replace. Default: false" }
19171
19204
  },
19172
19205
  required: ["find", "replace"]
19173
19206
  },
19174
19207
  async handler(args, bridge2) {
19175
19208
  const find = args.find;
19176
- const replace = args.replace;
19177
19209
  if (!find || typeof find !== "string" || find.trim() === "") {
19178
19210
  throw new ToolError("find string cannot be empty.");
19179
19211
  }
19180
- checkNoSpecialCodes(find, "find");
19181
- checkNoSpecialCodes(replace, "replace");
19182
19212
  const result = await bridge2.send("searchAndReplace", args);
19183
19213
  return jsonResult(result);
19184
19214
  }
@@ -19252,7 +19282,7 @@ var formatText = forwardTool(
19252
19282
  underline: { type: "boolean" },
19253
19283
  strikeThrough: { type: "boolean" },
19254
19284
  color: { type: "string", description: "Hex color e.g. #FF0000" },
19255
- highlightColor: { type: "string", description: "Highlight color name or hex" },
19285
+ highlightColor: { type: "string", description: "Highlight color name (Yellow, Green, Cyan, Magenta, Blue, Red, DarkBlue, DarkCyan, DarkGreen, DarkMagenta, DarkRed, DarkYellow, Gray25, Gray50, Black, White, NoHighlight)" },
19256
19286
  size: { type: "number", description: "Font size in points (1-1638)" },
19257
19287
  name: { type: "string", description: "Font name" },
19258
19288
  matchCase: { type: "boolean", description: "Default: false" }
@@ -19304,7 +19334,7 @@ var insertTable = forwardTool(
19304
19334
  data: { type: "array", items: { type: "array", items: { type: "string" } }, description: "Cell values as array of row arrays" },
19305
19335
  location: { type: "string", enum: ["Start", "End"] },
19306
19336
  style: { type: "string", description: "Table style name" },
19307
- headerRowCount: { type: "number" }
19337
+ headerRowCount: { type: "number", description: "Number of header rows (default: 0). Set to 1 to mark the first row as a repeating header." }
19308
19338
  },
19309
19339
  required: ["rows", "cols"]
19310
19340
  },
@@ -19556,7 +19586,7 @@ var commentTools = [
19556
19586
  // src/server/tools/footnotes.ts
19557
19587
  var insertFootnote = forwardTool(
19558
19588
  "word_insert_footnote",
19559
- "[Footnotes] Insert a footnote anchored to a text match.",
19589
+ "[Footnotes] Insert a footnote anchored to a text match. Note: multiple footnotes on the same anchor appear in reverse insertion order (most recent first). Use word_insert_footnote_at_index for explicit placement.",
19560
19590
  {
19561
19591
  properties: {
19562
19592
  anchorText: { type: "string", description: "Text to search for as anchor point" },
@@ -20170,6 +20200,11 @@ function createBatchTool(registry, actionMap) {
20170
20200
  if (operations.length > MAX_BATCH_OPERATIONS) {
20171
20201
  throw new ToolError(`maximum ${MAX_BATCH_OPERATIONS} operations per batch`);
20172
20202
  }
20203
+ for (const op of operations) {
20204
+ if (op.tool === "word_batch") {
20205
+ throw new ToolError("word_batch cannot be nested inside another batch. Flatten your operations into a single batch call.");
20206
+ }
20207
+ }
20173
20208
  const results = [];
20174
20209
  let nativeBuf = [];
20175
20210
  let stopped = false;
@@ -20283,6 +20318,20 @@ function buildToolRegistry() {
20283
20318
  return { tools: allTools, handlers };
20284
20319
  }
20285
20320
 
20321
+ // src/server/mutex.ts
20322
+ function createMutex() {
20323
+ let chain = Promise.resolve();
20324
+ return {
20325
+ run(fn) {
20326
+ const next = chain.then(fn, fn);
20327
+ chain = next.then(noop, noop);
20328
+ return next;
20329
+ }
20330
+ };
20331
+ }
20332
+ function noop() {
20333
+ }
20334
+
20286
20335
  // src/server/usage-guide.ts
20287
20336
  var usageGuide = `# MCP Word Bridge \u2014 Usage Guide
20288
20337
 
@@ -20393,7 +20442,7 @@ After inserting a Table of Contents, heading text appears twice (in TOC and body
20393
20442
  // package.json
20394
20443
  var package_default = {
20395
20444
  name: "mcp-word-bridge",
20396
- version: "4.0.4",
20445
+ version: "4.1.0",
20397
20446
  description: "MCP server for live Word document editing via Office Add-in",
20398
20447
  main: "dist/server.js",
20399
20448
  bin: {
@@ -20457,6 +20506,7 @@ function createMcpServer(bridge2) {
20457
20506
  { capabilities: { tools: {}, resources: {} } }
20458
20507
  );
20459
20508
  const { tools, handlers } = buildToolRegistry();
20509
+ const toolMutex = createMutex();
20460
20510
  server.setRequestHandler(import_types7.ListToolsRequestSchema, async () => ({
20461
20511
  tools: tools.map((t) => ({
20462
20512
  name: t.name,
@@ -20469,18 +20519,20 @@ function createMcpServer(bridge2) {
20469
20519
  }))
20470
20520
  }));
20471
20521
  server.setRequestHandler(import_types7.CallToolRequestSchema, async (request) => {
20472
- const { name, arguments: args } = request.params;
20473
- const handler = handlers.get(name);
20474
- if (!handler) {
20475
- return { content: [{ type: "text", text: "Unknown tool: " + name }], isError: true };
20476
- }
20477
- try {
20478
- const result = await handler(args || {}, bridge2);
20479
- return result;
20480
- } catch (e) {
20481
- const msg = e instanceof ToolError ? e.message : "Error: " + e.message;
20482
- return { content: [{ type: "text", text: msg }], isError: true };
20483
- }
20522
+ return toolMutex.run(async () => {
20523
+ const { name, arguments: args } = request.params;
20524
+ const handler = handlers.get(name);
20525
+ if (!handler) {
20526
+ return { content: [{ type: "text", text: "Unknown tool: " + name }], isError: true };
20527
+ }
20528
+ try {
20529
+ const result = await handler(args || {}, bridge2);
20530
+ return result;
20531
+ } catch (e) {
20532
+ const msg = e instanceof ToolError ? e.message : "Error: " + e.message;
20533
+ return { content: [{ type: "text", text: msg }], isError: true };
20534
+ }
20535
+ });
20484
20536
  });
20485
20537
  server.setRequestHandler(import_types7.ListResourcesRequestSchema, async () => ({
20486
20538
  resources: [{