mcp-word-bridge 4.0.5 → 4.1.1

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
@@ -289,6 +289,19 @@ src/
289
289
  └── equations.ts # LaTeX→OMML conversion pipeline
290
290
  ```
291
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
+
292
305
  ## License
293
306
 
294
307
  MIT
package/dist/server.js CHANGED
@@ -18722,7 +18722,15 @@ var Bridge = class {
18722
18722
 
18723
18723
  // src/server/mcp.ts
18724
18724
  var import_server = require("@modelcontextprotocol/sdk/server/index.js");
18725
- var import_types7 = require("@modelcontextprotocol/sdk/types.js");
18725
+ var import_types8 = require("@modelcontextprotocol/sdk/types.js");
18726
+
18727
+ // src/server/types.ts
18728
+ var ToolError = class extends Error {
18729
+ constructor(message) {
18730
+ super(message);
18731
+ this.name = "ToolError";
18732
+ }
18733
+ };
18726
18734
 
18727
18735
  // src/server/tools/helpers.ts
18728
18736
  function jsonResult(data) {
@@ -18823,6 +18831,9 @@ var getDocumentOutline = {
18823
18831
  },
18824
18832
  async handler(args, bridge2) {
18825
18833
  const maxLevel = args.maxLevel ?? 3;
18834
+ if (typeof args.maxLevel === "number" && (args.maxLevel < 1 || args.maxLevel > 9 || !Number.isInteger(args.maxLevel))) {
18835
+ throw new ToolError("maxLevel must be an integer between 1 and 9.");
18836
+ }
18826
18837
  const result = await bridge2.send("getParagraphs", {});
18827
18838
  const headings = [];
18828
18839
  for (const para of result.paragraphs) {
@@ -18854,24 +18865,7 @@ var documentTools = [
18854
18865
  getDocumentOutline
18855
18866
  ];
18856
18867
 
18857
- // src/server/types.ts
18858
- var ToolError = class extends Error {
18859
- constructor(message) {
18860
- super(message);
18861
- this.name = "ToolError";
18862
- }
18863
- };
18864
-
18865
18868
  // src/server/validation.ts
18866
- 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)/;
18867
- function checkNoSpecialCodes(text2, paramName) {
18868
- const match = text2.match(WORD_SPECIAL_CODES);
18869
- if (match) {
18870
- throw new ToolError(
18871
- `${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).`
18872
- );
18873
- }
18874
- }
18875
18869
  function checkNonEmpty(value, name) {
18876
18870
  if (!value || typeof value !== "string" || value.trim() === "") {
18877
18871
  throw new ToolError(`${name} must be a non-empty string.`);
@@ -18910,6 +18904,24 @@ function checkSpacingBounds(value, name) {
18910
18904
  );
18911
18905
  }
18912
18906
  }
18907
+ function checkIndentBounds(value, name) {
18908
+ if (name === "firstLineIndent") {
18909
+ if (value < -MAX_SPACING_POINTS || value > MAX_SPACING_POINTS) {
18910
+ throw new ToolError(
18911
+ `${name} value ${value} is out of range. Valid range: -${MAX_SPACING_POINTS} to ${MAX_SPACING_POINTS} points.`
18912
+ );
18913
+ }
18914
+ } else {
18915
+ if (value < 0) {
18916
+ throw new ToolError(`${name} must be non-negative (in points).`);
18917
+ }
18918
+ if (value > MAX_SPACING_POINTS) {
18919
+ throw new ToolError(
18920
+ `${name} value ${value} exceeds maximum (${MAX_SPACING_POINTS} points = 22 inches).`
18921
+ );
18922
+ }
18923
+ }
18924
+ }
18913
18925
  function checkPropertyKeyLength(key) {
18914
18926
  if (key.length > MAX_CUSTOM_PROPERTY_KEY_LENGTH) {
18915
18927
  throw new ToolError(
@@ -18972,7 +18984,7 @@ var insertParagraphAtIndex = forwardTool(
18972
18984
  );
18973
18985
  var deleteParagraph = forwardTool(
18974
18986
  "word_delete_paragraph",
18975
- "[Paragraphs] Delete a paragraph by its 0-based index.",
18987
+ "[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).",
18976
18988
  {
18977
18989
  properties: {
18978
18990
  index: { type: "number", description: "Paragraph index (0-based)" }
@@ -19027,14 +19039,21 @@ var setParagraphSpacing = {
19027
19039
  const spacingFields = [
19028
19040
  ["lineSpacing", args.lineSpacing],
19029
19041
  ["spaceBefore", args.spaceBefore],
19030
- ["spaceAfter", args.spaceAfter],
19042
+ ["spaceAfter", args.spaceAfter]
19043
+ ];
19044
+ for (const [name, value] of spacingFields) {
19045
+ if (value !== void 0 && typeof value === "number") {
19046
+ checkSpacingBounds(value, name);
19047
+ }
19048
+ }
19049
+ const indentFields = [
19031
19050
  ["firstLineIndent", args.firstLineIndent],
19032
19051
  ["leftIndent", args.leftIndent],
19033
19052
  ["rightIndent", args.rightIndent]
19034
19053
  ];
19035
- for (const [name, value] of spacingFields) {
19054
+ for (const [name, value] of indentFields) {
19036
19055
  if (value !== void 0 && typeof value === "number") {
19037
- checkSpacingBounds(value, name);
19056
+ checkIndentBounds(value, name);
19038
19057
  }
19039
19058
  }
19040
19059
  const result = await bridge2.send("setParagraphSpacing", args);
@@ -19067,7 +19086,7 @@ var moveParagraph = {
19067
19086
  throw new ToolError(`toIndex (${toIndex}) is inside the source range [${fromIndex}, ${fromIndex + count - 1}]. Move to a position outside the range.`);
19068
19087
  }
19069
19088
  const paraCount = await bridge2.send("getParagraphs", {});
19070
- const total = paraCount.count;
19089
+ const total = paraCount.total;
19071
19090
  checkBounds(fromIndex, total, "fromIndex");
19072
19091
  if (fromIndex + count - 1 >= total) throw new ToolError(`fromIndex + count (${fromIndex + count}) exceeds paragraph count (${total}).`);
19073
19092
  checkBounds(toIndex, total, "toIndex");
@@ -19076,6 +19095,15 @@ var moveParagraph = {
19076
19095
  throw new ToolError(`Paragraph ${para.index} is inside a table cell. Use table-specific tools to modify table content.`);
19077
19096
  }
19078
19097
  }
19098
+ const lastIdx = total - 1;
19099
+ if (fromIndex + count - 1 === lastIdx) {
19100
+ const lastPara = paraCount.paragraphs.find((p) => p.index === lastIdx);
19101
+ if (lastPara && lastPara.text === "") {
19102
+ throw new ToolError(
19103
+ `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.`
19104
+ );
19105
+ }
19106
+ }
19079
19107
  const adjustedTo = fromIndex < toIndex ? toIndex - count : toIndex;
19080
19108
  if (toIndex === fromIndex + count && location === "After") {
19081
19109
  return jsonResult({ success: true, warning: "No move performed \u2014 destination is equivalent to source position.", moved: null });
@@ -19123,7 +19151,7 @@ var copyParagraph = {
19123
19151
  if (!Number.isInteger(count)) throw new ToolError("count must be an integer.");
19124
19152
  if (count < 1) throw new ToolError("count must be at least 1");
19125
19153
  const paraCount = await bridge2.send("getParagraphs", {});
19126
- const total = paraCount.count;
19154
+ const total = paraCount.total;
19127
19155
  checkBounds(fromIndex, total, "fromIndex");
19128
19156
  if (fromIndex + count - 1 >= total) throw new ToolError(`fromIndex + count (${fromIndex + count}) exceeds paragraph count (${total}).`);
19129
19157
  checkBounds(toIndex, total, "toIndex");
@@ -19155,12 +19183,17 @@ var paragraphTools = [
19155
19183
  // src/server/tools/search.ts
19156
19184
  var search = forwardTool(
19157
19185
  "word_search",
19158
- '[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 "^^".',
19186
+ '[Search] Find text in the document. Returns match count and up to 30 matches. Query must be \u2264255 chars. Supports Word search codes (^p = paragraph mark, ^t = tab, etc.) and wildcard mode (?, *, [], {n,m}). To search for literal "^", use "^^".',
19159
19187
  {
19160
19188
  properties: {
19161
19189
  query: { type: "string" },
19162
19190
  matchCase: { type: "boolean", description: "Case-sensitive search. Default: false" },
19163
- matchWholeWord: { type: "boolean" }
19191
+ matchWholeWord: { type: "boolean", description: "Match whole words only. Default: false" },
19192
+ matchWildcards: { type: "boolean", description: "Enable wildcard/regex search (?, *, [], {n,m}, @). Default: false" },
19193
+ matchPrefix: { type: "boolean", description: "Match words that begin with the search string. Default: false" },
19194
+ matchSuffix: { type: "boolean", description: "Match words that end with the search string. Default: false" },
19195
+ ignorePunct: { type: "boolean", description: "Ignore punctuation between words when matching. Default: false" },
19196
+ ignoreSpace: { type: "boolean", description: "Ignore whitespace between words when matching. Default: false" }
19164
19197
  },
19165
19198
  required: ["query"]
19166
19199
  },
@@ -19168,38 +19201,46 @@ var search = forwardTool(
19168
19201
  );
19169
19202
  var searchAndReplace = {
19170
19203
  name: "word_search_and_replace",
19171
- description: "[Search] Find and replace ALL occurrences. For single-paragraph edits, prefer word_replace_paragraph_text.",
19204
+ description: "[Search] Find and replace ALL occurrences. Supports Word search codes in both find and replace: ^p (paragraph mark), ^l (line break), ^m (page break), ^n (column break), ^t (tab), ^s (non-breaking space), ^~ (non-breaking hyphen), ^- (optional hyphen), ^+ (em dash), ^= (en dash), ^^ (literal caret). Supports wildcard search in find string. For single-paragraph edits, prefer word_replace_paragraph_text.",
19172
19205
  schema: {
19173
19206
  properties: {
19174
19207
  find: { type: "string" },
19175
19208
  replace: { type: "string" },
19176
19209
  matchCase: { type: "boolean", description: "Default: false" },
19177
- matchWholeWord: { type: "boolean" }
19210
+ matchWholeWord: { type: "boolean", description: "Match whole words only. Default: false" },
19211
+ matchWildcards: { type: "boolean", description: "Enable wildcard/regex search in find string (?, *, [], {n,m}, @). Default: false" },
19212
+ matchPrefix: { type: "boolean", description: "Match words that begin with the find string. Default: false" },
19213
+ matchSuffix: { type: "boolean", description: "Match words that end with the find string. Default: false" },
19214
+ ignorePunct: { type: "boolean", description: "Ignore punctuation between words when matching. Default: false" },
19215
+ ignoreSpace: { type: "boolean", description: "Ignore whitespace between words when matching. Default: false" },
19216
+ preserveBookmarks: { type: "boolean", description: "Re-create bookmarks on replacement text after replace. Default: false" }
19178
19217
  },
19179
19218
  required: ["find", "replace"]
19180
19219
  },
19181
19220
  async handler(args, bridge2) {
19182
19221
  const find = args.find;
19183
- const replace = args.replace;
19184
19222
  if (!find || typeof find !== "string" || find.trim() === "") {
19185
19223
  throw new ToolError("find string cannot be empty.");
19186
19224
  }
19187
- checkNoSpecialCodes(find, "find");
19188
- checkNoSpecialCodes(replace, "replace");
19189
19225
  const result = await bridge2.send("searchAndReplace", args);
19190
19226
  return jsonResult(result);
19191
19227
  }
19192
19228
  };
19193
19229
  var insertTextAtMatch = forwardTool(
19194
19230
  "word_insert_text_at_match",
19195
- '[Search] Insert text before or after a search match. Provide "after" OR "before" as the anchor text. Use occurrence for Nth match.',
19231
+ '[Search] Insert text before or after a search match. Supports Word search codes in inserted text: ^p (paragraph mark), ^l (line break), ^t (tab), ^s (non-breaking space), ^m (page break), ^n (column break), ^~ (non-breaking hyphen), ^- (optional hyphen), ^+ (em dash), ^= (en dash), ^^ (literal caret). Provide "after" OR "before" as the anchor text. Use occurrence for Nth match.',
19196
19232
  {
19197
19233
  properties: {
19198
19234
  text: { type: "string", description: "Text to insert" },
19199
19235
  after: { type: "string", description: "Search for this text and insert AFTER it" },
19200
19236
  before: { type: "string", description: "Search for this text and insert BEFORE it" },
19201
19237
  occurrence: { type: "number", description: "0=first, 1=second, etc. Default: 0" },
19202
- matchCase: { type: "boolean", description: "Default: false" }
19238
+ matchCase: { type: "boolean", description: "Default: false" },
19239
+ matchWildcards: { type: "boolean", description: "Enable wildcard/regex search for anchor text. Default: false" },
19240
+ matchPrefix: { type: "boolean", description: "Match words that begin with the anchor text. Default: false" },
19241
+ matchSuffix: { type: "boolean", description: "Match words that end with the anchor text. Default: false" },
19242
+ ignorePunct: { type: "boolean", description: "Ignore punctuation between words when matching. Default: false" },
19243
+ ignoreSpace: { type: "boolean", description: "Ignore whitespace between words when matching. Default: false" }
19203
19244
  },
19204
19245
  required: ["text"]
19205
19246
  },
@@ -19259,7 +19300,7 @@ var formatText = forwardTool(
19259
19300
  underline: { type: "boolean" },
19260
19301
  strikeThrough: { type: "boolean" },
19261
19302
  color: { type: "string", description: "Hex color e.g. #FF0000" },
19262
- highlightColor: { type: "string", description: "Highlight color name or hex" },
19303
+ highlightColor: { type: "string", description: "Highlight color name (Yellow, Green, Cyan, Magenta, Blue, Red, DarkBlue, DarkCyan, DarkGreen, DarkMagenta, DarkRed, DarkYellow, Gray25, Gray50, Black, White, NoHighlight)" },
19263
19304
  size: { type: "number", description: "Font size in points (1-1638)" },
19264
19305
  name: { type: "string", description: "Font name" },
19265
19306
  matchCase: { type: "boolean", description: "Default: false" }
@@ -19311,7 +19352,7 @@ var insertTable = forwardTool(
19311
19352
  data: { type: "array", items: { type: "array", items: { type: "string" } }, description: "Cell values as array of row arrays" },
19312
19353
  location: { type: "string", enum: ["Start", "End"] },
19313
19354
  style: { type: "string", description: "Table style name" },
19314
- headerRowCount: { type: "number" }
19355
+ headerRowCount: { type: "number", description: "Number of header rows (default: 0). Set to 1 to mark the first row as a repeating header." }
19315
19356
  },
19316
19357
  required: ["rows", "cols"]
19317
19358
  },
@@ -19790,6 +19831,9 @@ var setContentControlText = {
19790
19831
  if (tag) {
19791
19832
  const ccResult = await bridge2.send("getContentControls", {});
19792
19833
  const matches = ccResult.controls.filter((c) => c.tag === tag);
19834
+ if (matches.length === 0) {
19835
+ throw new ToolError(`Content control with tag "${tag}" not found. Use word_get_content_controls to list available controls.`);
19836
+ }
19793
19837
  if (matches.length > 1) {
19794
19838
  throw new ToolError(
19795
19839
  `Multiple content controls (${matches.length}) share tag "${tag}". Use "id" instead to target a specific control. Matching IDs: ${matches.map((m) => m.id).join(", ")}.`
@@ -20177,6 +20221,11 @@ function createBatchTool(registry, actionMap) {
20177
20221
  if (operations.length > MAX_BATCH_OPERATIONS) {
20178
20222
  throw new ToolError(`maximum ${MAX_BATCH_OPERATIONS} operations per batch`);
20179
20223
  }
20224
+ for (const op of operations) {
20225
+ if (op.tool === "word_batch") {
20226
+ throw new ToolError("word_batch cannot be nested inside another batch. Flatten your operations into a single batch call.");
20227
+ }
20228
+ }
20180
20229
  const results = [];
20181
20230
  let nativeBuf = [];
20182
20231
  let stopped = false;
@@ -20290,6 +20339,20 @@ function buildToolRegistry() {
20290
20339
  return { tools: allTools, handlers };
20291
20340
  }
20292
20341
 
20342
+ // src/server/mutex.ts
20343
+ function createMutex() {
20344
+ let chain = Promise.resolve();
20345
+ return {
20346
+ run(fn) {
20347
+ const next = chain.then(fn, fn);
20348
+ chain = next.then(noop, noop);
20349
+ return next;
20350
+ }
20351
+ };
20352
+ }
20353
+ function noop() {
20354
+ }
20355
+
20293
20356
  // src/server/usage-guide.ts
20294
20357
  var usageGuide = `# MCP Word Bridge \u2014 Usage Guide
