mcp-word-bridge 3.4.1 → 3.5.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
@@ -58,7 +58,7 @@ That's it. The MCP server starts automatically when your MCP client loads the co
58
58
  mcp-word-bridge/
59
59
  ├── index.js # Entry point: HTTPS server + MCP handlers + WebSocket relay
60
60
  ├── lib/
61
- │ ├── tools.js # Tool definitions (87) and action mapping
61
+ │ ├── tools.js # Tool definitions and action mapping
62
62
  │ ├── equations.js # LaTeX→OMML pipeline (fixDelimiters, fixNaryOperands, latexToOmml)
63
63
  │ └── usage-guide.js # MCP resource content (usage patterns for LLMs)
64
64
  ├── taskpane-app.js # Client-side Word JS API logic (runs in add-in)
@@ -80,7 +80,7 @@ mcp-word-bridge/
80
80
  └── README.md
81
81
  ```
82
82
 
83
- ## Tools (87)
83
+ ## Tools (90)
84
84
 
85
85
  ### Document
86
86
  | Tool | Description |
@@ -95,14 +95,18 @@ mcp-word-bridge/
95
95
  | `word_get_styles` | List available styles |
96
96
  | `word_get_coauthors` | Get co-authoring status and active authors |
97
97
  | `word_set_change_tracking` | Enable/disable track changes |
98
+ | `word_get_document_outline` | Get heading hierarchy as a structured outline tree |
98
99
 
99
100
  ### Paragraphs
100
101
  | Tool | Description |
101
102
  |------|-------------|
102
103
  | `word_get_paragraphs` | Get paragraphs with style, alignment, TOC flag (paginated) |
103
104
  | `word_get_paragraph_by_index` | Get full details of one paragraph (font, spacing, indent) |
104
- | `word_insert_paragraph` | Insert a styled paragraph at Start or End |
105
+ | `word_insert_paragraph` | Append/prepend a styled paragraph at document Start or End |
106
+ | `word_insert_paragraph_at_index` | Insert a paragraph before or after a specific paragraph index |
105
107
  | `word_delete_paragraph` | Delete a paragraph by index |
108
+ | `word_replace_paragraph_text` | Replace paragraph text by index (preserves style) |
109
+ | `word_move_paragraph` | Move one or more consecutive paragraphs to another position |
106
110
  | `word_set_paragraph_style` | Change style or alignment of a paragraph |
107
111
  | `word_set_paragraph_spacing` | Set line spacing, before/after, and indentation |
108
112
 
@@ -111,7 +115,7 @@ mcp-word-bridge/
111
115
  |------|-------------|
112
116
  | `word_search` | Find text matches (case-insensitive by default) |
113
117
  | `word_search_and_replace` | Find and replace all occurrences |
114
- | `word_insert_text` | Insert text before or after a search match |
118
+ | `word_insert_text_at_match` | Insert text before or after a search match |
115
119
  | `word_get_selection_info` | Get current selection with font details |
116
120
  | `word_insert_text_at_selection` | Insert or replace text at cursor |
117
121
  | `word_insert_line_break` | Insert a soft line break (Shift+Enter) |
@@ -127,7 +131,7 @@ mcp-word-bridge/
127
131
  | Tool | Description |
128
132
  |------|-------------|
129
133
  | `word_insert_table` | Insert a table with data and optional style |
130
- | `word_get_tables` | Get all tables with values and styles |
134
+ | `word_list_tables` | List table metadata (count, dimensions, style) no cell values |
131
135
  | `word_get_table_data` | Get cell values from a specific table |
132
136
  | `word_set_table_cell` | Set text in a cell |
133
137
  | `word_add_table_row` | Add a row with optional values |
@@ -148,13 +152,11 @@ mcp-word-bridge/
148
152
  | Tool | Description |
149
153
  |------|-------------|
150
154
  | `word_add_comment` | Add a comment anchored to text |
151
- | `word_get_comments` | Get all comments with author, date, status |
155
+ | `word_get_comments` | Get all comments with author, date, status, and anchor text |
152
156
  | `word_get_comment_replies` | Get replies for a comment |
153
157
  | `word_reply_to_comment` | Reply to a comment |
154
158
  | `word_resolve_comment` | Mark a comment as resolved |
155
159
  | `word_delete_comment` | Delete a comment and its replies |
156
- | `word_get_comment_anchor` | Get the document text a comment is anchored to |
157
- | `word_get_comments_with_anchor` | Get all comments with their anchor text included |
158
160
 
159
161
  ### Footnotes & Endnotes
160
162
  | Tool | Description |
@@ -239,7 +241,12 @@ mcp-word-bridge/
239
241
  ### Equations
240
242
  | Tool | Description |
241
243
  |------|-------------|
242
- | `word_insert_equation` | Insert a LaTeX equation as a native editable Word equation. Supports display (centered block) and inline modes. |
244
+ | `word_insert_equation` | Insert a LaTeX equation as a native editable Word equation. Display (block) or inline mode with optional anchor text positioning. |
245
+
246
+ ### Batch
247
+ | Tool | Description |
248
+ |------|-------------|
249
+ | `word_batch` | Execute up to 50 operations in a single call. Native ops are bundled into one message to Word for maximum speed. |
243
250
 
244
251
  ## How it works
245
252
 
@@ -264,6 +271,8 @@ LaTeX → temml → MathML → mathml2omml → OMML → OOXML → Word
264
271
 
265
272
  - **Display mode** (`displayMode: true`, default): centered block equation on its own line
266
273
  - **Inline mode** (`displayMode: false`): inserted at the current cursor position within a paragraph
274
+ - With `anchorText`: searches for the text and inserts the equation after the match (recommended — no manual cursor positioning needed)
275
+ - Without `anchorText`: inserts at wherever the cursor currently is
267
276
  - Supports: fractions, roots, integrals, sums, products, matrices, Greek letters, piecewise functions, aligned systems, and all standard LaTeX math
268
277
  - Equations are fully editable in Word's built-in equation editor
269
278
  - Invalid LaTeX returns a descriptive error message
package/index.js CHANGED
@@ -119,7 +119,7 @@ function sendToTaskpane(action, params) {
119
119
  return;
120
120
  }
121
121
  const id = String(++mcpRequestId);
122
- const heavyOps = ['insertHtml', 'insertOoxml', 'getStyles', 'insertTableOfContents'];
122
+ const heavyOps = ['insertHtml', 'insertOoxml', 'getStyles', 'insertTableOfContents', 'batchExecute'];
123
123
  const timeoutMs = heavyOps.includes(action) ? 60000 : 30000;
124
124
  const timeout = setTimeout(() => {
125
125
  bridgePending.delete(id);
@@ -134,47 +134,211 @@ function sendToTaskpane(action, params) {
134
134
  }
135
135
 
136
136
  const mcpServer = new Server(
137
- { name: 'mcp-word-bridge', version: '3.4.1' },
137
+ { name: 'mcp-word-bridge', version: '3.5.0' },
138
138
  { capabilities: { tools: {}, resources: {} } }
139
139
  );
140
140
 
141
141
  mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
142
142
 
143
- mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
144
- const { name, arguments: args } = request.params;
145
-
146
- // Special handling for word_insert_equation (server-side LaTeX→OMML conversion)
143
+ // Helper: execute a single tool call (used by both direct calls and batch)
144
+ async function executeTool(name, args) {
145
+ // word_insert_equation: server-side LaTeX→OMML conversion
147
146
  if (name === 'word_insert_equation') {
147
+ const latex = args.latex;
148
+ const displayMode = args.displayMode !== false;
149
+
150
+ let result;
148
151
  try {
149
- const latex = args.latex;
150
- const displayMode = args.displayMode !== false;
152
+ const { mml2omml } = require('mathml2omml');
153
+ result = latexToOmml(latex, displayMode, mml2omml);
154
+ } catch (e) {
155
+ const msg = e.message.startsWith('LaTeX parse error') || e.message.startsWith('"latex"') ? e.message : 'Error: ' + e.message;
156
+ return { content: [{ type: 'text', text: msg }], isError: true };
157
+ }
151
158
 
152
- // Convert LaTeX → OMML via lib/equations.js
153
- let result;
154
- try {
155
- const { mml2omml } = require('mathml2omml');
156
- result = latexToOmml(latex, displayMode, mml2omml);
157
- } catch (e) {
158
- return { content: [{ type: 'text', text: e.message.startsWith('LaTeX parse error') || e.message.startsWith('"latex"') ? e.message : 'Error: ' + e.message }], isError: true };
159
+ const cleanOmml = result.omml;
160
+ const ooxml = buildEquationOoxml(cleanOmml, displayMode);
161
+
162
+ if (displayMode) {
163
+ await sendToTaskpane('insertOoxml', { ooxml, location: args.location || 'End' });
164
+ } else if (args.anchorText) {
165
+ // Inline mode with anchor: search for anchor text, insert space+marker after it, insert equation at cursor, clean up marker
166
+ const marker = '\u200B\uFEFF'; // unique two-char sequence unlikely to exist in normal text
167
+ const searchResult = await sendToTaskpane('search', { query: args.anchorText, matchCase: args.matchCase || false });
168
+ if (!searchResult || searchResult.count === 0) throw new Error('Anchor not found: ' + args.anchorText);
169
+ const occurrence = args.occurrence || 0;
170
+ if (occurrence >= searchResult.count) throw new Error('Occurrence ' + occurrence + ' not found (only ' + searchResult.count + ' match' + (searchResult.count === 1 ? '' : 'es') + ')');
171
+ // Insert space + marker to position cursor (space ensures equation doesn't glue to preceding text)
172
+ await sendToTaskpane('insertText', { text: ' ' + marker, after: args.anchorText, occurrence: occurrence, matchCase: args.matchCase || false });
173
+ await sendToTaskpane('insertOoxmlAtSelection', { ooxml });
174
+ // Clean up the marker (space remains, giving proper separation)
175
+ await sendToTaskpane('searchAndReplace', { find: marker, replace: '' });
176
+ } else {
177
+ await sendToTaskpane('insertOoxmlAtSelection', { ooxml });
178
+ }
179
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, displayMode, latex }) }] };
180
+ }
181
+
182
+ // word_get_document_outline: server-side filtering of paragraphs by heading style
183
+ if (name === 'word_get_document_outline') {
184
+ const maxLevel = args.maxLevel !== undefined ? args.maxLevel : 3;
185
+ const result = await sendToTaskpane('getParagraphs', {});
186
+ const headings = [];
187
+ for (const para of result.paragraphs) {
188
+ const match = para.style && para.style.match(/^Heading (\d)$/i);
189
+ if (match) {
190
+ const level = parseInt(match[1], 10);
191
+ if (level <= maxLevel) {
192
+ headings.push({ level, text: para.text, index: para.index });
193
+ }
194
+ }
195
+ }
196
+ return { content: [{ type: 'text', text: JSON.stringify({ count: headings.length, outline: headings }, null, 2) }] };
197
+ }
198
+
199
+ // word_move_paragraph: atomic move (read text+style, delete, insert at new position)
200
+ // Supports moving multiple consecutive paragraphs via optional 'count' parameter
201
+ if (name === 'word_move_paragraph') {
202
+ const fromIndex = args.fromIndex;
203
+ const toIndex = args.toIndex;
204
+ const count = args.count || 1;
205
+ const location = args.location || 'After';
206
+ if (fromIndex < 0) throw new Error('fromIndex must be non-negative');
207
+ if (toIndex < 0) throw new Error('toIndex must be non-negative');
208
+ if (count < 1) throw new Error('count must be at least 1');
209
+ if (fromIndex === toIndex && count === 1) throw new Error('fromIndex and toIndex must be different');
210
+ // Validate all indices are in bounds before mutating
211
+ const paraCount = await sendToTaskpane('getParagraphs', {});
212
+ const total = paraCount.count;
213
+ if (fromIndex >= total) throw new Error('fromIndex ' + fromIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
214
+ if (fromIndex + count - 1 >= total) throw new Error('fromIndex + count (' + (fromIndex + count) + ') exceeds paragraph count (' + total + ').');
215
+ if (toIndex >= total) throw new Error('toIndex ' + toIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
216
+ // Collect all paragraphs to move (text + style)
217
+ const collected = [];
218
+ for (let i = 0; i < count; i++) {
219
+ const details = await sendToTaskpane('getParagraphByIndex', { index: fromIndex + i });
220
+ collected.push({ text: details.text, style: details.style });
221
+ }
222
+ // Delete from last to first to preserve indices during deletion
223
+ for (let i = count - 1; i >= 0; i--) {
224
+ await sendToTaskpane('deleteParagraph', { index: fromIndex + i });
225
+ }
226
+ // Adjust destination index after deletions
227
+ let adjustedTo;
228
+ if (fromIndex < toIndex) {
229
+ adjustedTo = toIndex - count;
230
+ } else {
231
+ adjustedTo = toIndex;
232
+ }
233
+ // Insert in order at destination
234
+ for (let i = 0; i < collected.length; i++) {
235
+ if (i === 0) {
236
+ await sendToTaskpane('insertParagraphAtIndex', { index: adjustedTo, text: collected[i].text, style: collected[i].style, location: location });
237
+ } else {
238
+ // After the first insert, the paragraph we just inserted is at a known position.
239
+ // For 'Before': first para went to adjustedTo, so second goes After adjustedTo.
240
+ // For 'After': first para went to adjustedTo+1, so second goes After adjustedTo+1.
241
+ const prevInsertedAt = location === 'Before' ? adjustedTo + (i - 1) : adjustedTo + i;
242
+ await sendToTaskpane('insertParagraphAtIndex', { index: prevInsertedAt, text: collected[i].text, style: collected[i].style, location: 'After' });
159
243
  }
244
+ }
245
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, moved: { from: fromIndex, count, to: adjustedTo, location } }) }] };
246
+ }
160
247
 
161
- const cleanOmml = result.omml;
162
- const ooxml = buildEquationOoxml(cleanOmml, displayMode);
248
+ // word_batch: execute multiple operations in a single call
249
+ if (name === 'word_batch') {
250
+ const operations = args.operations;
251
+ if (!operations || !Array.isArray(operations) || operations.length === 0) {
252
+ return { content: [{ type: 'text', text: 'Error: operations must be a non-empty array' }], isError: true };
253
+ }
254
+ if (operations.length > 50) {
255
+ return { content: [{ type: 'text', text: 'Error: maximum 50 operations per batch' }], isError: true };
256
+ }
163
257
 
164
- const action = displayMode ? 'insertOoxml' : 'insertOoxmlAtSelection';
165
- const params = displayMode ? { ooxml, location: args.location || 'End' } : { ooxml };
166
- await sendToTaskpane(action, params);
167
- return { content: [{ type: 'text', text: JSON.stringify({ success: true, displayMode, latex }) }] };
168
- } catch (e) {
169
- return { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true };
258
+ // Partition into taskpane-native ops (have action mapping) vs server-composed ops
259
+ const SERVER_HANDLED = new Set(['word_insert_equation', 'word_batch', 'word_get_document_outline', 'word_move_paragraph']);
260
+ const results = [];
261
+
262
+ // Collect consecutive taskpane-native ops into batches, flush when hitting a server op
263
+ let nativeBuf = [];
264
+
265
+ const flushNative = async () => {
266
+ if (nativeBuf.length === 0) return;
267
+ const batchOps = nativeBuf.map(item => ({
268
+ action: toolActionMap[item.tool],
269
+ params: item.args || {}
270
+ }));
271
+ const batchResult = await sendToTaskpane('batchExecute', { operations: batchOps });
272
+ for (const r of batchResult.results) {
273
+ const item = nativeBuf[r.index];
274
+ if (r.success) {
275
+ results.push({ index: item.originalIndex, tool: item.tool, success: true, result: r.result });
276
+ } else {
277
+ results.push({ index: item.originalIndex, tool: item.tool, success: false, error: r.error });
278
+ // Mark that we should stop
279
+ nativeBuf = [];
280
+ return false;
281
+ }
282
+ }
283
+ nativeBuf = [];
284
+ return true;
285
+ };
286
+
287
+ let stopped = false;
288
+ for (let i = 0; i < operations.length && !stopped; i++) {
289
+ const op = operations[i];
290
+ if (!op.tool) {
291
+ results.push({ index: i, tool: op.tool, success: false, error: 'Missing tool name' });
292
+ stopped = true;
293
+ break;
294
+ }
295
+
296
+ if (SERVER_HANDLED.has(op.tool)) {
297
+ // Flush any pending native ops first
298
+ const ok = await flushNative();
299
+ if (ok === false) { stopped = true; break; }
300
+ // Execute server-side
301
+ try {
302
+ const opResult = await executeTool(op.tool, op.args || {});
303
+ if (opResult.isError) {
304
+ results.push({ index: i, tool: op.tool, success: false, result: opResult.content[0].text });
305
+ stopped = true;
306
+ } else {
307
+ results.push({ index: i, tool: op.tool, success: true, result: JSON.parse(opResult.content[0].text) });
308
+ }
309
+ } catch (e) {
310
+ results.push({ index: i, tool: op.tool, success: false, error: e.message });
311
+ stopped = true;
312
+ }
313
+ } else if (toolActionMap[op.tool]) {
314
+ // Buffer taskpane-native op
315
+ nativeBuf.push({ tool: op.tool, args: op.args || {}, originalIndex: i });
316
+ } else {
317
+ // Flush pending, then report unknown tool
318
+ const ok = await flushNative();
319
+ if (ok === false) { stopped = true; break; }
320
+ results.push({ index: i, tool: op.tool, success: false, error: 'Unknown tool: ' + op.tool });
321
+ stopped = true;
322
+ }
170
323
  }
324
+ // Flush any remaining native ops
325
+ if (!stopped) await flushNative();
326
+
327
+ const succeeded = results.filter(r => r.success).length;
328
+ return { content: [{ type: 'text', text: JSON.stringify({ completed: succeeded, failed: results.length - succeeded, total: operations.length, results }, null, 2) }] };
171
329
  }
172
330
 
331
+ // Default: forward to taskpane via action map
173
332
  const action = toolActionMap[name];
174
333
  if (!action) return { content: [{ type: 'text', text: 'Unknown tool: ' + name }], isError: true };
334
+ const result = await sendToTaskpane(action, args || {});
335
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
336
+ }
337
+
338
+ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
339
+ const { name, arguments: args } = request.params;
175
340
  try {
176
- const result = await sendToTaskpane(action, args || {});
177
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
341
+ return await executeTool(name, args || {});
178
342
  } catch (e) {
179
343
  return { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true };
180
344
  }