mcp-word-bridge 3.2.4 → 3.4.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 +26 -1
- package/index.js +378 -5
- package/package.json +11 -2
- package/taskpane-app.js +46 -1
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ mcp-word-bridge/
|
|
|
66
66
|
└── README.md
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
## Tools (
|
|
69
|
+
## Tools (85)
|
|
70
70
|
|
|
71
71
|
### Document
|
|
72
72
|
| Tool | Description |
|
|
@@ -137,6 +137,8 @@ mcp-word-bridge/
|
|
|
137
137
|
| `word_reply_to_comment` | Reply to a comment |
|
|
138
138
|
| `word_resolve_comment` | Mark a comment as resolved |
|
|
139
139
|
| `word_delete_comment` | Delete a comment and its replies |
|
|
140
|
+
| `word_get_comment_anchor` | Get the document text a comment is anchored to |
|
|
141
|
+
| `word_get_comments_with_anchor` | Get all comments with their anchor text included |
|
|
140
142
|
|
|
141
143
|
### Footnotes & Endnotes
|
|
142
144
|
| Tool | Description |
|
|
@@ -218,6 +220,11 @@ mcp-word-bridge/
|
|
|
218
220
|
| `word_insert_table_of_contents` | Insert a TOC based on headings |
|
|
219
221
|
| `word_get_fields` | Get all fields (hyperlinks, TOC, page numbers) |
|
|
220
222
|
|
|
223
|
+
### Equations
|
|
224
|
+
| Tool | Description |
|
|
225
|
+
|------|-------------|
|
|
226
|
+
| `word_insert_equation` | Insert a LaTeX equation as a native editable Word equation. Supports display (centered block) and inline modes. |
|
|
227
|
+
|
|
221
228
|
## How it works
|
|
222
229
|
|
|
223
230
|
1. The MCP client spawns `index.js` via stdio
|
|
@@ -227,6 +234,24 @@ mcp-word-bridge/
|
|
|
227
234
|
5. Changes go through Word's editing pipeline — cursor follows edits like a human typing
|
|
228
235
|
6. When the MCP client terminates the process, the bridge server stops automatically
|
|
229
236
|
|
|
237
|
+
## Resources
|
|
238
|
+
|
|
239
|
+
The server exposes an MCP resource at `word-bridge://usage-guide` containing patterns, workflows, and pitfalls for LLMs operating on Word documents. MCP clients that support resources can read this for context on how to use the tools effectively.
|
|
240
|
+
|
|
241
|
+
## Equations
|
|
242
|
+
|
|
243
|
+
`word_insert_equation` converts LaTeX math to native Word equations via:
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
LaTeX → temml → MathML → mathml2omml → OMML → OOXML → Word
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- **Display mode** (`displayMode: true`, default): centered block equation on its own line
|
|
250
|
+
- **Inline mode** (`displayMode: false`): inserted at the current cursor position within a paragraph
|
|
251
|
+
- Supports: fractions, roots, integrals, sums, products, matrices, Greek letters, piecewise functions, aligned systems, and all standard LaTeX math
|
|
252
|
+
- Equations are fully editable in Word's built-in equation editor
|
|
253
|
+
- Invalid LaTeX returns a descriptive error message
|
|
254
|
+
|
|
230
255
|
## TLS Certificate
|
|
231
256
|
|
|
232
257
|
A self-signed localhost certificate is **auto-generated on first run** if `certs/cert.pem` and `certs/key.pem` don't exist. The server prints the exact trust command with the resolved path:
|
package/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Single entry point: starts HTTPS bridge + MCP server in one process.
|
|
5
5
|
* The MCP client spawns this; everything starts and stops together.
|
|
6
6
|
*
|
|
7
|
-
* v3.
|
|
7
|
+
* v3.4.0 — 85 tools
|
|
8
8
|
*/
|
|
9
9
|
const https = require('https');
|
|
10
10
|
const fs = require('fs');
|
|
@@ -12,7 +12,102 @@ const path = require('path');
|
|
|
12
12
|
const { WebSocketServer, WebSocket } = require('ws');
|
|
13
13
|
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
14
14
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
15
|
-
const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
15
|
+
const { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
16
|
+
|
|
17
|
+
const temml = require('temml/dist/temml.cjs');
|
|
18
|
+
let mml2ommlFn = null;
|
|
19
|
+
const getMml2omml = async () => {
|
|
20
|
+
if (!mml2ommlFn) {
|
|
21
|
+
const mod = await import('mathml2omml');
|
|
22
|
+
mml2ommlFn = mod.mml2omml;
|
|
23
|
+
}
|
|
24
|
+
return mml2ommlFn;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Post-process OMML: convert literal delimiter characters into proper <m:d> elements
|
|
28
|
+
// so Word renders them as stretchy (auto-sizing) delimiters.
|
|
29
|
+
const DELIM_PAIRS = { '(': ')', '[': ']', '{': '}', '|': '|', '\u2016': '\u2016', '\u2308': '\u2309', '\u230A': '\u230B', '\u27E8': '\u27E9' };
|
|
30
|
+
const OPEN_CHARS = new Set(Object.keys(DELIM_PAIRS));
|
|
31
|
+
|
|
32
|
+
function fixDelimiters(omml) {
|
|
33
|
+
// First, split text runs that contain delimiters mixed with other chars
|
|
34
|
+
// so each delimiter ends up in its own <m:r><m:t>X</m:t></m:r>
|
|
35
|
+
const DELIM_CHARS_STR = '()[]{}|\u2016\u2308\u2309\u230A\u230B\u27E8\u27E9';
|
|
36
|
+
omml = omml.replace(/<m:r>(<m:rPr>[^]*?<\/m:rPr>)?<m:t([^>]*)>([^<]+)<\/m:t><\/m:r>/g, (match, rPr, attrs, text) => {
|
|
37
|
+
if (text.length <= 1) return match;
|
|
38
|
+
const hasDelim = [...text].some(c => DELIM_CHARS_STR.includes(c));
|
|
39
|
+
if (!hasDelim) return match;
|
|
40
|
+
const rPrStr = rPr || '';
|
|
41
|
+
const segments = [];
|
|
42
|
+
let current = '';
|
|
43
|
+
for (const ch of text) {
|
|
44
|
+
if (DELIM_CHARS_STR.includes(ch)) {
|
|
45
|
+
if (current) segments.push(current);
|
|
46
|
+
segments.push(ch);
|
|
47
|
+
current = '';
|
|
48
|
+
} else {
|
|
49
|
+
current += ch;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (current) segments.push(current);
|
|
53
|
+
return segments.map(s => '<m:r>' + rPrStr + '<m:t' + attrs + '>' + s + '</m:t></m:r>').join('');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const delimRun = /<m:r><m:t xml:space="preserve">([\(\)\[\]\{\}\|\u2016\u2308\u2309\u230A\u230B\u27E8\u27E9]|)<\/m:t><\/m:r>/g;
|
|
57
|
+
const delims = [];
|
|
58
|
+
let m;
|
|
59
|
+
while ((m = delimRun.exec(omml)) !== null) {
|
|
60
|
+
delims.push({ index: m.index, length: m[0].length, char: m[1] });
|
|
61
|
+
}
|
|
62
|
+
if (delims.length < 2) return omml;
|
|
63
|
+
|
|
64
|
+
const pairs = [];
|
|
65
|
+
const stack = [];
|
|
66
|
+
for (const d of delims) {
|
|
67
|
+
if (d.char === '|' || d.char === '\u2016') {
|
|
68
|
+
const stackIdx = stack.findIndex(s => s.char === d.char);
|
|
69
|
+
if (stackIdx >= 0) {
|
|
70
|
+
pairs.push({ open: stack[stackIdx], close: d });
|
|
71
|
+
stack.splice(stackIdx, 1);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (OPEN_CHARS.has(d.char)) {
|
|
76
|
+
stack.push(d);
|
|
77
|
+
} else {
|
|
78
|
+
const expectedOpen = d.char === ')' ? '(' : d.char === ']' ? '[' : d.char === '}' ? '{' : d.char === '' ? '{' : d.char === '\u2309' ? '\u2308' : d.char === '\u230B' ? '\u230A' : d.char === '\u27E9' ? '\u27E8' : null;
|
|
79
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
80
|
+
if (stack[i].char === expectedOpen) {
|
|
81
|
+
pairs.push({ open: stack[i], close: d });
|
|
82
|
+
stack.splice(i, 1);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
pairs.sort((a, b) => b.open.index - a.open.index);
|
|
90
|
+
for (const pair of pairs) {
|
|
91
|
+
const { open, close } = pair;
|
|
92
|
+
// Re-find the delimiter runs at current positions (indices may have shifted)
|
|
93
|
+
const openMatch = omml.indexOf('<m:r><m:t xml:space="preserve">' + open.char + '</m:t></m:r>', open.index > 10 ? open.index - 10 : 0);
|
|
94
|
+
const closeSearch = open.char === close.char ? openMatch + 50 : 0; // for | pairs, search after open
|
|
95
|
+
const closeMatch = omml.indexOf('<m:r><m:t xml:space="preserve">' + close.char + '</m:t></m:r>', closeSearch > openMatch ? closeSearch : openMatch + 1);
|
|
96
|
+
if (openMatch < 0 || closeMatch < 0 || closeMatch <= openMatch) continue;
|
|
97
|
+
|
|
98
|
+
const openLen = ('<m:r><m:t xml:space="preserve">' + open.char + '</m:t></m:r>').length;
|
|
99
|
+
const closeLen = ('<m:r><m:t xml:space="preserve">' + close.char + '</m:t></m:r>').length;
|
|
100
|
+
const content = omml.substring(openMatch + openLen, closeMatch);
|
|
101
|
+
|
|
102
|
+
let dPrContent = '';
|
|
103
|
+
if (open.char !== '(') dPrContent += '<m:begChr m:val="' + open.char + '"/>';
|
|
104
|
+
if (close.char !== ')') dPrContent += '<m:endChr m:val="' + close.char + '"/>';
|
|
105
|
+
const dPr = dPrContent ? '<m:dPr>' + dPrContent + '</m:dPr>' : '';
|
|
106
|
+
const replacement = '<m:d>' + dPr + '<m:e>' + content + '</m:e></m:d>';
|
|
107
|
+
omml = omml.substring(0, openMatch) + replacement + omml.substring(closeMatch + closeLen);
|
|
108
|
+
}
|
|
109
|
+
return omml;
|
|
110
|
+
}
|
|
16
111
|
|
|
17
112
|
const PORT = parseInt(process.env.MCP_WORD_BRIDGE_PORT || '3000', 10);
|
|
18
113
|
const CERTS_DIR = path.join(__dirname, 'certs');
|
|
@@ -185,6 +280,8 @@ const tools = [
|
|
|
185
280
|
{ name: 'word_reply_to_comment', description: 'Reply to a comment by its ID (from get_comments).', inputSchema: { type: 'object', properties: { commentId: { type: 'string' }, text: { type: 'string' } }, required: ['commentId', 'text'] } },
|
|
186
281
|
{ name: 'word_resolve_comment', description: 'Mark a comment as resolved by its ID.', inputSchema: { type: 'object', properties: { commentId: { type: 'string' } }, required: ['commentId'] } },
|
|
187
282
|
{ name: 'word_delete_comment', description: 'Delete a comment and its replies by ID.', inputSchema: { type: 'object', properties: { commentId: { type: 'string' } }, required: ['commentId'] } },
|
|
283
|
+
{ name: 'word_get_comment_anchor', description: 'Get the document text that a comment is anchored to (the highlighted/marked text the comment refers to).', inputSchema: { type: 'object', properties: { commentId: { type: 'string' } }, required: ['commentId'] } },
|
|
284
|
+
{ name: 'word_get_comments_with_anchor', description: 'Get all comments with ID, author, content, date, resolved status, AND the anchor text each comment is attached to.', inputSchema: { type: 'object', properties: {} } },
|
|
188
285
|
// 8. FOOTNOTES & ENDNOTES
|
|
189
286
|
{ name: 'word_insert_footnote', description: 'Insert a footnote anchored to a text match.', inputSchema: { type: 'object', properties: { anchorText: { type: 'string' }, text: { type: 'string' }, occurrence: { type: 'number', description: '0-indexed match to target (0=first, 1=second, etc)' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false (case-insensitive)' } }, required: ['anchorText', 'text'] } },
|
|
190
287
|
{ name: 'word_insert_footnote_at_index', description: 'Insert a footnote at the end of a paragraph by index (no search needed).', inputSchema: { type: 'object', properties: { paragraphIndex: { type: 'number' }, text: { type: 'string' } }, required: ['paragraphIndex', 'text'] } },
|
|
@@ -235,6 +332,8 @@ const tools = [
|
|
|
235
332
|
{ name: 'word_insert_ooxml', description: 'Insert raw Office Open XML for precise formatting control when HTML is insufficient.', inputSchema: { type: 'object', properties: { ooxml: { type: 'string' }, location: { type: 'string', enum: ['Start', 'End'] } }, required: ['ooxml'] } },
|
|
236
333
|
{ name: 'word_insert_table_of_contents', description: 'Insert a table of contents based on heading styles.', inputSchema: { type: 'object', properties: { location: { type: 'string', enum: ['Start', 'End'] }, switches: { type: 'string' } } } },
|
|
237
334
|
{ name: 'word_get_fields', description: 'Get all fields in the document (hyperlinks, TOC entries, page numbers, etc).', inputSchema: { type: 'object', properties: {} } },
|
|
335
|
+
// 18. EQUATIONS
|
|
336
|
+
{ name: 'word_insert_equation', description: 'Insert a LaTeX math equation as a native Word equation (editable in Word equation editor). Supports fractions, roots, integrals, matrices, Greek letters, and all standard LaTeX math. Use displayMode:true for centered block equations, false for inline.', inputSchema: { type: 'object', properties: { latex: { type: 'string', description: 'LaTeX math expression (e.g. "\\\\frac{a}{b}", "\\\\int_0^\\\\infty e^{-x} dx")' }, displayMode: { type: 'boolean', description: 'true = block/centered equation (default), false = inline equation' }, location: { type: 'string', enum: ['Start', 'End'], description: 'Where to insert. Default: End' } }, required: ['latex'] } },
|
|
238
337
|
];
|
|
239
338
|
|
|
240
339
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -263,6 +362,7 @@ const toolActionMap = {
|
|
|
263
362
|
word_add_comment: 'addComment', word_get_comments: 'getComments',
|
|
264
363
|
word_get_comment_replies: 'getCommentReplies', word_reply_to_comment: 'replyToComment',
|
|
265
364
|
word_resolve_comment: 'resolveComment', word_delete_comment: 'deleteComment',
|
|
365
|
+
word_get_comment_anchor: 'getCommentAnchor', word_get_comments_with_anchor: 'getCommentsWithAnchor',
|
|
266
366
|
word_insert_footnote: 'insertFootnote', word_insert_footnote_at_index: 'insertFootnoteAtIndex',
|
|
267
367
|
word_insert_endnote: 'insertEndnote', word_get_footnotes: 'getFootnotes',
|
|
268
368
|
word_get_endnotes: 'getEndnotes', word_delete_footnote: 'deleteFootnote', word_delete_endnote: 'deleteEndnote',
|
|
@@ -288,14 +388,157 @@ const toolActionMap = {
|
|
|
288
388
|
};
|
|
289
389
|
|
|
290
390
|
const mcpServer = new Server(
|
|
291
|
-
{ name: 'mcp-word-bridge', version: '3.
|
|
292
|
-
{ capabilities: { tools: {} } }
|
|
391
|
+
{ name: 'mcp-word-bridge', version: '3.4.0' },
|
|
392
|
+
{ capabilities: { tools: {}, resources: {} } }
|
|
293
393
|
);
|
|
294
394
|
|
|
295
395
|
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
296
396
|
|
|
297
397
|
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
298
398
|
const { name, arguments: args } = request.params;
|
|
399
|
+
|
|
400
|
+
// Special handling for word_insert_equation (server-side LaTeX→OMML conversion)
|
|
401
|
+
if (name === 'word_insert_equation') {
|
|
402
|
+
try {
|
|
403
|
+
const latex = args.latex;
|
|
404
|
+
if (!latex || typeof latex !== 'string') {
|
|
405
|
+
return { content: [{ type: 'text', text: 'Error: "latex" parameter is required and must be a string.' }], isError: true };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Step 1: LaTeX → MathML via temml
|
|
409
|
+
let mathml;
|
|
410
|
+
try {
|
|
411
|
+
mathml = temml.renderToString(latex);
|
|
412
|
+
} catch (e) {
|
|
413
|
+
return { content: [{ type: 'text', text: 'LaTeX parse error: ' + e.message + '\n\nCheck your LaTeX syntax. Common issues: unmatched braces, unknown commands, missing backslashes.' }], isError: true };
|
|
414
|
+
}
|
|
415
|
+
// temml returns a <span class="temml-error"> instead of throwing on parse errors
|
|
416
|
+
if (mathml.includes('temml-error') || !mathml.startsWith('<math')) {
|
|
417
|
+
const errMatch = mathml.match(/ParseError:\s*(.+?)(<|$)/);
|
|
418
|
+
const errMsg = errMatch ? errMatch[1].trim() : 'Invalid LaTeX expression';
|
|
419
|
+
return { content: [{ type: 'text', text: 'LaTeX parse error: ' + errMsg + '\n\nCheck your LaTeX syntax. Common issues: unmatched braces, unknown commands, missing backslashes.' }], isError: true };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Step 2: MathML → OMML via mathml2omml
|
|
423
|
+
const mml2omml = await getMml2omml();
|
|
424
|
+
const displayMode = args.displayMode !== false; // default: true (block)
|
|
425
|
+
let omml;
|
|
426
|
+
try {
|
|
427
|
+
// Preprocess: strip temml-specific attributes and mspace elements that cause conversion artifacts
|
|
428
|
+
let cleanMml = mathml;
|
|
429
|
+
cleanMml = cleanMml.replace(/<mspace[^>]*\/>/g, '');
|
|
430
|
+
cleanMml = cleanMml.replace(/<mspace[^>]*><\/mspace>/g, '');
|
|
431
|
+
cleanMml = cleanMml.replace(/ class="[^"]*"/g, '');
|
|
432
|
+
// In display mode, convert limit-like operators from <msub> to <munder>
|
|
433
|
+
// so they render with the subscript below (not to the side).
|
|
434
|
+
// Operators: lim, limsup, liminf, min, max, inf, sup
|
|
435
|
+
if (displayMode) {
|
|
436
|
+
cleanMml = cleanMml.replace(
|
|
437
|
+
/<msub><mi>(lim|lim sup|lim inf|min|max|inf|sup)<\/mi>/g,
|
|
438
|
+
'<munder><mi>$1</mi>'
|
|
439
|
+
);
|
|
440
|
+
cleanMml = cleanMml.replace(
|
|
441
|
+
/(<munder><mi>(?:lim|lim sup|lim inf|min|max|inf|sup)<\/mi><[^]*?)<\/msub>/g,
|
|
442
|
+
'$1</munder>'
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
// Fix nary operator handling: mathml2omml expects the integrand/summand to be wrapped
|
|
446
|
+
// in <mrow> as the next sibling of the nary <msubsup> or <msub>. Temml doesn't do this.
|
|
447
|
+
// We wrap all siblings after a nary operator (up to a top-level <mo>=</mo>) in <mrow>.
|
|
448
|
+
// Handle both <msubsup> (e.g. \int_a^b) and <msub> (e.g. \sum_i)
|
|
449
|
+
cleanMml = cleanMml.replace(
|
|
450
|
+
/(<msub(?:sup)?><mo[^>]*>[\s\S]*?<\/msub(?:sup)?>)([\s\S]*?)(<mo>[=<]<\/mo>)/g,
|
|
451
|
+
'$1<mrow>$2</mrow>$3'
|
|
452
|
+
);
|
|
453
|
+
// Also handle case where there's no = sign (nary at end of mrow or math)
|
|
454
|
+
cleanMml = cleanMml.replace(
|
|
455
|
+
/(<msub(?:sup)?><mo[^>]*>[\s\S]*?<\/msub(?:sup)?>)((?:<m(?:sup|sub|Sub|i|n|r|o|frac|sqrt|row)[^]*?)?)(<\/mrow>)/g,
|
|
456
|
+
'$1<mrow>$2</mrow>$3'
|
|
457
|
+
);
|
|
458
|
+
omml = mml2omml(cleanMml);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
return { content: [{ type: 'text', text: 'MathML→OMML conversion error: ' + e.message }], isError: true };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Strip redundant namespace declarations (will be declared at document level)
|
|
464
|
+
let cleanOmml = omml.replace(/ xmlns:m="[^"]*"/g, '').replace(/ xmlns:w="[^"]*"/g, '');
|
|
465
|
+
// Safety net: remove any remaining empty <m:e/> elements (renders as squares in Word)
|
|
466
|
+
cleanOmml = cleanOmml.replace(/<m:e\/>/g, '');
|
|
467
|
+
// Fix unescaped < and & in text nodes (mathml2omml doesn't escape special chars in <m:t>)
|
|
468
|
+
// Use split/rejoin to safely process only text content between <m:t...> and </m:t>
|
|
469
|
+
// Note: regex must not match <m:type> or other tags starting with "m:t"
|
|
470
|
+
const parts = cleanOmml.split(/(<m:t>|<m:t\s[^>]*>|<\/m:t>)/);
|
|
471
|
+
let inText = false;
|
|
472
|
+
for (let i = 0; i < parts.length; i++) {
|
|
473
|
+
if (parts[i] === '<m:t>' || (parts[i].startsWith('<m:t ') && parts[i].endsWith('>'))) { inText = true; continue; }
|
|
474
|
+
if (parts[i] === '</m:t>') { inText = false; continue; }
|
|
475
|
+
if (inText && (parts[i].includes('<') || parts[i].includes('&'))) {
|
|
476
|
+
parts[i] = parts[i].replace(/&(?!amp;|lt;|gt;|quot;|apos;)/g, '&').replace(/</g, '<');
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
cleanOmml = parts.join('');
|
|
480
|
+
// Fix lost trailing spaces in \text{} runs (mathml2omml trims trailing spaces from mtext)
|
|
481
|
+
cleanOmml = cleanOmml.replace(/(<m:r><m:rPr><m:nor\/><\/m:rPr><m:t[^>]*>)([^<]+)(<\/m:t><\/m:r><m:r>)/g, (match, prefix, text, suffix) => {
|
|
482
|
+
// Add a space after the text if it doesn't already end with one
|
|
483
|
+
if (!text.endsWith(' ')) return prefix + text + ' ' + suffix;
|
|
484
|
+
return match;
|
|
485
|
+
});
|
|
486
|
+
// Fix delimiters: convert literal paren/bracket/brace chars into <m:d> elements
|
|
487
|
+
cleanOmml = fixDelimiters(cleanOmml);
|
|
488
|
+
|
|
489
|
+
// Step 3: Wrap in OOXML flat OPC package
|
|
490
|
+
|
|
491
|
+
// Fix nary limit placement: in display mode, limits go under/over (not to the side)
|
|
492
|
+
if (displayMode) {
|
|
493
|
+
cleanOmml = cleanOmml.replace(/<m:limLoc m:val="subSup"\/>/g, '<m:limLoc m:val="undOvr"/>');
|
|
494
|
+
}
|
|
495
|
+
const mathContent = displayMode ? '<m:oMathPara>' + cleanOmml + '</m:oMathPara>' : cleanOmml;
|
|
496
|
+
|
|
497
|
+
// For display mode: equation in its own paragraph with center justification
|
|
498
|
+
// A trailing empty paragraph with explicit Normal style resets the formatting context
|
|
499
|
+
// so that subsequent insertParagraph calls don't inherit math font/alignment.
|
|
500
|
+
// For inline mode: equation inserted at current selection (cursor) within existing paragraph
|
|
501
|
+
// A trailing run with explicit normal font resets the context for subsequent text insertion.
|
|
502
|
+
let bodyContent;
|
|
503
|
+
if (displayMode) {
|
|
504
|
+
bodyContent = '<w:p><w:pPr><w:jc w:val="center"/></w:pPr>' + mathContent + '</w:p>' +
|
|
505
|
+
'<w:p><w:pPr><w:pStyle w:val="Normal"/><w:jc w:val="left"/><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:cs="Calibri"/><w:sz w:val="24"/></w:rPr></w:pPr></w:p>';
|
|
506
|
+
} else {
|
|
507
|
+
// Include a zero-width space run after the equation with explicit normal font to break math context
|
|
508
|
+
bodyContent = '<w:p>' + mathContent +
|
|
509
|
+
'<w:r><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:cs="Calibri"/><w:sz w:val="24"/><w:i w:val="0"/></w:rPr><w:t>\u200B</w:t></w:r>' +
|
|
510
|
+
'</w:p>';
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const ooxml = '<pkg:package xmlns:pkg="http://schemas.microsoft.com/office/2006/xmlPackage">' +
|
|
514
|
+
'<pkg:part pkg:name="/_rels/.rels" pkg:contentType="application/vnd.openxmlformats-package.relationships+xml">' +
|
|
515
|
+
'<pkg:xmlData>' +
|
|
516
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
517
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
|
|
518
|
+
'</Relationships>' +
|
|
519
|
+
'</pkg:xmlData>' +
|
|
520
|
+
'</pkg:part>' +
|
|
521
|
+
'<pkg:part pkg:name="/word/document.xml" pkg:contentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml">' +
|
|
522
|
+
'<pkg:xmlData>' +
|
|
523
|
+
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">' +
|
|
524
|
+
'<w:body>' + bodyContent + '</w:body>' +
|
|
525
|
+
'</w:document>' +
|
|
526
|
+
'</pkg:xmlData>' +
|
|
527
|
+
'</pkg:part>' +
|
|
528
|
+
'</pkg:package>';
|
|
529
|
+
|
|
530
|
+
// Step 4: Insert via taskpane
|
|
531
|
+
// Display mode: insert at body End (new paragraph)
|
|
532
|
+
// Inline mode: insert at current selection (cursor must be positioned by caller)
|
|
533
|
+
const action = displayMode ? 'insertOoxml' : 'insertOoxmlAtSelection';
|
|
534
|
+
const params = displayMode ? { ooxml, location: args.location || 'End' } : { ooxml };
|
|
535
|
+
const result = await sendToTaskpane(action, params);
|
|
536
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, displayMode, latex }) }] };
|
|
537
|
+
} catch (e) {
|
|
538
|
+
return { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
299
542
|
const action = toolActionMap[name];
|
|
300
543
|
if (!action) return { content: [{ type: 'text', text: 'Unknown tool: ' + name }], isError: true };
|
|
301
544
|
try {
|
|
@@ -306,18 +549,148 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
306
549
|
}
|
|
307
550
|
});
|
|
308
551
|
|
|
552
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
553
|
+
// PART 3b: Resources — Usage Guide for LLMs
|
|
554
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
555
|
+
|
|
556
|
+
const USAGE_GUIDE = `# MCP Word Bridge — Usage Guide
|
|
557
|
+
|
|
558
|
+
Controls a live Word document through an Office Add-in. All operations execute immediately in Word.
|
|
559
|
+
|
|
560
|
+
## Reading Content
|
|
561
|
+
- \`word_get_paragraphs\` — structured content (text, style, alignment, isTocEntry). Paginate with start/end.
|
|
562
|
+
- \`word_get_text\` — quick plain-text dump (no structure)
|
|
563
|
+
- \`word_search\` — locate text before operating on it
|
|
564
|
+
|
|
565
|
+
## Editing Text
|
|
566
|
+
- \`word_search_and_replace\` — bulk find/replace
|
|
567
|
+
- \`word_insert_text\` — insert before/after a search match (use \`occurrence\` for Nth match)
|
|
568
|
+
- \`word_insert_text_at_selection\` — insert at cursor or replace selection
|
|
569
|
+
- Verify edits with \`word_search\` or \`word_get_paragraphs\`
|
|
570
|
+
|
|
571
|
+
## Search Behavior (applies to ALL search-based tools)
|
|
572
|
+
- Case-insensitive by default. Pass \`matchCase: true\` for exact match.
|
|
573
|
+
- Affected tools: search, search_and_replace, format_text, insert_text, insert_footnote, add_comment, insert_hyperlink, insert_bookmark, insert_content_control, clear_formatting, get_font_info, insert_line_break, remove_hyperlink, insert_endnote.
|
|
574
|
+
|
|
575
|
+
## Alignment Values
|
|
576
|
+
The bridge normalizes aliases: Left, Center/Centered, Right, Justify/Justified.
|
|
577
|
+
|
|
578
|
+
## Change Tracking
|
|
579
|
+
- Call \`word_set_change_tracking({mode:"TrackAll"})\` BEFORE edits for tracked changes
|
|
580
|
+
- Adjacent insertions by the same author may coalesce into a single tracked change
|
|
581
|
+
- \`search_and_replace\` with tracking may only expose the "Added" half; use \`accept_all_tracked_changes\` if granular control isn't needed
|
|
582
|
+
|
|
583
|
+
## Comments — CRITICAL PATTERNS
|
|
584
|
+
|
|
585
|
+
### Reading
|
|
586
|
+
- \`word_get_comments_with_anchor\` — preferred: returns comments + their anchored document text
|
|
587
|
+
- \`word_get_comment_replies\` — reply thread for a specific comment
|
|
588
|
+
|
|
589
|
+
### Comment + text editing interaction
|
|
590
|
+
**Problem:** \`word_search_and_replace\` on text anchoring a comment collapses the anchor (shrinks to empty). The comment survives but loses its positional context.
|
|
591
|
+
|
|
592
|
+
**Safe pattern:**
|
|
593
|
+
1. \`word_get_comments_with_anchor\` — identify commented text
|
|
594
|
+
2. If replacement overlaps a comment anchor:
|
|
595
|
+
a. \`word_reply_to_comment\` explaining resolution (if appropriate)
|
|
596
|
+
b. \`word_resolve_comment\`
|
|
597
|
+
c. THEN replace the text
|
|
598
|
+
3. Resolved thread is preserved as a record
|
|
599
|
+
|
|
600
|
+
**Avoid:** replacing text first (anchor collapses), or deleting+recreating comments (loses author/date/thread).
|
|
601
|
+
|
|
602
|
+
### Adding
|
|
603
|
+
- \`word_add_comment\` — anchor to a text match
|
|
604
|
+
- \`word_reply_to_comment\` — reply to existing thread
|
|
605
|
+
- \`word_resolve_comment\` — hides in Word UI, preserves history
|
|
606
|
+
|
|
607
|
+
## Tables
|
|
608
|
+
- 0-based indexing: tableIndex, row, col
|
|
609
|
+
- \`word_get_tables\` for overview, \`word_get_table_data\` for a specific table's cells
|
|
610
|
+
- Table cell paragraphs can't be structurally deleted (only cleared)
|
|
611
|
+
- Can't insert page/section breaks inside table cells
|
|
612
|
+
|
|
613
|
+
## Footnotes & Endnotes
|
|
614
|
+
- \`word_insert_footnote\` — anchor to text match
|
|
615
|
+
- \`word_insert_footnote_at_index\` — anchor to paragraph by index (no search needed)
|
|
616
|
+
|
|
617
|
+
## Page Layout
|
|
618
|
+
- Margins in points (72 pt = 1 inch)
|
|
619
|
+
- \`lineSpacing\` in \`set_paragraph_spacing\` is in points, not a multiplier (12pt font: 12=single, 18=1.5x, 24=double)
|
|
620
|
+
|
|
621
|
+
## Content Controls
|
|
622
|
+
- RichText/PlainText: wraps the anchor text (non-destructive)
|
|
623
|
+
- CheckBox: REPLACES anchor text with a checkbox glyph (destructive, cannot be modified with set_content_control_text)
|
|
624
|
+
|
|
625
|
+
## TOC Behavior
|
|
626
|
+
After inserting a Table of Contents, heading text appears twice in the document (once in TOC, once in body). Search matches TOC entries first — use \`occurrence\` parameter to target the body instance.
|
|
627
|
+
|
|
628
|
+
## Error Messages
|
|
629
|
+
- "Word taskpane not connected" — user must open the MCP Word Bridge add-in in Word
|
|
630
|
+
- "Anchor not found" — search text not found in document
|
|
631
|
+
- "Occurrence N not found" — match index out of range
|
|
632
|
+
- Timeout: 30s default, 60s for HTML/OOXML/styles/TOC insertion
|
|
633
|
+
|
|
634
|
+
## Equations
|
|
635
|
+
- \`word_insert_equation\` takes a LaTeX string and inserts a native Word equation
|
|
636
|
+
- \`displayMode: true\` (default) = centered block equation; \`false\` = inline
|
|
637
|
+
- Inline mode inserts at the current cursor position. Use \`word_insert_paragraph\` or \`word_insert_text_at_selection\` first to position the cursor, then call \`word_insert_equation\` with \`displayMode: false\`.
|
|
638
|
+
- After an inline equation, use \`word_insert_text_at_selection\` (not \`word_insert_paragraph\`) to continue text in the same paragraph.
|
|
639
|
+
- Supports: fractions, roots, integrals, sums, matrices, Greek letters, AMS math
|
|
640
|
+
- Invalid LaTeX returns a descriptive parse error (not a crash)
|
|
641
|
+
- The equation is fully editable in Word's built-in equation editor
|
|
642
|
+
- Examples: \`\\\\frac{a}{b}\`, \`\\\\int_0^\\\\infty e^{-x} dx\`, \`\\\\sum_{i=1}^n x_i\`
|
|
643
|
+
|
|
644
|
+
## Best Practices
|
|
645
|
+
1. Read before writing — understand document structure first
|
|
646
|
+
2. \`word_get_comments_with_anchor\` before bulk replacements to avoid anchor damage
|
|
647
|
+
3. Enable change tracking for collaborative documents
|
|
648
|
+
4. \`word_save\` explicitly after significant changes
|
|
649
|
+
5. Resolve comments rather than deleting them (preserves audit trail)
|
|
650
|
+
`;
|
|
651
|
+
|
|
652
|
+
mcpServer.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
653
|
+
resources: [
|
|
654
|
+
{
|
|
655
|
+
uri: 'word-bridge://usage-guide',
|
|
656
|
+
name: 'Word Bridge Usage Guide',
|
|
657
|
+
description: 'Patterns, workflows, and pitfalls for LLMs operating on Word documents via this MCP server',
|
|
658
|
+
mimeType: 'text/markdown'
|
|
659
|
+
}
|
|
660
|
+
]
|
|
661
|
+
}));
|
|
662
|
+
|
|
663
|
+
mcpServer.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
664
|
+
const { uri } = request.params;
|
|
665
|
+
if (uri === 'word-bridge://usage-guide') {
|
|
666
|
+
return { contents: [{ uri, mimeType: 'text/markdown', text: USAGE_GUIDE }] };
|
|
667
|
+
}
|
|
668
|
+
throw new Error('Resource not found: ' + uri);
|
|
669
|
+
});
|
|
670
|
+
|
|
309
671
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
310
672
|
// PART 4: Startup & Shutdown
|
|
311
673
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
312
674
|
|
|
675
|
+
let shuttingDown = false;
|
|
313
676
|
function shutdown() {
|
|
677
|
+
if (shuttingDown) return;
|
|
678
|
+
shuttingDown = true;
|
|
314
679
|
process.stderr.write('[mcp-word-bridge] Shutting down...\n');
|
|
680
|
+
// Close all WebSocket connections immediately
|
|
681
|
+
wss.clients.forEach((ws) => ws.terminate());
|
|
682
|
+
// Close the HTTPS server and force-destroy all open sockets
|
|
315
683
|
httpsServer.close();
|
|
316
|
-
|
|
684
|
+
httpsServer.closeAllConnections();
|
|
685
|
+
// Ensure exit even if something holds the event loop
|
|
686
|
+
setTimeout(() => process.exit(0), 500);
|
|
317
687
|
}
|
|
318
688
|
|
|
319
689
|
process.on('SIGTERM', shutdown);
|
|
320
690
|
process.on('SIGINT', shutdown);
|
|
691
|
+
// MCP clients terminate by closing stdin — StdioServerTransport doesn't handle this
|
|
692
|
+
process.stdin.on('end', shutdown);
|
|
693
|
+
process.stdin.on('close', shutdown);
|
|
321
694
|
|
|
322
695
|
async function main() {
|
|
323
696
|
// Start HTTPS bridge server
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-word-bridge",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "MCP server for live Word document editing via Office Add-in",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
"start": "node index.js",
|
|
12
12
|
"install-manifest": "node install-manifest.js"
|
|
13
13
|
},
|
|
14
|
-
"keywords": [
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mcp",
|
|
16
|
+
"word",
|
|
17
|
+
"office",
|
|
18
|
+
"add-in",
|
|
19
|
+
"document",
|
|
20
|
+
"editing"
|
|
21
|
+
],
|
|
15
22
|
"repository": {
|
|
16
23
|
"type": "git",
|
|
17
24
|
"url": "https://github.com/likelion/mcp-word-bridge.git"
|
|
@@ -20,6 +27,8 @@
|
|
|
20
27
|
"license": "MIT",
|
|
21
28
|
"dependencies": {
|
|
22
29
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
30
|
+
"mathml2omml": "^0.5.0",
|
|
31
|
+
"temml": "^0.13.3",
|
|
23
32
|
"ws": "^8.16.0"
|
|
24
33
|
}
|
|
25
34
|
}
|
package/taskpane-app.js
CHANGED
|
@@ -6,7 +6,7 @@ const wsStatus = document.getElementById('ws-status');
|
|
|
6
6
|
function log(msg, cls) {
|
|
7
7
|
const line = document.createElement('div');
|
|
8
8
|
line.className = cls || '';
|
|
9
|
-
line.textContent = new Date().toLocaleTimeString() + ' ' + msg;
|
|
9
|
+
line.textContent = new Date().toLocaleTimeString('en-GB', { hour12: false }) + ' ' + msg;
|
|
10
10
|
logEl.appendChild(line);
|
|
11
11
|
logEl.scrollTop = logEl.scrollHeight;
|
|
12
12
|
if (logEl.children.length > 200) logEl.removeChild(logEl.firstChild);
|
|
@@ -998,6 +998,21 @@ commands.insertOoxml = (p) => Word.run(async (ctx) => {
|
|
|
998
998
|
return { success: true };
|
|
999
999
|
});
|
|
1000
1000
|
|
|
1001
|
+
commands.insertOoxmlAtSelection = (p) => Word.run(async (ctx) => {
|
|
1002
|
+
const sel = ctx.document.getSelection();
|
|
1003
|
+
const range = sel.insertOoxml(p.ooxml, Word.InsertLocation.replace);
|
|
1004
|
+
range.getRange('End').select();
|
|
1005
|
+
try {
|
|
1006
|
+
await ctx.sync();
|
|
1007
|
+
} catch (e) {
|
|
1008
|
+
if (e.message && e.message.includes('GeneralException')) {
|
|
1009
|
+
throw new Error('Invalid OOXML. Ensure the XML follows the Office Open XML package structure (pkg:package with word/document.xml part).');
|
|
1010
|
+
}
|
|
1011
|
+
throw e;
|
|
1012
|
+
}
|
|
1013
|
+
return { success: true };
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1001
1016
|
// == PARAGRAPH DETAILS & SPACING ==
|
|
1002
1017
|
commands.getParagraphByIndex = (p) => Word.run(async (ctx) => {
|
|
1003
1018
|
if (p.index < 0) throw new Error('Index must be non-negative');
|
|
@@ -1113,6 +1128,36 @@ commands.getCommentReplies = (p) => Word.run(async (ctx) => {
|
|
|
1113
1128
|
throw new Error('Comment not found: ' + p.commentId);
|
|
1114
1129
|
});
|
|
1115
1130
|
|
|
1131
|
+
// == COMMENT ANCHOR TEXT ==
|
|
1132
|
+
commands.getCommentAnchor = (p) => Word.run(async (ctx) => {
|
|
1133
|
+
const comments = ctx.document.body.getComments();
|
|
1134
|
+
comments.load('id');
|
|
1135
|
+
await ctx.sync();
|
|
1136
|
+
let target = null;
|
|
1137
|
+
for (const c of comments.items) {
|
|
1138
|
+
if (String(c.id) === String(p.commentId)) { target = c; break; }
|
|
1139
|
+
}
|
|
1140
|
+
if (!target) throw new Error('Comment not found: ' + p.commentId);
|
|
1141
|
+
const range = target.getRange();
|
|
1142
|
+
range.load('text');
|
|
1143
|
+
await ctx.sync();
|
|
1144
|
+
return { commentId: p.commentId, anchorText: range.text };
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
commands.getCommentsWithAnchor = () => Word.run(async (ctx) => {
|
|
1148
|
+
const comments = ctx.document.body.getComments();
|
|
1149
|
+
comments.load('id,authorName,content,creationDate,resolved');
|
|
1150
|
+
await ctx.sync();
|
|
1151
|
+
const items = [];
|
|
1152
|
+
for (const c of comments.items) {
|
|
1153
|
+
const range = c.getRange();
|
|
1154
|
+
range.load('text');
|
|
1155
|
+
await ctx.sync();
|
|
1156
|
+
items.push({ id: c.id, author: c.authorName, content: c.content, date: c.creationDate, resolved: c.resolved, anchorText: range.text });
|
|
1157
|
+
}
|
|
1158
|
+
return { count: items.length, comments: items };
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1116
1161
|
// == CHANGE TRACKING MODE ==
|
|
1117
1162
|
commands.getChangeTrackingMode = () => Word.run(async (ctx) => {
|
|
1118
1163
|
const doc = ctx.document;
|