mcp-word-bridge 3.3.0 → 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 +24 -1
- package/index.js +252 -2
- package/package.json +11 -2
- package/taskpane-app.js +16 -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 |
|
|
@@ -220,6 +220,11 @@ mcp-word-bridge/
|
|
|
220
220
|
| `word_insert_table_of_contents` | Insert a TOC based on headings |
|
|
221
221
|
| `word_get_fields` | Get all fields (hyperlinks, TOC, page numbers) |
|
|
222
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
|
+
|
|
223
228
|
## How it works
|
|
224
229
|
|
|
225
230
|
1. The MCP client spawns `index.js` via stdio
|
|
@@ -229,6 +234,24 @@ mcp-word-bridge/
|
|
|
229
234
|
5. Changes go through Word's editing pipeline — cursor follows edits like a human typing
|
|
230
235
|
6. When the MCP client terminates the process, the bridge server stops automatically
|
|
231
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
|
+
|
|
232
255
|
## TLS Certificate
|
|
233
256
|
|
|
234
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');
|
|
@@ -14,6 +14,101 @@ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
|
14
14
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
15
15
|
const { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
16
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
|
+
}
|
|
111
|
+
|
|
17
112
|
const PORT = parseInt(process.env.MCP_WORD_BRIDGE_PORT || '3000', 10);
|
|
18
113
|
const CERTS_DIR = path.join(__dirname, 'certs');
|
|
19
114
|
|
|
@@ -237,6 +332,8 @@ const tools = [
|
|
|
237
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'] } },
|
|
238
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' } } } },
|
|
239
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'] } },
|
|
240
337
|
];
|
|
241
338
|
|
|
242
339
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -291,7 +388,7 @@ const toolActionMap = {
|
|
|
291
388
|
};
|
|
292
389
|
|
|
293
390
|
const mcpServer = new Server(
|
|
294
|
-
{ name: 'mcp-word-bridge', version: '3.
|
|
391
|
+
{ name: 'mcp-word-bridge', version: '3.4.0' },
|
|
295
392
|
{ capabilities: { tools: {}, resources: {} } }
|
|
296
393
|
);
|
|
297
394
|
|
|
@@ -299,6 +396,149 @@ mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
|
299
396
|
|
|
300
397
|
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
301
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
|
+
|
|
302
542
|
const action = toolActionMap[name];
|
|
303
543
|
if (!action) return { content: [{ type: 'text', text: 'Unknown tool: ' + name }], isError: true };
|
|
304
544
|
try {
|
|
@@ -391,6 +631,16 @@ After inserting a Table of Contents, heading text appears twice in the document
|
|
|
391
631
|
- "Occurrence N not found" — match index out of range
|
|
392
632
|
- Timeout: 30s default, 60s for HTML/OOXML/styles/TOC insertion
|
|
393
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
|
+
|
|
394
644
|
## Best Practices
|
|
395
645
|
1. Read before writing — understand document structure first
|
|
396
646
|
2. \`word_get_comments_with_anchor\` before bulk replacements to avoid anchor damage
|
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');
|