@bevel-software/platform-mcp-core 0.25.2 → 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 (56) 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 +7 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +7 -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 +1 -1
  22. package/dist/meta-tools.d.ts.map +1 -1
  23. package/dist/meta-tools.js +28 -6
  24. package/dist/meta-tools.js.map +1 -1
  25. package/dist/proxied-tool.d.ts +33 -3
  26. package/dist/proxied-tool.d.ts.map +1 -1
  27. package/dist/proxied-tool.js +234 -21
  28. package/dist/proxied-tool.js.map +1 -1
  29. package/dist/results.d.ts +20 -1
  30. package/dist/results.d.ts.map +1 -1
  31. package/dist/results.js +117 -3
  32. package/dist/results.js.map +1 -1
  33. package/dist/schema-validity.d.ts +50 -0
  34. package/dist/schema-validity.d.ts.map +1 -0
  35. package/dist/schema-validity.js +254 -0
  36. package/dist/schema-validity.js.map +1 -0
  37. package/dist/tool-interface.d.ts +137 -0
  38. package/dist/tool-interface.d.ts.map +1 -0
  39. package/dist/tool-interface.js +640 -0
  40. package/dist/tool-interface.js.map +1 -0
  41. package/dist/utcp-namespace.d.ts +14 -0
  42. package/dist/utcp-namespace.d.ts.map +1 -1
  43. package/dist/utcp-namespace.js +7 -2
  44. package/dist/utcp-namespace.js.map +1 -1
  45. package/package.json +2 -1
  46. package/src/call-guards.ts +237 -0
  47. package/src/dispatch.ts +18 -1
  48. package/src/get-has-no-body.ts +110 -0
  49. package/src/index.ts +45 -0
  50. package/src/mcp-app.ts +194 -0
  51. package/src/meta-tools.ts +31 -6
  52. package/src/proxied-tool.ts +237 -19
  53. package/src/results.ts +129 -3
  54. package/src/schema-validity.ts +264 -0
  55. package/src/tool-interface.ts +673 -0
  56. 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,
