mcp-word-bridge 3.4.2 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,7 +58,7 @@ That's it. The MCP server starts automatically when your MCP client loads the co
58
58
  mcp-word-bridge/
59
59
  ├── index.js # Entry point: HTTPS server + MCP handlers + WebSocket relay
60
60
  ├── lib/
61
- │ ├── tools.js # Tool definitions (87) and action mapping
61
+ │ ├── tools.js # Tool definitions and action mapping
62
62
  │ ├── equations.js # LaTeX→OMML pipeline (fixDelimiters, fixNaryOperands, latexToOmml)
63
63
  │ └── usage-guide.js # MCP resource content (usage patterns for LLMs)
64
64
  ├── taskpane-app.js # Client-side Word JS API logic (runs in add-in)
@@ -80,7 +80,7 @@ mcp-word-bridge/
80
80
  └── README.md
81
81
  ```
82
82
 
83
- ## Tools (87)
83
+ ## Tools (90)
84
84
 
85
85
  ### Document
86
86
  | Tool | Description |
@@ -95,14 +95,18 @@ mcp-word-bridge/
95
95
  | `word_get_styles` | List available styles |
96
96
  | `word_get_coauthors` | Get co-authoring status and active authors |
97
97
  | `word_set_change_tracking` | Enable/disable track changes |
98
+ | `word_get_document_outline` | Get heading hierarchy as a structured outline tree |
98
99
 
99
100
  ### Paragraphs
100
101
  | Tool | Description |
101
102
  |------|-------------|
102
103
  | `word_get_paragraphs` | Get paragraphs with style, alignment, TOC flag (paginated) |
103
104
  | `word_get_paragraph_by_index` | Get full details of one paragraph (font, spacing, indent) |
104
- | `word_insert_paragraph` | Insert a styled paragraph at Start or End |
105
+ | `word_insert_paragraph` | Append/prepend a styled paragraph at document Start or End |
106
+ | `word_insert_paragraph_at_index` | Insert a paragraph before or after a specific paragraph index |
105
107
  | `word_delete_paragraph` | Delete a paragraph by index |
108
+ | `word_replace_paragraph_text` | Replace paragraph text by index (preserves style) |
109
+ | `word_move_paragraph` | Move one or more consecutive paragraphs to another position |
106
110
  | `word_set_paragraph_style` | Change style or alignment of a paragraph |
107
111
  | `word_set_paragraph_spacing` | Set line spacing, before/after, and indentation |
108
112
 
@@ -111,7 +115,7 @@ mcp-word-bridge/
111
115
  |------|-------------|
112
116
  | `word_search` | Find text matches (case-insensitive by default) |
113
117
  | `word_search_and_replace` | Find and replace all occurrences |
114
- | `word_insert_text` | Insert text before or after a search match |
118
+ | `word_insert_text_at_match` | Insert text before or after a search match |
115
119
  | `word_get_selection_info` | Get current selection with font details |
116
120
  | `word_insert_text_at_selection` | Insert or replace text at cursor |
117
121
  | `word_insert_line_break` | Insert a soft line break (Shift+Enter) |
@@ -127,7 +131,7 @@ mcp-word-bridge/
127
131
  | Tool | Description |
128
132
  |------|-------------|
129
133
  | `word_insert_table` | Insert a table with data and optional style |
130
- | `word_get_tables` | Get all tables with values and styles |
134
+ | `word_list_tables` | List table metadata (count, dimensions, style) no cell values |
131
135
  | `word_get_table_data` | Get cell values from a specific table |
132
136
  | `word_set_table_cell` | Set text in a cell |
133
137
  | `word_add_table_row` | Add a row with optional values |
@@ -148,13 +152,11 @@ mcp-word-bridge/
148
152
  | Tool | Description |
149
153
  |------|-------------|
150
154
  | `word_add_comment` | Add a comment anchored to text |
151
- | `word_get_comments` | Get all comments with author, date, status |
155
+ | `word_get_comments` | Get all comments with author, date, status, and anchor text |
152
156
  | `word_get_comment_replies` | Get replies for a comment |
153
157
  | `word_reply_to_comment` | Reply to a comment |
154
158
  | `word_resolve_comment` | Mark a comment as resolved |
155
159
  | `word_delete_comment` | Delete a comment and its replies |
156
- | `word_get_comment_anchor` | Get the document text a comment is anchored to |
157
- | `word_get_comments_with_anchor` | Get all comments with their anchor text included |
158
160
 
159
161
  ### Footnotes & Endnotes
160
162
  | Tool | Description |
@@ -239,7 +241,12 @@ mcp-word-bridge/
239
241
  ### Equations
240
242
  | Tool | Description |
241
243
  |------|-------------|
242
- | `word_insert_equation` | Insert a LaTeX equation as a native editable Word equation. Supports display (centered block) and inline modes. |
244
+ | `word_insert_equation` | Insert a LaTeX equation as a native editable Word equation. Display (block) or inline mode with optional anchor text positioning. |
245
+
246
+ ### Batch
247
+ | Tool | Description |
248
+ |------|-------------|
249
+ | `word_batch` | Execute up to 50 operations in a single call. Native ops are bundled into one message to Word for maximum speed. |
243
250
 
244
251
  ## How it works
245
252
 
@@ -264,6 +271,8 @@ LaTeX → temml → MathML → mathml2omml → OMML → OOXML → Word
264
271
 
265
272
  - **Display mode** (`displayMode: true`, default): centered block equation on its own line
266
273
  - **Inline mode** (`displayMode: false`): inserted at the current cursor position within a paragraph
274
+ - With `anchorText`: searches for the text and inserts the equation after the match (recommended — no manual cursor positioning needed)
275
+ - Without `anchorText`: inserts at wherever the cursor currently is
267
276
  - Supports: fractions, roots, integrals, sums, products, matrices, Greek letters, piecewise functions, aligned systems, and all standard LaTeX math
268
277
  - Equations are fully editable in Word's built-in equation editor
269
278
  - Invalid LaTeX returns a descriptive error message
package/index.js CHANGED
@@ -119,7 +119,7 @@ function sendToTaskpane(action, params) {
119
119
  return;
120
120
  }
121
121
  const id = String(++mcpRequestId);
122
- const heavyOps = ['insertHtml', 'insertOoxml', 'getStyles', 'insertTableOfContents'];
122
+ const heavyOps = ['insertHtml', 'insertOoxml', 'getStyles', 'insertTableOfContents', 'batchExecute'];
123
123
  const timeoutMs = heavyOps.includes(action) ? 60000 : 30000;
124
124
  const timeout = setTimeout(() => {
125
125
  bridgePending.delete(id);
@@ -134,47 +134,211 @@ function sendToTaskpane(action, params) {
134
134
  }
135
135
 
136
136
  const mcpServer = new Server(
137
- { name: 'mcp-word-bridge', version: '3.4.2' },
137
+ { name: 'mcp-word-bridge', version: '3.5.0' },
138
138
  { capabilities: { tools: {}, resources: {} } }
139
139
  );
140
140
 
141
141
  mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
142
142
 
143
- mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
144
- const { name, arguments: args } = request.params;
145
-
146
- // Special handling for word_insert_equation (server-side LaTeX→OMML conversion)
143
+ // Helper: execute a single tool call (used by both direct calls and batch)
144
+ async function executeTool(name, args) {
145
+ // word_insert_equation: server-side LaTeX→OMML conversion
147
146
  if (name === 'word_insert_equation') {
147
+ const latex = args.latex;
148
+ const displayMode = args.displayMode !== false;
149
+
150
+ let result;
148
151
  try {
149
- const latex = args.latex;
150
- const displayMode = args.displayMode !== false;
152
+ const { mml2omml } = require('mathml2omml');
153
+ result = latexToOmml(latex, displayMode, mml2omml);
154
+ } catch (e) {
155
+ const msg = e.message.startsWith('LaTeX parse error') || e.message.startsWith('"latex"') ? e.message : 'Error: ' + e.message;
156
+ return { content: [{ type: 'text', text: msg }], isError: true };
157
+ }
151
158
 
152
- // Convert LaTeX → OMML via lib/equations.js
153
- let result;
154
- try {
155
- const { mml2omml } = require('mathml2omml');
156
- result = latexToOmml(latex, displayMode, mml2omml);
157
- } catch (e) {
158
- return { content: [{ type: 'text', text: e.message.startsWith('LaTeX parse error') || e.message.startsWith('"latex"') ? e.message : 'Error: ' + e.message }], isError: true };
159
+ const cleanOmml = result.omml;
160
+ const ooxml = buildEquationOoxml(cleanOmml, displayMode);
161
+
162
+ if (displayMode) {
163
+ await sendToTaskpane('insertOoxml', { ooxml, location: args.location || 'End' });
164
+ } else if (args.anchorText) {
165
+ // Inline mode with anchor: search for anchor text, insert space+marker after it, insert equation at cursor, clean up marker
166
+ const marker = '\u200B\uFEFF'; // unique two-char sequence unlikely to exist in normal text
167
+ const searchResult = await sendToTaskpane('search', { query: args.anchorText, matchCase: args.matchCase || false });
168
+ if (!searchResult || searchResult.count === 0) throw new Error('Anchor not found: ' + args.anchorText);
169
+ const occurrence = args.occurrence || 0;
170
+ if (occurrence >= searchResult.count) throw new Error('Occurrence ' + occurrence + ' not found (only ' + searchResult.count + ' match' + (searchResult.count === 1 ? '' : 'es') + ')');
171
+ // Insert space + marker to position cursor (space ensures equation doesn't glue to preceding text)
172
+ await sendToTaskpane('insertText', { text: ' ' + marker, after: args.anchorText, occurrence: occurrence, matchCase: args.matchCase || false });
173
+ await sendToTaskpane('insertOoxmlAtSelection', { ooxml });
174
+ // Clean up the marker (space remains, giving proper separation)
175
+ await sendToTaskpane('searchAndReplace', { find: marker, replace: '' });
176
+ } else {
177
+ await sendToTaskpane('insertOoxmlAtSelection', { ooxml });
178
+ }
179
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, displayMode, latex }) }] };
180
+ }
181
+
182
+ // word_get_document_outline: server-side filtering of paragraphs by heading style
183
+ if (name === 'word_get_document_outline') {
184
+ const maxLevel = args.maxLevel !== undefined ? args.maxLevel : 3;
185
+ const result = await sendToTaskpane('getParagraphs', {});
186
+ const headings = [];
187
+ for (const para of result.paragraphs) {
188
+ const match = para.style && para.style.match(/^Heading (\d)$/i);
189
+ if (match) {
190
+ const level = parseInt(match[1], 10);
191
+ if (level <= maxLevel) {
192
+ headings.push({ level, text: para.text, index: para.index });
193
+ }
194
+ }
195
+ }
196
+ return { content: [{ type: 'text', text: JSON.stringify({ count: headings.length, outline: headings }, null, 2) }] };
197
+ }
198
+
199
+ // word_move_paragraph: atomic move (read text+style, delete, insert at new position)
200
+ // Supports moving multiple consecutive paragraphs via optional 'count' parameter
201
+ if (name === 'word_move_paragraph') {
202
+ const fromIndex = args.fromIndex;
203
+ const toIndex = args.toIndex;
204
+ const count = args.count || 1;
205
+ const location = args.location || 'After';
206
+ if (fromIndex < 0) throw new Error('fromIndex must be non-negative');
207
+ if (toIndex < 0) throw new Error('toIndex must be non-negative');
208
+ if (count < 1) throw new Error('count must be at least 1');
209
+ if (fromIndex === toIndex && count === 1) throw new Error('fromIndex and toIndex must be different');
210
+ // Validate all indices are in bounds before mutating
211
+ const paraCount = await sendToTaskpane('getParagraphs', {});
212
+ const total = paraCount.count;
213
+ if (fromIndex >= total) throw new Error('fromIndex ' + fromIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
214
+ if (fromIndex + count - 1 >= total) throw new Error('fromIndex + count (' + (fromIndex + count) + ') exceeds paragraph count (' + total + ').');
215
+ if (toIndex >= total) throw new Error('toIndex ' + toIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
216
+ // Collect all paragraphs to move (text + style)
217
+ const collected = [];
218
+ for (let i = 0; i < count; i++) {
219
+ const details = await sendToTaskpane('getParagraphByIndex', { index: fromIndex + i });
220
+ collected.push({ text: details.text, style: details.style });
221
+ }
222
+ // Delete from last to first to preserve indices during deletion
223
+ for (let i = count - 1; i >= 0; i--) {
224
+ await sendToTaskpane('deleteParagraph', { index: fromIndex + i });
225
+ }
226
+ // Adjust destination index after deletions
227
+ let adjustedTo;
228
+ if (fromIndex < toIndex) {
229
+ adjustedTo = toIndex - count;
230
+ } else {
231
+ adjustedTo = toIndex;
232
+ }
233
+ // Insert in order at destination
234
+ for (let i = 0; i < collected.length; i++) {
235
+ if (i === 0) {
236
+ await sendToTaskpane('insertParagraphAtIndex', { index: adjustedTo, text: collected[i].text, style: collected[i].style, location: location });
237
+ } else {
238
+ // After the first insert, the paragraph we just inserted is at a known position.
239
+ // For 'Before': first para went to adjustedTo, so second goes After adjustedTo.
240
+ // For 'After': first para went to adjustedTo+1, so second goes After adjustedTo+1.
241
+ const prevInsertedAt = location === 'Before' ? adjustedTo + (i - 1) : adjustedTo + i;
242
+ await sendToTaskpane('insertParagraphAtIndex', { index: prevInsertedAt, text: collected[i].text, style: collected[i].style, location: 'After' });
159
243
  }
244
+ }
245
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, moved: { from: fromIndex, count, to: adjustedTo, location } }) }] };
246
+ }
160
247
 
161
- const cleanOmml = result.omml;
162
- const ooxml = buildEquationOoxml(cleanOmml, displayMode);
248
+ // word_batch: execute multiple operations in a single call
249
+ if (name === 'word_batch') {
250
+ const operations = args.operations;
251
+ if (!operations || !Array.isArray(operations) || operations.length === 0) {
252
+ return { content: [{ type: 'text', text: 'Error: operations must be a non-empty array' }], isError: true };
253
+ }
254
+ if (operations.length > 50) {
255
+ return { content: [{ type: 'text', text: 'Error: maximum 50 operations per batch' }], isError: true };
256
+ }
163
257
 
164
- const action = displayMode ? 'insertOoxml' : 'insertOoxmlAtSelection';
165
- const params = displayMode ? { ooxml, location: args.location || 'End' } : { ooxml };
166
- await sendToTaskpane(action, params);
167
- return { content: [{ type: 'text', text: JSON.stringify({ success: true, displayMode, latex }) }] };
168
- } catch (e) {
169
- return { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true };
258
+ // Partition into taskpane-native ops (have action mapping) vs server-composed ops
259
+ const SERVER_HANDLED = new Set(['word_insert_equation', 'word_batch', 'word_get_document_outline', 'word_move_paragraph']);
260
+ const results = [];
261
+
262
+ // Collect consecutive taskpane-native ops into batches, flush when hitting a server op
263
+ let nativeBuf = [];
264
+
265
+ const flushNative = async () => {
266
+ if (nativeBuf.length === 0) return;
267
+ const batchOps = nativeBuf.map(item => ({
268
+ action: toolActionMap[item.tool],
269
+ params: item.args || {}
270
+ }));
271
+ const batchResult = await sendToTaskpane('batchExecute', { operations: batchOps });
272
+ for (const r of batchResult.results) {
273
+ const item = nativeBuf[r.index];
274
+ if (r.success) {
275
+ results.push({ index: item.originalIndex, tool: item.tool, success: true, result: r.result });
276
+ } else {
277
+ results.push({ index: item.originalIndex, tool: item.tool, success: false, error: r.error });
278
+ // Mark that we should stop
279
+ nativeBuf = [];
280
+ return false;
281
+ }
282
+ }
283
+ nativeBuf = [];
284
+ return true;
285
+ };
286
+
287
+ let stopped = false;
288
+ for (let i = 0; i < operations.length && !stopped; i++) {
289
+ const op = operations[i];
290
+ if (!op.tool) {
291
+ results.push({ index: i, tool: op.tool, success: false, error: 'Missing tool name' });
292
+ stopped = true;
293
+ break;
294
+ }
295
+
296
+ if (SERVER_HANDLED.has(op.tool)) {
297
+ // Flush any pending native ops first
298
+ const ok = await flushNative();
299
+ if (ok === false) { stopped = true; break; }
300
+ // Execute server-side
301
+ try {
302
+ const opResult = await executeTool(op.tool, op.args || {});
303
+ if (opResult.isError) {
304
+ results.push({ index: i, tool: op.tool, success: false, result: opResult.content[0].text });
305
+ stopped = true;
306
+ } else {
307
+ results.push({ index: i, tool: op.tool, success: true, result: JSON.parse(opResult.content[0].text) });
308
+ }
309
+ } catch (e) {
310
+ results.push({ index: i, tool: op.tool, success: false, error: e.message });
311
+ stopped = true;
312
+ }
313
+ } else if (toolActionMap[op.tool]) {
314
+ // Buffer taskpane-native op
315
+ nativeBuf.push({ tool: op.tool, args: op.args || {}, originalIndex: i });
316
+ } else {
317
+ // Flush pending, then report unknown tool
318
+ const ok = await flushNative();
319
+ if (ok === false) { stopped = true; break; }
320
+ results.push({ index: i, tool: op.tool, success: false, error: 'Unknown tool: ' + op.tool });
321
+ stopped = true;
322
+ }
170
323
  }
324
+ // Flush any remaining native ops
325
+ if (!stopped) await flushNative();
326
+
327
+ const succeeded = results.filter(r => r.success).length;
328
+ return { content: [{ type: 'text', text: JSON.stringify({ completed: succeeded, failed: results.length - succeeded, total: operations.length, results }, null, 2) }] };
171
329
  }
172
330
 
331
+ // Default: forward to taskpane via action map
173
332
  const action = toolActionMap[name];
174
333
  if (!action) return { content: [{ type: 'text', text: 'Unknown tool: ' + name }], isError: true };
334
+ const result = await sendToTaskpane(action, args || {});
335
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
336
+ }
337
+
338
+ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
339
+ const { name, arguments: args } = request.params;
175
340
  try {
176
- const result = await sendToTaskpane(action, args || {});
177
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
341
+ return await executeTool(name, args || {});
178
342
  } catch (e) {
179
343
  return { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true };
180
344
  }
package/lib/tools.js CHANGED
@@ -5,137 +5,140 @@
5
5
 
6
6
  const tools = [
7
7
  // 1. DOCUMENT
8
- { name: 'word_get_text', description: 'Get full plain text of the active document. Use for overview; for structured content use get_paragraphs.', inputSchema: { type: 'object', properties: {} } },
9
- { name: 'word_get_document_properties', description: 'Get all document metadata including title, author, path, changeTrackingMode, template, security, and timestamps.', inputSchema: { type: 'object', properties: {} } },
10
- { name: 'word_set_document_properties', description: 'Set document metadata (title, subject, author, keywords, comments, category, company, manager, format).', inputSchema: { type: 'object', properties: { title: { type: 'string' }, subject: { type: 'string' }, author: { type: 'string' }, keywords: { type: 'string' }, comments: { type: 'string' }, category: { type: 'string' }, company: { type: 'string' }, manager: { type: 'string' }, format: { type: 'string' } } } },
11
- { name: 'word_save', description: 'Save the document to disk.', inputSchema: { type: 'object', properties: {} } },
12
- { name: 'word_clear', description: 'Clear all document content (body, headers, footers). Leaves one empty Normal paragraph. Fast reset for testing.', inputSchema: { type: 'object', properties: {} } },
13
- { name: 'word_create_document', description: 'Create and open a new blank document in a new Word window. Optionally provide base64-encoded .docx as template. NOTE: the add-in stays in the original window — use word_clear to reset the current document instead.', inputSchema: { type: 'object', properties: { base64: { type: 'string', description: 'Optional base64-encoded .docx file to use as template' } } } },
14
- { name: 'word_get_word_count', description: 'Get word, character, and paragraph counts.', inputSchema: { type: 'object', properties: {} } },
15
- { name: 'word_get_styles', description: 'Get available document styles.', inputSchema: { type: 'object', properties: {} } },
16
- { name: 'word_get_coauthors', description: 'Get current co-authors and coauthoring status.', inputSchema: { type: 'object', properties: {} } },
17
- { name: 'word_set_change_tracking', description: 'Set change tracking mode. Use TrackAll to show edits as tracked changes.', inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['TrackAll', 'TrackMineOnly', 'Off'] } }, required: ['mode'] } },
8
+ { name: 'word_get_text', description: '[Document] Get full plain text of the active document. Use for a quick overview; for structured content with styles use word_get_paragraphs instead.', inputSchema: { type: 'object', properties: {} } },
9
+ { name: 'word_get_document_properties', description: '[Document] Get all document metadata including title, author, path, changeTrackingMode, template, security, and timestamps.', inputSchema: { type: 'object', properties: {} } },
10
+ { name: 'word_set_document_properties', description: '[Document] Set document metadata (title, subject, author, keywords, comments, category, company, manager, format).', inputSchema: { type: 'object', properties: { title: { type: 'string' }, subject: { type: 'string' }, author: { type: 'string' }, keywords: { type: 'string' }, comments: { type: 'string' }, category: { type: 'string' }, company: { type: 'string' }, manager: { type: 'string' }, format: { type: 'string' } } } },
11
+ { name: 'word_save', description: '[Document] Save the document to disk.', inputSchema: { type: 'object', properties: {} } },
12
+ { name: 'word_clear', description: '[Document] Clear all document body content. Leaves one empty Normal paragraph. Fast reset does not clear headers/footers or custom properties.', inputSchema: { type: 'object', properties: {} } },
13
+ { name: 'word_create_document', description: '[Document] Create and open a new blank document in a new Word window. Optionally provide base64-encoded .docx as template. NOTE: the add-in stays in the original window — use word_clear to reset the current document instead.', inputSchema: { type: 'object', properties: { base64: { type: 'string', description: 'Optional base64-encoded .docx file to use as template' } } } },
14
+ { name: 'word_get_word_count', description: '[Document] Get word, character, and paragraph counts.', inputSchema: { type: 'object', properties: {} } },
15
+ { name: 'word_get_styles', description: '[Document] Get available document styles (returns up to 80 styles with name, type, and builtIn flag).', inputSchema: { type: 'object', properties: {} } },
16
+ { name: 'word_get_coauthors', description: '[Document] Get current co-authors and coauthoring status (Desktop only).', inputSchema: { type: 'object', properties: {} } },
17
+ { name: 'word_set_change_tracking', description: '[Document] Set change tracking mode. Call with "TrackAll" BEFORE making edits to show them as tracked changes.', inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['TrackAll', 'TrackMineOnly', 'Off'] } }, required: ['mode'] } },
18
+ { name: 'word_get_document_outline', description: '[Document] Get the document heading hierarchy as a structured outline tree. Returns headings with their level, text, and paragraph index — useful for understanding document structure before editing.', inputSchema: { type: 'object', properties: { maxLevel: { type: 'number', description: 'Maximum heading level to include (1-9). Default: 3 (includes Heading 1, 2, 3)' } } } },
18
19
  // 2. PARAGRAPHS
19
- { name: 'word_get_paragraphs', description: 'Get paragraphs with text, style, alignment, isTocEntry. Optional start/end index range for pagination.', inputSchema: { type: 'object', properties: { start: { type: 'number' }, end: { type: 'number' } } } },
20
- { name: 'word_get_paragraph_by_index', description: 'Get full details of a single paragraph including font, spacing, indentation, and outline level.', inputSchema: { type: 'object', properties: { index: { type: 'number' } }, required: ['index'] } },
21
- { name: 'word_insert_paragraph', description: 'Insert a styled paragraph at Start or End. Specify style (e.g. "Heading 1", "Heading 2", "Normal") and optional alignment (Left, Center, Right, Justified).', inputSchema: { type: 'object', properties: { text: { type: 'string' }, location: { type: 'string', enum: ['Start', 'End'] }, style: { type: 'string' }, alignment: { type: 'string', description: 'Paragraph alignment: Left, Center, Right, or Justified' } }, required: ['text'] } },
22
- { name: 'word_delete_paragraph', description: 'Delete a paragraph by its index.', inputSchema: { type: 'object', properties: { index: { type: 'number' } }, required: ['index'] } },
23
- { name: 'word_set_paragraph_style', description: 'Change the style or alignment of a paragraph by index. Alignment accepts: Left, Center, Right, Justified.', inputSchema: { type: 'object', properties: { index: { type: 'number' }, style: { type: 'string' }, alignment: { type: 'string', description: 'Paragraph alignment: Left, Center, Right, or Justified' } }, required: ['index'] } },
24
- { name: 'word_set_paragraph_spacing', description: 'Set line spacing (in points, e.g. 12=single for 12pt font, 24=double), before/after spacing (points), and indentation (points) on a paragraph by index.', inputSchema: { type: 'object', properties: { index: { type: 'number' }, lineSpacing: { type: 'number', description: 'Line spacing in points (e.g. 12=single for 12pt, 18=1.5x, 24=double)' }, spaceBefore: { type: 'number', description: 'Space before paragraph in points' }, spaceAfter: { type: 'number', description: 'Space after paragraph in points' }, firstLineIndent: { type: 'number', description: 'First line indent in points' }, leftIndent: { type: 'number', description: 'Left indent in points' }, rightIndent: { type: 'number', description: 'Right indent in points' } }, required: ['index'] } },
20
+ { name: 'word_get_paragraphs', description: '[Paragraphs] Get paragraphs with text, style, alignment, and isTocEntry flag. Supports pagination via optional start/end index range (0-based). Returns total paragraph count.', inputSchema: { type: 'object', properties: { start: { type: 'number', description: 'First paragraph index to return (0-based, inclusive)' }, end: { type: 'number', description: 'Last paragraph index (exclusive). Omit to get all from start.' } } } },
21
+ { name: 'word_get_paragraph_by_index', description: '[Paragraphs] Get full details of a single paragraph including font, spacing, indentation, and outline level.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' } }, required: ['index'] } },
22
+ { name: 'word_insert_paragraph', description: '[Paragraphs] Append or prepend a styled paragraph to the document (Start or End only). For inserting at a specific position, use word_insert_paragraph_at_index instead.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, location: { type: 'string', enum: ['Start', 'End'], description: 'Where in the document. Default: End' }, style: { type: 'string', description: 'Paragraph style (e.g. "Heading 1", "Normal"). Default: Normal' }, alignment: { type: 'string', description: 'Paragraph alignment: Left, Center, Right, or Justified' } }, required: ['text'] } },
23
+ { name: 'word_insert_paragraph_at_index', description: '[Paragraphs] Insert a new paragraph Before or After a specific paragraph index. Use this for precise positioning within the document (preferred over word_insert_paragraph for mid-document insertions).', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Reference paragraph index (0-based)' }, text: { type: 'string', description: 'Text content for the new paragraph' }, location: { type: 'string', enum: ['Before', 'After'], description: 'Insert before or after the reference paragraph. Default: After' }, style: { type: 'string', description: 'Paragraph style (e.g. "Heading 1", "Normal")' } }, required: ['index', 'text'] } },
24
+ { name: 'word_delete_paragraph', description: '[Paragraphs] Delete a paragraph by its 0-based index.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' } }, required: ['index'] } },
25
+ { name: 'word_replace_paragraph_text', description: '[Paragraphs] Replace the entire text of a paragraph by index. Preserves style/formatting. Preferred over word_search_and_replace in collaborative editing (targets by position, immune to text-drift).', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' }, text: { type: 'string', description: 'New text content for the paragraph' } }, required: ['index', 'text'] } },
26
+ { name: 'word_move_paragraph', description: '[Paragraphs] Move a paragraph from one position to another. Handles the delete+insert atomically with correct index adjustment.', inputSchema: { type: 'object', properties: { fromIndex: { type: 'number', description: 'Source paragraph index (0-based)' }, toIndex: { type: 'number', description: 'Destination paragraph index (0-based, position after removal)' }, location: { type: 'string', enum: ['Before', 'After'], description: 'Insert before or after the destination index. Default: After' }, count: { type: 'number', description: 'Number of consecutive paragraphs to move (default: 1). Use to move a heading + its body together.' } }, required: ['fromIndex', 'toIndex'] } },
27
+ { name: 'word_set_paragraph_style', description: '[Paragraphs] Change the style or alignment of a paragraph by index. Alignment accepts: Left, Center, Right, Justified.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' }, style: { type: 'string' }, alignment: { type: 'string', description: 'Paragraph alignment: Left, Center, Right, or Justified' } }, required: ['index'] } },
28
+ { name: 'word_set_paragraph_spacing', description: '[Paragraphs] Set line spacing (in points: 12=single for 12pt font, 18=1.5x, 24=double), before/after spacing, and indentation on a paragraph by index.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' }, lineSpacing: { type: 'number', description: 'Line spacing in points (e.g. 12=single for 12pt, 18=1.5x, 24=double)' }, spaceBefore: { type: 'number', description: 'Space before paragraph in points' }, spaceAfter: { type: 'number', description: 'Space after paragraph in points' }, firstLineIndent: { type: 'number', description: 'First line indent in points' }, leftIndent: { type: 'number', description: 'Left indent in points' }, rightIndent: { type: 'number', description: 'Right indent in points' } }, required: ['index'] } },
25
29
  // 3. SEARCH & TEXT
26
- { name: 'word_search', description: 'Search for text in the document (case-insensitive by default). Query must be non-empty and ≤255 chars. Returns match count and up to 30 matches with their text. Supports Word wildcards (e.g. ^p for paragraph mark).', inputSchema: { type: 'object', properties: { query: { type: 'string' }, matchCase: { type: 'boolean', description: 'Case-sensitive search. Default: false' }, matchWholeWord: { type: 'boolean' } }, required: ['query'] } },
27
- { name: 'word_search_and_replace', description: 'Find and replace all occurrences of text (case-insensitive by default). Returns replacement count. Note: if find equals replace, Word still reports it as a replacement (the text is rewritten in-place). Supports Word wildcards (e.g. ^p for paragraph mark).', inputSchema: { type: 'object', properties: { find: { type: 'string' }, replace: { type: 'string' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' }, matchWholeWord: { type: 'boolean' } }, required: ['find', 'replace'] } },
28
- { name: 'word_insert_text', description: 'Insert text before or after a search match. Provide "after" OR "before" (not both) as the anchor string to locate.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, after: { type: 'string' }, before: { 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: ['text'] } },
29
- { name: 'word_get_selection_info', description: 'Get the current selection text with full font and style details.', inputSchema: { type: 'object', properties: {} } },
30
- { name: 'word_insert_text_at_selection', description: 'Insert text at the current cursor position, or replace the current selection (set replace=true).', inputSchema: { type: 'object', properties: { text: { type: 'string' }, replace: { type: 'boolean', description: 'Replace current selection instead of appending. Default: false' } }, required: ['text'] } },
31
- { name: 'word_insert_line_break', description: 'Insert a soft line break (Shift+Enter) before or after a text match.', inputSchema: { type: 'object', properties: { anchorText: { type: 'string' }, before: { type: 'boolean' }, 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'] } },
30
+ { name: 'word_search', description: '[Search] Find text in the document (case-insensitive by default). Returns match count and up to 30 matches. Query must be ≤255 chars. Supports Word wildcards (^p for paragraph mark).', inputSchema: { type: 'object', properties: { query: { type: 'string' }, matchCase: { type: 'boolean', description: 'Case-sensitive search. Default: false' }, matchWholeWord: { type: 'boolean' } }, required: ['query'] } },
31
+ { name: 'word_search_and_replace', description: '[Search] Find and replace ALL occurrences (case-insensitive by default). Returns replacement count. For single-paragraph edits, prefer word_replace_paragraph_text (safer in collaborative docs).', inputSchema: { type: 'object', properties: { find: { type: 'string' }, replace: { type: 'string' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' }, matchWholeWord: { type: 'boolean' } }, required: ['find', 'replace'] } },
32
+ { name: 'word_insert_text_at_match', description: '[Search] Insert text before or after a SEARCH MATCH. First searches for the anchor string, then inserts adjacent to it. Provide "after" OR "before" (not both) as the anchor text to locate. Use occurrence (0-indexed) to target the Nth match.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to insert' }, after: { type: 'string', description: 'Search for this text and insert AFTER it' }, before: { type: 'string', description: 'Search for this text and insert BEFORE it' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['text'] } },
33
+ { name: 'word_get_selection_info', description: '[Search] Get the current cursor selection text with full font and style details.', inputSchema: { type: 'object', properties: {} } },
34
+ { name: 'word_insert_text_at_selection', description: '[Search] Insert text at the current cursor position, or replace the current selection (set replace=true). Cursor moves to end of inserted text.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, replace: { type: 'boolean', description: 'Replace current selection instead of appending. Default: false' } }, required: ['text'] } },
35
+ { name: 'word_insert_line_break', description: '[Search] Insert a soft line break (Shift+Enter) before or after a text match.', inputSchema: { type: 'object', properties: { anchorText: { type: 'string', description: 'Text to search for as anchor point' }, before: { type: 'boolean', description: 'Insert before the match (default: after)' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['anchorText'] } },
32
36
  // 4. FORMATTING
33
- { name: 'word_format_text', description: 'Apply formatting (bold, italic, color, size, font) to a text match found by search. Color must be hex (e.g. #FF0000). Size range: 1-1638 points.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, occurrence: { type: 'number', description: '0-indexed match to target (0=first, 1=second, etc)' }, bold: { type: 'boolean' }, italic: { type: 'boolean' }, underline: { type: 'boolean' }, strikeThrough: { type: 'boolean' }, color: { type: 'string', description: 'Hex color e.g. #FF0000' }, highlightColor: { type: 'string' }, size: { type: 'number', description: 'Font size in points (1-1638)' }, name: { type: 'string', description: 'Font name' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false (case-insensitive)' } }, required: ['text'] } },
34
- { name: 'word_clear_formatting', description: 'Clear direct formatting from a text match, reverting it to the paragraph style defaults.', inputSchema: { type: 'object', properties: { 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: ['text'] } },
35
- { name: 'word_get_font_info', description: 'Inspect font properties (name, size, bold, italic, color) of a text match.', inputSchema: { type: 'object', properties: { 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: ['text'] } },
37
+ { name: 'word_format_text', description: '[Formatting] Apply formatting (bold, italic, color, size, font) to a text match found by search. Color must be hex (#FF0000). Size: 1-1638pt.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to search for and format' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, bold: { type: 'boolean' }, italic: { type: 'boolean' }, underline: { type: 'boolean' }, strikeThrough: { type: 'boolean' }, color: { type: 'string', description: 'Hex color e.g. #FF0000' }, highlightColor: { type: 'string' }, size: { type: 'number', description: 'Font size in points (1-1638)' }, name: { type: 'string', description: 'Font name' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['text'] } },
38
+ { name: 'word_clear_formatting', description: '[Formatting] Clear direct formatting from a text match, reverting it to the paragraph style defaults.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to search for' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['text'] } },
39
+ { name: 'word_get_font_info', description: '[Formatting] Inspect font properties (name, size, bold, italic, color) of a text match.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to search for' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['text'] } },
36
40
  // 5. TABLES
37
- { name: 'word_insert_table', description: 'Insert a table with data. Provide rows (1-500), cols (1-63), and data as array of arrays (e.g. [["A","B"],["C","D"]]).', inputSchema: { type: 'object', properties: { rows: { type: 'number' }, cols: { type: 'number' }, data: { type: 'array', items: { type: 'array', items: { type: 'string' } } }, location: { type: 'string', enum: ['Start', 'End'] }, style: { type: 'string' }, headerRowCount: { type: 'number' } }, required: ['rows', 'cols'] } },
38
- { name: 'word_get_tables', description: 'Get all tables with row counts, styles, and cell values.', inputSchema: { type: 'object', properties: {} } },
39
- { name: 'word_get_table_data', description: 'Get all cell values from a specific table by index.', inputSchema: { type: 'object', properties: { index: { type: 'number' } }, required: ['index'] } },
40
- { name: 'word_set_table_cell', description: 'Set text in a specific table cell by tableIndex, row, and col.', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number' }, row: { type: 'number' }, col: { type: 'number' }, text: { type: 'string' } }, required: ['tableIndex', 'row', 'col', 'text'] } },
41
- { name: 'word_add_table_row', description: 'Add a row to a table with optional cell values.', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number' }, values: { type: 'array', items: { type: 'string' } }, location: { type: 'string', enum: ['Start', 'End'] } }, required: ['tableIndex'] } },
42
- { name: 'word_delete_table_row', description: 'Delete a row from a table by tableIndex and rowIndex.', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number' }, rowIndex: { type: 'number' } }, required: ['tableIndex', 'rowIndex'] } },
43
- { name: 'word_merge_table_cells', description: 'Merge a rectangular range of cells (topRow/firstCell to bottomRow/lastCell).', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number' }, topRow: { type: 'number' }, firstCell: { type: 'number' }, bottomRow: { type: 'number' }, lastCell: { type: 'number' } }, required: ['tableIndex', 'topRow', 'firstCell', 'bottomRow', 'lastCell'] } },
44
- { name: 'word_split_table_cell', description: 'Split a table cell into multiple rows/columns.', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number' }, row: { type: 'number' }, col: { type: 'number' }, rowCount: { type: 'number' }, colCount: { type: 'number' } }, required: ['tableIndex', 'row', 'col'] } },
45
- { name: 'word_set_table_style', description: 'Apply a built-in table style (e.g. "Grid Table 4 - Accent 1").', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number' }, style: { type: 'string' } }, required: ['tableIndex', 'style'] } },
46
- { name: 'word_set_table_cell_shading', description: 'Set background color on a table cell.', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number' }, row: { type: 'number' }, col: { type: 'number' }, color: { type: 'string', description: 'Hex color e.g. #FFD700' } }, required: ['tableIndex', 'row', 'col', 'color'] } },
41
+ { name: 'word_insert_table', description: '[Tables] Insert a table with data. Provide rows (1-500), cols (1-63), and data as array of arrays (e.g. [["A","B"],["C","D"]]). Data dimensions must match rows×cols.', inputSchema: { type: 'object', properties: { rows: { type: 'number' }, cols: { type: 'number' }, data: { type: 'array', items: { type: 'array', items: { type: 'string' } }, description: 'Cell values as array of row arrays. Must have exactly rows×cols dimensions.' }, location: { type: 'string', enum: ['Start', 'End'] }, style: { type: 'string', description: 'Table style name (e.g. "Grid Table 4 - Accent 1")' }, headerRowCount: { type: 'number' } }, required: ['rows', 'cols'] } },
42
+ { name: 'word_list_tables', description: '[Tables] List table metadata: count, rowCount, columnCount, style, headerRowCount for each table. Does NOT return cell values — use word_get_table_data for cell content.', inputSchema: { type: 'object', properties: {} } },
43
+ { name: 'word_get_table_data', description: '[Tables] Get all cell values from a specific table as a 2D array. Use word_list_tables first to find the table index.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Table index (0-based)' } }, required: ['index'] } },
44
+ { name: 'word_set_table_cell', description: '[Tables] Set text in a specific table cell.', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number', description: 'Table index (0-based)' }, row: { type: 'number', description: 'Row index (0-based)' }, col: { type: 'number', description: 'Column index (0-based)' }, text: { type: 'string' } }, required: ['tableIndex', 'row', 'col', 'text'] } },
45
+ { name: 'word_add_table_row', description: '[Tables] Add a row to a table with optional cell values.', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number', description: 'Table index (0-based)' }, values: { type: 'array', items: { type: 'string' }, description: 'Cell values for the new row' }, location: { type: 'string', enum: ['Start', 'End'], description: 'Add at start or end of table. Default: End' } }, required: ['tableIndex'] } },
46
+ { name: 'word_delete_table_row', description: '[Tables] Delete a row from a table.', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number', description: 'Table index (0-based)' }, rowIndex: { type: 'number', description: 'Row index (0-based)' } }, required: ['tableIndex', 'rowIndex'] } },
47
+ { name: 'word_merge_table_cells', description: '[Tables] Merge a rectangular range of cells (topRow/firstCell to bottomRow/lastCell). All indices 0-based.', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number' }, topRow: { type: 'number' }, firstCell: { type: 'number' }, bottomRow: { type: 'number' }, lastCell: { type: 'number' } }, required: ['tableIndex', 'topRow', 'firstCell', 'bottomRow', 'lastCell'] } },
48
+ { name: 'word_split_table_cell', description: '[Tables] Split a table cell into multiple rows/columns.', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number' }, row: { type: 'number' }, col: { type: 'number' }, rowCount: { type: 'number', description: 'Split into this many rows. Default: 1' }, colCount: { type: 'number', description: 'Split into this many columns. Default: 2' } }, required: ['tableIndex', 'row', 'col'] } },
49
+ { name: 'word_set_table_style', description: '[Tables] Apply a built-in table style (e.g. "Grid Table 4 - Accent 1").', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number' }, style: { type: 'string' } }, required: ['tableIndex', 'style'] } },
50
+ { name: 'word_set_table_cell_shading', description: '[Tables] Set background color on a table cell. Color must be hex (e.g. #FFD700).', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number' }, row: { type: 'number' }, col: { type: 'number' }, color: { type: 'string', description: 'Hex color e.g. #FFD700' } }, required: ['tableIndex', 'row', 'col', 'color'] } },
47
51
  // 6. LISTS
48
- { name: 'word_insert_list', description: 'Insert a bulleted or numbered list from an array of item strings.', inputSchema: { type: 'object', properties: { items: { type: 'array', items: { type: 'string' } }, numbered: { type: 'boolean' }, location: { type: 'string', enum: ['Start', 'End'] } }, required: ['items'] } },
49
- { name: 'word_get_list_info', description: 'Get list formatting details (level, numbering) for a paragraph by index.', inputSchema: { type: 'object', properties: { index: { type: 'number' } }, required: ['index'] } },
50
- { name: 'word_set_list_level', description: 'Set indent level of a list item (0=top, 1=sub-item, etc).', inputSchema: { type: 'object', properties: { index: { type: 'number' }, level: { type: 'number' } }, required: ['index', 'level'] } },
52
+ { name: 'word_insert_list', description: '[Lists] Insert a bulleted or numbered list from an array of item strings.', inputSchema: { type: 'object', properties: { items: { type: 'array', items: { type: 'string' }, description: 'List item strings' }, numbered: { type: 'boolean', description: 'true = numbered list, false/omit = bulleted' }, location: { type: 'string', enum: ['Start', 'End'] } }, required: ['items'] } },
53
+ { name: 'word_get_list_info', description: '[Lists] Get list formatting details (level, numbering) for a paragraph by index. Returns isListItem:false if not in a list.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' } }, required: ['index'] } },
54
+ { name: 'word_set_list_level', description: '[Lists] Set indent level of a list item (0=top level, 1=sub-item, up to 8).', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' }, level: { type: 'number', description: 'Indent level: 0-8' } }, required: ['index', 'level'] } },
51
55
  // 7. COMMENTS
52
- { name: 'word_add_comment', description: 'Add a review comment anchored to a text match in the document.', inputSchema: { type: 'object', properties: { anchorText: { type: 'string' }, comment: { 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', 'comment'] } },
53
- { name: 'word_get_comments', description: 'Get all comments with ID, author, content, date, and resolved status.', inputSchema: { type: 'object', properties: {} } },
54
- { name: 'word_get_comment_replies', description: 'Get all replies for a specific comment by ID.', inputSchema: { type: 'object', properties: { commentId: { type: 'string' } }, required: ['commentId'] } },
55
- { 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'] } },
56
- { name: 'word_resolve_comment', description: 'Mark a comment as resolved by its ID.', inputSchema: { type: 'object', properties: { commentId: { type: 'string' } }, required: ['commentId'] } },
57
- { name: 'word_delete_comment', description: 'Delete a comment and its replies by ID.', inputSchema: { type: 'object', properties: { commentId: { type: 'string' } }, required: ['commentId'] } },
58
- { 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'] } },
59
- { 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: {} } },
56
+ { name: 'word_add_comment', description: '[Comments] Add a review comment anchored to a text match. IMPORTANT: Read word_get_comments before bulk text replacements to avoid damaging comment anchors.', inputSchema: { type: 'object', properties: { anchorText: { type: 'string', description: 'Text to search for as comment anchor' }, comment: { type: 'string', description: 'Comment text' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['anchorText', 'comment'] } },
57
+ { name: 'word_get_comments', description: '[Comments] Get all comments with ID, author, content, date, resolved status, and the anchor text each comment is attached to. Call this BEFORE any bulk text replacements.', inputSchema: { type: 'object', properties: {} } },
58
+ { name: 'word_get_comment_replies', description: '[Comments] Get all replies for a specific comment by its ID (from word_get_comments).', inputSchema: { type: 'object', properties: { commentId: { type: 'string' } }, required: ['commentId'] } },
59
+ { name: 'word_reply_to_comment', description: '[Comments] Reply to a comment thread by its ID.', inputSchema: { type: 'object', properties: { commentId: { type: 'string' }, text: { type: 'string' } }, required: ['commentId', 'text'] } },
60
+ { name: 'word_resolve_comment', description: '[Comments] Mark a comment as resolved (hides in Word UI, preserves audit trail). Preferred over delete.', inputSchema: { type: 'object', properties: { commentId: { type: 'string' } }, required: ['commentId'] } },
61
+ { name: 'word_delete_comment', description: '[Comments] Permanently delete a comment and all its replies. Prefer word_resolve_comment to preserve history.', inputSchema: { type: 'object', properties: { commentId: { type: 'string' } }, required: ['commentId'] } },
60
62
  // 8. FOOTNOTES & ENDNOTES
61
- { 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'] } },
62
- { 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'] } },
63
- { name: 'word_insert_endnote', description: 'Insert an endnote 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'] } },
64
- { name: 'word_get_footnotes', description: 'Get all footnotes with index and text content.', inputSchema: { type: 'object', properties: {} } },
65
- { name: 'word_get_endnotes', description: 'Get all endnotes with index and text content.', inputSchema: { type: 'object', properties: {} } },
66
- { name: 'word_delete_footnote', description: 'Delete a footnote by its index (0-based).', inputSchema: { type: 'object', properties: { index: { type: 'number' } }, required: ['index'] } },
67
- { name: 'word_delete_endnote', description: 'Delete an endnote by its index (0-based).', inputSchema: { type: 'object', properties: { index: { type: 'number' } }, required: ['index'] } },
63
+ { name: 'word_insert_footnote', description: '[Footnotes] Insert a footnote anchored to a text match (searches for anchorText, attaches footnote there).', inputSchema: { type: 'object', properties: { anchorText: { type: 'string', description: 'Text to search for as anchor point' }, text: { type: 'string', description: 'Footnote content' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['anchorText', 'text'] } },
64
+ { name: 'word_insert_footnote_at_index', description: '[Footnotes] Insert a footnote at the end of a paragraph by index (no search needed — use when you know the paragraph position).', inputSchema: { type: 'object', properties: { paragraphIndex: { type: 'number', description: 'Paragraph index (0-based)' }, text: { type: 'string', description: 'Footnote content' } }, required: ['paragraphIndex', 'text'] } },
65
+ { name: 'word_insert_endnote', description: '[Footnotes] Insert an endnote anchored to a text match.', inputSchema: { type: 'object', properties: { anchorText: { type: 'string', description: 'Text to search for as anchor point' }, text: { type: 'string', description: 'Endnote content' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['anchorText', 'text'] } },
66
+ { name: 'word_get_footnotes', description: '[Footnotes] Get all footnotes with index and text content.', inputSchema: { type: 'object', properties: {} } },
67
+ { name: 'word_get_endnotes', description: '[Footnotes] Get all endnotes with index and text content.', inputSchema: { type: 'object', properties: {} } },
68
+ { name: 'word_delete_footnote', description: '[Footnotes] Delete a footnote by its 0-based index.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Footnote index (0-based)' } }, required: ['index'] } },
69
+ { name: 'word_delete_endnote', description: '[Footnotes] Delete an endnote by its 0-based index.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Endnote index (0-based)' } }, required: ['index'] } },
68
70
  // 9. TRACK CHANGES
69
- { name: 'word_get_tracked_changes', description: 'Get all tracked changes with index, type, author, date, and text.', inputSchema: { type: 'object', properties: {} } },
70
- { name: 'word_accept_tracked_change', description: 'Accept a tracked change by index.', inputSchema: { type: 'object', properties: { index: { type: 'number' } }, required: ['index'] } },
71
- { name: 'word_reject_tracked_change', description: 'Reject a tracked change by index.', inputSchema: { type: 'object', properties: { index: { type: 'number' } }, required: ['index'] } },
72
- { name: 'word_accept_all_tracked_changes', description: 'Accept all tracked changes.', inputSchema: { type: 'object', properties: {} } },
73
- { name: 'word_reject_all_tracked_changes', description: 'Reject all tracked changes.', inputSchema: { type: 'object', properties: {} } },
71
+ { name: 'word_get_tracked_changes', description: '[Track Changes] Get all tracked changes with index, type (Added/Deleted), author, date, and text.', inputSchema: { type: 'object', properties: {} } },
72
+ { name: 'word_accept_tracked_change', description: '[Track Changes] Accept a single tracked change by its index.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Change index (0-based, from word_get_tracked_changes)' } }, required: ['index'] } },
73
+ { name: 'word_reject_tracked_change', description: '[Track Changes] Reject a single tracked change by its index.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Change index (0-based, from word_get_tracked_changes)' } }, required: ['index'] } },
74
+ { name: 'word_accept_all_tracked_changes', description: '[Track Changes] Accept all tracked changes at once.', inputSchema: { type: 'object', properties: {} } },
75
+ { name: 'word_reject_all_tracked_changes', description: '[Track Changes] Reject all tracked changes at once.', inputSchema: { type: 'object', properties: {} } },
74
76
  // 10. CONTENT CONTROLS
75
- { name: 'word_get_content_controls', description: 'Get all content controls with id, tag, title, type, and text.', inputSchema: { type: 'object', properties: {} } },
76
- { name: 'word_insert_content_control', description: 'Wrap a text match in a content control (RichText or PlainText preserve the anchor text; CheckBox REPLACES the anchor text with a checkbox widget).', inputSchema: { type: 'object', properties: { anchorText: { type: 'string' }, type: { type: 'string', enum: ['RichText', 'PlainText', 'CheckBox'] }, title: { type: 'string' }, tag: { type: 'string' }, color: { 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)' } } } },
77
- { name: 'word_set_content_control_text', description: 'Set text in a content control by ID or tag.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, tag: { type: 'string' }, text: { type: 'string' } }, required: ['text'] } },
77
+ { name: 'word_get_content_controls', description: '[Content Controls] Get all content controls with id, tag, title, type (RichText/PlainText/CheckBox), and text.', inputSchema: { type: 'object', properties: {} } },
78
+ { name: 'word_insert_content_control', description: '[Content Controls] Wrap a text match in a content control. RichText/PlainText preserve anchor text; CheckBox REPLACES it with a checkbox widget.', inputSchema: { type: 'object', properties: { anchorText: { type: 'string', description: 'Text to search for and wrap' }, type: { type: 'string', enum: ['RichText', 'PlainText', 'CheckBox'], description: 'Control type. Default: RichText' }, title: { type: 'string' }, tag: { type: 'string', description: 'Tag for programmatic identification' }, color: { type: 'string', description: 'Border color' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } } } },
79
+ { name: 'word_set_content_control_text', description: '[Content Controls] Set text in a content control identified by ID or tag. Does NOT work on CheckBox controls.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Content control ID (from word_get_content_controls)' }, tag: { type: 'string', description: 'Content control tag (alternative to ID)' }, text: { type: 'string' } }, required: ['text'] } },
78
80
  // 11. BOOKMARKS
79
- { name: 'word_get_bookmarks', description: 'Get all bookmark names.', inputSchema: { type: 'object', properties: {} } },
80
- { name: 'word_insert_bookmark', description: 'Insert a bookmark at anchor text. If a bookmark with the same name already exists, it is moved to the new location (returns warning).', inputSchema: { type: 'object', properties: { name: { type: 'string' }, anchorText: { 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: ['name', 'anchorText'] } },
81
- { name: 'word_delete_bookmark', description: 'Delete a bookmark by name.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
82
- { name: 'word_go_to_bookmark', description: 'Navigate to a bookmark and select its text range.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
83
- { name: 'word_get_bookmark_text', description: 'Get the text content within a named bookmark.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
81
+ { name: 'word_get_bookmarks', description: '[Bookmarks] Get all bookmark names in the document.', inputSchema: { type: 'object', properties: {} } },
82
+ { name: 'word_insert_bookmark', description: '[Bookmarks] Create a named bookmark at a text match. If name already exists, the bookmark is moved (returns warning).', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Bookmark name (must be unique)' }, anchorText: { type: 'string', description: 'Text to search for as bookmark location' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['name', 'anchorText'] } },
83
+ { name: 'word_delete_bookmark', description: '[Bookmarks] Delete a bookmark by name (text remains, only the bookmark reference is removed).', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
84
+ { name: 'word_go_to_bookmark', description: '[Bookmarks] Navigate to a bookmark and select its text range. Returns the bookmark text.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
85
+ { name: 'word_get_bookmark_text', description: '[Bookmarks] Get the text content within a named bookmark.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
84
86
  // 12. HYPERLINKS
85
- { name: 'word_insert_hyperlink', description: 'Insert a hyperlink on existing text.', inputSchema: { type: 'object', properties: { anchorText: { type: 'string' }, url: { 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', 'url'] } },
86
- { name: 'word_get_hyperlinks', description: 'List all hyperlinks with URL, display text, and whether they are internal (TOC) links.', inputSchema: { type: 'object', properties: {} } },
87
- { name: 'word_remove_hyperlink', description: 'Remove a hyperlink from text (keeps the text, removes the link).', inputSchema: { type: 'object', properties: { anchorText: { 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'] } },
87
+ { name: 'word_insert_hyperlink', description: '[Hyperlinks] Add a hyperlink URL to existing text (searches for anchorText, applies the link).', inputSchema: { type: 'object', properties: { anchorText: { type: 'string', description: 'Text to search for and link' }, url: { type: 'string', description: 'URL (must start with http:// or https://)' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['anchorText', 'url'] } },
88
+ { name: 'word_get_hyperlinks', description: '[Hyperlinks] List all hyperlinks with URL, display text, and internal flag (TOC links).', inputSchema: { type: 'object', properties: {} } },
89
+ { name: 'word_remove_hyperlink', description: '[Hyperlinks] Remove a hyperlink from text (keeps the text, removes only the link).', inputSchema: { type: 'object', properties: { anchorText: { type: 'string', description: 'Text of the hyperlink to remove' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['anchorText'] } },
88
90
  // 13. HEADERS & FOOTERS
89
- { name: 'word_get_header_footer', description: 'Get header or footer text.', inputSchema: { type: 'object', properties: { type: { type: 'string', enum: ['header', 'footer'] }, sectionIndex: { type: 'number' }, headerType: { type: 'string', enum: ['Primary', 'FirstPage', 'EvenPages'] } }, required: ['type'] } },
90
- { name: 'word_set_header_footer', description: 'Set header or footer text.', inputSchema: { type: 'object', properties: { type: { type: 'string', enum: ['header', 'footer'] }, text: { type: 'string' }, sectionIndex: { type: 'number' }, headerType: { type: 'string', enum: ['Primary', 'FirstPage', 'EvenPages'] } }, required: ['type', 'text'] } },
91
+ { name: 'word_get_header_footer', description: '[Headers/Footers] Get header or footer text content.', inputSchema: { type: 'object', properties: { type: { type: 'string', enum: ['header', 'footer'] }, sectionIndex: { type: 'number', description: 'Section index (0-based). Default: 0' }, headerType: { type: 'string', enum: ['Primary', 'FirstPage', 'EvenPages'], description: 'Default: Primary' } }, required: ['type'] } },
92
+ { name: 'word_set_header_footer', description: '[Headers/Footers] Set header or footer text (replaces existing content).', inputSchema: { type: 'object', properties: { type: { type: 'string', enum: ['header', 'footer'] }, text: { type: 'string' }, sectionIndex: { type: 'number', description: 'Section index (0-based). Default: 0' }, headerType: { type: 'string', enum: ['Primary', 'FirstPage', 'EvenPages'], description: 'Default: Primary' } }, required: ['type', 'text'] } },
91
93
  // 14. IMAGES
92
- { name: 'word_insert_image', description: 'Insert an image from base64 data.', inputSchema: { type: 'object', properties: { base64: { type: 'string', description: 'Base64-encoded image data' }, location: { type: 'string', enum: ['Start', 'End'] }, width: { type: 'number' }, height: { type: 'number' }, altText: { type: 'string' } }, required: ['base64'] } },
93
- { name: 'word_get_images', description: 'List all inline images with dimensions, alt text, and hyperlinks.', inputSchema: { type: 'object', properties: {} } },
94
- { name: 'word_delete_image', description: 'Delete an inline image by its index (0-based).', inputSchema: { type: 'object', properties: { index: { type: 'number' } }, required: ['index'] } },
94
+ { name: 'word_insert_image', description: '[Images] Insert an inline image from base64-encoded data (PNG, JPEG, or GIF).', inputSchema: { type: 'object', properties: { base64: { type: 'string', description: 'Base64-encoded image data' }, location: { type: 'string', enum: ['Start', 'End'] }, width: { type: 'number', description: 'Width in points' }, height: { type: 'number', description: 'Height in points' }, altText: { type: 'string', description: 'Alt text for accessibility' } }, required: ['base64'] } },
95
+ { name: 'word_get_images', description: '[Images] List all inline images with index, dimensions, alt text, and hyperlinks.', inputSchema: { type: 'object', properties: {} } },
96
+ { name: 'word_delete_image', description: '[Images] Delete an inline image by its 0-based index.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Image index (0-based)' } }, required: ['index'] } },
95
97
  // 15. PAGE LAYOUT & SECTIONS
96
- { name: 'word_get_page_layout', description: 'Get page layout (margins, orientation, paper size) for a section.', inputSchema: { type: 'object', properties: { sectionIndex: { type: 'number' } } } },
97
- { name: 'word_set_page_layout', description: 'Set page margins (in points), orientation, or paper size for a section.', inputSchema: { type: 'object', properties: { orientation: { type: 'string', enum: ['Portrait', 'Landscape'] }, topMargin: { type: 'number' }, bottomMargin: { type: 'number' }, leftMargin: { type: 'number' }, rightMargin: { type: 'number' }, paperSize: { type: 'string', enum: ['Letter', 'A4', 'A3', 'Legal', 'Custom'] }, sectionIndex: { type: 'number' } } } },
98
- { name: 'word_get_sections', description: 'List all sections with their page setup (margins, orientation, paper size).', inputSchema: { type: 'object', properties: {} } },
99
- { name: 'word_insert_page_break', description: 'Insert a page break after a paragraph. Omit paragraphIndex for end of document.', inputSchema: { type: 'object', properties: { paragraphIndex: { type: 'number', description: 'Paragraph index to insert break after. Omit for end of document.' } } } },
100
- { name: 'word_insert_section_break', description: 'Insert a section break after a paragraph.', inputSchema: { type: 'object', properties: { paragraphIndex: { type: 'number' }, breakType: { type: 'string', enum: ['SectionNext', 'SectionContinuous', 'SectionEven', 'SectionOdd'], description: 'Default: SectionNext' } } } },
98
+ { name: 'word_get_page_layout', description: '[Layout] Get page layout (margins, orientation, paper size) for a section.', inputSchema: { type: 'object', properties: { sectionIndex: { type: 'number', description: 'Section index (0-based). Default: 0' } } } },
99
+ { name: 'word_set_page_layout', description: '[Layout] Set page margins (in points, 72pt = 1 inch), orientation, or paper size for a section.', inputSchema: { type: 'object', properties: { orientation: { type: 'string', enum: ['Portrait', 'Landscape'] }, topMargin: { type: 'number', description: 'Points (72 = 1 inch)' }, bottomMargin: { type: 'number', description: 'Points (72 = 1 inch)' }, leftMargin: { type: 'number', description: 'Points (72 = 1 inch)' }, rightMargin: { type: 'number', description: 'Points (72 = 1 inch)' }, paperSize: { type: 'string', enum: ['Letter', 'A4', 'A3', 'Legal', 'Custom'] }, sectionIndex: { type: 'number', description: 'Section index (0-based). Default: 0' } } } },
100
+ { name: 'word_get_sections', description: '[Layout] List all sections with their page setup (margins, orientation, paper size).', inputSchema: { type: 'object', properties: {} } },
101
+ { name: 'word_insert_page_break', description: '[Layout] Insert a page break after a paragraph. Omit paragraphIndex to insert at end of document.', inputSchema: { type: 'object', properties: { paragraphIndex: { type: 'number', description: 'Paragraph index (0-based). Omit for end of document.' } } } },
102
+ { name: 'word_insert_section_break', description: '[Layout] Insert a section break after a paragraph. Cannot be used inside table cells.', inputSchema: { type: 'object', properties: { paragraphIndex: { type: 'number', description: 'Paragraph index (0-based). Omit for end of document.' }, breakType: { type: 'string', enum: ['SectionNext', 'SectionContinuous', 'SectionEven', 'SectionOdd'], description: 'Default: SectionNext' } } } },
101
103
  // 16. CUSTOM PROPERTIES
102
- { name: 'word_get_custom_properties', description: 'Get all custom document properties (key-value pairs with types).', inputSchema: { type: 'object', properties: {} } },
103
- { name: 'word_set_custom_property', description: 'Set a custom document property. Creates or updates the key-value pair.', inputSchema: { type: 'object', properties: { key: { type: 'string' }, value: { type: 'string' } }, required: ['key', 'value'] } },
104
- { name: 'word_delete_custom_property', description: 'Delete a custom document property by key.', inputSchema: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] } },
104
+ { name: 'word_get_custom_properties', description: '[Properties] Get all custom document properties (key-value pairs with types).', inputSchema: { type: 'object', properties: {} } },
105
+ { name: 'word_set_custom_property', description: '[Properties] Set a custom document property. Creates or updates the key-value pair.', inputSchema: { type: 'object', properties: { key: { type: 'string' }, value: { type: 'string' } }, required: ['key', 'value'] } },
106
+ { name: 'word_delete_custom_property', description: '[Properties] Delete a custom document property by key.', inputSchema: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] } },
105
107
  // 17. ADVANCED INSERTION & FIELDS
106
- { name: 'word_insert_html', description: 'Insert HTML content that Word converts to native formatting. Supports headings, bold, italic, links, tables.', inputSchema: { type: 'object', properties: { html: { type: 'string' }, location: { type: 'string', enum: ['Start', 'End'] } }, required: ['html'] } },
107
- { 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'] } },
108
- { 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' } } } },
109
- { name: 'word_get_fields', description: 'Get all fields in the document (hyperlinks, TOC entries, page numbers, etc).', inputSchema: { type: 'object', properties: {} } },
108
+ { name: 'word_insert_html', description: '[Advanced] Insert HTML content that Word converts to native formatting. Supports headings, bold, italic, links, tables, and lists.', inputSchema: { type: 'object', properties: { html: { type: 'string' }, location: { type: 'string', enum: ['Start', 'End'], description: 'Default: End' } }, required: ['html'] } },
109
+ { name: 'word_insert_ooxml', description: '[Advanced] Insert raw Office Open XML for precise formatting control when HTML is insufficient. Must follow pkg:package structure.', inputSchema: { type: 'object', properties: { ooxml: { type: 'string' }, location: { type: 'string', enum: ['Start', 'End'], description: 'Default: End' } }, required: ['ooxml'] } },
110
+ { name: 'word_insert_table_of_contents', description: '[Advanced] Insert a table of contents based on heading styles. Note: after insertion, heading text appears twice (in TOC and body) — search matches TOC entries first.', inputSchema: { type: 'object', properties: { location: { type: 'string', enum: ['Start', 'End'], description: 'Default: Start' }, switches: { type: 'string', description: 'TOC field switches. Default: \\o "1-3" \\h \\z \\u' } } } },
111
+ { name: 'word_get_fields', description: '[Advanced] Get all fields in the document (hyperlinks, TOC entries, page numbers, etc).', inputSchema: { type: 'object', properties: {} } },
110
112
  // 18. EQUATIONS
111
- { 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'] } },
113
+ { name: 'word_insert_equation', description: '[Equations] Insert a LaTeX math equation as a native editable Word equation. Supports fractions, roots, integrals, matrices, Greek letters, and all standard LaTeX math. Display mode (default) inserts a centered block equation. Inline mode inserts after a search match (provide anchorText) or at cursor position.', 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 (default) = centered block equation, false = inline equation' }, location: { type: 'string', enum: ['Start', 'End'], description: 'Where to insert display-mode equations. Default: End' }, anchorText: { type: 'string', description: 'For inline mode: search for this text and insert equation after it (avoids manual cursor positioning)' }, occurrence: { type: 'number', description: 'Which anchorText match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive anchor matching. Default: false' } }, required: ['latex'] } },
114
+ // 19. BATCH OPERATIONS
115
+ { name: 'word_batch', description: '[Batch] Execute multiple operations in a single call to reduce round-trips. Each operation is a tool call object with "tool" and "args" fields. Operations execute sequentially — if one fails, subsequent operations are skipped. Returns results array with success/error for each operation. Consecutive native operations are sent to Word in one message for maximum speed.', inputSchema: { type: 'object', properties: { operations: { type: 'array', items: { type: 'object', properties: { tool: { type: 'string', description: 'Tool name (e.g. "word_insert_paragraph")' }, args: { type: 'object', description: 'Arguments for the tool' } }, required: ['tool'] }, description: 'Array of {tool, args} objects to execute sequentially' } }, required: ['operations'] } },
112
116
  ];
113
117
 
114
118
  const toolActionMap = {
115
119
  word_get_text: 'getDocumentText', word_get_document_properties: 'getDocumentProperties',
116
120
  word_set_document_properties: 'setDocumentProperties', word_save: 'saveDocument',
117
- word_clear: 'clearDocument',
118
- word_create_document: 'createNewDocument',
121
+ word_clear: 'clearDocument', word_create_document: 'createNewDocument',
119
122
  word_get_word_count: 'getWordCount', word_get_styles: 'getStyles',
120
123
  word_get_coauthors: 'getCoauthors', word_set_change_tracking: 'setChangeTracking',
121
124
  word_get_paragraphs: 'getParagraphs', word_get_paragraph_by_index: 'getParagraphByIndex',
122
- word_insert_paragraph: 'insertParagraph', word_delete_paragraph: 'deleteParagraph',
125
+ word_insert_paragraph: 'insertParagraph', word_insert_paragraph_at_index: 'insertParagraphAtIndex',
126
+ word_delete_paragraph: 'deleteParagraph', word_replace_paragraph_text: 'replaceParagraphText',
123
127
  word_set_paragraph_style: 'setParagraphStyle', word_set_paragraph_spacing: 'setParagraphSpacing',
124
128
  word_search: 'search', word_search_and_replace: 'searchAndReplace',
125
- word_insert_text: 'insertText', word_get_selection_info: 'getSelectionInfo',
129
+ word_insert_text_at_match: 'insertText', word_get_selection_info: 'getSelectionInfo',
126
130
  word_insert_text_at_selection: 'insertTextAtSelection', word_insert_line_break: 'insertLineBreak',
127
131
  word_format_text: 'formatRange', word_clear_formatting: 'clearFormatting',
128
132
  word_get_font_info: 'getFontInfo',
129
- word_insert_table: 'insertTable', word_get_tables: 'getTables',
133
+ word_insert_table: 'insertTable', word_list_tables: 'getTables',
130
134
  word_get_table_data: 'getTableData', word_set_table_cell: 'setTableCell',
131
135
  word_add_table_row: 'addTableRow', word_delete_table_row: 'deleteTableRow',
132
136
  word_merge_table_cells: 'mergeTableCells', word_split_table_cell: 'splitTableCell',
133
137
  word_set_table_style: 'setTableStyle', word_set_table_cell_shading: 'setTableCellShading',
134
138
  word_insert_list: 'insertList', word_get_list_info: 'getListInfo', word_set_list_level: 'setListLevel',
135
- word_add_comment: 'addComment', word_get_comments: 'getComments',
139
+ word_add_comment: 'addComment', word_get_comments: 'getCommentsWithAnchor',
136
140
  word_get_comment_replies: 'getCommentReplies', word_reply_to_comment: 'replyToComment',
137
141
  word_resolve_comment: 'resolveComment', word_delete_comment: 'deleteComment',
138
- word_get_comment_anchor: 'getCommentAnchor', word_get_comments_with_anchor: 'getCommentsWithAnchor',
139
142
  word_insert_footnote: 'insertFootnote', word_insert_footnote_at_index: 'insertFootnoteAtIndex',
140
143
  word_insert_endnote: 'insertEndnote', word_get_footnotes: 'getFootnotes',
141
144
  word_get_endnotes: 'getEndnotes', word_delete_footnote: 'deleteFootnote', word_delete_endnote: 'deleteEndnote',
@@ -158,6 +161,8 @@ const toolActionMap = {
158
161
  word_delete_custom_property: 'deleteCustomProperty',
159
162
  word_insert_html: 'insertHtml', word_insert_ooxml: 'insertOoxml',
160
163
  word_insert_table_of_contents: 'insertTableOfContents', word_get_fields: 'getFields',
164
+ // word_insert_equation, word_batch, word_get_document_outline, word_move_paragraph
165
+ // are handled directly in index.js (not via taskpane action map)
161
166
  };
162
167
 
163
168
  module.exports = { tools, toolActionMap };
@@ -6,25 +6,52 @@ const USAGE_GUIDE = `# MCP Word Bridge — Usage Guide
6
6
 
