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 CHANGED
@@ -66,7 +66,7 @@ mcp-word-bridge/
66
66
  └── README.md
67
67
  ```
68
68
 
69
- ## Tools (82)
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.2.0 — 82 tools
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.2.0' },
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, '&amp;').replace(/</g, '&lt;');
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
- process.exit(0);
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.2.4",
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": ["mcp", "word", "office", "add-in", "document", "editing"],
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;