20295
20358
 
@@ -20400,7 +20463,7 @@ After inserting a Table of Contents, heading text appears twice (in TOC and body
20400
20463
  // package.json
20401
20464
  var package_default = {
20402
20465
  name: "mcp-word-bridge",
20403
- version: "4.0.5",
20466
+ version: "4.1.1",
20404
20467
  description: "MCP server for live Word document editing via Office Add-in",
20405
20468
  main: "dist/server.js",
20406
20469
  bin: {
@@ -20464,7 +20527,8 @@ function createMcpServer(bridge2) {
20464
20527
  { capabilities: { tools: {}, resources: {} } }
20465
20528
  );
20466
20529
  const { tools, handlers } = buildToolRegistry();
20467
- server.setRequestHandler(import_types7.ListToolsRequestSchema, async () => ({
20530
+ const toolMutex = createMutex();
20531
+ server.setRequestHandler(import_types8.ListToolsRequestSchema, async () => ({
20468
20532
  tools: tools.map((t) => ({
20469
20533
  name: t.name,
20470
20534
  description: t.description,
@@ -20475,21 +20539,23 @@ function createMcpServer(bridge2) {
20475
20539
  }
20476
20540
  }))
20477
20541
  }));
20478
- server.setRequestHandler(import_types7.CallToolRequestSchema, async (request) => {
20479
- const { name, arguments: args } = request.params;
20480
- const handler = handlers.get(name);
20481
- if (!handler) {
20482
- return { content: [{ type: "text", text: "Unknown tool: " + name }], isError: true };
20483
- }
20484
- try {
20485
- const result = await handler(args || {}, bridge2);
20486
- return result;
20487
- } catch (e) {
20488
- const msg = e instanceof ToolError ? e.message : "Error: " + e.message;
20489
- return { content: [{ type: "text", text: msg }], isError: true };
20490
- }
20542
+ server.setRequestHandler(import_types8.CallToolRequestSchema, async (request) => {
20543
+ return toolMutex.run(async () => {
20544
+ const { name, arguments: args } = request.params;
20545
+ const handler = handlers.get(name);
20546
+ if (!handler) {
20547
+ return { content: [{ type: "text", text: "Unknown tool: " + name }], isError: true };
20548
+ }
20549
+ try {
20550
+ const result = await handler(args || {}, bridge2);
20551
+ return result;
20552
+ } catch (e) {
20553
+ const msg = e instanceof ToolError ? e.message : "Error: " + e.message;
20554
+ return { content: [{ type: "text", text: msg }], isError: true };
20555
+ }
20556
+ });
20491
20557
  });
20492
- server.setRequestHandler(import_types7.ListResourcesRequestSchema, async () => ({
20558
+ server.setRequestHandler(import_types8.ListResourcesRequestSchema, async () => ({
20493
20559
  resources: [{
20494
20560
  uri: "word-bridge://usage-guide",
20495
20561
  name: "Word Bridge Usage Guide",
@@ -20497,7 +20563,7 @@ function createMcpServer(bridge2) {
20497
20563
  mimeType: "text/markdown"
20498
20564
  }]
20499
20565
  }));
20500
- server.setRequestHandler(import_types7.ReadResourceRequestSchema, async (request) => {
20566
+ server.setRequestHandler(import_types8.ReadResourceRequestSchema, async (request) => {
20501
20567
  if (request.params.uri === "word-bridge://usage-guide") {
20502
20568
  return { contents: [{ uri: request.params.uri, mimeType: "text/markdown", text: usageGuide }] };
20503
20569
  }