7
7
  Controls a live Word document through an Office Add-in. All operations execute immediately in Word.
8
8
 
9
+ ## Quick Start — Read This First
10
+
11
+ 1. **Read before writing** — call \`word_get_document_outline\` or \`word_get_paragraphs\` to understand the document structure
12
+ 2. **Use the right tool for the job:**
13
+ - Appending content → \`word_insert_paragraph\` (Start/End)
14
+ - Inserting at a position → \`word_insert_paragraph_at_index\` (Before/After by index)
15
+ - Editing existing text → \`word_replace_paragraph_text\` (by index, safe for collaboration)
16
+ - Bulk find/replace → \`word_search_and_replace\` (all occurrences)
17
+ - Inserting adjacent to text → \`word_insert_text_at_match\` (searches then inserts)
18
+ 3. **Batch multiple operations** — use \`word_batch\` to send up to 50 operations in one call
19
+ 4. **Save explicitly** — call \`word_save\` after significant changes
20
+
9
21
  ## Reading Content
10
- - \`word_get_paragraphs\` — structured content (text, style, alignment, isTocEntry). Paginate with start/end.
22
+ - \`word_get_document_outline\` — heading hierarchy tree (fast structural overview)
23
+ - \`word_get_paragraphs\` — all paragraphs with text, style, alignment. Paginate with start/end.
11
24
  - \`word_get_text\` — quick plain-text dump (no structure)
12
25
  - \`word_search\` — locate text before operating on it
13
26
 
14
27
  ## Document Lifecycle
15
- - \`word_clear\` — clear all body content in one call (fast reset). Leaves one empty Normal paragraph. Does not clear headers/footers or custom properties.
16
- - \`word_create_document\` — create and open a new blank document in a separate Word window. The add-in stays connected to the original document. Optionally pass base64-encoded .docx as template.
28
+ - \`word_clear\` — clear all body content (fast reset). Does not clear headers/footers.
29
+ - \`word_create_document\` — create a new document in a separate Word window
17
30
  - \`word_save\` — persist changes to disk
18
31
 
19
32
  ## Editing Text
20
- - \`word_search_and_replace\` — bulk find/replace
21
- - \`word_insert_text\` — insert before/after a search match (use \`occurrence\` for Nth match)
33
+ - \`word_replace_paragraph_text\` — replace text by paragraph index (PREFERRED for collaborative docs)
34
+ - \`word_search_and_replace\` — bulk find/replace across document
35
+ - \`word_insert_text_at_match\` — insert before/after a search match (use \`occurrence\` for Nth match)
22
36
  - \`word_insert_text_at_selection\` — insert at cursor or replace selection
23
37
  - Verify edits with \`word_search\` or \`word_get_paragraphs\`
24
38
 
39
+ ## Batch Operations
40
+ \`word_batch\` executes multiple operations in a single MCP call:
41
+ \`\`\`json
42
+ {"operations": [
43
+ {"tool": "word_insert_paragraph", "args": {"text": "Hello", "style": "Heading 1"}},
44
+ {"tool": "word_insert_paragraph", "args": {"text": "World", "style": "Normal"}},
45
+ {"tool": "word_save", "args": {}}
46
+ ]}
47
+ \`\`\`
48
+ Operations run sequentially. If one fails, the rest are skipped. Maximum 50 per batch.
49
+
50
+ **Performance:** Consecutive standard operations (paragraphs, search, formatting, tables, etc.) are bundled into a single WebSocket message to Word — executing in one round-trip instead of one per operation. Only server-composed tools (equations, outline, move_paragraph) break the batch into segments.
51
+
25
52
  ## Search Behavior (applies to ALL search-based tools)
26
- - Case-insensitive by default. Pass \`matchCase: true\` for exact match.
27
- - 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.
53
+ - Case-insensitive by default. Pass \`matchCase: true\` for exact case match.
54
+ - Affected tools: search, search_and_replace, insert_text_at_match, format_text, insert_footnote, add_comment, insert_hyperlink, insert_bookmark, insert_content_control, clear_formatting, get_font_info, insert_line_break, remove_hyperlink, insert_endnote, insert_equation (inline with anchor).
28
55
 
29
56
  ## Alignment Values
30
57
  The bridge normalizes aliases: Left, Center/Centered, Right, Justify/Justified.
@@ -32,19 +59,18 @@ The bridge normalizes aliases: Left, Center/Centered, Right, Justify/Justified.
32
59
  ## Change Tracking
33
60
  - Call \`word_set_change_tracking({mode:"TrackAll"})\` BEFORE edits for tracked changes
34
61
  - Adjacent insertions by the same author may coalesce into a single tracked change
35
- - \`search_and_replace\` with tracking may only expose the "Added" half; use \`accept_all_tracked_changes\` if granular control isn't needed
62
+ - \`search_and_replace\` with tracking may only expose the "Added" half
36
63
 
37
64
  ## Comments — CRITICAL PATTERNS
38
65
 
39
66
  ### Reading
40
- - \`word_get_comments_with_anchor\` — preferred: returns comments + their anchored document text
41
- - \`word_get_comment_replies\` — reply thread for a specific comment
67
+ - \`word_get_comments\` — returns all comments with their anchored document text, author, dates, resolved status
42
68
 
43
69
  ### Comment + text editing interaction
44
70
  **Problem:** \`word_search_and_replace\` on text anchoring a comment collapses the anchor (shrinks to empty). The comment survives but loses its positional context.
45
71
 
46
72
  **Safe pattern:**
47
- 1. \`word_get_comments_with_anchor\` — identify commented text
73
+ 1. \`word_get_comments\` — identify commented text via anchorText field
48
74
  2. If replacement overlaps a comment anchor:
49
75
  a. \`word_reply_to_comment\` explaining resolution (if appropriate)
50
76
  b. \`word_resolve_comment\`
@@ -53,19 +79,15 @@ The bridge normalizes aliases: Left, Center/Centered, Right, Justify/Justified.
53
79
 
54
80
  **Avoid:** replacing text first (anchor collapses), or deleting+recreating comments (loses author/date/thread).
55
81
 
56
- ### Adding
57
- - \`word_add_comment\` — anchor to a text match
58
- - \`word_reply_to_comment\` — reply to existing thread
59
- - \`word_resolve_comment\` — hides in Word UI, preserves history
60
-
61
82
  ## Tables
62
- - 0-based indexing: tableIndex, row, col
63
- - \`word_get_tables\` for overview, \`word_get_table_data\` for a specific table's cells
83
+ - All indices 0-based: tableIndex, row, col
84
+ - \`word_list_tables\` for metadata overview (count, dimensions, style no cell values)
85
+ - \`word_get_table_data\` for a specific table's cell values
64
86
  - Table cell paragraphs can't be structurally deleted (only cleared)
65
87
  - Can't insert page/section breaks inside table cells
66
88
 
67
89
  ## Footnotes & Endnotes
68
- - \`word_insert_footnote\` — anchor to text match
90
+ - \`word_insert_footnote\` — anchor to text match (searches for anchorText)
69
91
  - \`word_insert_footnote_at_index\` — anchor to paragraph by index (no search needed)
70
92
 
71
93
  ## Page Layout
@@ -79,28 +101,34 @@ The bridge normalizes aliases: Left, Center/Centered, Right, Justify/Justified.
79
101
  ## TOC Behavior
80
102
  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.
81
103
 
82
- ## Error Messages
83
- - "Word taskpane not connected" — user must open the MCP Word Bridge add-in in Word
84
- - "Anchor not found" — search text not found in document
85
- - "Occurrence N not found" — match index out of range
86
- - Timeout: 30s default, 60s for HTML/OOXML/styles/TOC insertion
87
-
88
104
  ## Equations
89
105
  - \`word_insert_equation\` takes a LaTeX string and inserts a native Word equation
90
- - \`displayMode: true\` (default) = centered block equation; \`false\` = inline
91
- - 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\`.
92
- - After an inline equation, use \`word_insert_text_at_selection\` (not \`word_insert_paragraph\`) to continue text in the same paragraph.
106
+ - \`displayMode: true\` (default) = centered block equation
107
+ - \`displayMode: false\` = inline equation
108
+ - For inline equations, provide \`anchorText\` to position the equation after a search match (avoids manual cursor management)
109
+ - Without \`anchorText\`, inline equations insert at the current cursor position
93
110
  - Supports: fractions, roots, integrals, sums, matrices, Greek letters, AMS math
94
- - Invalid LaTeX returns a descriptive parse error (not a crash)
111
+ - Invalid LaTeX returns a descriptive parse error
95
112
  - The equation is fully editable in Word's built-in equation editor
96
113
  - Examples: \`\\\\frac{a}{b}\`, \`\\\\int_0^\\\\infty e^{-x} dx\`, \`\\\\sum_{i=1}^n x_i\`
97
114
 
115
+ ## Error Messages
116
+ - "Word taskpane not connected" — user must open the MCP Word Bridge add-in in Word
117
+ - "Anchor not found" — search text not found in document
118
+ - "Occurrence N not found" — match index out of range
119
+ - Timeout: 30s default, 60s for HTML/OOXML/styles/TOC insertion
120
+
98
121
  ## Best Practices
99
- 1. Read before writing understand document structure first
100
- 2. \`word_get_comments_with_anchor\` before bulk replacements to avoid anchor damage
101
- 3. Enable change tracking for collaborative documents
102
- 4. \`word_save\` explicitly after significant changes
103
- 5. Resolve comments rather than deleting them (preserves audit trail)
122
+ 1. Start with \`word_get_document_outline\` to understand structure
123
+ 2. \`word_get_comments\` before bulk replacements to avoid anchor damage
124
+ 3. Use \`word_batch\` for multiple sequential operations (reduces latency)
125
+ 4. In collaborative editing, prefer index-based tools over search-based:
126
+ - \`word_replace_paragraph_text\` over \`word_search_and_replace\`
127
+ - \`word_insert_paragraph_at_index\` over \`word_insert_paragraph\`
128
+ 5. Enable change tracking for collaborative documents
129
+ 6. \`word_save\` explicitly after significant changes
130
+ 7. Resolve comments rather than deleting them (preserves audit trail)
131
+ 8. Use \`word_move_paragraph\` to reorder content (avoids index-shifting bugs with manual delete+insert)
104
132
  `;
