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 +18 -9
- package/index.js +189 -25
- package/lib/tools.js +99 -94
- package/lib/usage-guide.js +62 -34
- package/package.json +1 -1
- package/taskpane-app.js +219 -27
- package/taskpane.html +1 -0
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
|
|
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 (
|
|
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` |
|
|
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
|
-
| `
|
|
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
|
-
| `
|
|
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.
|
|
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.
|
|
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
|
-
|
|
144
|
-
|
|
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
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
}
|