@bevel-software/platform-mcp-core 0.23.0 → 0.25.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/dist/chain-example.d.ts +58 -0
- package/dist/chain-example.d.ts.map +1 -0
- package/dist/chain-example.js +259 -0
- package/dist/chain-example.js.map +1 -0
- package/dist/chain-runtime.d.ts +122 -0
- package/dist/chain-runtime.d.ts.map +1 -0
- package/dist/chain-runtime.js +352 -0
- package/dist/chain-runtime.js.map +1 -0
- package/dist/google-service-account/google-auth-http.protocol.d.ts +24 -0
- package/dist/google-service-account/google-auth-http.protocol.d.ts.map +1 -0
- package/dist/google-service-account/google-auth-http.protocol.js +47 -0
- package/dist/google-service-account/google-auth-http.protocol.js.map +1 -0
- package/dist/google-service-account/google-service-account.auth.d.ts +45 -0
- package/dist/google-service-account/google-service-account.auth.d.ts.map +1 -0
- package/dist/google-service-account/google-service-account.auth.js +153 -0
- package/dist/google-service-account/google-service-account.auth.js.map +1 -0
- package/dist/google-service-account/google-service-account.token-source.d.ts +28 -0
- package/dist/google-service-account/google-service-account.token-source.d.ts.map +1 -0
- package/dist/google-service-account/google-service-account.token-source.js +206 -0
- package/dist/google-service-account/google-service-account.token-source.js.map +1 -0
- package/dist/google-service-account/index.d.ts +5 -0
- package/dist/google-service-account/index.d.ts.map +1 -0
- package/dist/google-service-account/index.js +7 -0
- package/dist/google-service-account/index.js.map +1 -0
- package/dist/google-service-account/service-account-token.contract.d.ts +33 -0
- package/dist/google-service-account/service-account-token.contract.d.ts.map +1 -0
- package/dist/google-service-account/service-account-token.contract.js +14 -0
- package/dist/google-service-account/service-account-token.contract.js.map +1 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +100 -1
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +181 -53
- package/dist/meta-tools.js.map +1 -1
- package/dist/results.d.ts +20 -0
- package/dist/results.d.ts.map +1 -1
- package/dist/results.js +111 -1
- package/dist/results.js.map +1 -1
- package/dist/retired-tools.d.ts +0 -12
- package/dist/retired-tools.d.ts.map +1 -1
- package/dist/retired-tools.js +0 -22
- package/dist/retired-tools.js.map +1 -1
- package/package.json +3 -2
- package/src/chain-example.ts +315 -0
- package/src/chain-runtime.ts +382 -0
- package/src/google-service-account/google-auth-http.protocol.ts +62 -0
- package/src/google-service-account/google-service-account.auth.ts +161 -0
- package/src/google-service-account/google-service-account.token-source.ts +239 -0
- package/src/google-service-account/index.ts +15 -0
- package/src/google-service-account/service-account-token.contract.ts +38 -0
- package/src/index.ts +47 -2
- package/src/meta-tools.ts +212 -58
- package/src/results.ts +106 -1
- package/src/retired-tools.ts +0 -22
package/src/meta-tools.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { Tool as McpTool, CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import type { CodeModeUtcpClient } from '@utcp/code-mode';
|
|
3
|
-
import { utcpNameToTsInterfaceName, findToolsByNames } from './code-mode-names.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { utcpNameToTsInterfaceName, findToolsByNames, sanitizeIdentifier } from './code-mode-names.js';
|
|
4
|
+
import { chainExample, type ChainExample, type ChainExampleTool } from './chain-example.js';
|
|
5
|
+
import { toCallToolResult, toolError, describeToolFailure, withTransportDetail, omitImagePayloads } from './results.js';
|
|
6
|
+
import { retiredToolInFailure } from './retired-tools.js';
|
|
7
|
+
import {
|
|
8
|
+
CHAIN_TIMEOUT_DEFAULT_MS,
|
|
9
|
+
CHAIN_TIMEOUT_MAX_MS,
|
|
10
|
+
CHAIN_TIMEOUT_MIN_MS,
|
|
11
|
+
runToolChain,
|
|
12
|
+
} from './chain-runtime.js';
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
15
|
* Code-mode meta-tools exposed ALONGSIDE the direct tools. They let an external
|
|
@@ -13,9 +20,22 @@ import { retiredToolInFailure, retiredToolChainFailure } from './retired-tools.j
|
|
|
13
20
|
* are how it discovers what to call. There IS a system prompt over MCP now: the
|
|
14
21
|
* platform header and the admin's preamble arrive as `instructions` on the
|
|
15
22
|
* initialize handshake (see core-backend's modules/agent-instructions/compose.ts).
|
|
16
|
-
* The
|
|
23
|
+
* The PROTOCOL stays in the description regardless, because several clients
|
|
17
24
|
* (claude.ai on the web, the Agent SDK, Cline) drop that field.
|
|
18
25
|
*
|
|
26
|
+
* What a chain DOES, though — to a failure, to a large result, to an image —
|
|
27
|
+
* is true of every call, and those rules are stated once, in the handshake
|
|
28
|
+
* instructions and in the platform-managed agent guide, rather than on each
|
|
29
|
+
* tool they cover. So on a surface that has those rules the chain description
|
|
30
|
+
* ends with the same pointer sentence every file tool ends with, composed
|
|
31
|
+
* where the tool is served and the guide's configured name is known
|
|
32
|
+
* (core-backend's mcp.service.ts; nothing here may spell `AGENTS.md`, since
|
|
33
|
+
* the name is a deployment setting). A client that drops `instructions` is
|
|
34
|
+
* then still told WHERE the rules are, which is what the guide is for.
|
|
35
|
+
* The standalone bridge in `hexis-mcp` passes no pointer and so serves the
|
|
36
|
+
* description WHOLE, rules included: it proxies a remote knowledge base and
|
|
37
|
+
* does not know that deployment's layout.
|
|
38
|
+
*
|
|
19
39
|
* Security is identical to the direct surface: the chain runs in an isolated-vm
|
|
20
40
|
* but calls tools with the CALLER's credentials against the external catalog —
|
|
21
41
|
* internal-only tools aren't in that catalog, so a chain can't reach them either.
|
|
@@ -26,51 +46,166 @@ import { retiredToolInFailure, retiredToolChainFailure } from './retired-tools.j
|
|
|
26
46
|
* the remote trio describes the remote registry, and locally they must describe
|
|
27
47
|
* the merged one.
|
|
28
48
|
*/
|
|
29
|
-
const CALL_TOOL_CHAIN_DESCRIPTION = [
|
|
30
|
-
'Execute a short JavaScript program with direct access to every registered UTCP tool as a synchronous function. Call tools as `KNOWLEDGE_BASE.<tool>({ body: { ...args } })` with NO `await` (results are already resolved), and `return` the final value. The runtime is plain JavaScript (no type annotations / no TypeScript-only syntax).',
|
|
31
|
-
'Discover first: `list_tools` lists every tool in callable form (e.g. `KNOWLEDGE_BASE.read_file`); `tools_info` returns their exact argument + return shapes — do not guess. Batch multiple tool calls into one chain to avoid a round-trip per call. The chain runs with your own connection key, so it can only reach the tools you can already call directly.',
|
|
32
|
-
'Large results: if the combined result+logs exceed `max_output_size` (default 200000 chars) the full JSON is spilled to a shared store and you get back a `__tool_chain_spill__/…` ref instead. Read it with `read_file` (pass that ref as `path` — `branch` is ignored — plus `offset`/`limit` to slice it), or better, re-run a narrower chain that returns only what you need.',
|
|
33
|
-
'Images: image files are returned as native MCP image content on a DIRECT `read_file` call only — a chained `read_file` of an image yields `{ image_omitted: true, note }` instead of the picture, so call it outside the chain to actually see the image.',
|
|
34
|
-
].join('\n\n');
|
|
35
49
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
50
|
+
/** The chain tool's name, for the callers that single it out by name. */
|
|
51
|
+
export const CALL_TOOL_CHAIN_NAME = 'call_tool_chain';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The namespace every example in the three descriptions is written against.
|
|
55
|
+
*
|
|
56
|
+
* It is NOT fixed text, because it is not the same name on every connection:
|
|
57
|
+
* the hosted endpoint registers the knowledge-base tools as `KNOWLEDGE_BASE`,
|
|
58
|
+
* while the local server registers the whole deployment as one `hexis` manual.
|
|
59
|
+
* A description that named the other one taught the agent a namespace the
|
|
60
|
+
* runtime had no binding for, and the example call it copied died of
|
|
61
|
+
* `ReferenceError` — which is how this was reported. So each surface passes
|
|
62
|
+
* the name it actually registers, and the examples are built from it.
|
|
63
|
+
*
|
|
64
|
+
* Sanitized the way the runtime sanitizes it, so the example is callable even
|
|
65
|
+
* when the registered manual's name is not a bare identifier: `@utcp/code-mode`
|
|
66
|
+
* exposes `global.<sanitized manual name>`, and an example spelled any other
|
|
67
|
+
* way would not run.
|
|
68
|
+
*/
|
|
69
|
+
export function chainNamespaceExample(namespace: string): string {
|
|
70
|
+
return sanitizeIdentifier(namespace);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What a chain does when it FAILS — one of the rules about a chain that are
|
|
75
|
+
* true of every call, not of how to write one.
|
|
76
|
+
*
|
|
77
|
+
* Exported as text because it is stated in ONE of two places, never both: in
|
|
78
|
+
* the rules every tool shares (the handshake instructions and the managed
|
|
79
|
+
* agent guide, see core-backend's `agent-instructions/shared-file-rules.ts`)
|
|
80
|
+
* on a surface that has them, and in the chain's own description on one that
|
|
81
|
+
* does not. The same sentences either way, so the rule cannot read differently
|
|
82
|
+
* depending on where an agent found it.
|
|
83
|
+
*/
|
|
84
|
+
export const CHAIN_FAILURES_RULE = `Failures are answered, never dropped: a chain that throws comes back as an error carrying the reason, and one that outlives \`timeout\` (default ${CHAIN_TIMEOUT_DEFAULT_MS} ms, maximum ${CHAIN_TIMEOUT_MAX_MS} ms) comes back saying so — raise \`timeout\` or split the work and run it again. Either way the connection stays open and your next call works as usual.`;
|
|
85
|
+
|
|
86
|
+
/** What a chain does with a result too large to return. Placed as {@link CHAIN_FAILURES_RULE} is. */
|
|
87
|
+
export const CHAIN_LARGE_RESULTS_RULE =
|
|
88
|
+
'Large results: if the combined result+logs exceed `max_output_size` (default 200000 chars) the full JSON is spilled to a shared store and you get back a `__tool_chain_spill__/…` ref instead. Read it with `read_file` (pass that ref as `path` — `branch` is ignored — plus `offset`/`limit` to slice it), or better, re-run a narrower chain that returns only what you need.';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What a chained read of an IMAGE gives back. On a surface with the shared
|
|
92
|
+
* rules this is part of their content rule, which states it among everything
|
|
93
|
+
* else a read returns; it is spelled here only for a description that has to
|
|
94
|
+
* carry it itself.
|
|
95
|
+
*/
|
|
96
|
+
const CHAIN_IMAGES_RULE =
|
|
97
|
+
'Images: image files are returned as native MCP image content on a DIRECT `read_file` call only — a chained `read_file` of an image yields `{ image_omitted: true, note }` instead of the picture, so call it outside the chain to actually see the image.';
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The chain's description, in one of two forms.
|
|
101
|
+
*
|
|
102
|
+
* WITH a `sharedRulesPointer`, it is how to write a chain and how to discover
|
|
103
|
+
* what to call, ending in that sentence: the rules about what a chain does —
|
|
104
|
+
* failures, large results, images — are stated once for every tool, in the
|
|
105
|
+
* place the pointer names, and repeating them here is what made this
|
|
106
|
+
* description long enough for a client to cut.
|
|
107
|
+
*
|
|
108
|
+
* WITHOUT one, it carries those rules itself. A surface that has no shared
|
|
109
|
+
* rules to point at (the standalone bridge, which proxies a deployment whose
|
|
110
|
+
* guide it cannot name) would otherwise tell an agent nothing about a chain
|
|
111
|
+
* that timed out.
|
|
112
|
+
*/
|
|
113
|
+
function callToolChainDescription(example: ChainExample, sharedRulesPointer?: string): string {
|
|
114
|
+
const { namespace: ns, name, call } = example;
|
|
115
|
+
// Printed only when the catalog determines every required argument. A call
|
|
116
|
+
// the agent cannot trust is worse than the shape on its own, and `tools_info`
|
|
117
|
+
// is one hop away either way.
|
|
118
|
+
const worked = call ? ` A call that works exactly as written: \`return ${call};\`.` : '';
|
|
119
|
+
// A name the catalog really has, or no name: see `ChainExample.name`.
|
|
120
|
+
const forInstance = name ? ` (e.g. \`${name}\`)` : '';
|
|
121
|
+
const howToWriteOne = [
|
|
122
|
+
`Execute a short JavaScript program with direct access to every registered UTCP tool as a synchronous function. Call tools as \`${ns}.<tool>({ body: { ...args } })\` with NO \`await\` (results are already resolved), and \`return\` the final value.${worked} Every argument a tool declares REQUIRED must be present — \`tools_info\` gives the exact shapes, and for the knowledge-base tools that includes \`branch\`. The runtime is plain JavaScript (no type annotations / no TypeScript-only syntax), plus \`atob\`, \`btoa\`, \`TextEncoder\` and \`TextDecoder\` for base64 and UTF-8 bytes, as in a browser. There is no \`Buffer\`, no \`fetch\` and no \`require\`.`,
|
|
123
|
+
`Discover first: \`list_tools\` lists every tool in callable form${forInstance}; \`tools_info\` returns their exact argument + return shapes — do not guess. Batch multiple tool calls into one chain to avoid a round-trip per call. The chain runs with your own connection key, so it can only reach the tools you can already call directly.`,
|
|
124
|
+
];
|
|
125
|
+
if (sharedRulesPointer !== undefined) return `${howToWriteOne.join('\n\n')}${sharedRulesPointer}`;
|
|
126
|
+
return [...howToWriteOne, CHAIN_FAILURES_RULE, CHAIN_LARGE_RESULTS_RULE, CHAIN_IMAGES_RULE].join('\n\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** What a surface says about itself when it asks for the meta-tools. */
|
|
130
|
+
export interface CodeModeMetaToolsOptions {
|
|
131
|
+
/**
|
|
132
|
+
* The sentence that ends the chain's description on a surface whose
|
|
133
|
+
* knowledge base states the shared rules (see {@link callToolChainDescription}).
|
|
134
|
+
* Opaque text: the caller composes it, because it names the agent guide and
|
|
135
|
+
* that name is a deployment setting — nothing here may spell `AGENTS.md`.
|
|
136
|
+
* Absent, the description carries the chain's rules itself.
|
|
137
|
+
*/
|
|
138
|
+
sharedRulesPointer?: string;
|
|
139
|
+
}
|
|
71
140
|
|
|
72
|
-
|
|
141
|
+
/**
|
|
142
|
+
* The three meta-tools, with every example written against the namespace this
|
|
143
|
+
* connection really exposes and a tool name its catalog really has.
|
|
144
|
+
*
|
|
145
|
+
* Built per listing rather than held as a module constant: both belong to the
|
|
146
|
+
* surface, and a description computed once and shared across surfaces is the
|
|
147
|
+
* fixed text this replaces. `tools` is the surface's catalog — pass every tool
|
|
148
|
+
* it serves, names AND input schemas, since the arguments in the example come
|
|
149
|
+
* from the schema (see `chainExample`).
|
|
150
|
+
*
|
|
151
|
+
* `list_tools` and `tools_info` describe the registry, not what a call does,
|
|
152
|
+
* so neither ever carried a shared rule and neither gains the pointer.
|
|
153
|
+
*/
|
|
154
|
+
export function codeModeMetaTools(
|
|
155
|
+
namespace: string,
|
|
156
|
+
tools: readonly ChainExampleTool[] = [],
|
|
157
|
+
options: CodeModeMetaToolsOptions = {},
|
|
158
|
+
): McpTool[] {
|
|
159
|
+
const example = chainExample(namespace, tools);
|
|
160
|
+
const { name } = example;
|
|
161
|
+
return [
|
|
162
|
+
{
|
|
163
|
+
name: 'list_tools',
|
|
164
|
+
description: `List every UTCP tool currently registered, in TypeScript-accessible form${name ? ` (e.g. \`${name}\`)` : ''} for use inside \`call_tool_chain\`.`,
|
|
165
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false } as McpTool['inputSchema'],
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: 'tools_info',
|
|
169
|
+
description:
|
|
170
|
+
'Get full TypeScript interface definitions for named tools (names from `list_tools`). The schemas are the source of truth — do not guess shapes.',
|
|
171
|
+
inputSchema: {
|
|
172
|
+
type: 'object',
|
|
173
|
+
properties: {
|
|
174
|
+
tool_names: { type: 'array', items: { type: 'string' }, minItems: 1, description: 'Tool names to describe.' },
|
|
175
|
+
},
|
|
176
|
+
required: ['tool_names'],
|
|
177
|
+
additionalProperties: false,
|
|
178
|
+
} as McpTool['inputSchema'],
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: CALL_TOOL_CHAIN_NAME,
|
|
182
|
+
description: callToolChainDescription(example, options.sharedRulesPointer),
|
|
183
|
+
inputSchema: {
|
|
184
|
+
type: 'object',
|
|
185
|
+
properties: {
|
|
186
|
+
code: { type: 'string', minLength: 1, description: 'JavaScript to execute against the registered tools.' },
|
|
187
|
+
timeout: {
|
|
188
|
+
type: 'integer',
|
|
189
|
+
minimum: CHAIN_TIMEOUT_MIN_MS,
|
|
190
|
+
maximum: CHAIN_TIMEOUT_MAX_MS,
|
|
191
|
+
description: `Timeout in ms (default ${CHAIN_TIMEOUT_DEFAULT_MS}, max ${CHAIN_TIMEOUT_MAX_MS}).`,
|
|
192
|
+
},
|
|
193
|
+
max_output_size: { type: 'integer', minimum: 1000, maximum: 1000000, description: 'Max result+logs size in chars before spilling (default 200000, max 1000000).' },
|
|
194
|
+
},
|
|
195
|
+
required: ['code'],
|
|
196
|
+
additionalProperties: false,
|
|
197
|
+
} as McpTool['inputSchema'],
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
}
|
|
73
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The meta-tool NAMES, which no namespace can change. Kept separate from
|
|
204
|
+
* {@link codeModeMetaTools} because every surface needs them to route a call
|
|
205
|
+
* and to keep a discovered copy out of its listing, and neither of those knows
|
|
206
|
+
* — or should need — the namespace.
|
|
207
|
+
*/
|
|
208
|
+
export const META_TOOL_NAMES: ReadonlySet<string> = new Set(['list_tools', 'tools_info', CALL_TOOL_CHAIN_NAME]);
|
|
74
209
|
/** Default cap on a `call_tool_chain` result's stringified size before it spills. */
|
|
75
210
|
export const CALL_TOOL_CHAIN_MAX_OUTPUT = 200_000;
|
|
76
211
|
|
|
@@ -149,19 +284,38 @@ export async function dispatchMetaTool(
|
|
|
149
284
|
// the isolate far past the documented 120s cap.
|
|
150
285
|
const timeout =
|
|
151
286
|
typeof args.timeout === 'number' && Number.isFinite(args.timeout)
|
|
152
|
-
? Math.min(
|
|
153
|
-
:
|
|
287
|
+
? Math.min(CHAIN_TIMEOUT_MAX_MS, Math.max(CHAIN_TIMEOUT_MIN_MS, Math.trunc(args.timeout)))
|
|
288
|
+
: CHAIN_TIMEOUT_DEFAULT_MS;
|
|
154
289
|
// Clamp to [1000, 1_000_000] so a caller can't force oversized inline
|
|
155
290
|
// output past the spill.
|
|
156
291
|
const maxOutputSize =
|
|
157
292
|
typeof args.max_output_size === 'number' && Number.isFinite(args.max_output_size)
|
|
158
293
|
? Math.min(1_000_000, Math.max(1_000, Math.trunc(args.max_output_size)))
|
|
159
294
|
: CALL_TOOL_CHAIN_MAX_OUTPUT;
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
295
|
+
// The shared runner, which answers every failure instead of leaving one to
|
|
296
|
+
// pass for a success with a null result: the chain's own browser globals
|
|
297
|
+
// are in place, and a timeout, an exhausted heap and an unknown namespace
|
|
298
|
+
// each come back as the sentence that says so.
|
|
299
|
+
const outcome = await runToolChain(client, code, timeout);
|
|
300
|
+
if (!outcome.ok) {
|
|
301
|
+
// A chain that died calling a REMOVED tool gets the reason it was
|
|
302
|
+
// removed, not the runtime's "is not a function". Read from the failure
|
|
303
|
+
// itself, never from the chain's source: a chain that merely mentions
|
|
304
|
+
// the name and died of something else keeps its own reason. A migration
|
|
305
|
+
// notice answers alone — the transport detail below would be noise
|
|
306
|
+
// beside an answer that is not about the transport.
|
|
307
|
+
const retired = retiredToolInFailure(outcome.error);
|
|
308
|
+
if (retired) return toolError(retired);
|
|
309
|
+
// An MCP caller is answered with TEXT and nothing else, so the
|
|
310
|
+
// transport's own status and body are folded into it. `runToolChain`
|
|
311
|
+
// composes the message with `describeToolFailure` (which lifts an
|
|
312
|
+
// axios-shaped `response.data.error` out) and carries `status`/`data`
|
|
313
|
+
// beside it for the shapes that put the reason there instead; returning
|
|
314
|
+
// `outcome.error` alone dropped that half on this surface, leaving the
|
|
315
|
+
// caller with generic transport text.
|
|
316
|
+
return toolError(withTransportDetail(outcome.error, outcome.status, outcome.data));
|
|
317
|
+
}
|
|
318
|
+
const { result: rawResult, logs } = outcome;
|
|
165
319
|
// Images never ride a chain result: the chain's value is stringified JSON,
|
|
166
320
|
// where base64 is context flood, not a picture. A chained `read_file` of an
|
|
167
321
|
// image comes back as an omitted-image note instead (see omitImagePayloads);
|
|
@@ -196,12 +350,12 @@ export async function dispatchMetaTool(
|
|
|
196
350
|
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.`,
|
|
197
351
|
});
|
|
198
352
|
} catch (err) {
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
353
|
+
// `runToolChain` answers rather than throws, so a chain no longer reaches
|
|
354
|
+
// here — what does is a catalog read, a name lookup or the spill write.
|
|
355
|
+
// The retired-tool mapping is kept all the same: it costs nothing, and it
|
|
356
|
+
// is the one answer that must survive however the failure arrived.
|
|
203
357
|
const failure = describeToolFailure(err);
|
|
204
|
-
const retired = name ===
|
|
358
|
+
const retired = name === CALL_TOOL_CHAIN_NAME ? retiredToolInFailure(failure) : undefined;
|
|
205
359
|
if (retired) return toolError(retired);
|
|
206
360
|
return toolError(`The "${name}" tool failed: ${failure}`);
|
|
207
361
|
}
|
package/src/results.ts
CHANGED
|
@@ -84,7 +84,17 @@ function isMcpImageBlockObject(value: unknown): value is { type: 'image'; data:
|
|
|
84
84
|
* MCP caller sees the tool's real message instead of a bare "status code 500".
|
|
85
85
|
*/
|
|
86
86
|
export function describeToolFailure(err: unknown): string {
|
|
87
|
-
|
|
87
|
+
// Read through to the body UNDER the guard. `err` is whatever a transport
|
|
88
|
+
// threw, and a getter or Proxy on `response` — or on `response.data` — that
|
|
89
|
+
// throws while being read must not escape: this function runs inside catch
|
|
90
|
+
// paths, and `runToolChain` promises an answer for every outcome, so a throw
|
|
91
|
+
// here would surface as the dropped connection that contract rules out.
|
|
92
|
+
let data: unknown;
|
|
93
|
+
try {
|
|
94
|
+
data = (err as { response?: { data?: unknown } })?.response?.data;
|
|
95
|
+
} catch {
|
|
96
|
+
data = undefined;
|
|
97
|
+
}
|
|
88
98
|
if (data && typeof data === 'object') {
|
|
89
99
|
let inner: unknown;
|
|
90
100
|
try {
|
|
@@ -121,6 +131,101 @@ export function describeToolFailure(err: unknown): string {
|
|
|
121
131
|
}
|
|
122
132
|
}
|
|
123
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Does `message` already state `status` AS a status code?
|
|
136
|
+
*
|
|
137
|
+
* The bare digits are not enough to go on: a message like `Processed 404 files`
|
|
138
|
+
* contains them without saying anything about a response code, and treating
|
|
139
|
+
* that as "already said" would drop the status from the one string the caller
|
|
140
|
+
* gets. So only the two phrasings a transport actually uses count — axios's
|
|
141
|
+
* `status code 404` and this file's own `HTTP 404` — and neither matches a
|
|
142
|
+
* longer number that merely starts with the same digits (`HTTP 4042`).
|
|
143
|
+
*/
|
|
144
|
+
function statesStatus(message: string, status: number): boolean {
|
|
145
|
+
return new RegExp(`(?:HTTP\\s+|status code\\s+)${status}(?!\\d)`, 'i').test(message);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `message` with the transport's own `status` and body folded INTO it, for a
|
|
150
|
+
* surface that can only answer with text.
|
|
151
|
+
*
|
|
152
|
+
* An MCP caller sees one string: the structured fields a `ToolChainOutcome`
|
|
153
|
+
* carries beside its message (`status`, `data`) reach it only if they are in
|
|
154
|
+
* that string. {@link describeToolFailure} already lifts an axios-shaped
|
|
155
|
+
* `response.data.error` out, but the UTCP http transport also throws failures
|
|
156
|
+
* carrying `status`/`data` directly, and THAT body is the actionable half —
|
|
157
|
+
* without this it was simply dropped on the way to the caller.
|
|
158
|
+
*
|
|
159
|
+
* Nothing is said twice: a message that already carries the body's own
|
|
160
|
+
* `error` reason — which is what `describeToolFailure` lifts out of an
|
|
161
|
+
* axios-shaped failure — does not get that reason again, and a status the
|
|
162
|
+
* message already STATES as a status code (see {@link statesStatus}) is not
|
|
163
|
+
* repeated either. And nothing is LOST to that: whatever else the body holds
|
|
164
|
+
* that the message does not already say (the `kind` a caller switches on, the
|
|
165
|
+
* `proposal` steps of a refused write) still follows it.
|
|
166
|
+
*/
|
|
167
|
+
export function withTransportDetail(message: string, status?: unknown, data?: unknown): string {
|
|
168
|
+
let out = message;
|
|
169
|
+
// An integer: a status is a response code, and `(HTTP 404.5)` would be
|
|
170
|
+
// nonsense to print and a sloppy pattern to match with.
|
|
171
|
+
if (Number.isInteger(status) && !statesStatus(out, status as number)) out += ` (HTTP ${status as number})`;
|
|
172
|
+
if (data !== undefined) {
|
|
173
|
+
// Already said? The actionable part of a body is its `error` field, and
|
|
174
|
+
// `describeToolFailure` lifts exactly that (plus `kind`) out of an
|
|
175
|
+
// axios-shaped failure — so a message already carrying it has the body in
|
|
176
|
+
// it, and appending the raw JSON beside it would read as two failures.
|
|
177
|
+
let reason: unknown;
|
|
178
|
+
try {
|
|
179
|
+
reason = (data as { error?: unknown })?.error;
|
|
180
|
+
} catch {
|
|
181
|
+
reason = undefined;
|
|
182
|
+
}
|
|
183
|
+
if (typeof reason === 'string' && reason.length > 0 && out.includes(reason)) {
|
|
184
|
+
// The reason is said. The REST of the body is said only as far as the
|
|
185
|
+
// message happens to contain it: a refusal's `kind`, its `proposal`
|
|
186
|
+
// steps or the paths it names are the half a caller acts on, and
|
|
187
|
+
// treating the whole body as covered because one of its fields was
|
|
188
|
+
// dropped them. What the message does not already carry follows it.
|
|
189
|
+
const unsaid = fieldsNotIn(out, data, 'error');
|
|
190
|
+
return unsaid === null ? out : `${out} Error data: ${unsaid}`;
|
|
191
|
+
}
|
|
192
|
+
// Total, like the rest of this file: a body that does not serialise
|
|
193
|
+
// degrades through `safeJsonText` rather than throwing in a catch path.
|
|
194
|
+
const text = typeof data === 'string' ? data : safeJsonText(data);
|
|
195
|
+
if (text.length > 0 && text !== '{}' && text !== '""' && !out.includes(text)) out += ` Error data: ${text}`;
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The fields of `data` — all but `skip` — that `message` does not already
|
|
202
|
+
* state, as JSON; null when there is none. A field is stated when its value
|
|
203
|
+
* appears in the message: as it is for a string, a number or a boolean, as
|
|
204
|
+
* its JSON for anything else. Total: a body that cannot be walked or
|
|
205
|
+
* serialised says nothing more rather than throwing in a catch path.
|
|
206
|
+
*/
|
|
207
|
+
function fieldsNotIn(message: string, data: unknown, skip: string): string | null {
|
|
208
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
209
|
+
let unsaid: [string, unknown][];
|
|
210
|
+
try {
|
|
211
|
+
unsaid = Object.entries(data as Record<string, unknown>).filter(([key, value]) => {
|
|
212
|
+
if (key === skip || value === undefined) return false;
|
|
213
|
+
const scalar = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
|
|
214
|
+
const said = scalar ? String(value) : safeJsonText(value);
|
|
215
|
+
return !(said.length > 0 && message.includes(said));
|
|
216
|
+
});
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
if (unsaid.length === 0) return null;
|
|
221
|
+
// `Object.fromEntries` DEFINES each entry, where an assignment would run a
|
|
222
|
+
// setter: a body is JSON from a server, any name is a legal key in it, and
|
|
223
|
+
// one called `__proto__` assigned into a plain object sets its prototype
|
|
224
|
+
// and vanishes from what is serialised. Defined, every key is just a key.
|
|
225
|
+
const text = safeJsonText(Object.fromEntries(unsaid));
|
|
226
|
+
return text.length > 0 && text !== '{}' ? text : null;
|
|
227
|
+
}
|
|
228
|
+
|
|
124
229
|
/**
|
|
125
230
|
* Turn a tool's final value into an MCP result:
|
|
126
231
|
* - a tool that already returns the MCP agentic shape (`{ content: [...] }`,
|
package/src/retired-tools.ts
CHANGED
|
@@ -73,25 +73,3 @@ export function retiredToolInFailure(failure: string): string | undefined {
|
|
|
73
73
|
}
|
|
74
74
|
return undefined;
|
|
75
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
|
-
}
|