105
133
 
106
134
  module.exports = USAGE_GUIDE;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-word-bridge",
3
- "version": "3.4.2",
3
+ "version": "3.5.0",
4
4
  "description": "MCP server for live Word document editing via Office Add-in",
5
5
  "main": "index.js",
6
6
  "bin": {
package/taskpane-app.js CHANGED
@@ -70,6 +70,30 @@ async function handleCommand(cmd) {
70
70
  // --- Command registry ---
71
71
  const commands = {};
72
72
 
73
+ // == BATCH EXECUTION (runs multiple actions in one WebSocket message) ==
74
+ commands.batchExecute = async (p) => {
75
+ if (!p.operations || !Array.isArray(p.operations) || p.operations.length === 0) {
76
+ throw new Error('operations must be a non-empty array');
77
+ }
78
+ log(' batch: ' + p.operations.length + ' ops', 'log-batch');
79
+ const results = [];
80
+ for (let i = 0; i < p.operations.length; i++) {
81
+ const op = p.operations[i];
82
+ try {
83
+ const handler = commands[op.action];
84
+ if (!handler) throw new Error('Unknown action: ' + op.action);
85
+ log(' [' + (i + 1) + '/' + p.operations.length + '] ' + op.action, 'log-batch');
86
+ const result = await handler(op.params || {});
87
+ results.push({ index: i, success: true, result: result });
88
+ } catch (e) {
89
+ log(' [' + (i + 1) + '/' + p.operations.length + '] ' + (op.action || '?') + ' ✗ ' + e.message, 'log-err');
90
+ results.push({ index: i, success: false, error: e.message });
91
+ break; // stop on first error
92
+ }
93
+ }
94
+ return { results: results };
95
+ };
96
+
73
97
  // == BASIC ==
74
98
  commands.ping = async () => ({ status: 'ok', wordReady });
75
99
 
@@ -231,6 +255,15 @@ commands.deleteParagraph = (p) => Word.run(async (ctx) => {
231
255
  if (p.index >= paragraphs.items.length) throw new Error('Paragraph index out of range. Valid indices: 0-' + (paragraphs.items.length - 1) + ' (document has ' + paragraphs.items.length + ' paragraphs).');
232
256
  paragraphs.items[p.index].delete();
233
257
  await ctx.sync();
258
+ // Move cursor to the paragraph that now occupies this position (or previous if at end)
259
+ const parasAfter = ctx.document.body.paragraphs;
260
+ parasAfter.load('text');
261
+ await ctx.sync();
262
+ if (parasAfter.items.length > 0) {
263
+ const cursorIdx = Math.min(p.index, parasAfter.items.length - 1);
264
+ parasAfter.items[cursorIdx].getRange('Start').select();
265
+ await ctx.sync();
266
+ }
234
267
  return { success: true };
235
268
  });
236
269
 
@@ -270,6 +303,9 @@ commands.setParagraphStyle = (p) => Word.run(async (ctx) => {
270
303
  para.alignment = alignment;
271
304
  await ctx.sync();
272
305
  }
306
+ // Move cursor to the styled paragraph
307
+ para.getRange('End').select();
308
+ await ctx.sync();
273
309
  return { success: true };
274
310
  });
275
311
 
@@ -400,8 +436,7 @@ commands.getTables = () => Word.run(async (ctx) => {
400
436
  tables.load('rowCount,values,style,headerRowCount');
401
437
  await ctx.sync();
402
438
  const items = tables.items.map((t, i) => ({
403
- index: i, rowCount: t.rowCount, style: t.style, headerRowCount: t.headerRowCount,
404
- values: t.values
439
+ index: i, rowCount: t.rowCount, columnCount: (t.values && t.values[0]) ? t.values[0].length : 0, style: t.style, headerRowCount: t.headerRowCount
405
440
  }));
406
441
  return { count: items.length, tables: items };
407
442
  });
@@ -1180,6 +1215,35 @@ commands.setParagraphSpacing = (p) => Word.run(async (ctx) => {
1180
1215
  return { success: true };
1181
1216
  });
1182
1217
 
1218
+ // == SURGICAL PARAGRAPH EDITING ==
1219
+ commands.replaceParagraphText = (p) => Word.run(async (ctx) => {
1220
+ if (p.index < 0) throw new Error('Index must be non-negative');
1221
+ const paragraphs = ctx.document.body.paragraphs;
1222
+ paragraphs.load('text');
1223
+ await ctx.sync();
1224
+ if (p.index >= paragraphs.items.length) throw new Error('Paragraph index out of range. Valid indices: 0-' + (paragraphs.items.length - 1) + ' (document has ' + paragraphs.items.length + ' paragraphs).');
1225
+ const para = paragraphs.items[p.index];
1226
+ const inserted = para.insertText(p.text, Word.InsertLocation.replace);
1227
+ inserted.getRange('End').select();
1228
+ await ctx.sync();
1229
+ return { success: true };
1230
+ });
1231
+
1232
+ commands.insertParagraphAtIndex = (p) => Word.run(async (ctx) => {
1233
+ if (p.index < 0) throw new Error('Index must be non-negative');
1234
+ const paragraphs = ctx.document.body.paragraphs;
1235
+ paragraphs.load('text');
1236
+ await ctx.sync();
1237
+ if (p.index >= paragraphs.items.length) throw new Error('Paragraph index out of range. Valid indices: 0-' + (paragraphs.items.length - 1) + ' (document has ' + paragraphs.items.length + ' paragraphs).');
1238
+ const ref = paragraphs.items[p.index];
1239
+ const location = p.location === 'Before' ? Word.InsertLocation.before : Word.InsertLocation.after;
1240
+ const newPara = ref.insertParagraph(p.text, location);
1241
+ newPara.style = p.style || 'Normal';
1242
+ newPara.getRange('End').select();
1243
+ await ctx.sync();
1244
+ return { success: true };
1245
+ });
1246
+
1183
1247
  // == BOOKMARK NAVIGATION ==
1184
1248
  commands.goToBookmark = (p) => Word.run(async (ctx) => {
1185
1249
  let range;
package/taskpane.html CHANGED
@@ -38,6 +38,7 @@
38
38
  .log-cmd { color: #6cb6ff; }
39
39
  .log-ok { color: #4ec94e; }
40
40
  .log-err { color: #f87171; }
41
+ .log-batch { color: #f0a050; }
41
42
  </style>
42
43
  </head>
43
44
  <body>