@tiwater/office-mcp 0.21.51 → 0.21.53

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.
@@ -1,4 +1,5 @@
1
1
  import process from 'node:process';
2
+ import { withCommandContext } from './tool-runtime.mjs';
2
3
 
3
4
  const JSONRPC_VERSION = '2.0';
4
5
  const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07'];
@@ -30,11 +31,16 @@ export class McpStdioServer {
30
31
  this.lineBuffer = '';
31
32
  this.binaryBuffer = Buffer.alloc(0);
32
33
  this.initialized = false;
34
+ this.calls = new Map();
33
35
  }
34
36
 
35
37
  start() {
36
38
  process.stdin.on('data', chunk => this.#onData(chunk));
37
- process.stdin.on('end', () => process.exit(0));
39
+ process.stdin.on('end', () => {
40
+ for (const call of this.calls.values()) call.controller.abort();
41
+ // Child cancellation must finish before the transport exits.
42
+ void Promise.allSettled([...this.calls.values()].map(call => call.promise)).then(() => process.exit(0));
43
+ });
38
44
  }
39
45
 
40
46
  #onData(chunk) {
@@ -122,6 +128,10 @@ export class McpStdioServer {
122
128
  this.initialized = true;
123
129
  return;
124
130
  }
131
+ case 'notifications/cancelled': {
132
+ this.calls.get(params.requestId)?.controller.abort();
133
+ return;
134
+ }
125
135
  case 'ping': {
126
136
  if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, result: {} });
127
137
  return;
@@ -131,14 +141,21 @@ export class McpStdioServer {
131
141
  return;
132
142
  }
133
143
  case 'tools/call': {
144
+ if (isNotification) return;
145
+ if (this.calls.has(id)) throw Object.assign(new Error('Duplicate active request id'), { code: -32600 });
134
146
  const name = params?.name;
135
147
  const args = params?.arguments ?? {};
136
148
  if (typeof name !== 'string' || !name) {
137
149
  if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, error: toError(-32602, 'Invalid params: missing tool name') });
138
150
  return;
139
151
  }
140
- const result = await this.callTool(name, args);
141
- if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, result });
152
+ const controller = new AbortController();
153
+ const promise = Promise.resolve().then(() => withCommandContext({signal:controller.signal}, () => this.callTool(name, args)));
154
+ this.calls.set(id, {controller, promise});
155
+ try {
156
+ const result = await promise;
157
+ if (!controller.signal.aborted) writeMessage({ jsonrpc: JSONRPC_VERSION, id, result });
158
+ } finally { this.calls.delete(id); }
142
159
  return;
143
160
  }
