@bevel-software/platform-mcp-core 0.26.0 → 0.27.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/call-guards.d.ts +47 -0
- package/dist/call-guards.d.ts.map +1 -0
- package/dist/call-guards.js +215 -0
- package/dist/call-guards.js.map +1 -0
- package/dist/dispatch.d.ts +5 -1
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +19 -2
- package/dist/dispatch.js.map +1 -1
- package/dist/get-has-no-body.d.ts +49 -0
- package/dist/get-has-no-body.d.ts.map +1 -0
- package/dist/get-has-no-body.js +104 -0
- package/dist/get-has-no-body.js.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp-app.d.ts +118 -0
- package/dist/mcp-app.d.ts.map +1 -0
- package/dist/mcp-app.js +126 -0
- package/dist/mcp-app.js.map +1 -0
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +27 -5
- package/dist/meta-tools.js.map +1 -1
- package/dist/proxied-tool.d.ts +11 -0
- package/dist/proxied-tool.d.ts.map +1 -1
- package/dist/proxied-tool.js +13 -1
- package/dist/proxied-tool.js.map +1 -1
- package/dist/results.d.ts +20 -1
- package/dist/results.d.ts.map +1 -1
- package/dist/results.js +117 -3
- package/dist/results.js.map +1 -1
- package/dist/tool-interface.d.ts +137 -0
- package/dist/tool-interface.d.ts.map +1 -0
- package/dist/tool-interface.js +640 -0
- package/dist/tool-interface.js.map +1 -0
- package/dist/utcp-namespace.d.ts +14 -0
- package/dist/utcp-namespace.d.ts.map +1 -1
- package/dist/utcp-namespace.js +7 -2
- package/dist/utcp-namespace.js.map +1 -1
- package/package.json +1 -1
- package/src/call-guards.ts +237 -0
- package/src/dispatch.ts +18 -1
- package/src/get-has-no-body.ts +110 -0
- package/src/index.ts +39 -0
- package/src/mcp-app.ts +194 -0
- package/src/meta-tools.ts +30 -5
- package/src/proxied-tool.ts +23 -1
- package/src/results.ts +129 -3
- package/src/tool-interface.ts +673 -0
- package/src/utcp-namespace.ts +7 -2
package/src/mcp-app.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP Apps extension (`io.modelcontextprotocol/ui`, specification
|
|
3
|
+
* 2026-01-26), as much of it as a SERVER has to speak.
|
|
4
|
+
*
|
|
5
|
+
* The pattern is two primitives tied together by one URI: a tool whose
|
|
6
|
+
* `_meta.ui.resourceUri` names a `ui://` resource, and that resource — an HTML
|
|
7
|
+
* page served under {@link MCP_APP_MIME_TYPE} — which the host renders in an
|
|
8
|
+
* iframe and then pushes the tool's result into. A host without the extension
|
|
9
|
+
* sees an ordinary tool with an ordinary text result and ignores the metadata,
|
|
10
|
+
* which is why the tool's answer must stand on its own.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately no dependency: the extension's SDK would be a new package on
|
|
13
|
+
* both the server and the view, and the wire shape it would hide is the three
|
|
14
|
+
* literals below (see the Specification's Decision 10). Hexis tracks the
|
|
15
|
+
* specification by hand instead.
|
|
16
|
+
*
|
|
17
|
+
* Lives in `mcp-core` because BOTH surfaces serve the same view: the hosted
|
|
18
|
+
* proxy from the deployment it runs in, and `hexis-mcp` by fetching it from
|
|
19
|
+
* the deployment it bridges. One definition, so the two cannot drift.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The `_meta` key the extension's metadata rides under, on a tool and on a resource alike. */
|
|
23
|
+
export const MCP_APP_UI_META_KEY = 'ui';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The media type of an MCP App view. A host that supports the extension
|
|
27
|
+
* recognises an app by this and nothing else; a non-conforming explicit type
|
|
28
|
+
* is rejected rather than coerced, so it is spelled once, here.
|
|
29
|
+
*/
|
|
30
|
+
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
|
|
31
|
+
|
|
32
|
+
/** The `ui://` scheme every app resource's URI uses. */
|
|
33
|
+
export const MCP_APP_URI_SCHEME = 'ui://';
|
|
34
|
+
|
|
35
|
+
/** A tool's UI metadata: which view renders its result. */
|
|
36
|
+
export interface McpAppToolUi {
|
|
37
|
+
/** The `ui://` URI of the view resource. */
|
|
38
|
+
resourceUri: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A view resource's UI metadata: what the host's sandbox may do with it. */
|
|
42
|
+
export interface McpAppResourceUi {
|
|
43
|
+
/**
|
|
44
|
+
* What the sandbox may load from outside itself. `frameDomains` are the
|
|
45
|
+
* origins the view may put in an iframe — for Hexis, exactly the
|
|
46
|
+
* deployment's own public origin, because the view's whole job is to frame
|
|
47
|
+
* this deployment's `/embed` page.
|
|
48
|
+
*/
|
|
49
|
+
csp?: {
|
|
50
|
+
frameDomains?: string[];
|
|
51
|
+
connectDomains?: string[];
|
|
52
|
+
resourceDomains?: string[];
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The stable sandbox domain the host gives the view. Stable matters: the
|
|
56
|
+
* host derives the iframe's opaque origin from it, so a value that changed
|
|
57
|
+
* per render would throw away the sandbox's storage on every call.
|
|
58
|
+
*/
|
|
59
|
+
domain?: string;
|
|
60
|
+
/** Whether the host should draw a border around the view. */
|
|
61
|
+
prefersBorder?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One MCP App view, as `resources/list` and `resources/read` answer for it. */
|
|
65
|
+
export interface McpAppResource {
|
|
66
|
+
/** `ui://…` — the URI a tool's `resourceUri` points at. */
|
|
67
|
+
uri: string;
|
|
68
|
+
name: string;
|
|
69
|
+
title?: string;
|
|
70
|
+
description?: string;
|
|
71
|
+
/** Always {@link MCP_APP_MIME_TYPE}. */
|
|
72
|
+
mimeType: string;
|
|
73
|
+
/** The view's HTML. */
|
|
74
|
+
text: string;
|
|
75
|
+
ui: McpAppResourceUi;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Everything a surface needs to serve a deployment's MCP Apps: which tools
|
|
80
|
+
* carry UI metadata, and the views they name.
|
|
81
|
+
*
|
|
82
|
+
* `hexis-mcp` fetches exactly this shape over HTTP from the deployment, so
|
|
83
|
+
* the local server advertises the same apps as the hosted endpoint without
|
|
84
|
+
* knowing anything about what they render.
|
|
85
|
+
*/
|
|
86
|
+
export interface McpAppManifest {
|
|
87
|
+
/** Tool name → its UI metadata. */
|
|
88
|
+
tools: Record<string, McpAppToolUi>;
|
|
89
|
+
resources: McpAppResource[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The `_meta` object a tool carrying a view advertises. */
|
|
93
|
+
export function toolUiMeta(ui: McpAppToolUi): Record<string, unknown> {
|
|
94
|
+
return { [MCP_APP_UI_META_KEY]: { resourceUri: ui.resourceUri } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The `_meta` object a view resource advertises, on both list and read. */
|
|
98
|
+
export function resourceUiMeta(ui: McpAppResourceUi): Record<string, unknown> {
|
|
99
|
+
return { [MCP_APP_UI_META_KEY]: { ...ui } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A resource's listing entry (no content), as `resources/list` returns it. */
|
|
103
|
+
export function toListedResource(resource: McpAppResource): {
|
|
104
|
+
uri: string;
|
|
105
|
+
name: string;
|
|
106
|
+
title?: string;
|
|
107
|
+
description?: string;
|
|
108
|
+
mimeType: string;
|
|
109
|
+
_meta: Record<string, unknown>;
|
|
110
|
+
} {
|
|
111
|
+
return {
|
|
112
|
+
uri: resource.uri,
|
|
113
|
+
name: resource.name,
|
|
114
|
+
...(resource.title !== undefined ? { title: resource.title } : {}),
|
|
115
|
+
...(resource.description !== undefined ? { description: resource.description } : {}),
|
|
116
|
+
mimeType: resource.mimeType,
|
|
117
|
+
_meta: resourceUiMeta(resource.ui),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A resource's content, as `resources/read` returns it. The metadata rides
|
|
123
|
+
* the CONTENTS entry as well as the listing: a host that preloads a view
|
|
124
|
+
* straight from a tool's `resourceUri` never saw the listing, and the CSP is
|
|
125
|
+
* what decides whether its sandbox may frame anything at all.
|
|
126
|
+
*/
|
|
127
|
+
export function toReadResourceResult(resource: McpAppResource): {
|
|
128
|
+
contents: Array<{ uri: string; mimeType: string; text: string; _meta: Record<string, unknown> }>;
|
|
129
|
+
} {
|
|
130
|
+
return {
|
|
131
|
+
contents: [
|
|
132
|
+
{
|
|
133
|
+
uri: resource.uri,
|
|
134
|
+
mimeType: resource.mimeType,
|
|
135
|
+
text: resource.text,
|
|
136
|
+
_meta: resourceUiMeta(resource.ui),
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse a manifest off the wire (the local server's fetch from a deployment),
|
|
144
|
+
* keeping only what is well-formed. A deployment too old to serve one, or one
|
|
145
|
+
* whose answer is a proxy's HTML, must leave the local server with NO apps
|
|
146
|
+
* rather than with a half-built one — the tools it bridges still work.
|
|
147
|
+
*/
|
|
148
|
+
export function parseMcpAppManifest(body: unknown): McpAppManifest {
|
|
149
|
+
const empty: McpAppManifest = { tools: {}, resources: [] };
|
|
150
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return empty;
|
|
151
|
+
const raw = body as { tools?: unknown; resources?: unknown };
|
|
152
|
+
const tools: Record<string, McpAppToolUi> = {};
|
|
153
|
+
if (raw.tools && typeof raw.tools === 'object' && !Array.isArray(raw.tools)) {
|
|
154
|
+
for (const [name, value] of Object.entries(raw.tools as Record<string, unknown>)) {
|
|
155
|
+
const uri = (value as { resourceUri?: unknown })?.resourceUri;
|
|
156
|
+
if (typeof uri === 'string' && uri.startsWith(MCP_APP_URI_SCHEME)) {
|
|
157
|
+
tools[name] = { resourceUri: uri };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const resources: McpAppResource[] = [];
|
|
162
|
+
if (Array.isArray(raw.resources)) {
|
|
163
|
+
for (const entry of raw.resources) {
|
|
164
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
165
|
+
const r = entry as Partial<McpAppResource>;
|
|
166
|
+
// A view with no URI or no HTML is nothing a host can render, and a
|
|
167
|
+
// media type that is not the app one would be rejected downstream
|
|
168
|
+
// anyway — drop it here, where the reason is still legible.
|
|
169
|
+
if (typeof r.uri !== 'string' || !r.uri.startsWith(MCP_APP_URI_SCHEME)) continue;
|
|
170
|
+
if (typeof r.text !== 'string' || r.text === '') continue;
|
|
171
|
+
if (r.mimeType !== MCP_APP_MIME_TYPE) continue;
|
|
172
|
+
resources.push({
|
|
173
|
+
uri: r.uri,
|
|
174
|
+
name: typeof r.name === 'string' && r.name ? r.name : r.uri,
|
|
175
|
+
...(typeof r.title === 'string' ? { title: r.title } : {}),
|
|
176
|
+
...(typeof r.description === 'string' ? { description: r.description } : {}),
|
|
177
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
178
|
+
text: r.text,
|
|
179
|
+
ui:
|
|
180
|
+
r.ui && typeof r.ui === 'object' && !Array.isArray(r.ui)
|
|
181
|
+
? (r.ui as McpAppResourceUi)
|
|
182
|
+
: {},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Only tools whose view actually arrived: a `resourceUri` pointing at a
|
|
187
|
+
// resource this surface cannot serve would have a host preload a 404 and
|
|
188
|
+
// show the reader an empty frame where the text used to be.
|
|
189
|
+
const served = new Set(resources.map((r) => r.uri));
|
|
190
|
+
for (const name of Object.keys(tools)) {
|
|
191
|
+
if (!served.has(tools[name].resourceUri)) delete tools[name];
|
|
192
|
+
}
|
|
193
|
+
return { tools, resources };
|
|
194
|
+
}
|
package/src/meta-tools.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { utcpNameToTsInterfaceName, findToolsByNames, sanitizeIdentifier } from
|
|
|
4
4
|
import { chainExample, type ChainExample, type ChainExampleTool } from './chain-example.js';
|
|
5
5
|
import { toCallToolResult, toolError, describeToolFailure, withTransportDetail, omitImagePayloads } from './results.js';
|
|
6
6
|
import { retiredToolInFailure } from './retired-tools.js';
|
|
7
|
+
import { withCallExample } from './tool-interface.js';
|
|
7
8
|
import {
|
|
8
9
|
CHAIN_TIMEOUT_DEFAULT_MS,
|
|
9
10
|
CHAIN_TIMEOUT_MAX_MS,
|
|
@@ -119,7 +120,8 @@ function callToolChainDescription(example: ChainExample, sharedRulesPointer?: st
|
|
|
119
120
|
// A name the catalog really has, or no name: see `ChainExample.name`.
|
|
120
121
|
const forInstance = name ? ` (e.g. \`${name}\`)` : '';
|
|
121
122
|
const howToWriteOne = [
|
|
122
|
-
`Execute a short JavaScript program with direct access to every registered UTCP tool as a synchronous function
|
|
123
|
+
`Execute a short JavaScript program with direct access to every registered UTCP tool as a synchronous function, with NO \`await\` (results are already resolved), and \`return\` the final value.${worked} 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\`.`,
|
|
124
|
+
`There is NO single calling shape: call each tool as the \`Call:\` line atop its description shows; every tool in \`${ns}\` has one. Mismatched arguments are refused before anything runs.`,
|
|
123
125
|
`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
126
|
];
|
|
125
127
|
if (sharedRulesPointer !== undefined) return `${howToWriteOne.join('\n\n')}${sharedRulesPointer}`;
|
|
@@ -158,7 +160,7 @@ export function codeModeMetaTools(
|
|
|
158
160
|
): McpTool[] {
|
|
159
161
|
const example = chainExample(namespace, tools);
|
|
160
162
|
const { name } = example;
|
|
161
|
-
return [
|
|
163
|
+
return withCallExamples([
|
|
162
164
|
{
|
|
163
165
|
name: 'list_tools',
|
|
164
166
|
description: `List every UTCP tool currently registered, in TypeScript-accessible form${name ? ` (e.g. \`${name}\`)` : ''} for use inside \`call_tool_chain\`.`,
|
|
@@ -196,7 +198,20 @@ export function codeModeMetaTools(
|
|
|
196
198
|
additionalProperties: false,
|
|
197
199
|
} as McpTool['inputSchema'],
|
|
198
200
|
},
|
|
199
|
-
];
|
|
201
|
+
]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Each of the three with its call example ahead of its description, from the
|
|
206
|
+
* same generator every other tool's comes from — so "every description opens
|
|
207
|
+
* with its call" holds with no exception an agent has to learn.
|
|
208
|
+
*
|
|
209
|
+
* These three are called DIRECTLY over MCP rather than from inside a chain, so
|
|
210
|
+
* the example shows that shape: the tool's name and its required arguments,
|
|
211
|
+
* with no namespace in front.
|
|
212
|
+
*/
|
|
213
|
+
function withCallExamples(tools: McpTool[]): McpTool[] {
|
|
214
|
+
return tools.map((t) => ({ ...t, description: withCallExample(t.description, t.name, t.inputSchema) }));
|
|
200
215
|
}
|
|
201
216
|
|
|
202
217
|
/**
|
|
@@ -267,8 +282,18 @@ export async function dispatchMetaTool(
|
|
|
267
282
|
const resolved = await findToolsByNames(client, names);
|
|
268
283
|
for (const n of names) {
|
|
269
284
|
const found = resolved.get(n);
|
|
270
|
-
|
|
271
|
-
|
|
285
|
+
// The call example travels with the interface too: `tools_info` is
|
|
286
|
+
// where an agent writing a chain reads the shape, and reading it there
|
|
287
|
+
// without the example is how a flat tool's arguments end up in a
|
|
288
|
+
// `body`. Added to a COPY — the repository's tool is not ours to edit.
|
|
289
|
+
if (found) {
|
|
290
|
+
interfaces.push(
|
|
291
|
+
client.toolToTypeScriptInterface({
|
|
292
|
+
...found.tool,
|
|
293
|
+
description: withCallExample(found.tool.description, found.utcpName, found.tool.inputs),
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
} else notFound.push(n);
|
|
272
297
|
}
|
|
273
298
|
return toCallToolResult({ interfaces: interfaces.join('\n\n'), not_found: notFound });
|
|
274
299
|
}
|
package/src/proxied-tool.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import type { JsonSchema, Tool as UtcpTool } from '@utcp/sdk';
|
|
3
|
+
import { withCallExample } from './tool-interface.js';
|
|
4
|
+
import { toolUiMeta, type McpAppToolUi } from './mcp-app.js';
|
|
3
5
|
|
|
4
6
|
/** A tool discovered from a UTCP manual, flattened into what an MCP surface advertises. */
|
|
5
7
|
export interface ProxiedTool {
|
|
@@ -10,6 +12,16 @@ export interface ProxiedTool {
|
|
|
10
12
|
/** The UTCP manual this tool came from (the `<manual>` in `<manual>.<tool>`),
|
|
11
13
|
* used to look up the manual's declared per-user credentials before dispatch. */
|
|
12
14
|
manualName: string;
|
|
15
|
+
/**
|
|
16
|
+
* The MCP Apps view this tool's result renders in, when it carries one.
|
|
17
|
+
*
|
|
18
|
+
* Not part of the UTCP manual a tool is discovered from — UTCP has no place
|
|
19
|
+
* for it — so a surface attaches it by tool NAME from the deployment's app
|
|
20
|
+
* manifest (`McpAppManifest.tools`) after flattening. `toListedTool` turns
|
|
21
|
+
* it into the `_meta` an MCP client reads; a client without the extension
|
|
22
|
+
* ignores the field, so carrying it costs nothing.
|
|
23
|
+
*/
|
|
24
|
+
ui?: McpAppToolUi;
|
|
13
25
|
}
|
|
14
26
|
|
|
15
27
|
/**
|
|
@@ -72,8 +84,18 @@ export function toListedTool(tool: ProxiedTool): McpTool | null {
|
|
|
72
84
|
}
|
|
73
85
|
return {
|
|
74
86
|
name: tool.mcpName,
|
|
75
|
-
|
|
87
|
+
// Every tool an agent can see opens with the one line that shows how it is
|
|
88
|
+
// called — generated from this tool's own input schema, so the platform's
|
|
89
|
+
// tools, a deployment's and a connected server's all get one and none of
|
|
90
|
+
// them can drift from the shape the tool really takes.
|
|
91
|
+
// From the schema as it is LISTED (sanitized, local `$ref`s inlined), so
|
|
92
|
+
// the example and the interface the client is shown agree.
|
|
93
|
+
description: withCallExample(tool.description, tool.utcpName, inputSchema),
|
|
76
94
|
inputSchema: inputSchema as McpTool['inputSchema'],
|
|
95
|
+
// The MCP Apps view, when this tool carries one. `_meta` is an open map
|
|
96
|
+
// every client is required to tolerate, so a client without the
|
|
97
|
+
// extension reads the tool exactly as it did before.
|
|
98
|
+
...(tool.ui ? { _meta: toolUiMeta(tool.ui) } : {}),
|
|
77
99
|
};
|
|
78
100
|
}
|
|
79
101
|
|
package/src/results.ts
CHANGED
|
@@ -119,7 +119,7 @@ export function describeToolFailure(err: unknown): string {
|
|
|
119
119
|
return inner;
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
-
if (typeof data === 'string' && data.length > 0) return data;
|
|
122
|
+
if (typeof data === 'string' && data.length > 0) return nonJsonFailure(err, data) ?? data;
|
|
123
123
|
// Total, like `safeJsonText`: a thrown value whose own `toString` throws
|
|
124
124
|
// (e.g. a null-prototype object) must still come back as a description —
|
|
125
125
|
// this function runs inside catch paths, where a second throw would turn
|
|
@@ -131,6 +131,105 @@ export function describeToolFailure(err: unknown): string {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/** The `kind` an answer that was not JSON where JSON was expected carries. */
|
|
135
|
+
export const NOT_JSON_KIND = 'not-json';
|
|
136
|
+
|
|
137
|
+
/** How much of a non-JSON answer reaches the agent. The rest is a web page, not information. */
|
|
138
|
+
const NON_JSON_MAX = 200;
|
|
139
|
+
|
|
140
|
+
/** Collapse every run of whitespace, so a page's indentation doesn't fill the budget. */
|
|
141
|
+
function firstLine(text: string): string {
|
|
142
|
+
const collapsed = text.replace(/\s+/g, ' ').trim();
|
|
143
|
+
return collapsed.length > NON_JSON_MAX ? `${collapsed.slice(0, NON_JSON_MAX - 1)}…` : collapsed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function looksLikeJson(text: string): boolean {
|
|
147
|
+
try {
|
|
148
|
+
JSON.parse(text);
|
|
149
|
+
return true;
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A SUCCESSFUL answer that is a web page rather than JSON, cut to its first
|
|
157
|
+
* {@link NON_JSON_MAX} characters — or `undefined` when the value is not a
|
|
158
|
+
* page. Used by the call guards, which know whether the tool answers over HTTP
|
|
159
|
+
* at all; a markdown file that happens to start with a tag is not a page, and
|
|
160
|
+
* only an http-family tool's bare string body can be one.
|
|
161
|
+
*/
|
|
162
|
+
export function pageInsteadOfJson(value: string): string | undefined {
|
|
163
|
+
if (!/^\s*<(!doctype|html|\?xml|head|body)\b/i.test(value)) return undefined;
|
|
164
|
+
return firstLine(value);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The short form of a failure whose body was not JSON: the status, the host
|
|
169
|
+
* that answered, and the first {@link NON_JSON_MAX} characters.
|
|
170
|
+
*
|
|
171
|
+
* A service's edge answers a refused request with its own HTML page, and that
|
|
172
|
+
* page used to reach the agent whole — thousands of characters of markup in
|
|
173
|
+
* place of a reason. The status and the first line of it say everything the
|
|
174
|
+
* agent can act on. A body that IS JSON keeps today's wording: it is the
|
|
175
|
+
* service's own message, however long, and cutting it would lose the reason.
|
|
176
|
+
*/
|
|
177
|
+
function nonJsonFailure(err: unknown, body: string): string | undefined {
|
|
178
|
+
if (looksLikeJson(body)) return undefined;
|
|
179
|
+
const status = readNumber(err, ['response', 'status']) ?? readNumber(err, ['status']);
|
|
180
|
+
const host = failureHost(err);
|
|
181
|
+
const where = [status === undefined ? '' : String(status), host === undefined ? '' : `from ${host}`]
|
|
182
|
+
.filter((p) => p !== '')
|
|
183
|
+
.join(' ');
|
|
184
|
+
const line = firstLine(body);
|
|
185
|
+
return where === '' ? line : `${where}: ${line}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function readNumber(source: unknown, path: string[]): number | undefined {
|
|
189
|
+
let node: unknown = source;
|
|
190
|
+
for (const key of path) {
|
|
191
|
+
if (node === null || typeof node !== 'object') return undefined;
|
|
192
|
+
try {
|
|
193
|
+
node = (node as Record<string, unknown>)[key];
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return typeof node === 'number' ? node : undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The host that answered, from wherever the transport left the request's URL. */
|
|
202
|
+
function failureHost(err: unknown): string | undefined {
|
|
203
|
+
for (const path of [
|
|
204
|
+
['response', 'config', 'url'],
|
|
205
|
+
['config', 'url'],
|
|
206
|
+
['response', 'url'],
|
|
207
|
+
['url'],
|
|
208
|
+
]) {
|
|
209
|
+
let node: unknown = err;
|
|
210
|
+
for (const key of path) {
|
|
211
|
+
if (node === null || typeof node !== 'object') {
|
|
212
|
+
node = undefined;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
node = (node as Record<string, unknown>)[key];
|
|
217
|
+
} catch {
|
|
218
|
+
node = undefined;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (typeof node === 'string' && node.length > 0) {
|
|
223
|
+
try {
|
|
224
|
+
return new URL(node).host;
|
|
225
|
+
} catch {
|
|
226
|
+
// not an absolute URL — nothing to name, try the next place
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
134
233
|
/**
|
|
135
234
|
* Does `message` already state `status` AS a status code?
|
|
136
235
|
*
|
|
@@ -319,7 +418,19 @@ function noteText(value: unknown): string | undefined {
|
|
|
319
418
|
return undefined;
|
|
320
419
|
}
|
|
321
420
|
|
|
322
|
-
export function toCallToolResult(
|
|
421
|
+
export function toCallToolResult(
|
|
422
|
+
value: unknown,
|
|
423
|
+
options?: {
|
|
424
|
+
/**
|
|
425
|
+
* Also answer a plain-object result as `structuredContent`. Asked for by
|
|
426
|
+
* a tool that carries an MCP App view: the view reads the result's fields
|
|
427
|
+
* from `structuredContent` (it has no other place to read them), while
|
|
428
|
+
* the model keeps reading the text block. Off for every other tool, so no
|
|
429
|
+
* client is handed each result twice.
|
|
430
|
+
*/
|
|
431
|
+
structured?: boolean;
|
|
432
|
+
},
|
|
433
|
+
): CallToolResult {
|
|
323
434
|
// An image sentinel (see McpImageResult): the tool's result IS a picture.
|
|
324
435
|
// Emit a native image content block so a multimodal client renders it, plus
|
|
325
436
|
// the note as a text block so the transcript stays self-describing.
|
|
@@ -374,7 +485,22 @@ export function toCallToolResult(value: unknown): CallToolResult {
|
|
|
374
485
|
return value as CallToolResult;
|
|
375
486
|
}
|
|
376
487
|
const text = typeof value === 'string' ? value : safeJsonText(value ?? null);
|
|
377
|
-
|
|
488
|
+
const result: CallToolResult = { content: [{ type: 'text', text: text || '(tool produced no output)' }] };
|
|
489
|
+
if (options?.structured && value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
490
|
+
// The JSON-safe reading of the value, the same one the text block carries:
|
|
491
|
+
// the object itself may hold a BigInt, a cycle or a `toJSON` that throws,
|
|
492
|
+
// and the response serializer would fail on it where the text did not. A
|
|
493
|
+
// value with no JSON object reading carries no structured content.
|
|
494
|
+
try {
|
|
495
|
+
const structured: unknown = JSON.parse(text);
|
|
496
|
+
if (structured !== null && typeof structured === 'object' && !Array.isArray(structured)) {
|
|
497
|
+
result.structuredContent = structured as Record<string, unknown>;
|
|
498
|
+
}
|
|
499
|
+
} catch {
|
|
500
|
+
/* text was not JSON: nothing structured to attach */
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return result;
|
|
378
504
|
}
|
|
379
505
|
|
|
380
506
|
/**
|