@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.
- package/_shared/mcp-stdio.mjs +20 -3
- package/_shared/tool-runtime.mjs +100 -13
- package/office/contracts/docx_set_text.schema.json +6 -1
- package/office/contracts/tiwater-office-provider-contract-manifest-v1.json +3 -3
- package/office/index.mjs +5 -4
- 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) {
|
|
@@ -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.
|
|
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": "
|
|
243
|
+
"sha256": "6a69b2ba25551698b8cbc6040fda2c1d2b81afde4a9629a613ca37c9120bebe6"
|
|
244
244
|
},
|
|
245
245
|
"inputContract": {
|
|
246
246
|
"path": "office/contracts/docx_set_text.schema.json",
|
|
247
|
-
"sha256": "
|
|
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