@bevel-software/platform-mcp-core 0.15.1 → 0.19.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/THIRD-PARTY-NOTICES.md +4 -4
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +22 -4
- package/dist/meta-tools.js.map +1 -1
- package/dist/printable.d.ts +19 -0
- package/dist/printable.d.ts.map +1 -0
- package/dist/printable.js +21 -0
- package/dist/printable.js.map +1 -0
- package/dist/results.d.ts.map +1 -1
- package/dist/results.js +24 -2
- package/dist/results.js.map +1 -1
- package/dist/retired-tools.d.ts +67 -0
- package/dist/retired-tools.d.ts.map +1 -0
- package/dist/retired-tools.js +96 -0
- package/dist/retired-tools.js.map +1 -0
- package/dist/session-recovery.d.ts +66 -0
- package/dist/session-recovery.d.ts.map +1 -0
- package/dist/session-recovery.js +309 -0
- package/dist/session-recovery.js.map +1 -0
- package/package.json +4 -4
- package/src/index.ts +22 -3
- package/src/meta-tools.ts +20 -4
- package/src/printable.ts +23 -0
- package/src/results.ts +22 -2
- package/src/retired-tools.ts +97 -0
- package/src/session-recovery.ts +378 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { registerManual } from './dispatch.js';
|
|
2
|
+
/**
|
|
3
|
+
* Client-side recovery from MCP session loss: when a remote MCP server has
|
|
4
|
+
* forgotten the session our manual holds — almost always because it restarted —
|
|
5
|
+
* re-register that manual once and retry the call once.
|
|
6
|
+
*
|
|
7
|
+
* WHY IT LIVES ON THE CLIENT OBJECT. Two paths reach a tool call in our stack:
|
|
8
|
+
* `dispatchToolCall` (the MCP surface of the hosted proxy and of the local
|
|
9
|
+
* server) goes through `callToolStreaming`, and a code-mode chain
|
|
10
|
+
* (`call_tool_chain`, gate probes) goes through `callTool` — `callToolChain`
|
|
11
|
+
* bridges every in-isolate tool function to `this.callTool`. Wrapping those two
|
|
12
|
+
* methods ON THE CLIENT INSTANCE is the one seam both paths cross, so the
|
|
13
|
+
* policy exists exactly once instead of being copied into each caller.
|
|
14
|
+
*
|
|
15
|
+
* WHY ONLY SESSION LOSS. A retry is only safe when the first attempt provably
|
|
16
|
+
* did nothing. Session loss is decided in the server's ROUTING layer, before
|
|
17
|
+
* the request is dispatched to a tool, so a mutating tool cannot have run.
|
|
18
|
+
* Every other failure — a tool error, an auth refusal, a timeout, a reset
|
|
19
|
+
* connection — could have executed the tool, and is surfaced unchanged. That is
|
|
20
|
+
* the whole safety argument: it rests on the trigger class, so
|
|
21
|
+
* {@link isSessionLoss} is deliberately narrow and separately testable.
|
|
22
|
+
*/
|
|
23
|
+
/** The JSON-RPC code the Streamable HTTP transport reserves for a missing session. */
|
|
24
|
+
const SESSION_NOT_FOUND_CODE = -32001;
|
|
25
|
+
/** How far up a `cause` chain to look before giving up. */
|
|
26
|
+
const MAX_CAUSE_DEPTH = 8;
|
|
27
|
+
/** Each error in `err`'s `cause` chain, nearest first, bounded and cycle-safe. */
|
|
28
|
+
function* errorChain(err) {
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
let current = err;
|
|
31
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) {
|
|
32
|
+
if (current === null || typeof current !== 'object' || seen.has(current))
|
|
33
|
+
return;
|
|
34
|
+
seen.add(current);
|
|
35
|
+
yield current;
|
|
36
|
+
current = current.cause;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The HTTP status an error carries, or undefined when it carries none.
|
|
41
|
+
*
|
|
42
|
+
* The MCP SDK's `StreamableHTTPError` puts the HTTP status in `code`, which is
|
|
43
|
+
* also where `McpError` puts its JSON-RPC code — and those two namespaces
|
|
44
|
+
* OVERLAP on the very number this module cares about: `-32001` is "Session not
|
|
45
|
+
* found" on the wire but `ErrorCode.RequestTimeout` in the SDK's own enum. The
|
|
46
|
+
* range test is what keeps them apart: a JSON-RPC code is negative and can
|
|
47
|
+
* never be read as a status, so a local request timeout never looks like a
|
|
48
|
+
* session miss (and is never retried).
|
|
49
|
+
*/
|
|
50
|
+
function httpStatusOf(err) {
|
|
51
|
+
for (const key of ['code', 'status', 'statusCode']) {
|
|
52
|
+
const value = err[key];
|
|
53
|
+
if (typeof value === 'number' && value >= 100 && value <= 599)
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Does this error's text carry the session-not-found signal?
|
|
60
|
+
*
|
|
61
|
+
* The transport surfaces a failed POST as `Error POSTing to endpoint: <body>`,
|
|
62
|
+
* so the server's JSON-RPC body rides along in the message and is the only
|
|
63
|
+
* place the `-32001` is visible. Both halves of the spec's signal are accepted
|
|
64
|
+
* — the code (structural) and the reserved message — because a server may send
|
|
65
|
+
* either; requiring the 404 alongside is what keeps this from over-matching.
|
|
66
|
+
*/
|
|
67
|
+
function saysSessionNotFound(message) {
|
|
68
|
+
return (new RegExp(`"code"\\s*:\\s*${SESSION_NOT_FOUND_CODE}\\b`).test(message) ||
|
|
69
|
+
/\bsession not found\b/i.test(message));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Is `err` unambiguously "the server has forgotten this session"?
|
|
73
|
+
*
|
|
74
|
+
* The signal is HTTP 404 carrying JSON-RPC `-32001` / "Session not found", which
|
|
75
|
+
* the MCP spec reserves for exactly one meaning: the request presented an
|
|
76
|
+
* `Mcp-Session-Id` the server no longer holds, and the client should
|
|
77
|
+
* re-initialize. The presented-a-session-id half is not observable from here,
|
|
78
|
+
* and does not need to be: a request with NO session id is answered 400 /
|
|
79
|
+
* `-32000` (a client mistake with nothing to recover), so a 404 in this shape
|
|
80
|
+
* implies a session id was sent.
|
|
81
|
+
*
|
|
82
|
+
* Everything else is false — including a 404 whose body is an ordinary
|
|
83
|
+
* not-found, an auth refusal, a timeout (see {@link httpStatusOf}), and a
|
|
84
|
+
* refused connection. Pure and exported so this boundary can be pinned by test
|
|
85
|
+
* rather than inferred from the recovery path around it.
|
|
86
|
+
*/
|
|
87
|
+
export function isSessionLoss(err) {
|
|
88
|
+
for (const candidate of errorChain(err)) {
|
|
89
|
+
if (httpStatusOf(candidate) !== 404)
|
|
90
|
+
continue;
|
|
91
|
+
const message = candidate.message;
|
|
92
|
+
if (typeof message === 'string' && saysSessionNotFound(message))
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
const messageOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
98
|
+
/**
|
|
99
|
+
* Per-client re-registration counters, keyed by manual — the same maps the
|
|
100
|
+
* installed recovery reads, reachable from {@link noteManualReregistered} so a
|
|
101
|
+
* surface can report a re-registration of its own.
|
|
102
|
+
*/
|
|
103
|
+
const GENERATIONS = new WeakMap();
|
|
104
|
+
/**
|
|
105
|
+
* Tell recovery that `manualName`'s session was replaced by something OTHER
|
|
106
|
+
* than recovery. `hexis-mcp` re-registers the remote manual whenever a renewed
|
|
107
|
+
* connection key arrives; a call that lost its session around such a swap must
|
|
108
|
+
* then retry against THAT session instead of deregistering it and dialing a
|
|
109
|
+
* third one. Without this the two paths each replace a session the other just
|
|
110
|
+
* made — correct, but a round trip and a discovery pass wasted on every
|
|
111
|
+
* overlap. No-op when recovery is not installed on `client`.
|
|
112
|
+
*/
|
|
113
|
+
export function noteManualReregistered(client, manualName) {
|
|
114
|
+
const generations = GENERATIONS.get(client);
|
|
115
|
+
if (!generations)
|
|
116
|
+
return;
|
|
117
|
+
generations.set(manualName, (generations.get(manualName) ?? 0) + 1);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Wrap `client`'s tool-call entry points with session recovery, in place.
|
|
121
|
+
*
|
|
122
|
+
* Returns the same client so it can be used as an expression at the
|
|
123
|
+
* construction site. Installing twice is a no-op: the second call would stack a
|
|
124
|
+
* second retry on the first, which is precisely the "exactly once" guarantee
|
|
125
|
+
* this module exists to make.
|
|
126
|
+
*/
|
|
127
|
+
const INSTALLED = Symbol.for('@bevel-software/platform-mcp-core.sessionRecovery');
|
|
128
|
+
export function installSessionRecovery(client, options) {
|
|
129
|
+
const marked = client;
|
|
130
|
+
if (marked[INSTALLED])
|
|
131
|
+
return client;
|
|
132
|
+
marked[INSTALLED] = true;
|
|
133
|
+
const log = options.log ?? ((message) => console.error(message));
|
|
134
|
+
const callTool = client.callTool.bind(client);
|
|
135
|
+
const callToolStreaming = client.callToolStreaming.bind(client);
|
|
136
|
+
/**
|
|
137
|
+
* How many times each manual has been re-registered. A call reads this
|
|
138
|
+
* BEFORE its attempt; if the number moved while the attempt was in flight,
|
|
139
|
+
* some other call already replaced the session this one was using and the
|
|
140
|
+
* retry can go straight through — no second re-registration, and no window
|
|
141
|
+
* in which a burst of failures that arrive slightly apart each starts its
|
|
142
|
+
* own. Together with `inflight` below, this is what makes the coalescing
|
|
143
|
+
* hold for concurrent calls whether they fail together or in sequence.
|
|
144
|
+
*/
|
|
145
|
+
const generations = new Map();
|
|
146
|
+
GENERATIONS.set(client, generations);
|
|
147
|
+
const inflight = new Map();
|
|
148
|
+
const generationOf = (manualName) => generations.get(manualName) ?? 0;
|
|
149
|
+
/**
|
|
150
|
+
* Deregister + register once. Reports what happened; the caller does the
|
|
151
|
+
* logging. `generation` is what the failing call saw before its attempt: if
|
|
152
|
+
* the counter has moved, the session has ALREADY been replaced — by the
|
|
153
|
+
* surface's own credential swap, or by another call — and replacing it again
|
|
154
|
+
* would throw away a live session to dial an identical one.
|
|
155
|
+
*
|
|
156
|
+
* Tested TWICE, because this function has two places it can wait and either
|
|
157
|
+
* is long enough for a swap to land: the surface's gate (before the call
|
|
158
|
+
* arrives here) and `manualTemplate`, which a surface may deliberately park
|
|
159
|
+
* in. The check that matters is the one immediately before the deregister —
|
|
160
|
+
* the first is only there to skip work nobody needs.
|
|
161
|
+
*/
|
|
162
|
+
async function reregisterNow(manualName, generation) {
|
|
163
|
+
const notes = [];
|
|
164
|
+
const reused = { ok: true, reused: true, notes };
|
|
165
|
+
if (generationOf(manualName) !== generation)
|
|
166
|
+
return reused;
|
|
167
|
+
const template = await options.manualTemplate(manualName);
|
|
168
|
+
// Not ours to re-register — or not an MCP manual at all, in which case it
|
|
169
|
+
// holds no session and the 404 came from somewhere we must not second-guess.
|
|
170
|
+
// Silent on purpose: nothing happened, so there is nothing to report.
|
|
171
|
+
if (!template || template.call_template_type !== 'mcp')
|
|
172
|
+
return { ok: false, notes };
|
|
173
|
+
// Resolving the template is itself a place a surface waits.
|
|
174
|
+
if (generationOf(manualName) !== generation)
|
|
175
|
+
return reused;
|
|
176
|
+
try {
|
|
177
|
+
// Deregistering is what closes the manual's (now dead) session, so the
|
|
178
|
+
// registration below dials a fresh one instead of reusing the cached
|
|
179
|
+
// transport. Best effort: a manual already gone is not a failure here.
|
|
180
|
+
await client.deregisterManual(manualName);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
notes.push(`deregistering first failed: ${messageOf(err)}`);
|
|
184
|
+
}
|
|
185
|
+
const result = await registerManual(client, template);
|
|
186
|
+
if (!result.ok) {
|
|
187
|
+
// The server is very likely still coming back up. The call fails as it
|
|
188
|
+
// would have anyway; the NEXT one recovers once the server answers.
|
|
189
|
+
notes.push(`re-registration failed: ${result.error}`);
|
|
190
|
+
return { ok: false, notes };
|
|
191
|
+
}
|
|
192
|
+
generations.set(manualName, generationOf(manualName) + 1);
|
|
193
|
+
if (options.afterReregister) {
|
|
194
|
+
try {
|
|
195
|
+
await options.afterReregister(manualName);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
notes.push(`post-re-registration cleanup failed: ${messageOf(err)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { ok: true, notes };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* {@link reregisterNow} under the surface's own gate, if it has one, and with
|
|
205
|
+
* every escape route closed. NEVER throws — and that is load-bearing: a
|
|
206
|
+
* throwing template resolver (or gate) must not replace the caller's real
|
|
207
|
+
* session-loss error with a recovery-internal one. The answer here is only
|
|
208
|
+
* ever "may we retry?".
|
|
209
|
+
*/
|
|
210
|
+
async function reregister(manualName, generation) {
|
|
211
|
+
try {
|
|
212
|
+
return options.withReregister
|
|
213
|
+
? await options.withReregister(manualName, () => reregisterNow(manualName, generation))
|
|
214
|
+
: await reregisterNow(manualName, generation);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
return { ok: false, notes: [`re-registration threw: ${messageOf(err)}`] };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Single-flight {@link reregister}: concurrent losers share one attempt.
|
|
222
|
+
*
|
|
223
|
+
* The generation belongs to whoever started the attempt, and that is right
|
|
224
|
+
* for the joiners too — {@link recover} sends nobody here whose generation
|
|
225
|
+
* differs from the current one, so every sharer of this promise failed
|
|
226
|
+
* against the same session.
|
|
227
|
+
*/
|
|
228
|
+
function reregisterOnce(manualName, generation) {
|
|
229
|
+
const existing = inflight.get(manualName);
|
|
230
|
+
if (existing)
|
|
231
|
+
return existing;
|
|
232
|
+
const tracked = reregister(manualName, generation).finally(() => {
|
|
233
|
+
if (inflight.get(manualName) === tracked)
|
|
234
|
+
inflight.delete(manualName);
|
|
235
|
+
});
|
|
236
|
+
inflight.set(manualName, tracked);
|
|
237
|
+
return tracked;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* May this failed call be retried? True only for session loss on a manual we
|
|
241
|
+
* hold a template for, and only after the session has actually been replaced.
|
|
242
|
+
*/
|
|
243
|
+
async function recover(toolName, generation, err) {
|
|
244
|
+
if (!isSessionLoss(err))
|
|
245
|
+
return false;
|
|
246
|
+
const manualName = toolName.split('.')[0];
|
|
247
|
+
if (!manualName)
|
|
248
|
+
return false;
|
|
249
|
+
/** The session was replaced by someone else — a concurrent call, or the surface. */
|
|
250
|
+
const alreadyReplaced = () => {
|
|
251
|
+
log(`[mcp] session lost on '${manualName}' — re-registered by a concurrent call; retrying.`);
|
|
252
|
+
return true;
|
|
253
|
+
};
|
|
254
|
+
// Someone else re-registered while this call was in flight: the session it
|
|
255
|
+
// failed against is already gone, so retry against the new one directly.
|
|
256
|
+
if (generationOf(manualName) !== generation)
|
|
257
|
+
return alreadyReplaced();
|
|
258
|
+
const outcome = await reregisterOnce(manualName, generation);
|
|
259
|
+
// The same finding, made too late to skip the queue: the re-registration
|
|
260
|
+
// landed while this call waited for the surface's gate.
|
|
261
|
+
if (outcome.reused)
|
|
262
|
+
return alreadyReplaced();
|
|
263
|
+
// One recovered call, one line — whatever went sideways on the way rides
|
|
264
|
+
// along in it rather than arriving as a line of its own.
|
|
265
|
+
const notes = outcome.notes.length > 0 ? ` (${outcome.notes.join('; ')})` : '';
|
|
266
|
+
if (!outcome.ok) {
|
|
267
|
+
if (notes) {
|
|
268
|
+
log(`[mcp] session lost on '${manualName}' — not recovered${notes}; the original failure stands.`);
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
log(`[mcp] session lost on '${manualName}' — re-registered and retried.${notes}`);
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
client.callTool = async function recoveringCallTool(toolName, toolArgs) {
|
|
276
|
+
const generation = generationOf(toolName.split('.')[0] ?? '');
|
|
277
|
+
try {
|
|
278
|
+
return await callTool(toolName, toolArgs);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
if (!(await recover(toolName, generation, err)))
|
|
282
|
+
throw err;
|
|
283
|
+
// Exactly one retry: whatever this produces is what the caller sees,
|
|
284
|
+
// including a second session loss.
|
|
285
|
+
return await callTool(toolName, toolArgs);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
client.callToolStreaming = async function* recoveringCallToolStreaming(toolName, toolArgs) {
|
|
289
|
+
const generation = generationOf(toolName.split('.')[0] ?? '');
|
|
290
|
+
let yielded = false;
|
|
291
|
+
try {
|
|
292
|
+
for await (const chunk of callToolStreaming(toolName, toolArgs)) {
|
|
293
|
+
yielded = true;
|
|
294
|
+
yield chunk;
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
// A stream that already produced output is past the point where session
|
|
300
|
+
// loss can happen (the session is validated before the first byte), and
|
|
301
|
+
// replaying it would duplicate the chunks the caller already saw.
|
|
302
|
+
if (yielded || !(await recover(toolName, generation, err)))
|
|
303
|
+
throw err;
|
|
304
|
+
}
|
|
305
|
+
yield* callToolStreaming(toolName, toolArgs);
|
|
306
|
+
};
|
|
307
|
+
return client;
|
|
308
|
+
}
|
|
309
|
+
//# sourceMappingURL=session-recovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-recovery.js","sourceRoot":"","sources":["../src/session-recovery.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bevel-software/platform-mcp-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "The transport-agnostic half of Bevel's MCP surface: UTCP manual registration, tool discovery, MCP listing/dispatch and the code-mode meta-tools. Shared by the hosted proxy and the local stdio server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,14 +20,14 @@
|
|
|
20
20
|
"THIRD-PARTY-NOTICES.md"
|
|
21
21
|
],
|
|
22
22
|
"engines": {
|
|
23
|
-
"node": ">=22.13 <23"
|
|
23
|
+
"node": ">=22.13 <23 || >=24 <25"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
27
27
|
"@utcp/code-mode": "^1.2.13",
|
|
28
28
|
"@utcp/http": "^1.1.12",
|
|
29
|
-
"@utcp/mcp": "^1.
|
|
30
|
-
"@utcp/sdk": "^1.
|
|
29
|
+
"@utcp/mcp": "^1.2.0",
|
|
30
|
+
"@utcp/sdk": "^1.2.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"typescript": "^5.8.0",
|
package/src/index.ts
CHANGED
|
@@ -16,12 +16,14 @@
|
|
|
16
16
|
* Everything between "a UTCP client with manuals registered" and "an MCP result"
|
|
17
17
|
* is identical, and lives here: name flattening, the tool-name/schema guards
|
|
18
18
|
* that stop one bad tool blanking a client's whole toolset, streaming dispatch,
|
|
19
|
-
*
|
|
19
|
+
* the code-mode meta-tools, and recovery from a remote server that restarted
|
|
20
|
+
* and forgot our session (see `session-recovery.ts`).
|
|
20
21
|
*
|
|
21
22
|
* What is NOT here, on purpose: manual DISCOVERY (who may see which manual is
|
|
22
23
|
* an access-control question the hosted REST surface answers), credential
|
|
23
|
-
* resolution (a vault loader server-side, `process.env` locally), and
|
|
24
|
-
* policy (see `registerManual`)
|
|
24
|
+
* resolution (a vault loader server-side, `process.env` locally), and
|
|
25
|
+
* REGISTRATION retry policy (see `registerManual`) — which is not the same
|
|
26
|
+
* thing as session recovery, and stays each surface's own.
|
|
25
27
|
*/
|
|
26
28
|
|
|
27
29
|
export {
|
|
@@ -55,6 +57,21 @@ export {
|
|
|
55
57
|
|
|
56
58
|
export { registerManual, dispatchToolCall } from './dispatch.js';
|
|
57
59
|
|
|
60
|
+
export {
|
|
61
|
+
RETIRED_TOOL_MESSAGES,
|
|
62
|
+
RETIRED_TOOL_NAMES,
|
|
63
|
+
retiredToolMessage,
|
|
64
|
+
retiredToolInFailure,
|
|
65
|
+
retiredToolChainFailure,
|
|
66
|
+
} from './retired-tools.js';
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
isSessionLoss,
|
|
70
|
+
installSessionRecovery,
|
|
71
|
+
noteManualReregistered,
|
|
72
|
+
type SessionRecoveryOptions,
|
|
73
|
+
} from './session-recovery.js';
|
|
74
|
+
|
|
58
75
|
export {
|
|
59
76
|
type SkillSummary,
|
|
60
77
|
type LoadedSkill,
|
|
@@ -74,3 +91,5 @@ export {
|
|
|
74
91
|
findToolsByNames,
|
|
75
92
|
AmbiguousToolNameError,
|
|
76
93
|
} from './code-mode-names.js';
|
|
94
|
+
|
|
95
|
+
export { printable } from './printable.js';
|
package/src/meta-tools.ts
CHANGED
|
@@ -2,14 +2,19 @@ import type { Tool as McpTool, CallToolResult } from '@modelcontextprotocol/sdk/
|
|
|
2
2
|
import type { CodeModeUtcpClient } from '@utcp/code-mode';
|
|
3
3
|
import { utcpNameToTsInterfaceName, findToolsByNames } from './code-mode-names.js';
|
|
4
4
|
import { toCallToolResult, toolError, describeToolFailure, omitImagePayloads } from './results.js';
|
|
5
|
+
import { retiredToolInFailure, retiredToolChainFailure } from './retired-tools.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Code-mode meta-tools exposed ALONGSIDE the direct tools. They let an external
|
|
8
9
|
* agent batch many Bevel calls into one isolated-vm run (`call_tool_chain`)
|
|
9
10
|
* instead of one MCP round-trip per call — the same efficiency our own agent
|
|
10
|
-
* gets. `call_tool_chain`'s description carries the code-mode protocol
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* gets. `call_tool_chain`'s description carries the code-mode protocol, so the
|
|
12
|
+
* client learns the convention from the tool itself; `list_tools`/`tools_info`
|
|
13
|
+
* are how it discovers what to call. There IS a system prompt over MCP now: the
|
|
14
|
+
* platform header and the admin's preamble arrive as `instructions` on the
|
|
15
|
+
* initialize handshake (see core-backend's modules/agent-instructions/compose.ts).
|
|
16
|
+
* The protocol stays in the description regardless, because several clients
|
|
17
|
+
* (claude.ai on the web, the Agent SDK, Cline) drop that field.
|
|
13
18
|
*
|
|
14
19
|
* Security is identical to the direct surface: the chain runs in an isolated-vm
|
|
15
20
|
* but calls tools with the CALLER's credentials against the external catalog —
|
|
@@ -153,6 +158,10 @@ export async function dispatchMetaTool(
|
|
|
153
158
|
? Math.min(1_000_000, Math.max(1_000, Math.trunc(args.max_output_size)))
|
|
154
159
|
: CALL_TOOL_CHAIN_MAX_OUTPUT;
|
|
155
160
|
const { result: rawResult, logs } = await client.callToolChain(code, timeout);
|
|
161
|
+
// The runner reports a failed chain in `logs` rather than throwing, so a
|
|
162
|
+
// chain that died calling a removed tool is recognised here, not in the catch.
|
|
163
|
+
const retired = retiredToolChainFailure({ result: rawResult, logs });
|
|
164
|
+
if (retired) return toolError(retired);
|
|
156
165
|
// Images never ride a chain result: the chain's value is stringified JSON,
|
|
157
166
|
// where base64 is context flood, not a picture. A chained `read_file` of an
|
|
158
167
|
// image comes back as an omitted-image note instead (see omitImagePayloads);
|
|
@@ -187,6 +196,13 @@ export async function dispatchMetaTool(
|
|
|
187
196
|
message: `Result+logs payload was ${fullJson.length} characters (exceeded max_output_size of ${maxOutputSize}). Full JSON saved to the shared spill store as \`${ref}\`. Read it back with \`read_file\` (pass that ref as \`path\`, \`branch\` ignored, plus \`offset\`/\`limit\` to slice), or re-run a narrower chain that returns only what you need.`,
|
|
188
197
|
});
|
|
189
198
|
} catch (err) {
|
|
190
|
-
|
|
199
|
+
// A chain that failed while calling a removed tool gets the reason it was
|
|
200
|
+
// removed, not the runtime's "is not a function". Read from the failure
|
|
201
|
+
// itself, never from the chain's source: a chain that merely mentions the
|
|
202
|
+
// name and died of something else must report what really happened.
|
|
203
|
+
const failure = describeToolFailure(err);
|
|
204
|
+
const retired = name === 'call_tool_chain' ? retiredToolInFailure(failure) : undefined;
|
|
205
|
+
if (retired) return toolError(retired);
|
|
206
|
+
return toolError(`The "${name}" tool failed: ${failure}`);
|
|
191
207
|
}
|
|
192
208
|
}
|
package/src/printable.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Untrusted text, rendered so it cannot forge or colour a log line.
|
|
3
|
+
*
|
|
4
|
+
* Anything a caller controls — a request header, a URL, a name from an
|
|
5
|
+
* identity provider — may carry newlines or ANSI escapes, and interpolated
|
|
6
|
+
* verbatim it would start a line of its own in the operator log, or paint
|
|
7
|
+
* the terminal. `JSON.stringify` escapes the C0 controls (U+0000–U+001F)
|
|
8
|
+
* plus quote and backslash; the C1 controls (U+007F–U+009F — including
|
|
9
|
+
* U+009B, the one-byte CSI that starts an ANSI sequence on its own) and the
|
|
10
|
+
* JS line separators U+2028/U+2029 pass through raw, so those are escaped
|
|
11
|
+
* here. The result is one quoted token: `"python-httpx/0.28.1"`,
|
|
12
|
+
* `"evil\n[forged]"`.
|
|
13
|
+
*
|
|
14
|
+
* Every log line that carries caller-supplied text goes through this, and
|
|
15
|
+
* only this: the rule "the log is one line per event" is enforced at the
|
|
16
|
+
* one place text enters a line, not by each call site remembering.
|
|
17
|
+
*/
|
|
18
|
+
export function printable(text: string): string {
|
|
19
|
+
return JSON.stringify(text).replace(
|
|
20
|
+
/[\x7F-\x9F\u{2028}\u{2029}]/gu,
|
|
21
|
+
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
|
22
|
+
);
|
|
23
|
+
}
|
package/src/results.ts
CHANGED
|
@@ -86,8 +86,28 @@ function isMcpImageBlockObject(value: unknown): value is { type: 'image'; data:
|
|
|
86
86
|
export function describeToolFailure(err: unknown): string {
|
|
87
87
|
const data = (err as { response?: { data?: unknown } })?.response?.data;
|
|
88
88
|
if (data && typeof data === 'object') {
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
let inner: unknown;
|
|
90
|
+
try {
|
|
91
|
+
inner = (data as { error?: unknown }).error;
|
|
92
|
+
} catch {
|
|
93
|
+
inner = undefined;
|
|
94
|
+
}
|
|
95
|
+
if (typeof inner === 'string' && inner.length > 0) {
|
|
96
|
+
// A typed refusal (`{ error, kind, … }`) keeps its machine-readable
|
|
97
|
+
// fields: an MCP caller sees only this string, and `kind` is what it
|
|
98
|
+
// branches on. Every read of `data` past `.error` runs under the guard,
|
|
99
|
+
// so a throwing getter or Proxy cannot escape this catch path; details
|
|
100
|
+
// that do not serialise (a cycle, a BigInt) degrade via `safeJsonText`
|
|
101
|
+
// rather than dropping `kind`.
|
|
102
|
+
try {
|
|
103
|
+
const details: Record<string, unknown> = { ...(data as Record<string, unknown>) };
|
|
104
|
+
delete details.error;
|
|
105
|
+
if (typeof (details as { kind?: unknown }).kind === 'string') return `${inner} ${safeJsonText(details)}`;
|
|
106
|
+
} catch {
|
|
107
|
+
// fall through to the plain message
|
|
108
|
+
}
|
|
109
|
+
return inner;
|
|
110
|
+
}
|
|
91
111
|
}
|
|
92
112
|
if (typeof data === 'string' && data.length > 0) return data;
|
|
93
113
|
// Total, like `safeJsonText`: a thrown value whose own `toString` throws
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tools that were removed on purpose, and what a caller still using the
|
|
3
|
+
* old name is told instead of a bare "Unknown tool".
|
|
4
|
+
*
|
|
5
|
+
* An agent proposes and syncs; a person merges. `merge_change_request` let an
|
|
6
|
+
* agent land a change request itself, so it is gone from every agent-facing
|
|
7
|
+
* tool set — but models (and saved scripts) keep calling a name they learned,
|
|
8
|
+
* and "Unknown tool" invites them to go looking for another way in. The answer
|
|
9
|
+
* says who does it and where.
|
|
10
|
+
*
|
|
11
|
+
* One table, read by every surface that resolves a tool name: the hosted MCP
|
|
12
|
+
* endpoint, the local `hexis-mcp` server, the code-mode `call_tool_chain`
|
|
13
|
+
* runners, and the backend's own route for the old name.
|
|
14
|
+
*/
|
|
15
|
+
export const RETIRED_TOOL_MESSAGES: Readonly<Record<string, string>> = Object.freeze({
|
|
16
|
+
merge_change_request:
|
|
17
|
+
'`merge_change_request` is no longer available to agents: a change request is merged by a person in the app. ' +
|
|
18
|
+
'Ask the user to review and merge it there. To bring a draft up to date with its target, use `merge_branch` ' +
|
|
19
|
+
'with the target as `source` and the draft as `target`.',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The retired tool names themselves. A surface that PROXIES another
|
|
24
|
+
* deployment needs these: an older deployment still advertises the tool, and a
|
|
25
|
+
* discovered copy left in the registry is callable — directly and from a
|
|
26
|
+
* code-mode chain — which would perform the very merge the removal forbids.
|
|
27
|
+
* Purge by name, then answer the name from {@link retiredToolMessage}.
|
|
28
|
+
*/
|
|
29
|
+
export const RETIRED_TOOL_NAMES: ReadonlySet<string> = new Set(Object.keys(RETIRED_TOOL_MESSAGES));
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The retired-tool message for `name`, or undefined when it is not retired.
|
|
33
|
+
* Matches the bare name and any namespaced spelling of it (`manual.tool`,
|
|
34
|
+
* `manual_tool` flattening aside — `manual__tool`, `a.b.tool`), since each
|
|
35
|
+
* surface advertises tool names in its own shape.
|
|
36
|
+
*/
|
|
37
|
+
export function retiredToolMessage(name: string): string | undefined {
|
|
38
|
+
for (const [retired, message] of Object.entries(RETIRED_TOOL_MESSAGES)) {
|
|
39
|
+
if (name === retired || name.endsWith(`.${retired}`) || name.endsWith(`__${retired}`)) return message;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The retired-tool message when `failure` — the text of a FAILED chain's
|
|
46
|
+
* error, not its source — names a retired tool, or undefined.
|
|
47
|
+
*
|
|
48
|
+
* The failure text is the signal, deliberately. Scanning the chain's source
|
|
49
|
+
* instead would rewrite any failure from a chain that merely MENTIONS the name
|
|
50
|
+
* in a comment, a string or an unrelated call, hiding the real reason it died
|
|
51
|
+
* behind a migration notice. The runtime names the callee it could not find
|
|
52
|
+
* ("KNOWLEDGE_BASE.merge_change_request is not a function"), so the tool that
|
|
53
|
+
* actually failed is right there in the message.
|
|
54
|
+
*
|
|
55
|
+
* Matched in the shape the runtime reports a missing callee — `<expr>.<name>
|
|
56
|
+
* is not a function`, `<name> is not defined` — and not anywhere in the text:
|
|
57
|
+
* a failure that merely QUOTES the name (a missing file whose path carries
|
|
58
|
+
* it, a server's error echoing the request) died of something else, and the
|
|
59
|
+
* migration notice would hide that.
|
|
60
|
+
*
|
|
61
|
+
* A chain that reaches the retired name through a computed property
|
|
62
|
+
* (`KNOWLEDGE_BASE['merge_' + 'change_request']()`) is not recognised: the
|
|
63
|
+
* runtime prints the expression, not the resolved name. It still fails — the
|
|
64
|
+
* tool is gone from every registry — it just fails with the runtime's own
|
|
65
|
+
* words instead of ours.
|
|
66
|
+
*/
|
|
67
|
+
export function retiredToolInFailure(failure: string): string | undefined {
|
|
68
|
+
for (const [retired, message] of Object.entries(RETIRED_TOOL_MESSAGES)) {
|
|
69
|
+
// The name as an identifier or a member (`X.name`), not as quoted text:
|
|
70
|
+
// a server that echoes the sentence back inside quotes is a different
|
|
71
|
+
// failure, and the quote mark before the name is what tells them apart.
|
|
72
|
+
if (new RegExp(`(?:^|[\\s.])${retired} is not (?:a function|defined)\\b`).test(failure)) return message;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The log line `@utcp/code-mode` records when a chain's code fails. */
|
|
78
|
+
const CHAIN_FAILURE_LOG = '[ERROR] Code execution failed';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The retired-tool message for a chain that FAILED on a retired tool, read
|
|
82
|
+
* from what `callToolChain` returned. The runner does not throw when the code
|
|
83
|
+
* fails — it resolves `{ result: null, logs }` with a `[ERROR] Code execution
|
|
84
|
+
* failed: …` line (e.g. `KNOWLEDGE_BASE.merge_change_request is not a
|
|
85
|
+
* function`) — so a caller's catch never sees it. Undefined for a chain that
|
|
86
|
+
* succeeded, or one whose failure does not name a retired tool.
|
|
87
|
+
*/
|
|
88
|
+
export function retiredToolChainFailure(outcome: { result: unknown; logs?: unknown }): string | undefined {
|
|
89
|
+
if (outcome.result !== null && outcome.result !== undefined) return undefined;
|
|
90
|
+
const logs = Array.isArray(outcome.logs) ? outcome.logs : [];
|
|
91
|
+
const failures = logs.filter((l): l is string => typeof l === 'string' && l.startsWith(CHAIN_FAILURE_LOG));
|
|
92
|
+
for (const failure of failures) {
|
|
93
|
+
const message = retiredToolInFailure(failure);
|
|
94
|
+
if (message) return message;
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|