@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.
@@ -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.15.1",
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.1.7",
30
- "@utcp/sdk": "^1.1.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
- * and the code-mode meta-tools.
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 retry
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 (there is
11
- * no system prompt over MCP), so the client learns the convention from the tool
12
- * itself; `list_tools`/`tools_info` are how it discovers what to call.
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
- return toolError(`The "${name}" tool failed: ${describeToolFailure(err)}`);
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
  }
@@ -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
- const inner = (data as { error?: unknown }).error;
90
- if (typeof inner === 'string' && inner.length > 0) return inner;
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
+ }