toolfunnel 0.7.1 → 0.8.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 +14 -4
- package/bin/toolfunnel.js +103 -24
- package/docs/KNOWN_BUGS.md +48 -28
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +1 -1
- package/docs/packaging.md +2 -1
- package/mcp/servers/mock-modern/server.js +1 -1
- package/mcp/servers/mock-upstream/server.js +3 -4
- package/package.json +1 -1
- package/src/auth/config.js +2 -4
- package/src/auth/resource-server.js +6 -1
- package/src/core/atomic-write.js +97 -0
- package/src/core/config-home.js +100 -8
- package/src/core/hook-engine.js +29 -54
- package/src/core/hook-loader.js +3 -61
- package/src/core/hook-runner.js +92 -43
- package/src/core/logger.js +5 -7
- package/src/core/metrics.js +17 -2
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +36 -21
- package/src/extend/add-hook.md +4 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/package.md +1 -1
- package/src/mcp/aggregator.js +583 -54
- package/src/mcp/expose-store.js +246 -68
- package/src/mcp/http-transport.js +111 -17
- package/src/mcp/mcp-client.js +130 -26
- package/src/mcp/modern.js +9 -6
- package/src/mcp/protocol.js +12 -7
- package/src/mcp/server.js +268 -63
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +33 -87
- package/src/tools/tool-state.js +1 -1
- package/src/ui/public/app.js +60 -1
- package/src/ui/server.js +142 -16
- package/tools/scripts/tf-accept-change.js +67 -0
- package/tools/scripts/tf-mcp-add.js +28 -15
- package/tools/tools.register.json +16 -5
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* tool-approvals.js - tool descriptor approval ("hash-pinning").
|
|
5
|
+
*
|
|
6
|
+
* An upstream can change what its tools SAY after you attached it. The description (and every
|
|
7
|
+
* parameter description inside the input schema) goes straight into the model's context, so a
|
|
8
|
+
* changed descriptor is the attack whether or not the tool is ever called. This module holds the
|
|
9
|
+
* PURE rules plus the state file; the aggregator applies them at its single listing chokepoint
|
|
10
|
+
* (_listAndCache) so every listing shows only approved text and every execution path can refuse.
|
|
11
|
+
*
|
|
12
|
+
* Rules, per upstream (evaluate):
|
|
13
|
+
* - no record yet -> FIRST USE: approve every tool as listed.
|
|
14
|
+
* - same version, a tool changed/new -> PENDING: the listing keeps the APPROVED descriptor (a new
|
|
15
|
+
* tool stays hidden) and calls refuse until accepted.
|
|
16
|
+
* - version changed -> UPGRADE: auto-approved and reported, unless `strict`
|
|
17
|
+
* (expose.json strictApproval) - then pending as above.
|
|
18
|
+
* - the server NAME changed -> the same as a version change (reported as server-changed);
|
|
19
|
+
* never a fresh first use, or a rename would get past strict.
|
|
20
|
+
* - a server that reports no version -> the stricter branch (treated as "same version").
|
|
21
|
+
* - a removed tool -> its approval stays (harmless); a pending entry for it goes.
|
|
22
|
+
* Known limit, documented: a hostile maintainer can bump the version too - that is what strict is for.
|
|
23
|
+
*
|
|
24
|
+
* What is hashed: the model-facing fields, as canonical JSON (sorted keys): HASHED_FIELDS. `_meta`
|
|
25
|
+
* and `icons` are presentation and excluded. sha256 from node:crypto (zero-dep).
|
|
26
|
+
*
|
|
27
|
+
* The state file (mcp/tool-approvals.json) is USER STATE, never seeded or shipped. It carries the
|
|
28
|
+
* pending entries too, because the UI and CLI (separate processes) accept by moving pending ->
|
|
29
|
+
* approved in the file. Missing = empty. Unreadable = THROW: treating it as empty would silently
|
|
30
|
+
* approve everything as first use, which is exactly what corrupting it would be for.
|
|
31
|
+
*
|
|
32
|
+
* Tool names and upstream ids come from outside, so every map write goes through put() (an own
|
|
33
|
+
* data property even for "__proto__") and every read through own().
|
|
34
|
+
*
|
|
35
|
+
* CommonJS, node built-ins only.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const fs = require('node:fs');
|
|
39
|
+
const crypto = require('node:crypto');
|
|
40
|
+
const { atomicWriteJson } = require('../core/atomic-write');
|
|
41
|
+
|
|
42
|
+
const HASHED_FIELDS = ['name', 'title', 'description', 'inputSchema', 'outputSchema', 'annotations'];
|
|
43
|
+
const FILE_VERSION = 1;
|
|
44
|
+
|
|
45
|
+
const hasOwn = (o, k) => o !== null && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k);
|
|
46
|
+
const own = (o, k) => (hasOwn(o, k) ? o[k] : undefined);
|
|
47
|
+
function put(o, k, v) {
|
|
48
|
+
Object.defineProperty(o, k, { value: v, enumerable: true, writable: true, configurable: true });
|
|
49
|
+
}
|
|
50
|
+
const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
|
51
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
52
|
+
|
|
53
|
+
/** Stable JSON: object keys sorted at every level, undefined members dropped (as JSON does). */
|
|
54
|
+
function canonical(v) {
|
|
55
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v === undefined ? null : v);
|
|
56
|
+
if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']';
|
|
57
|
+
const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
|
|
58
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The hashed, stored and served part of a tool: its HASHED_FIELDS that are present. */
|
|
62
|
+
function descriptorOf(tool) {
|
|
63
|
+
const d = {};
|
|
64
|
+
for (const f of HASHED_FIELDS) {
|
|
65
|
+
if (tool && tool[f] !== undefined) put(d, f, clone(tool[f]));
|
|
66
|
+
}
|
|
67
|
+
return d;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 'sha256:<hex>' over the canonical descriptor. */
|
|
71
|
+
function hashTool(tool) {
|
|
72
|
+
return 'sha256:' + crypto.createHash('sha256').update(canonical(descriptorOf(tool)), 'utf8').digest('hex');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// C0, DEL, C1 and the bidi controls: a server's text must not drive a terminal or reorder a line.
|
|
76
|
+
const UNPRINTABLE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
|
|
77
|
+
const NAMED_ESCAPES = { '\n': '\\n', '\r': '\\r', '\t': '\\t' };
|
|
78
|
+
|
|
79
|
+
/** A server's text made safe to print: control and bidi characters shown as escapes. */
|
|
80
|
+
function printable(s) {
|
|
81
|
+
return String(s).replace(UNPRINTABLE, (c) => NAMED_ESCAPES[c] || '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The HASHED_FIELDS that differ between two descriptors (every present one when `approved` is null). */
|
|
85
|
+
function changedFields(approved, current) {
|
|
86
|
+
return HASHED_FIELDS.filter((f) => hasOwn(approved, f) !== hasOwn(current, f) || canonical(own(approved, f)) !== canonical(own(current, f)));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Apply the rules to one upstream's current listing.
|
|
91
|
+
* @param {object} o
|
|
92
|
+
* @param {object} [o.record] this upstream's stored record, or undefined (never approved)
|
|
93
|
+
* @param {object} [o.serverInfo] { name, version } the upstream reported this connect
|
|
94
|
+
* @param {object[]} o.tools the raw tools/list result
|
|
95
|
+
* @param {boolean} [o.strict] strictApproval: a version change does not auto-approve
|
|
96
|
+
* @param {string} [o.now] ISO time for new entries (tests pass a fixed one)
|
|
97
|
+
* @param {object} [o.writer] { pid, id } of the gateway evaluating, stamped on each pending
|
|
98
|
+
* entry as `by` so another run can tell a live writer from a gone one
|
|
99
|
+
* @returns {{ record: object, effective: object[], pending: object[], events: object[], changed: boolean }}
|
|
100
|
+
* record the new record to store; effective the tools to SERVE (approved descriptors, in the
|
|
101
|
+
* upstream's order; unapproved new tools absent); pending [{tool, reason, approved, current,
|
|
102
|
+
* changed}] for the refusal; events [{kind:'first-use'|'server-changed'|'upgrade', ...}];
|
|
103
|
+
* changed = the record differs from the one passed in (save it).
|
|
104
|
+
*/
|
|
105
|
+
function evaluate({ record, serverInfo, tools, strict, now, writer } = {}) {
|
|
106
|
+
const at = typeof now === 'string' && now ? now : new Date().toISOString();
|
|
107
|
+
const seen = { name: str(serverInfo && serverInfo.name), version: str(serverInfo && serverInfo.version) };
|
|
108
|
+
const current = new Map(); // name -> { tool, hash, descriptor }, first occurrence wins (listing order)
|
|
109
|
+
for (const t of Array.isArray(tools) ? tools : []) {
|
|
110
|
+
if (!t || typeof t.name !== 'string' || !t.name.length || current.has(t.name)) continue;
|
|
111
|
+
current.set(t.name, { tool: t, hash: hashTool(t), descriptor: descriptorOf(t) });
|
|
112
|
+
}
|
|
113
|
+
const before = record ? canonical(record) : null;
|
|
114
|
+
const events = [];
|
|
115
|
+
|
|
116
|
+
if (!record) {
|
|
117
|
+
const approved = {};
|
|
118
|
+
for (const [n, c] of current) put(approved, n, { hash: c.hash, descriptor: c.descriptor, at, how: 'first-use' });
|
|
119
|
+
const rec = { server: { ...seen }, seen: { ...seen }, approved, pending: {} };
|
|
120
|
+
events.push({ kind: 'first-use', tools: [...current.keys()] });
|
|
121
|
+
return { record: rec, effective: [...current.values()].map((c) => c.tool), pending: [], events, changed: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const rec = { server: { ...record.server }, seen: { ...seen }, approved: {}, pending: {} };
|
|
125
|
+
for (const k of Object.keys(record.approved || {})) put(rec.approved, k, record.approved[k]);
|
|
126
|
+
const versionChanged = !!seen.version && !!record.server.version && seen.version !== record.server.version;
|
|
127
|
+
// A different server NAME is an identity change like a version change: approved (and reported)
|
|
128
|
+
// when not strict, pending under strictApproval. It is never a fresh first use - that let a
|
|
129
|
+
// server rename itself past strictApproval, or release a pending change by flipping its name.
|
|
130
|
+
const nameChanged = !!seen.name && !!record.server.name && seen.name !== record.server.name;
|
|
131
|
+
const autoApprove = (versionChanged || nameChanged) && !strict;
|
|
132
|
+
const effective = [];
|
|
133
|
+
const pending = [];
|
|
134
|
+
const upgraded = [];
|
|
135
|
+
for (const [n, c] of current) {
|
|
136
|
+
const a = own(rec.approved, n);
|
|
137
|
+
if (a && a.hash === c.hash) { effective.push(c.tool); continue; }
|
|
138
|
+
const reason = a ? 'changed' : 'new';
|
|
139
|
+
if (autoApprove) {
|
|
140
|
+
put(rec.approved, n, { hash: c.hash, descriptor: c.descriptor, at, how: 'upgrade' });
|
|
141
|
+
effective.push(c.tool);
|
|
142
|
+
upgraded.push({ tool: n, reason });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const prior = own(record.pending, n);
|
|
146
|
+
put(rec.pending, n, {
|
|
147
|
+
hash: c.hash,
|
|
148
|
+
descriptor: c.descriptor,
|
|
149
|
+
since: prior && prior.hash === c.hash && prior.since ? prior.since : at,
|
|
150
|
+
reason,
|
|
151
|
+
...(writer ? { by: writer } : {}),
|
|
152
|
+
});
|
|
153
|
+
pending.push({
|
|
154
|
+
tool: n,
|
|
155
|
+
hash: c.hash, // what an accept must match: the descriptor the user was shown, not a later one
|
|
156
|
+
reason,
|
|
157
|
+
approved: a ? clone(a.descriptor) : null,
|
|
158
|
+
current: clone(c.descriptor),
|
|
159
|
+
changed: changedFields(a && a.descriptor, c.descriptor),
|
|
160
|
+
});
|
|
161
|
+
if (a) effective.push(clone(a.descriptor)); // serve the APPROVED text; the name is unchanged
|
|
162
|
+
}
|
|
163
|
+
// The approved identity follows the server only while nothing is waiting: a pending change was
|
|
164
|
+
// seen at the new version, and accepting it (accept) is what moves the identity forward.
|
|
165
|
+
if (Object.keys(rec.pending).length === 0) {
|
|
166
|
+
if (seen.name) rec.server.name = seen.name;
|
|
167
|
+
if (seen.version) rec.server.version = seen.version;
|
|
168
|
+
}
|
|
169
|
+
if (upgraded.length && nameChanged) events.push({ kind: 'server-changed', from: record.server.name, to: seen.name, tools: upgraded });
|
|
170
|
+
else if (upgraded.length) events.push({ kind: 'upgrade', from: record.server.version, to: seen.version, tools: upgraded });
|
|
171
|
+
return { record: rec, effective, pending, events, changed: canonical(rec) !== before };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Several gateways can share one home, each with its own child of an upstream, so each can see a
|
|
176
|
+
* different listing. Before this process writes its record, carry over the pending entries in the
|
|
177
|
+
* STORED record that another process wrote: an entry this record neither holds as pending nor has
|
|
178
|
+
* approved at that hash, and that this process did not write itself (`ownWritten(tool, hash)`; its
|
|
179
|
+
* own entry that it no longer sees means its server reverted, so that one goes), and whose writer
|
|
180
|
+
* is still running (`isAlive(by)`; an entry left by a gateway that has exited - this gateway's own
|
|
181
|
+
* previous run, say - is no one's view any more, and kept it would never clear). One pending slot
|
|
182
|
+
* per tool: this process's own pending entry for a tool wins.
|
|
183
|
+
* @returns {object} a NEW record
|
|
184
|
+
*/
|
|
185
|
+
function keepForeignPending(stored, next, ownWritten, isAlive) {
|
|
186
|
+
const rec = clone(next);
|
|
187
|
+
if (!rec.pending || typeof rec.pending !== 'object') rec.pending = {};
|
|
188
|
+
const sp = stored && stored.pending && typeof stored.pending === 'object' ? stored.pending : {};
|
|
189
|
+
for (const n of Object.keys(sp)) {
|
|
190
|
+
const e = sp[n];
|
|
191
|
+
if (!e || typeof e.hash !== 'string' || hasOwn(rec.pending, n)) continue;
|
|
192
|
+
const a = own(rec.approved, n);
|
|
193
|
+
if (a && a.hash === e.hash) continue; // accepted since
|
|
194
|
+
if (typeof ownWritten === 'function' && ownWritten(n, e.hash)) continue;
|
|
195
|
+
if (e.by && typeof isAlive === 'function' && !isAlive(e.by)) continue;
|
|
196
|
+
put(rec.pending, n, clone(e));
|
|
197
|
+
}
|
|
198
|
+
return rec;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Accept pending descriptors: one tool, or every pending one when `toolName` is undefined.
|
|
203
|
+
* @returns {{ record: object, accepted: string[] }} a NEW record (the input is not modified)
|
|
204
|
+
*/
|
|
205
|
+
function accept(record, toolName, now) {
|
|
206
|
+
const at = typeof now === 'string' && now ? now : new Date().toISOString();
|
|
207
|
+
const rec = clone(record || {});
|
|
208
|
+
if (!rec.approved || typeof rec.approved !== 'object') rec.approved = {};
|
|
209
|
+
if (!rec.pending || typeof rec.pending !== 'object') rec.pending = {};
|
|
210
|
+
const names = toolName === undefined ? Object.keys(rec.pending) : (hasOwn(rec.pending, toolName) ? [toolName] : []);
|
|
211
|
+
for (const n of names) {
|
|
212
|
+
const p = rec.pending[n];
|
|
213
|
+
put(rec.approved, n, { hash: p.hash, descriptor: p.descriptor, at, how: 'accepted' });
|
|
214
|
+
delete rec.pending[n];
|
|
215
|
+
}
|
|
216
|
+
if (Object.keys(rec.pending).length === 0 && rec.seen && rec.server) {
|
|
217
|
+
if (rec.seen.name) rec.server.name = rec.seen.name;
|
|
218
|
+
if (rec.seen.version) rec.server.version = rec.seen.version;
|
|
219
|
+
}
|
|
220
|
+
return { record: rec, accepted: names };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── the state file ────────────────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
function emptyApprovals() {
|
|
226
|
+
return { version: FILE_VERSION, upstreams: {} };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
230
|
+
|
|
231
|
+
/** Throw, naming the file and the field, on anything that is not a well-formed approvals file. */
|
|
232
|
+
function validateApprovals(doc, filePath) {
|
|
233
|
+
const fail = (what) => { throw new Error(`tool approvals: ${filePath}: ${what}`); };
|
|
234
|
+
if (!isPlainObject(doc)) fail('must be a JSON object');
|
|
235
|
+
if (typeof doc.version !== 'number') fail('"version" must be a number');
|
|
236
|
+
if (!isPlainObject(doc.upstreams)) fail('"upstreams" must be an object keyed by upstream id');
|
|
237
|
+
for (const id of Object.keys(doc.upstreams)) {
|
|
238
|
+
const r = doc.upstreams[id];
|
|
239
|
+
const where = `upstream ${JSON.stringify(id)}`;
|
|
240
|
+
if (!isPlainObject(r)) fail(`${where} must be an object`);
|
|
241
|
+
if (!isPlainObject(r.server) || typeof r.server.name !== 'string' || typeof r.server.version !== 'string') {
|
|
242
|
+
fail(`${where}: "server" must be { name: string, version: string }`);
|
|
243
|
+
}
|
|
244
|
+
for (const part of ['approved', 'pending']) {
|
|
245
|
+
if (!isPlainObject(r[part])) fail(`${where}: "${part}" must be an object keyed by tool name`);
|
|
246
|
+
for (const n of Object.keys(r[part])) {
|
|
247
|
+
const e = r[part][n];
|
|
248
|
+
if (!isPlainObject(e) || typeof e.hash !== 'string' || !e.hash.startsWith('sha256:') || !isPlainObject(e.descriptor)) {
|
|
249
|
+
fail(`${where}: "${part}" entry ${JSON.stringify(n)} must be { hash: "sha256:...", descriptor: {...} }`);
|
|
250
|
+
}
|
|
251
|
+
if (e.descriptor.name !== n) fail(`${where}: "${part}" entry ${JSON.stringify(n)} has a descriptor named ${JSON.stringify(e.descriptor.name)}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Read the approvals file. Missing -> empty. Unreadable or malformed -> throw naming the file. */
|
|
258
|
+
function loadApprovals(filePath) {
|
|
259
|
+
let raw;
|
|
260
|
+
try {
|
|
261
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (err && err.code === 'ENOENT') return emptyApprovals();
|
|
264
|
+
throw new Error(`tool approvals: cannot read ${filePath}: ${(err && err.message) || String(err)}`);
|
|
265
|
+
}
|
|
266
|
+
let doc;
|
|
267
|
+
try {
|
|
268
|
+
doc = JSON.parse(raw);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
throw new Error(`tool approvals: ${filePath} is not valid JSON: ${(err && err.message) || String(err)}`);
|
|
271
|
+
}
|
|
272
|
+
validateApprovals(doc, filePath);
|
|
273
|
+
return doc;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Write the approvals file atomically (the shared temp + fsync + rename writer). */
|
|
277
|
+
function saveApprovals(filePath, doc) {
|
|
278
|
+
// A home without an mcp/ folder must still record first use (else nothing is ever remembered).
|
|
279
|
+
fs.mkdirSync(require('node:path').dirname(filePath), { recursive: true });
|
|
280
|
+
atomicWriteJson(filePath, doc);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The ONE accept every route shares - the tf_accept_change management tool, the CLI, the UI server
|
|
285
|
+
* and the gateway itself: read the CURRENT file, accept, write it back atomically. With
|
|
286
|
+
* `expectHash`, only if the pending descriptor is still the one the user was shown (a server that
|
|
287
|
+
* changed again is not approved sight unseen). Living in the engine, it reaches every install -
|
|
288
|
+
* including an external config home, whose copied scripts and register never refresh.
|
|
289
|
+
* Throws on an unreadable file.
|
|
290
|
+
* @returns {{ accepted: string[], pending: string[], stale?: true, unknown?: true }}
|
|
291
|
+
*/
|
|
292
|
+
function acceptInFile(filePath, upstreamId, toolName, expectHash, now) {
|
|
293
|
+
const doc = loadApprovals(filePath);
|
|
294
|
+
const rec = getRecord(doc, upstreamId);
|
|
295
|
+
if (!rec) return { accepted: [], pending: [], unknown: true };
|
|
296
|
+
if (expectHash !== undefined && expectHash !== null) {
|
|
297
|
+
const p = toolName !== undefined ? own(rec.pending, toolName) : undefined;
|
|
298
|
+
if (!p || p.hash !== expectHash) return { accepted: [], pending: Object.keys(rec.pending || {}), stale: true };
|
|
299
|
+
}
|
|
300
|
+
const res = accept(rec, toolName, now);
|
|
301
|
+
if (res.accepted.length) {
|
|
302
|
+
setRecord(doc, upstreamId, res.record);
|
|
303
|
+
saveApprovals(filePath, doc);
|
|
304
|
+
}
|
|
305
|
+
return { accepted: res.accepted, pending: Object.keys(res.record.pending || {}) };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Every change waiting for acceptance in a loaded doc, flat - the one view the CLI (`changes`) and
|
|
310
|
+
* the UI (/api/tool-changes) both render. Descriptions are the SERVER's text: render as text only.
|
|
311
|
+
* `fields` holds { approved, new } for every other changed field (a schema's parameter descriptions
|
|
312
|
+
* reach the model too, so the user must be able to read them before accepting).
|
|
313
|
+
* @returns {Array<{upstream, tool, reason, since, hash, approvedDescription, newDescription, changed, fields}>}
|
|
314
|
+
*/
|
|
315
|
+
function listPending(doc) {
|
|
316
|
+
const out = [];
|
|
317
|
+
const ups = (doc && doc.upstreams) || {};
|
|
318
|
+
for (const id of Object.keys(ups)) {
|
|
319
|
+
const rec = ups[id] || {};
|
|
320
|
+
const pend = rec.pending || {};
|
|
321
|
+
for (const name of Object.keys(pend)) {
|
|
322
|
+
const p = pend[name] || {};
|
|
323
|
+
const a = own(rec.approved, name);
|
|
324
|
+
const changed = changedFields(a && a.descriptor, p.descriptor);
|
|
325
|
+
out.push({
|
|
326
|
+
upstream: id,
|
|
327
|
+
tool: name,
|
|
328
|
+
reason: p.reason || (a ? 'changed' : 'new'),
|
|
329
|
+
since: p.since || null,
|
|
330
|
+
hash: p.hash,
|
|
331
|
+
approvedDescription: a && a.descriptor && typeof a.descriptor.description === 'string' ? a.descriptor.description : null,
|
|
332
|
+
newDescription: p.descriptor && typeof p.descriptor.description === 'string' ? p.descriptor.description : null,
|
|
333
|
+
changed,
|
|
334
|
+
fields: Object.fromEntries(changed.filter((f) => f !== 'name' && f !== 'description')
|
|
335
|
+
.map((f) => [f, { approved: a ? own(a.descriptor, f) ?? null : null, new: own(p.descriptor, f) ?? null }])),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Tools approved automatically on a server version upgrade, latest first (at most `limit`). */
|
|
343
|
+
function listUpgrades(doc, limit) {
|
|
344
|
+
const out = [];
|
|
345
|
+
const ups = (doc && doc.upstreams) || {};
|
|
346
|
+
for (const id of Object.keys(ups)) {
|
|
347
|
+
const appr = (ups[id] && ups[id].approved) || {};
|
|
348
|
+
for (const name of Object.keys(appr)) {
|
|
349
|
+
if (appr[name] && appr[name].how === 'upgrade') out.push({ upstream: id, tool: name, at: appr[name].at || null });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
out.sort((x, y) => String(y.at).localeCompare(String(x.at)));
|
|
353
|
+
return typeof limit === 'number' ? out.slice(0, limit) : out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Why an acceptInFile result accepted nothing (the wording every route shares), or null when it did. */
|
|
357
|
+
function acceptFailure(res, upstreamId, toolName) {
|
|
358
|
+
if (res && res.unknown) return `no tool approvals are recorded for upstream "${upstreamId}"`;
|
|
359
|
+
if (res && res.stale) return 'that change is no longer the one pending - the server changed the tool again';
|
|
360
|
+
if (!res || !res.accepted || res.accepted.length === 0) {
|
|
361
|
+
return toolName ? `tool "${toolName}" has no change waiting on upstream "${upstreamId}"` : `upstream "${upstreamId}" has no changes waiting`;
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** One upstream's record from a loaded doc (own property only), or undefined. */
|
|
367
|
+
function getRecord(doc, upstreamId) {
|
|
368
|
+
return own(doc && doc.upstreams, upstreamId);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Set one upstream's record on a loaded doc (an own property, whatever the id). */
|
|
372
|
+
function setRecord(doc, upstreamId, record) {
|
|
373
|
+
put(doc.upstreams, upstreamId, record);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
module.exports = {
|
|
377
|
+
HASHED_FIELDS,
|
|
378
|
+
canonical,
|
|
379
|
+
descriptorOf,
|
|
380
|
+
hashTool,
|
|
381
|
+
changedFields,
|
|
382
|
+
printable,
|
|
383
|
+
evaluate,
|
|
384
|
+
keepForeignPending,
|
|
385
|
+
accept,
|
|
386
|
+
emptyApprovals,
|
|
387
|
+
validateApprovals,
|
|
388
|
+
loadApprovals,
|
|
389
|
+
saveApprovals,
|
|
390
|
+
getRecord,
|
|
391
|
+
setRecord,
|
|
392
|
+
acceptInFile,
|
|
393
|
+
listPending,
|
|
394
|
+
listUpgrades,
|
|
395
|
+
acceptFailure,
|
|
396
|
+
};
|
package/src/tools/registry.js
CHANGED
|
@@ -41,11 +41,12 @@ const fs = require('node:fs');
|
|
|
41
41
|
const path = require('node:path');
|
|
42
42
|
const { spawn, execFile } = require('node:child_process');
|
|
43
43
|
const reaper = require('../core/child-reaper');
|
|
44
|
-
const {
|
|
44
|
+
const { takeWithinCap } = require('../core/output-cap');
|
|
45
|
+
const { migrateForStoreFile, snapshotForStoreFile, restoreSnapshot } = require('../core/rename-migration');
|
|
45
46
|
|
|
46
|
-
// LOCAL tool execution bounds
|
|
47
|
-
//
|
|
48
|
-
//
|
|
47
|
+
// LOCAL tool execution bounds. The upstream path has per-method timeouts; stdio serialises on one
|
|
48
|
+
// promise chain, so a local tool with no clock would starve every later request (including
|
|
49
|
+
// `ping`, which clients use as a liveness probe). 120 s
|
|
49
50
|
// matches the upstream PAYLOAD window so a slow-but-alive local tool behaves like a slow upstream.
|
|
50
51
|
// Test seam: the suite proves the timeout/cap BEHAVIOUR (not just these names) by shrinking the
|
|
51
52
|
// windows via env; production never sets them.
|
|
@@ -106,61 +107,9 @@ function cloneEntry(entry) {
|
|
|
106
107
|
return JSON.parse(JSON.stringify(entry));
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
* successful write of the same target; the generous age keeps a concurrent writer safe.
|
|
113
|
-
*/
|
|
114
|
-
const STALE_TMP_MS = 60 * 60 * 1000;
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Remove STALE stranded temps for `base` in `dir` (the `.<base>.<pid>.<ts>.tmp` shape the
|
|
118
|
-
* atomic writers below produce). Scoped to the target's own temp shape - never another
|
|
119
|
-
* file's - and entirely best-effort: sweeping must never fail a write.
|
|
120
|
-
*/
|
|
121
|
-
function sweepStaleTmp(dir, base) {
|
|
122
|
-
let names;
|
|
123
|
-
try { names = fs.readdirSync(dir); } catch (_e) { return; }
|
|
124
|
-
const prefix = `.${base}.`;
|
|
125
|
-
const now = Date.now();
|
|
126
|
-
for (const name of names) {
|
|
127
|
-
if (!name.startsWith(prefix) || !name.endsWith('.tmp')) continue;
|
|
128
|
-
const p = path.join(dir, name);
|
|
129
|
-
try {
|
|
130
|
-
if (now - fs.statSync(p).mtimeMs > STALE_TMP_MS) fs.unlinkSync(p);
|
|
131
|
-
} catch (_e) { /* a concurrent writer renamed/removed it - fine */ }
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Atomic write: serialise to a temp file in the SAME directory as the target
|
|
137
|
-
* (so rename is atomic on the same filesystem/drive), fsync, then rename over
|
|
138
|
-
* the target. A crash leaves either the old file or the new file, never a
|
|
139
|
-
* half-written register. The temp NEVER outlives a failure: any throw (a refused
|
|
140
|
-
* rename - win32 scanners hold targets briefly - a full disk, a write error)
|
|
141
|
-
* unlinks it before rethrowing, and stale strandings from writers that died
|
|
142
|
-
* mid-flight are swept on the next successful write (sweepStaleTmp above).
|
|
143
|
-
*/
|
|
144
|
-
function atomicWriteJson(targetPath, obj) {
|
|
145
|
-
const dir = path.dirname(targetPath);
|
|
146
|
-
const base = path.basename(targetPath);
|
|
147
|
-
sweepStaleTmp(dir, base);
|
|
148
|
-
const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
|
|
149
|
-
const data = JSON.stringify(obj, null, 2) + '\n';
|
|
150
|
-
try {
|
|
151
|
-
const fd = fs.openSync(tmp, 'w');
|
|
152
|
-
try {
|
|
153
|
-
fs.writeSync(fd, data, 0, 'utf8');
|
|
154
|
-
fs.fsyncSync(fd);
|
|
155
|
-
} finally {
|
|
156
|
-
fs.closeSync(fd);
|
|
157
|
-
}
|
|
158
|
-
fs.renameSync(tmp, targetPath);
|
|
159
|
-
} catch (err) {
|
|
160
|
-
try { fs.unlinkSync(tmp); } catch (_e) { /* best-effort - the error below is the story */ }
|
|
161
|
-
throw err;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
110
|
+
// THE atomic writer (temp + fsync + rename, failure-path unlink, stale sweep) - shared by every store.
|
|
111
|
+
// Re-exported below for the callers that historically imported it from here.
|
|
112
|
+
const { atomicWriteJson, atomicWriteText } = require('../core/atomic-write');
|
|
164
113
|
|
|
165
114
|
/** Validate a register entry shape; throws with a clear message on a bad shape. */
|
|
166
115
|
function validateEntry(entry, { requireId = true } = {}) {
|
|
@@ -245,12 +194,11 @@ function defaultRunScript(scriptsRoot, invoke, args) {
|
|
|
245
194
|
let stdout = '';
|
|
246
195
|
let stderr = '';
|
|
247
196
|
let settled = false;
|
|
248
|
-
// BOUNDED + TIMED
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
// a dead gateway. Same shape as the fix in core/hook-runner.js.
|
|
197
|
+
// BOUNDED + TIMED, like the upstream call path's per-method windows (10 s control / 120 s
|
|
198
|
+
// payload). stdio serialises every message on one promise chain, so a single hanging local
|
|
199
|
+
// tool would wedge the entire session: a following `ping` goes unanswered, which a client
|
|
200
|
+
// using ping as its liveness probe reads as a dead gateway. Same shape as the timeout in
|
|
201
|
+
// core/hook-runner.js.
|
|
254
202
|
const finish = (result) => {
|
|
255
203
|
if (settled) return;
|
|
256
204
|
settled = true;
|
|
@@ -286,8 +234,10 @@ function defaultRunScript(scriptsRoot, invoke, args) {
|
|
|
286
234
|
let errBytes = 0;
|
|
287
235
|
child.stdout.on('data', (d) => {
|
|
288
236
|
if (outBytes >= MAX_TOOL_OUTPUT_BYTES) return;
|
|
289
|
-
|
|
290
|
-
|
|
237
|
+
const kept = takeWithinCap(d, outBytes, MAX_TOOL_OUTPUT_BYTES);
|
|
238
|
+
stdout += kept;
|
|
239
|
+
outBytes += Buffer.byteLength(kept, 'utf8');
|
|
240
|
+
if (kept.length < d.length) outBytes = MAX_TOOL_OUTPUT_BYTES; // cut at a character: the cap is reached
|
|
291
241
|
if (outBytes >= MAX_TOOL_OUTPUT_BYTES) {
|
|
292
242
|
killTree(child);
|
|
293
243
|
finish({ ok: false, code: null, stdout, stderr: stderr + '\nRegistry: tool output exceeded the cap and was terminated', outputCapped: true });
|
|
@@ -295,8 +245,10 @@ function defaultRunScript(scriptsRoot, invoke, args) {
|
|
|
295
245
|
});
|
|
296
246
|
child.stderr.on('data', (d) => {
|
|
297
247
|
if (errBytes >= MAX_TOOL_OUTPUT_BYTES) return;
|
|
298
|
-
|
|
299
|
-
|
|
248
|
+
const kept = takeWithinCap(d, errBytes, MAX_TOOL_OUTPUT_BYTES);
|
|
249
|
+
stderr += kept;
|
|
250
|
+
errBytes = kept.length < d.length ? MAX_TOOL_OUTPUT_BYTES : errBytes + Buffer.byteLength(kept, 'utf8');
|
|
251
|
+
if (errBytes >= MAX_TOOL_OUTPUT_BYTES) stderr += '\n[stderr truncated at cap]';
|
|
300
252
|
});
|
|
301
253
|
child.on('error', (err) => {
|
|
302
254
|
if (settled) return;
|
|
@@ -419,11 +371,21 @@ class Registry {
|
|
|
419
371
|
// in rename-migration.js). Runs BEFORE persist so a refusal leaves the register untouched.
|
|
420
372
|
const oldSurfaced = existing.name || id;
|
|
421
373
|
const newSurfaced = merged.name || id;
|
|
374
|
+
const snap = oldSurfaced !== newSurfaced ? snapshotForStoreFile(this._filePath) : null;
|
|
422
375
|
if (oldSurfaced !== newSurfaced) {
|
|
423
376
|
migrateForStoreFile(this._filePath, oldSurfaced, newSurfaced, { migrateState: false });
|
|
424
377
|
}
|
|
425
378
|
this._byId.set(id, cloneEntry(merged));
|
|
426
|
-
|
|
379
|
+
try {
|
|
380
|
+
this._persist();
|
|
381
|
+
} catch (err) {
|
|
382
|
+
// The other half of "runs BEFORE persist": a persist that fails AFTER a successful migration
|
|
383
|
+
// must not leave the manifest naming a surface the register does not. Unwind memory and
|
|
384
|
+
// migrate back (best-effort), then rethrow - a failed rename is a no-op on disk.
|
|
385
|
+
this._byId.set(id, existing);
|
|
386
|
+
if (snap) restoreSnapshot(snap); // byte-for-byte; a reverse migration would re-validate and can refuse
|
|
387
|
+
throw err;
|
|
388
|
+
}
|
|
427
389
|
return this.getEntry(id);
|
|
428
390
|
}
|
|
429
391
|
|
|
@@ -498,23 +460,7 @@ class Registry {
|
|
|
498
460
|
throw new Error(`Registry.writeScript: script path escapes scripts root ("${invokePath}")`);
|
|
499
461
|
}
|
|
500
462
|
fs.mkdirSync(scriptsRoot, { recursive: true });
|
|
501
|
-
|
|
502
|
-
const base = path.basename(resolved);
|
|
503
|
-
sweepStaleTmp(dir, base);
|
|
504
|
-
const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
|
|
505
|
-
try {
|
|
506
|
-
const fd = fs.openSync(tmp, 'w');
|
|
507
|
-
try {
|
|
508
|
-
fs.writeSync(fd, text, 0, 'utf8');
|
|
509
|
-
fs.fsyncSync(fd);
|
|
510
|
-
} finally {
|
|
511
|
-
fs.closeSync(fd);
|
|
512
|
-
}
|
|
513
|
-
fs.renameSync(tmp, resolved);
|
|
514
|
-
} catch (err) {
|
|
515
|
-
try { fs.unlinkSync(tmp); } catch (_e) { /* best-effort - the error below is the story */ }
|
|
516
|
-
throw err;
|
|
517
|
-
}
|
|
463
|
+
atomicWriteText(resolved, text); // bytes as given (no line-ending normalisation), temp + rename
|
|
518
464
|
return resolved;
|
|
519
465
|
}
|
|
520
466
|
|
package/src/tools/tool-state.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Shape of tools.state.json: { "<id>": { "enabled": false, "hidden": true, "hot": true }, ... }
|
|
26
26
|
*
|
|
27
|
-
* PASSTHROUGH
|
|
27
|
+
* PASSTHROUGH: one RESERVED top-level key, `"passthrough": "<upstreamId>"` (a STRING,
|
|
28
28
|
* unambiguous against the per-tool object entries). When set, the gateway becomes a transparent
|
|
29
29
|
* wrapper for that one upstream: its tools ARE the advertised surface (real schemas, implicitly
|
|
30
30
|
* hot), the meta-tools and everything else are hidden and uncallable, and every call still fires
|
package/src/ui/public/app.js
CHANGED
|
@@ -35,6 +35,15 @@
|
|
|
35
35
|
return node;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// A server's text shown with its control and bidi characters visible, so it cannot reorder a line.
|
|
39
|
+
// Same escapes as the engine's printable(): a newline HTML would collapse stays visible.
|
|
40
|
+
var NAMED_ESCAPES = { '\n': '\\n', '\r': '\\r', '\t': '\\t' };
|
|
41
|
+
function printable(s) {
|
|
42
|
+
return String(s).replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, function (c) {
|
|
43
|
+
return NAMED_ESCAPES[c] || '\\u' + ('000' + c.charCodeAt(0).toString(16)).slice(-4);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
// ── fetch wrappers ──────────────────────────────────────────────────────────
|
|
39
48
|
function getJson(url) {
|
|
40
49
|
return fetch(url, { headers: { Accept: 'application/json' } }).then(function (r) { return r.json(); });
|
|
@@ -503,7 +512,7 @@
|
|
|
503
512
|
function () { input.checked = !want; });
|
|
504
513
|
});
|
|
505
514
|
|
|
506
|
-
// legacyPin
|
|
515
|
+
// legacyPin: keep speaking MCP 2024-11-05 to this upstream forever - never auto-
|
|
507
516
|
// upgrades, warns at startup and per call. Opt-in; takes effect on the next (re)connect.
|
|
508
517
|
var pinSwitch = makeSwitch(u.legacyPin === true, true, function (input) {
|
|
509
518
|
var want = input.checked;
|
|
@@ -656,7 +665,57 @@
|
|
|
656
665
|
list.parentNode.insertBefore(banner, list);
|
|
657
666
|
}
|
|
658
667
|
|
|
668
|
+
// Tool descriptor changes held back for acceptance. Descriptions are the SERVER's own,
|
|
669
|
+
// unverified text: rendered with textContent only (el's `text`), never as HTML.
|
|
670
|
+
function renderToolChanges(data) {
|
|
671
|
+
var existing = document.getElementById('tool-changes');
|
|
672
|
+
if (existing) existing.remove();
|
|
673
|
+
var list = $('#mcp-list');
|
|
674
|
+
if (!list || !list.parentNode) return;
|
|
675
|
+
if (data && data.ok === false) {
|
|
676
|
+
list.parentNode.insertBefore(el('div', { id: 'tool-changes', class: 'row' }, [
|
|
677
|
+
el('p', { text: 'TOOL APPROVALS UNREADABLE: ' + (data.error || 'unknown error') + ' - the gateway withholds the ' +
|
|
678
|
+
'affected tools until the file is repaired (removing it, or an upstream\'s entry, approves the current tools as first use).' }),
|
|
679
|
+
]), list);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
var pending = (data && Array.isArray(data.pending)) ? data.pending : [];
|
|
683
|
+
if (!pending.length) return;
|
|
684
|
+
var box = el('div', { id: 'tool-changes' }, [
|
|
685
|
+
el('p', { text: pending.length + ' tool change(s) held back: the server changed what these tools say without ' +
|
|
686
|
+
'changing its version (or strictApproval is on). Until you accept, the gateway keeps showing the approved text ' +
|
|
687
|
+
'and refuses calls to them. Accept only if you recognise the change.' }),
|
|
688
|
+
]);
|
|
689
|
+
pending.forEach(function (p) {
|
|
690
|
+
var acceptBtn = el('button', { class: 'btn btn-sm', type: 'button' }, ['Accept']);
|
|
691
|
+
acceptBtn.addEventListener('click', function () {
|
|
692
|
+
acceptBtn.disabled = true;
|
|
693
|
+
postJson('/api/tool-changes/accept', { upstream: p.upstream, tool: p.tool, hash: p.hash }).then(function (res) {
|
|
694
|
+
acceptBtn.disabled = false;
|
|
695
|
+
if (!res || res.ok !== true) { toast((res && res.error) || 'Accept failed', 'error'); loadToolChanges(); return; }
|
|
696
|
+
toast('Accepted ' + p.upstream + ' / ' + printable(p.tool), 'good');
|
|
697
|
+
loadToolChanges();
|
|
698
|
+
}).catch(function (e) { acceptBtn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
|
|
699
|
+
});
|
|
700
|
+
box.appendChild(el('div', { class: 'row' }, [
|
|
701
|
+
el('p', { text: p.upstream + ' / ' + printable(p.tool) + ' (' + (p.reason === 'new' ? 'new tool' : 'changed: ' + p.changed.join(', ')) + ')' }),
|
|
702
|
+
el('p', { class: 'muted', text: 'Approved: ' + (p.approvedDescription == null ? '(none - a new tool)' : printable(p.approvedDescription)) }),
|
|
703
|
+
el('p', { text: 'New (the server\'s text): ' + (p.newDescription == null ? '(no description)' : printable(p.newDescription)) }),
|
|
704
|
+
el('div', {}, Object.keys(p.fields || {}).map(function (f) {
|
|
705
|
+
return el('p', { style: 'font-family: var(--mono)', text: 'New ' + f + ': ' + printable(JSON.stringify(p.fields[f].new)) });
|
|
706
|
+
})),
|
|
707
|
+
acceptBtn,
|
|
708
|
+
]));
|
|
709
|
+
});
|
|
710
|
+
list.parentNode.insertBefore(box, list);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function loadToolChanges() {
|
|
714
|
+
return getJson('/api/tool-changes').then(renderToolChanges).catch(function () { /* the list below still loads */ });
|
|
715
|
+
}
|
|
716
|
+
|
|
659
717
|
function loadMcps() {
|
|
718
|
+
loadToolChanges();
|
|
660
719
|
return getJson('/api/upstreams').then(function (data) {
|
|
661
720
|
upstreams = (data && Array.isArray(data.upstreams)) ? data.upstreams : [];
|
|
662
721
|
var expose = (data && Array.isArray(data.expose)) ? data.expose : [];
|