@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.
Files changed (50) hide show
  1. package/dist/call-guards.d.ts +47 -0
  2. package/dist/call-guards.d.ts.map +1 -0
  3. package/dist/call-guards.js +215 -0
  4. package/dist/call-guards.js.map +1 -0
  5. package/dist/dispatch.d.ts +5 -1
  6. package/dist/dispatch.d.ts.map +1 -1
  7. package/dist/dispatch.js +19 -2
  8. package/dist/dispatch.js.map +1 -1
  9. package/dist/get-has-no-body.d.ts +49 -0
  10. package/dist/get-has-no-body.d.ts.map +1 -0
  11. package/dist/get-has-no-body.js +104 -0
  12. package/dist/get-has-no-body.js.map +1 -0
  13. package/dist/index.d.ts +6 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/mcp-app.d.ts +118 -0
  18. package/dist/mcp-app.d.ts.map +1 -0
  19. package/dist/mcp-app.js +126 -0
  20. package/dist/mcp-app.js.map +1 -0
  21. package/dist/meta-tools.d.ts.map +1 -1
  22. package/dist/meta-tools.js +27 -5
  23. package/dist/meta-tools.js.map +1 -1
  24. package/dist/proxied-tool.d.ts +11 -0
  25. package/dist/proxied-tool.d.ts.map +1 -1
  26. package/dist/proxied-tool.js +13 -1
  27. package/dist/proxied-tool.js.map +1 -1
  28. package/dist/results.d.ts +20 -1
  29. package/dist/results.d.ts.map +1 -1
  30. package/dist/results.js +117 -3
  31. package/dist/results.js.map +1 -1
  32. package/dist/tool-interface.d.ts +137 -0
  33. package/dist/tool-interface.d.ts.map +1 -0
  34. package/dist/tool-interface.js +640 -0
  35. package/dist/tool-interface.js.map +1 -0
  36. package/dist/utcp-namespace.d.ts +14 -0
  37. package/dist/utcp-namespace.d.ts.map +1 -1
  38. package/dist/utcp-namespace.js +7 -2
  39. package/dist/utcp-namespace.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/call-guards.ts +237 -0
  42. package/src/dispatch.ts +18 -1
  43. package/src/get-has-no-body.ts +110 -0
  44. package/src/index.ts +39 -0
  45. package/src/mcp-app.ts +194 -0
  46. package/src/meta-tools.ts +30 -5
  47. package/src/proxied-tool.ts +23 -1
  48. package/src/results.ts +129 -3
  49. package/src/tool-interface.ts +673 -0
  50. 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. 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
+ `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
- if (found) interfaces.push(client.toolToTypeScriptInterface(found.tool));
271
- else notFound.push(n);
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
  }
@@ -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
- description: tool.description,
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(value: unknown): CallToolResult {
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
- return { content: [{ type: 'text', text: text || '(tool produced no output)' }] };
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
  /**