@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.
- package/_shared/mcp-stdio.mjs +20 -3
- package/_shared/tool-runtime.mjs +100 -13
- package/office/contracts/tiwater-office-provider-contract-manifest-v1.json +1 -1
- package/office/index.mjs +3 -2
- package/package.json +1 -1
- package/pdf/contracts/tiwater-pdf-provider-contract-manifest-v1.json +1 -1
- package/text/contracts/tiwater-text-provider-contract-manifest-v1.json +1 -1
package/_shared/mcp-stdio.mjs
CHANGED
|
@@ -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', () =>
|
|
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
|
|
141
|
-
|
|
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: {
|
package/_shared/tool-runtime.mjs
CHANGED
|
@@ -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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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) {
|
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