@@ -25,7 +26,7 @@ import {
25
26
  *
26
27
  * What a chain DOES, though — to a failure, to a large result, to an image —
27
28
  * 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
+ * instructions and in the platform's agent guide, rather than on each
29
30
  * tool they cover. So on a surface that has those rules the chain description
30
31
  * ends with the same pointer sentence every file tool ends with, composed
31
32
  * where the tool is served and the guide's configured name is known
@@ -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,11 +84,84 @@ 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
 
102
+ /**
103
+ * How deep {@link sanitizeInputSchema} descends before it stops walking and
104
+ * passes the remainder through untouched. Generous on purpose: a real schema
105
+ * costs two levels per nesting (the `properties` keyword, then the field name),
106
+ * and the whole reason for this constant is the call stack, not the schema.
107
+ */
108
+ const MAX_SANITIZE_DEPTH = 200;
109
+
110
+ /**
111
+ * How many nodes one schema's `$ref` inlining may produce before a `$ref`
112
+ * stops being expanded.
113
+ *
114
+ * The recursion guard tracks only the pointers on the ACTIVE path, which is
115
+ * what `$ref` recursion means — but it does not make expansion cheap. A
116
+ * schema whose references form a DAG rather than a tree (two `allOf` branches
117
+ * pointing at one `$defs` entry, that entry doing the same) expands its target
118
+ * once per branch, and that doubles per level: a few hundred bytes on the wire
119
+ * can ask `tools/list` for a reply no amount of memory will hold. Past this
120
+ * budget a `$ref` degrades to the same permissive `{}` a recursive one does,
121
+ * which stands at a schema position and leaves the result valid JSON Schema.
122
+ */
123
+ const MAX_INLINED_NODES = 20_000;
124
+
125
+ /**
126
+ * The stand-in for a subtree past {@link MAX_SANITIZE_DEPTH}: every plain
127
+ * object becomes `{}`, while arrays and scalars keep their shape. Iterative,
128
+ * because the whole reason for being here is that the recursion has to stop.
129
+ *
130
+ * `{}` is legal exactly where a SCHEMA stands, and an object this deep inside a
131
+ * schema is at a schema position. The positions the old cap destroyed were an
132
+ * `anyOf` LIST, a `required` entry and a `type` STRING — none of them an
133
+ * object, all of them handed back as they came. And because no object survives,
134
+ * nothing past the cap can carry a `$ref` left dangling by the `$defs` block
135
+ * this walk drops, or a `format` the Anthropic validator refuses.
136
+ */
137
+ function stripPastDepth(node: unknown): unknown {
138
+ if (!node || typeof node !== 'object') return node;
139
+ if (!Array.isArray(node)) return {};
140
+ const out: unknown[] = [];
141
+ const queue: Array<{ from: readonly unknown[]; to: unknown[] }> = [{ from: node, to: out }];
142
+ for (let i = 0; i < queue.length; i += 1) {
143
+ const { from, to } = queue[i];
144
+ for (const item of from) {
145
+ if (!item || typeof item !== 'object') to.push(item);
146
+ else if (!Array.isArray(item)) to.push({});
147
+ else {
148
+ const nested: unknown[] = [];
149
+ to.push(nested);
150
+ queue.push({ from: item, to: nested });
151
+ }
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * Keywords whose value is INSTANCE DATA rather than a schema. A schema says
159
+ * what a value may be; these carry values themselves, so nothing in them is a
160
+ * construct for the sanitizer to touch — and nothing in them is a place where
161
+ * `{}` would mean "any value" either.
162
+ */
163
+ const DATA_VALUED_KEYWORDS = new Set(['const', 'default', 'enum', 'examples']);
164
+
80
165
  /** JSON-Schema string `format` values the Anthropic tool validator accepts. */
81
166
  const SUPPORTED_SCHEMA_FORMATS = new Set([
82
167
  'date-time',
@@ -99,9 +184,28 @@ const SUPPORTED_SCHEMA_FORMATS = new Set([
99
184
  * - drop non-standard `format` values (OpenAPI's `int32`/`byte`/… — only the
100
185
  * JSON-Schema-standard formats above are accepted; `format` is advisory, so
101
186
  * dropping it doesn't change tool behavior).
102
- * Depth-bounded so a recursive schema degrades to a permissive `{}` node instead
103
- * of hanging or emitting the unsupported recursion; non-local/external refs
104
- * degrade the same way. Exported for direct testing.
187
+ * Everything else is passed through UNCHANGED, and both recursion guards are
188
+ * built so that hitting one cannot change a schema either:
189
+ *
190
+ * - a `$ref` that resolves back onto a schema we are already inlining is
191
+ * recursive, and inlining has no finite answer for it. It degrades to a
192
+ * permissive `{}` — which is legal, because a `$ref` only ever stands where
193
+ * a SCHEMA is expected and `{}` there means "any value". Non-local and
194
+ * unresolvable refs degrade the same way, and so does one past
195
+ * {@link MAX_INLINED_NODES}.
196
+ * - the depth cap keeps the rest of the subtree's shape, replacing only the
197
+ * objects in it with `{}` — see {@link stripPastDepth}. It cannot reach
198
+ * inside instance data, because the walk never descends into a
199
+ * {@link DATA_VALUED_KEYWORDS} value in the first place.
200
+ *
201
+ * That second rule is the fix for three tools AI clients silently dropped. The
202
+ * cap used to return `{}` at WHATEVER position it stopped at, and most
203
+ * positions in a schema are not schema positions: a tool nested deeper than
204
+ * the cap reached clients with `anyOf: {}`, `required: [{}]` or `type: {}` in
205
+ * it. None of those is valid JSON Schema, so the client refused the tool — and
206
+ * said so about a server that had sent a perfectly good schema.
207
+ *
208
+ * Exported for direct testing.
105
209
  */
106
210
  export function sanitizeInputSchema(schema: unknown): unknown {
107
211
  const root = schema;
@@ -111,30 +215,131 @@ export function sanitizeInputSchema(schema: unknown): unknown {
111
215
  for (const partRaw of pointer.slice(2).split('/')) {
112
216
  const part = partRaw.replace(/~1/g, '/').replace(/~0/g, '~');
113
217
  if (!node || typeof node !== 'object') return undefined;
218
+ // Own members only: `#/constructor` names nothing, not `Object`.
219
+ if (!Object.prototype.hasOwnProperty.call(node, part)) return undefined;
114
220
  node = (node as Record<string, unknown>)[part];
115
221
  }
116
222
  return node;
117
223
  };
118
- // `isPropertyMap` marks the value of `properties`/`patternProperties`: its
119
- // keys are the tool's OWN field names, not schema keywords, so a field
120
- // literally named `format`, `$ref` or `definitions` must survive untouched
121
- // (its VALUE is still a schema and is walked as one).
224
+ // The pointers currently being inlined, on THIS path. A `$ref` that points
225
+ // at a schema we are already inside is recursive: JSON Schema says that with
226
+ // the reference, and an inlined copy has no finite form.
227
+ const inlining = new Set<string>();
228
+ // Nodes charged to the expansion budget so far, against MAX_INLINED_NODES.
229
+ let inlined = 0;
230
+ // The size of each referenced target — objects, arrays, their entries and
231
+ // scalars, one count per pointer — stopped early past the budget, since
232
+ // past it the exact number no longer matters.
233
+ const costs = new Map<string, number>();
234
+ const costOf = (pointer: string): number => {
235
+ const known = costs.get(pointer);
236
+ if (known !== undefined) return known;
237
+ // Counted with a bounded frontier: children are pushed one at a time and
238
+ // only while the count is under the cap, so a target wider than the
239
+ // budget costs the cap in work and in memory, never its own width.
240
+ let count = 0;
241
+ const stack: unknown[] = [resolvePointer(pointer)];
242
+ const over = () => count + stack.length > MAX_INLINED_NODES;
243
+ while (stack.length > 0 && !over()) {
244
+ const item = stack.pop();
245
+ count += 1;
246
+ if (!item || typeof item !== 'object') continue;
247
+ if (Array.isArray(item)) {
248
+ for (let i = 0; i < item.length && !over(); i += 1) stack.push(item[i]);
249
+ } else {
250
+ for (const key in item as Record<string, unknown>) {
251
+ if (over()) break;
252
+ if (Object.prototype.hasOwnProperty.call(item, key)) stack.push((item as Record<string, unknown>)[key]);
253
+ }
254
+ }
255
+ }
256
+ const cost = over() ? MAX_INLINED_NODES + 1 : count;
257
+ costs.set(pointer, cost);
258
+ return cost;
259
+ };
260
+ // `isPropertyMap` marks the value of `properties`/`patternProperties`, and
261
+ // of `dependentSchemas`/`dependencies`: its keys are the tool's OWN field
262
+ // names, not schema keywords, so a field literally named `format`, `$ref`,
263
+ // `definitions` or `default` must survive untouched (its VALUE is still a
264
+ // schema and is walked as one).
122
265
  const walk = (node: unknown, depth: number, isPropertyMap = false): unknown => {
123
- if (depth > 20) return {}; // recursion/cycle guard — permissive fallback
124
- if (Array.isArray(node)) return node.map((item) => walk(item, depth + 1));
266
+ // Stack guard, not a schema rule: `$ref` recursion is caught below, so
267
+ // nothing a server legitimately sends reaches this. Stopping must never
268
+ // make a schema INVALID — which is exactly what the old cap did, by
269
+ // returning `{}` at whatever position it had reached.
270
+ if (depth > MAX_SANITIZE_DEPTH) return stripPastDepth(node);
125
271
  if (!node || typeof node !== 'object') return node;
272
+ if (Array.isArray(node)) return node.map((item) => walk(item, depth + 1));
126
273
  const obj = node as Record<string, unknown>;
127
- if (!isPropertyMap && typeof obj.$ref === 'string') {
128
- const target = resolvePointer(obj.$ref);
274
+ // `$dynamicRef` is a reference like `$ref` (2020-12's late-bound form):
275
+ // resolved the same way when it is a local pointer, degraded the same way
276
+ // when it is not — never left standing, because the `$defs` block it
277
+ // reaches into is dropped below and a dangling reference is what clients
278
+ // reject. Both at once is legal and both apply, so both are inlined, as
279
+ // the `allOf` they amount to.
280
+ const references = (['$ref', '$dynamicRef'] as const)
281
+ .map((keyword) => obj[keyword])
282
+ .filter((value): value is string => typeof value === 'string');
283
+ if (!isPropertyMap && references.length > 0) {
129
284
  // JSON Schema allows siblings next to $ref; keep them, target wins ties.
130
285
  const siblings: Record<string, unknown> = { ...obj };
131
286
  delete siblings.$ref;
132
- const resolved = walk(target ?? {}, depth + 1);
133
- return resolved && typeof resolved === 'object' && !Array.isArray(resolved)
134
- ? { ...siblings, ...(resolved as Record<string, unknown>) }
135
- : Object.keys(siblings).length
136
- ? siblings
137
- : resolved ?? {};
287
+ delete siblings.$dynamicRef;
288
+ // Sanitized ONCE, here, because every path below can return them: the
289
+ // siblings are schema keywords in their own right, and an unsupported
290
+ // `format` or a nested `$ref` left in them is precisely what this
291
+ // function exists to keep out of a listing. (`siblings` no longer holds
292
+ // a `$ref`, so this cannot re-enter this branch at this node; deeper
293
+ // ones terminate on `inlining` or on the depth cap.)
294
+ const kept = Object.keys(siblings).length ? (walk(siblings, depth + 1) as Record<string, unknown>) : null;
295
+ // Recursive, or past the expansion budget: `{}` ("any value") is the only
296
+ // finite answer, and a `$ref` stands at a schema position, so `{}` there
297
+ // is valid JSON Schema. The reference itself has to go — the `$defs`
298
+ // block it points into is dropped below, and a dangling `$ref` is what
299
+ // clients reject.
300
+ //
301
+ // The budget is charged AT THE REFERENCE, by the whole size of what it
302
+ // would copy — every object, array, array entry and scalar in the
303
+ // target, counted once per pointer — before a byte of it is copied. A
304
+ // target that does not fit the remaining budget is `{}` whole, never a
305
+ // partial copy: the only thing a copy can cost is what the reference
306
+ // multiplies, and that is the target's full size whatever shapes it is
307
+ // made of (a `required` list of ten thousand names as much as ten
308
+ // thousand properties).
309
+ const resolveOne = (pointer: string): unknown => {
310
+ if (inlining.has(pointer)) return {};
311
+ const cost = costOf(pointer);
312
+ if (inlined + cost > MAX_INLINED_NODES) return {};
313
+ inlined += cost;
314
+ inlining.add(pointer);
315
+ try {
316
+ return walk(resolvePointer(pointer) ?? {}, depth + 1);
317
+ } finally {
318
+ inlining.delete(pointer);
319
+ }
320
+ };
321
+ const asObject = (value: unknown): Record<string, unknown> =>
322
+ value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
323
+ if (references.length === 1) {
324
+ const resolved = resolveOne(references[0]!);
325
+ // An object target merges over the siblings; a boolean target is a
326
+ // schema in its own right (`false` rejects everything) and stands
327
+ // alone, or under `allOf` beside siblings; anything else a pointer
328
+ // can land on — a string, a number, an array — is not a schema and
329
+ // becomes `{}`, never the raw value.
330
+ if (typeof resolved === 'boolean') {
331
+ return kept ? { ...kept, allOf: [...(Array.isArray(kept.allOf) ? kept.allOf : []), resolved] } : resolved;
332
+ }
333
+ return { ...(kept ?? {}), ...asObject(resolved) };
334
+ }
335
+ // A boolean target is a schema too (`false` rejects everything) and is
336
+ // kept as it is; anything that is not an object schema is `{}`.
337
+ const targets = references.map((pointer) => {
338
+ const resolved = resolveOne(pointer);
339
+ return typeof resolved === 'boolean' ? resolved : asObject(resolved);
340
+ });
341
+ const allOf = Array.isArray(kept?.allOf) ? (kept!.allOf as unknown[]) : [];
342
+ return { ...(kept ?? {}), allOf: [...allOf, ...targets] };
138
343
  }
139
344
  const out: Record<string, unknown> = {};
140
345
  for (const [key, value] of Object.entries(obj)) {
@@ -143,12 +348,25 @@ export function sanitizeInputSchema(schema: unknown): unknown {
143
348
  continue;
144
349
  }
145
350
  if (key === '$defs' || key === 'definitions') continue; // inlined above
351
+ // Instance DATA, not a schema: handed back exactly as it came. A `default`
352
+ // or an `enum` entry that happens to carry a key named `format` or `$ref`
353
+ // is a value the tool expects, not a construct to rewrite — and since the
354
+ // walk never descends into one, the depth cap cannot reach inside it
355
+ // either.
356
+ if (DATA_VALUED_KEYWORDS.has(key)) {
357
+ out[key] = value;
358
+ continue;
359
+ }
146
360
  // Drop a non-standard `format` (OpenAPI `int32`/`byte`/…) — the validator
147
361
  // only allows the JSON-Schema-standard set; the annotation is non-load-bearing.
148
362
  if (key === 'format' && (typeof value !== 'string' || !SUPPORTED_SCHEMA_FORMATS.has(value))) {
149
363
  continue;
150
364
  }
151
- out[key] = walk(value, depth + 1, key === 'properties' || key === 'patternProperties');
365
+ out[key] = walk(
366
+ value,
367
+ depth + 1,
368
+ key === 'properties' || key === 'patternProperties' || key === 'dependentSchemas' || key === 'dependencies',
369
+ );
152
370
  }
153
371
  return out;
154
372
  };