144
161
  default: {
@@ -4,6 +4,52 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { spawn } from 'node:child_process';
7
+ import { AsyncLocalStorage } from 'node:async_hooks';
8
+
9
+ const invocation = new AsyncLocalStorage();
10
+ // Capacity is deployment-specific. Unconfigured execution preserves the
11
+ // pre-limiter concurrency; deadlines, cancellation and output caps still apply.
12
+ const maxCommands = process.env.TIWATER_MCP_MAX_COMMANDS === undefined
13
+ ? Infinity : positiveInteger(process.env.TIWATER_MCP_MAX_COMMANDS, 'TIWATER_MCP_MAX_COMMANDS');
14
+ const maxQueued = process.env.TIWATER_MCP_MAX_QUEUED === undefined
15
+ ? Infinity : positiveInteger(process.env.TIWATER_MCP_MAX_QUEUED, 'TIWATER_MCP_MAX_QUEUED');
16
+ let activeCommands = 0;
17
+ const commandQueue = [];
18
+
19
+ export function withCommandContext(context, fn) { return invocation.run(context, fn); }
20
+
21
+ function positiveInteger(value, name) {
22
+ const result = Number(value);
23
+ if (!Number.isSafeInteger(result) || result < 1 || result > 2_147_483_647) throw new Error(`${name} must be a positive bounded integer`);
24
+ return result;
25
+ }
26
+
27
+ function commandError(code, message, executionStarted = false) {
28
+ return Object.assign(new Error(message), { code, executionStarted });
29
+ }
30
+
31
+ function acquireCommand(signal) {
32
+ if (signal.aborted) return Promise.reject(signal.reason);
33
+ if (activeCommands < maxCommands) { activeCommands += 1; return Promise.resolve(releaseCommand); }
34
+ if (commandQueue.length >= maxQueued) return Promise.reject(commandError('EBUSY', 'Command queue is full; execution did not start'));
35
+ return new Promise((resolve, reject) => {
36
+ const entry = { resolve, signal, abort: () => {
37
+ const index = commandQueue.indexOf(entry);
38
+ if (index >= 0) commandQueue.splice(index, 1);
39
+ reject(signal.reason);
40
+ } };
41
+ commandQueue.push(entry);
42
+ signal.addEventListener('abort', entry.abort, { once: true });
43
+ });
44
+ }
45
+
46
+ function releaseCommand() {
47
+ const next = commandQueue.shift();
48
+ if (next) {
49
+ next.signal.removeEventListener('abort', next.abort);
50
+ next.resolve(releaseCommand);
51
+ } else activeCommands -= 1;
52
+ }
7
53
 
8
54
  const sharedDir = path.dirname(fileURLToPath(import.meta.url));
9
55
  export const repoRoot = path.resolve(sharedDir, '..', '..');
@@ -27,6 +73,22 @@ export function commandCandidate(command, argsPrefix = [], options = {}) {
27
73
  }
28
74
 
29
75
  export async function runCandidateChain(candidates, args, options = {}) {
76
+ options = { ...invocation.getStore(), ...options };
77
+ const timeoutMs = positiveInteger(options.timeoutMs ?? process.env.TIWATER_MCP_COMMAND_TIMEOUT_MS ?? 1_800_000, 'timeoutMs');
78
+ const controller = new AbortController();
79
+ const cancel = () => controller.abort(commandError('ABORT_ERR', 'Command cancelled; reconcile any started side effect before retrying'));
80
+ if (options.signal?.aborted) cancel();
81
+ else options.signal?.addEventListener('abort', cancel, { once: true });
82
+ const deadline = setTimeout(() => controller.abort(commandError('ETIMEDOUT', 'Command deadline exceeded; reconcile any started side effect before retrying')), timeoutMs);
83
+ try {
84
+ return await runCandidates(candidates, args, { ...options, signal: controller.signal });
85
+ } finally {
86
+ clearTimeout(deadline);
87
+ options.signal?.removeEventListener('abort', cancel);
88
+ }
89
+ }
90
+
91
+ async function runCandidates(candidates, args, options) {
30
92
  const errors = [];
31
93
  for (const candidate of candidates) {
32
94
  try {
@@ -77,25 +139,49 @@ export function requireString(value, label) {
77
139
  }
78
140
 
79
141
  async function runCommand(candidate, args, options) {
142
+ const release = await acquireCommand(options.signal);
143
+ try {
144
+ options.signal.throwIfAborted();
80
145
  const env = await withDotnetRoot({ ...process.env, ...(candidate.env || {}), ...(options.env || {}) });
146
+ options.signal.throwIfAborted();
81
147
  const cwd = candidate.cwd || options.cwd || repoRoot;
82
148
  const commandArgs = [...(candidate.argsPrefix || []), ...args];
149
+ const maxOutputBytes = positiveInteger(options.maxOutputBytes ?? process.env.TIWATER_MCP_MAX_OUTPUT_BYTES ?? 67_108_864, 'maxOutputBytes');
150
+ const killGraceMs = positiveInteger(options.killGraceMs ?? 1000, 'killGraceMs');
83
151
 
84
152
  return await new Promise((resolve, reject) => {
85
- const child = spawn(candidate.command, commandArgs, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
86
- let stdout = '';
87
- let stderr = '';
88
-
89
- child.stdout.on('data', chunk => {
90
- stdout += chunk.toString();
91
- });
92
-
93
- child.stderr.on('data', chunk => {
94
- stderr += chunk.toString();
95
- });
96
-
97
- child.on('error', reject);
153
+ const grouped = process.platform !== 'win32';
154
+ const child = spawn(candidate.command, commandArgs, { cwd, env, detached: grouped, stdio: ['ignore', 'pipe', 'pipe'] });
155
+ const stdoutChunks = [], stderrChunks = [];
156
+ let outputBytes = 0, failure = null, killTimer = null;
157
+ const kill = signal => {
158
+ try { if (grouped && child.pid) process.kill(-child.pid, signal); else child.kill(signal); }
159
+ catch (error) { if (error.code !== 'ESRCH') failure ||= error; }
160
+ };
161
+ const stop = error => {
162
+ if (failure) return;
163
+ failure = commandError(error.code || 'ABORT_ERR', error.message, Boolean(child.pid));
164
+ kill('SIGTERM');
165
+ killTimer = setTimeout(() => kill('SIGKILL'), killGraceMs);
166
+ };
167
+ const abort = () => stop(options.signal.reason);
168
+ options.signal.addEventListener('abort', abort, { once: true });
169
+ const collect = (chunks, chunk) => {
170
+ if (failure) return;
171
+ outputBytes += chunk.length;
172
+ if (outputBytes > maxOutputBytes) { stop(commandError('ENOBUFS', 'Command output limit exceeded; output is incomplete')); return; }
173
+ chunks.push(chunk);
174
+ };
175
+ child.stdout.on('data', chunk => collect(stdoutChunks, chunk));
176
+ child.stderr.on('data', chunk => collect(stderrChunks, chunk));
177
+ child.on('error', error => { failure ||= error; });
98
178
  child.on('close', code => {
179
+ clearTimeout(killTimer);
180
+ options.signal.removeEventListener('abort', abort);
181
+ // A descendant may outlive the leader after closing inherited pipes.
182
+ if (failure) { kill('SIGKILL'); reject(failure); return; }
183
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
184
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
99
185
  const allowedExitCodes = options.allowedExitCodes ?? [0];
100
186
  if (allowedExitCodes.includes(code)) {
101
187
  resolve({ code, stdout, stderr, command: candidate.command, args: commandArgs, cwd });
@@ -104,6 +190,7 @@ async function runCommand(candidate, args, options) {
104
190
  reject(new Error(`${candidate.command} ${commandArgs.join(' ')} failed with exit code ${code}\n${stderr || stdout}`));
105
191
  });
106
192
  });
193
+ } finally { release(); }
107
194
  }
108
195
 
109
196
  async function withDotnetRoot(env) {
@@ -48,6 +48,12 @@
48
48
  "additionalProperties": false
49
49
  }
50
50
  },
51
+ "changesInput": {
52
+ "type": "string",
53
+ "minLength": 1,
54
+ "description": "Absolute path to a JSON file containing the same non-empty changes array accepted by changes. Use this for large batches so the array stays out of the tool call.",
55
+ "x-tiwater-file-role": "read"
56
+ },
51
57
  "output": {
52
58
  "type": "string",
53
59
  "minLength": 1,
@@ -63,7 +69,6 @@
63
69
  },
64
70
  "required": [
65
71
  "input",
66
- "changes",
67
72
  "output",
68
73
  "receiptOutput"
69
74
  ],
@@ -2,7 +2,7 @@
2
2
  "schema": "tiwater.office-provider-contract-manifest/v1",
3
3
  "provider": {
4
4
  "id": "@tiwater/office-mcp",
5
- "version": "0.21.51"
5
+ "version": "0.21.53"
6
6
  },
7
7
  "tools": [
8
8
  {
@@ -240,11 +240,11 @@
240
240
  "name": "docx_set_text",
241
241
  "providerContract": {
242
242
  "source": "packages/docx-cli/contracts/mcp-input/docx_set_text.schema.json",
243
- "sha256": "45d8aa5d95402ac5b17fe07540355f81afa0804214ef319f33d7da11cf3c56cc"
243
+ "sha256": "6a69b2ba25551698b8cbc6040fda2c1d2b81afde4a9629a613ca37c9120bebe6"
244
244
  },
245
245
  "inputContract": {
246
246
  "path": "office/contracts/docx_set_text.schema.json",
247
- "sha256": "45d8aa5d95402ac5b17fe07540355f81afa0804214ef319f33d7da11cf3c56cc"
247
+ "sha256": "6a69b2ba25551698b8cbc6040fda2c1d2b81afde4a9629a613ca37c9120bebe6"
248
248
  }
249
249
  },
250
250
  {
package/office/index.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  runCandidateChain,
14
14
  runJsonCandidateChain,
15
15
  withTempJsonFile,
16
+ withCommandContext,
16
17
  } from '../_shared/tool-runtime.mjs';
17
18
  import {
18
19
  deliverLargeJsonResult,
@@ -613,10 +614,10 @@ const tools = [
613
614
  {
614
615
  name: 'docx_set_text',
615
616
  effectKind: 'document-mutation',
616
- description: 'Replace the whole text content of paragraph or cell objects observed from this exact input DOCX while retaining target formatting, bookmarks, spans, and vertical merges. A change may explicitly set the Latin and complex-script font family of its nonempty replacement run; East Asian font settings remain inherited from the target. For a vertically merged logical cell, write its visible text to the restart cell rather than a continue cell. Tabs and line breaks remain native document text controls; targets containing non-text objects are rejected. Use this only for newly derived text. Content copied or selected from a source DOCX uses docx_replace_content_from_source so native runs such as superscript and subscript are retained. This does not insert objects, change table structure, copy source formatting, or decide business wording.',
617
+ description: 'Replace the whole text content of paragraph or cell objects observed from this exact input DOCX while retaining target formatting, bookmarks, spans, and vertical merges. Pass small batches in changes or keep a large batch out of the tool call by putting the same changes array in changesInput; both may be combined in one atomic commit. A change may explicitly set the Latin and complex-script font family of its nonempty replacement run; East Asian font settings remain inherited from the target. For a vertically merged logical cell, write its visible text to the restart cell rather than a continue cell. Tabs and line breaks remain native document text controls; targets containing non-text objects are rejected. Use this only for newly derived text. Content copied or selected from a source DOCX uses docx_replace_content_from_source so native runs such as superscript and subscript are retained. This does not insert objects, change table structure, copy source formatting, or decide business wording.',
617
618
  inputSchema: inputContract('docx_set_text'),
618
619
  outputSchema: fixedEditOutput('docx_set_text'),
619
- handler: (args, tool) => fixedEdit(tool, args, docxCandidates),
620
+ handler: async (args, tool) => fixedEdit(tool, await resolveFileBackedChanges(args), docxCandidates),
620
621
  },
621
622
  {
622
623
  name: 'docx_set_paragraph_pagination',
@@ -925,12 +926,12 @@ function buildServer() {
925
926
  },
926
927
  } : {}),
927
928
  },
928
- async args => {
929
+ async (args, context) => withCommandContext({ signal: context.signal }, async () => {
929
930
  const payload = typeof args.output === 'string'
930
931
  ? await withOutputWriteLock(args.output, () => tool.handler(args, tool))
931
932
  : await tool.handler(args, tool);
932
933
  return createToolResult(payload, { isError: payload?.summary?.pass === false });
933
- },
934
+ }),
934
935
  );
935
936
  }
936
937
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiwater/office-mcp",
3
- "version": "0.21.51",
3
+ "version": "0.21.53",
4
4
  "description": "Published MCP distribution for independent Tiwater Office, PDF, and Text document capabilities",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,7 @@
2
2
  "schema": "tiwater.pdf-provider-contract-manifest/v1",
3
3
  "provider": {
4
4
  "id": "@tiwater/office-mcp",
5
- "version": "0.21.51"
5
+ "version": "0.21.53"
6
6
  },
7
7
  "runtime": {
8
8
  "command": "tiwater-pdf"
@@ -2,7 +2,7 @@
2
2
  "schema": "tiwater.text-provider-contract-manifest/v1",
3
3
  "provider": {
4
4
  "id": "@tiwater/office-mcp",
5
- "version": "0.21.51"
5
+ "version": "0.21.53"
6
6
  },
7
7
  "tools": [
8
8
  {