@tiwater/office-mcp 0.21.51 → 0.21.52

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) {
@@ -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.52"
6
6
  },
7
7
  "tools": [
8
8
  {
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,
@@ -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.52",
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.52"
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.52"
6
6
  },
7
7
  "tools": [
8
8
  {