@bevel-software/platform-mcp-core 0.8.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 (43) hide show
  1. package/LICENSE +202 -0
  2. package/THIRD-PARTY-NOTICES.md +3065 -0
  3. package/dist/code-mode-names.d.ts +40 -0
  4. package/dist/code-mode-names.d.ts.map +1 -0
  5. package/dist/code-mode-names.js +93 -0
  6. package/dist/code-mode-names.js.map +1 -0
  7. package/dist/dispatch.d.ts +44 -0
  8. package/dist/dispatch.d.ts.map +1 -0
  9. package/dist/dispatch.js +72 -0
  10. package/dist/dispatch.js.map +1 -0
  11. package/dist/index.d.ts +33 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +33 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/meta-tools.d.ts +27 -0
  16. package/dist/meta-tools.d.ts.map +1 -0
  17. package/dist/meta-tools.js +161 -0
  18. package/dist/meta-tools.js.map +1 -0
  19. package/dist/proxied-tool.d.ts +49 -0
  20. package/dist/proxied-tool.d.ts.map +1 -0
  21. package/dist/proxied-tool.js +183 -0
  22. package/dist/proxied-tool.js.map +1 -0
  23. package/dist/results.d.ts +19 -0
  24. package/dist/results.d.ts.map +1 -0
  25. package/dist/results.js +130 -0
  26. package/dist/results.js.map +1 -0
  27. package/dist/skills.d.ts +22 -0
  28. package/dist/skills.d.ts.map +1 -0
  29. package/dist/skills.js +20 -0
  30. package/dist/skills.js.map +1 -0
  31. package/dist/utcp-namespace.d.ts +30 -0
  32. package/dist/utcp-namespace.d.ts.map +1 -0
  33. package/dist/utcp-namespace.js +66 -0
  34. package/dist/utcp-namespace.js.map +1 -0
  35. package/package.json +50 -0
  36. package/src/code-mode-names.ts +107 -0
  37. package/src/dispatch.ts +88 -0
  38. package/src/index.ts +71 -0
  39. package/src/meta-tools.ts +186 -0
  40. package/src/proxied-tool.ts +200 -0
  41. package/src/results.ts +131 -0
  42. package/src/skills.ts +34 -0
  43. package/src/utcp-namespace.ts +70 -0
@@ -0,0 +1,107 @@
1
+ import type { Tool } from '@utcp/sdk';
2
+ import type { CodeModeUtcpClient } from '@utcp/code-mode';
3
+
4
+ /**
5
+ * Pure helpers for mapping UTCP tool names to their code-mode (TypeScript)
6
+ * accessible form. Kept Mastra-free so the agent's Mastra tools
7
+ * (`code-mode.tool.ts`), the hosted MCP proxy and the local MCP server can all
8
+ * share them.
9
+ */
10
+
11
+ export function sanitizeIdentifier(name: string): string {
12
+ return name.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^[0-9]/, '_$&');
13
+ }
14
+
15
+ /** `MANUAL.tool` → the `manual.tool` form the code-mode runtime exposes. */
16
+ export function utcpNameToTsInterfaceName(utcpName: string): string {
17
+ if (utcpName.includes('.')) {
18
+ const [manualName, ...toolParts] = utcpName.split('.');
19
+ return `${sanitizeIdentifier(manualName!)}.${toolParts.map(sanitizeIdentifier).join('_')}`;
20
+ }
21
+ return sanitizeIdentifier(utcpName);
22
+ }
23
+
24
+ /**
25
+ * Sanitized TS name → every UTCP tool that claims it, from ONE catalog fetch.
26
+ * Collisions are kept, not collapsed: sanitization is lossy (`a-b` and `a_b`
27
+ * both become `a_b`), and a lookup that silently picked one would describe —
28
+ * or dispatch — the wrong tool.
29
+ */
30
+ async function tsNameIndex(client: CodeModeUtcpClient): Promise<Map<string, Tool[]>> {
31
+ const index = new Map<string, Tool[]>();
32
+ for (const tool of await client.config.tool_repository.getTools()) {
33
+ const tsName = utcpNameToTsInterfaceName(tool.name);
34
+ const bucket = index.get(tsName);
35
+ if (bucket) bucket.push(tool);
36
+ else index.set(tsName, [tool]);
37
+ }
38
+ return index;
39
+ }
40
+
41
+ function resolveSanitized(
42
+ index: Map<string, Tool[]>,
43
+ name: string,
44
+ ): { tool: Tool; utcpName: string } | null {
45
+ const bucket = index.get(name);
46
+ if (!bucket || bucket.length === 0) return null;
47
+ if (bucket.length > 1) {
48
+ throw new AmbiguousToolNameError(
49
+ `Tool name "${name}" is ambiguous: ${bucket.map((t) => `"${t.name}"`).join(', ')} ` +
50
+ 'all sanitize to it. Call the tool by its exact UTCP name instead.',
51
+ );
52
+ }
53
+ return { tool: bucket[0]!, utcpName: bucket[0]!.name };
54
+ }
55
+
56
+ /**
57
+ * The TYPED half of the ambiguity contract: callers that contain ambiguity
58
+ * per-name while letting real failures (an outage, a broken repository)
59
+ * fail the call need something sturdier to branch on than the message text
60
+ * of a bare Error thrown in another package.
61
+ */
62
+ export class AmbiguousToolNameError extends Error {
63
+ constructor(message: string) {
64
+ super(message);
65
+ this.name = 'AmbiguousToolNameError';
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Resolve a tool by its raw UTCP name or its sanitized TS-accessible name.
71
+ * Throws when the sanitized name is claimed by more than one tool (an exact
72
+ * UTCP name always wins and can't be ambiguous).
73
+ */
74
+ export async function findToolByName(
75
+ client: CodeModeUtcpClient,
76
+ name: string,
77
+ ): Promise<{ tool: Tool; utcpName: string } | null> {
78
+ const direct = await client.config.tool_repository.getTool(name);
79
+ if (direct) return { tool: direct, utcpName: name };
80
+ return resolveSanitized(await tsNameIndex(client), name);
81
+ }
82
+
83
+ /**
84
+ * Resolve a batch of names against ONE catalog fetch (`tools_info` takes a
85
+ * list, and a per-name `getTools()` re-fetch scales with the list). Names that
86
+ * resolve are in the map; missing ones are simply absent; an ambiguous
87
+ * sanitized name throws, as in {@link findToolByName}.
88
+ */
89
+ export async function findToolsByNames(
90
+ client: CodeModeUtcpClient,
91
+ names: string[],
92
+ ): Promise<Map<string, { tool: Tool; utcpName: string }>> {
93
+ const out = new Map<string, { tool: Tool; utcpName: string }>();
94
+ let index: Map<string, Tool[]> | undefined;
95
+ for (const name of names) {
96
+ if (out.has(name)) continue;
97
+ const direct = await client.config.tool_repository.getTool(name);
98
+ if (direct) {
99
+ out.set(name, { tool: direct, utcpName: name });
100
+ continue;
101
+ }
102
+ index ??= await tsNameIndex(client);
103
+ const resolved = resolveSanitized(index, name);
104
+ if (resolved) out.set(name, resolved);
105
+ }
106
+ return out;
107
+ }
@@ -0,0 +1,88 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import type { CodeModeUtcpClient } from '@utcp/code-mode';
3
+ import type { CallTemplate } from '@utcp/sdk';
4
+ import type { ProxiedTool } from './proxied-tool.js';
5
+ import { toCallToolResult, toolError, describeToolFailure, renderProgress } from './results.js';
6
+
7
+ /**
8
+ * Register one manual on a client, reduced to a verdict.
9
+ *
10
+ * Deliberately NOT a loop over every manual: the two surfaces disagree about
11
+ * what a failure means. The hosted proxy memoizes per (user, manual) so a
12
+ * broken credential doesn't re-dial its provider on every session rebuild; the
13
+ * local server has one user and one process and just logs. Sharing the
14
+ * per-manual mechanics without sharing the retry policy keeps both honest.
15
+ *
16
+ * Never throws: a registration failure is a runtime problem (network, dead
17
+ * credential), not a schema one — the templates were validated before they got
18
+ * here — so it comes back as `{ ok: false }` for the caller to police.
19
+ */
20
+ export async function registerManual(
21
+ client: CodeModeUtcpClient,
22
+ manual: CallTemplate,
23
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
24
+ try {
25
+ const result = await client.registerManual(manual);
26
+ if (result && result.success === false) {
27
+ const errors = Array.isArray(result.errors) ? result.errors.join('; ') : 'unknown error';
28
+ return { ok: false, error: errors };
29
+ }
30
+ return { ok: true };
31
+ } catch (err) {
32
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Run one tool call through `callToolStreaming` with a one-chunk lookahead:
38
+ * every chunk except the last becomes a progress notification, the last is the
39
+ * result.
40
+ *
41
+ * Dispatch always uses `callToolStreaming`, which is uniform across tool kinds:
42
+ * a plain `http` tool yields exactly one chunk (its final result, emitted as
43
+ * the tool result with no progress), while a `streamable_http` tool yields many.
44
+ * Because of that, adding — or later upgrading a tool to streaming — never
45
+ * touches this function.
46
+ *
47
+ * Continuity is the caller's: a tool that supports it (e.g. `ask`) returns its
48
+ * `sessionId` in the result verbatim, and the caller echoes it back per the
49
+ * tool's own schema — nothing here rewrites args. Args pass through to UTCP
50
+ * verbatim; each communication protocol does its own serialization (http reads
51
+ * the template's `body_field` out of the args, mcp forwards them untouched as
52
+ * MCP `arguments`), and the advertised schema is the tool's UTCP `inputs`
53
+ * verbatim too, so any reshaping here would be wrong for at least one protocol.
54
+ */
55
+ export async function dispatchToolCall(
56
+ client: CodeModeUtcpClient,
57
+ tool: ProxiedTool,
58
+ args: Record<string, unknown>,
59
+ onProgress?: (progress: number, message: string) => Promise<void>,
60
+ ): Promise<CallToolResult> {
61
+ let prev: unknown;
62
+ let hasPrev = false;
63
+ let progress = 0;
64
+
65
+ try {
66
+ for await (const chunk of client.callToolStreaming(tool.utcpName, args)) {
67
+ if (hasPrev && onProgress) {
68
+ progress += 1;
69
+ await onProgress(progress, renderProgress(prev)).catch((err) =>
70
+ console.warn('[mcp] progress notification failed:', err),
71
+ );
72
+ }
73
+ prev = chunk;
74
+ hasPrev = true;
75
+ }
76
+ } catch (err) {
77
+ return toolError(`The "${tool.mcpName}" tool failed: ${describeToolFailure(err)}`);
78
+ }
79
+
80
+ // A stream that closes without ever yielding is a transport fault, not an
81
+ // empty result — every tool kind yields at least its final value (see above).
82
+ // Say so, instead of serializing the never-assigned `prev` into a "null".
83
+ if (!hasPrev) {
84
+ return toolError(`The "${tool.mcpName}" tool produced no output: its stream ended without a result.`);
85
+ }
86
+
87
+ return toCallToolResult(prev);
88
+ }
package/src/index.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @bevel-software/platform-mcp-core — the transport-agnostic half of Bevel's
3
+ * MCP surface.
4
+ *
5
+ * Two surfaces re-expose the same UTCP tool catalog over MCP and differ only in
6
+ * where they run and how they reach it:
7
+ *
8
+ * - the HOSTED proxy (`platform-core-backend`) registers the KB manual over
9
+ * loopback plus each `.tool` the caller can read, resolves `${VAR}` from
10
+ * the Secrets Vault, and speaks streamable HTTP;
11
+ * - the LOCAL server (`@bevel-software/hexis-mcp`) registers the deployment's
12
+ * own MCP endpoint as one `mcp` manual plus the `remote: false` manuals the
13
+ * hosted endpoint cannot serve, resolves `${VAR}` from the process env, and
14
+ * speaks stdio.
15
+ *
16
+ * Everything between "a UTCP client with manuals registered" and "an MCP result"
17
+ * is identical, and lives here: name flattening, the tool-name/schema guards
18
+ * that stop one bad tool blanking a client's whole toolset, streaming dispatch,
19
+ * and the code-mode meta-tools.
20
+ *
21
+ * What is NOT here, on purpose: manual DISCOVERY (who may see which manual is
22
+ * an access-control question the hosted REST surface answers), credential
23
+ * resolution (a vault loader server-side, `process.env` locally), and retry
24
+ * policy (see `registerManual`).
25
+ */
26
+
27
+ export {
28
+ type ProxiedTool,
29
+ toListedTool,
30
+ sanitizeInputSchema,
31
+ flattenManualTool,
32
+ flattenDiscoveredTool,
33
+ } from './proxied-tool.js';
34
+
35
+ export {
36
+ describeToolFailure,
37
+ toCallToolResult,
38
+ renderProgress,
39
+ toolError,
40
+ needsAuthorizationResult,
41
+ } from './results.js';
42
+
43
+ export {
44
+ CODE_MODE_META_TOOLS,
45
+ META_TOOL_NAMES,
46
+ CALL_TOOL_CHAIN_MAX_OUTPUT,
47
+ type SpillPort,
48
+ dispatchMetaTool,
49
+ } from './meta-tools.js';
50
+
51
+ export { registerManual, dispatchToolCall } from './dispatch.js';
52
+
53
+ export {
54
+ type SkillSummary,
55
+ type LoadedSkill,
56
+ skillPromptText,
57
+ } from './skills.js';
58
+
59
+ export {
60
+ utcpNamespacePrefix,
61
+ utcpNamespacedKey,
62
+ seedBevelHostedManualVars,
63
+ } from './utcp-namespace.js';
64
+
65
+ export {
66
+ sanitizeIdentifier,
67
+ utcpNameToTsInterfaceName,
68
+ findToolByName,
69
+ findToolsByNames,
70
+ AmbiguousToolNameError,
71
+ } from './code-mode-names.js';
@@ -0,0 +1,186 @@
1
+ import type { Tool as McpTool, CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import type { CodeModeUtcpClient } from '@utcp/code-mode';
3
+ import { utcpNameToTsInterfaceName, findToolsByNames } from './code-mode-names.js';
4
+ import { toCallToolResult, toolError, describeToolFailure } from './results.js';
5
+
6
+ /**
7
+ * Code-mode meta-tools exposed ALONGSIDE the direct tools. They let an external
8
+ * agent batch many Bevel calls into one isolated-vm run (`call_tool_chain`)
9
+ * instead of one MCP round-trip per call — the same efficiency our own agent
10
+ * gets. `call_tool_chain`'s description carries the code-mode protocol (there is
11
+ * no system prompt over MCP), so the client learns the convention from the tool
12
+ * itself; `list_tools`/`tools_info` are how it discovers what to call.
13
+ *
14
+ * Security is identical to the direct surface: the chain runs in an isolated-vm
15
+ * but calls tools with the CALLER's credentials against the external catalog —
16
+ * internal-only tools aren't in that catalog, so a chain can't reach them either.
17
+ *
18
+ * These three belong to whichever client holds the registry. A surface that
19
+ * registers ANOTHER Bevel MCP endpoint as one of its manuals therefore has to
20
+ * drop that endpoint's copies from the passthrough (see {@link META_TOOL_NAMES}):
21
+ * the remote trio describes the remote registry, and locally they must describe
22
+ * the merged one.
23
+ */
24
+ const CALL_TOOL_CHAIN_DESCRIPTION = [
25
+ '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).',
26
+ '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.',
27
+ '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.',
28
+ ].join('\n\n');
29
+
30
+ export const CODE_MODE_META_TOOLS: McpTool[] = [
31
+ {
32
+ name: 'list_tools',
33
+ description:
34
+ 'List every UTCP tool currently registered, in TypeScript-accessible form (e.g. `KNOWLEDGE_BASE.read_file`) for use inside `call_tool_chain`.',
35
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false } as McpTool['inputSchema'],
36
+ },
37
+ {
38
+ name: 'tools_info',
39
+ description:
40
+ 'Get full TypeScript interface definitions for named tools (names from `list_tools`). The schemas are the source of truth — do not guess shapes.',
41
+ inputSchema: {
42
+ type: 'object',
43
+ properties: {
44
+ tool_names: { type: 'array', items: { type: 'string' }, minItems: 1, description: 'Tool names to describe.' },
45
+ },
46
+ required: ['tool_names'],
47
+ additionalProperties: false,
48
+ } as McpTool['inputSchema'],
49
+ },
50
+ {
51
+ name: 'call_tool_chain',
52
+ description: CALL_TOOL_CHAIN_DESCRIPTION,
53
+ inputSchema: {
54
+ type: 'object',
55
+ properties: {
56
+ code: { type: 'string', minLength: 1, description: 'JavaScript to execute against the registered tools.' },
57
+ timeout: { type: 'integer', minimum: 1000, maximum: 120000, description: 'Timeout in ms (default 30000).' },
58
+ max_output_size: { type: 'integer', minimum: 1000, maximum: 1000000, description: 'Max result+logs size in chars before spilling (default 200000, max 1000000).' },
59
+ },
60
+ required: ['code'],
61
+ additionalProperties: false,
62
+ } as McpTool['inputSchema'],
63
+ },
64
+ ];
65
+
66
+ export const META_TOOL_NAMES: ReadonlySet<string> = new Set(CODE_MODE_META_TOOLS.map((t) => t.name));
67
+
68
+ /** Default cap on a `call_tool_chain` result's stringified size before it spills. */
69
+ export const CALL_TOOL_CHAIN_MAX_OUTPUT = 200_000;
70
+
71
+ /**
72
+ * UTF-8 byte length without Node's `Buffer` — this module stays free of
73
+ * runtime-specific globals. Matches `Buffer.byteLength`: a lone surrogate
74
+ * encodes as the 3-byte replacement character.
75
+ */
76
+ function utf8ByteLength(s: string): number {
77
+ let bytes = 0;
78
+ for (const ch of s) {
79
+ const cp = ch.codePointAt(0)!;
80
+ bytes += cp <= 0x7f ? 1 : cp <= 0x7ff ? 2 : cp <= 0xffff ? 3 : 4;
81
+ }
82
+ return bytes;
83
+ }
84
+
85
+ /**
86
+ * Where an oversized `call_tool_chain` payload goes. The hosted proxy hands in
87
+ * the shared workspace spill store, whose refs `read_file` can read back. A
88
+ * surface with nowhere to put it (the local server has no server-side store of
89
+ * its own) passes nothing and gets a truncation notice instead — the caller is
90
+ * told to narrow the chain rather than handed a ref that resolves nowhere.
91
+ */
92
+ export interface SpillPort {
93
+ write(json: string): Promise<{ ref: string; bytes: number }>;
94
+ }
95
+
96
+ /**
97
+ * Handle a code-mode meta-tool. `list_tools`/`tools_info` reflect on the
98
+ * client's discovered catalog; `call_tool_chain` runs the caller's JavaScript in
99
+ * the client's isolated-vm, where every registered tool is reachable as
100
+ * `<manual>.tool(...)`.
101
+ */
102
+ export async function dispatchMetaTool(
103
+ client: CodeModeUtcpClient,
104
+ name: string,
105
+ args: Record<string, unknown>,
106
+ spill?: SpillPort,
107
+ ): Promise<CallToolResult> {
108
+ try {
109
+ if (name === 'list_tools') {
110
+ const tools = await client.config.tool_repository.getTools();
111
+ return toCallToolResult({ tools: tools.map((t) => utcpNameToTsInterfaceName(t.name)) });
112
+ }
113
+ if (name === 'tools_info') {
114
+ // The schema is advisory over a raw JSON-RPC call: a missing array or a
115
+ // non-string entry must be a named validation error here, not a generic
116
+ // failure out of a repository lookup it was never valid input for.
117
+ const rawNames = args.tool_names;
118
+ // Empty included — the schema says minItems 1, and an empty success
119
+ // payload for invalid input would read as "no tools exist".
120
+ if (!Array.isArray(rawNames) || rawNames.length === 0 || rawNames.some((n) => typeof n !== 'string')) {
121
+ return toolError('The "tools_info" tool requires "tool_names": a non-empty array of tool name strings.');
122
+ }
123
+ const names = rawNames as string[];
124
+ const interfaces: string[] = [];
125
+ const notFound: string[] = [];
126
+ const resolved = await findToolsByNames(client, names);
127
+ for (const n of names) {
128
+ const found = resolved.get(n);
129
+ if (found) interfaces.push(client.toolToTypeScriptInterface(found.tool));
130
+ else notFound.push(n);
131
+ }
132
+ return toCallToolResult({ interfaces: interfaces.join('\n\n'), not_found: notFound });
133
+ }
134
+ // call_tool_chain
135
+ // Same advisory-schema rule as above: a missing or non-string `code` must
136
+ // not silently execute an empty program and report success.
137
+ const code = args.code;
138
+ if (typeof code !== 'string' || code.length === 0) {
139
+ return toolError('The "call_tool_chain" tool requires a non-empty "code" string.');
140
+ }
141
+ // Clamp both knobs to their schema bounds — the schema is advisory over a
142
+ // raw JSON-RPC call, and an unclamped `timeout` would let one chain hold
143
+ // the isolate far past the documented 120s cap.
144
+ const timeout =
145
+ typeof args.timeout === 'number' && Number.isFinite(args.timeout)
146
+ ? Math.min(120_000, Math.max(1_000, Math.trunc(args.timeout)))
147
+ : 30_000;
148
+ // Clamp to [1000, 1_000_000] so a caller can't force oversized inline
149
+ // output past the spill.
150
+ const maxOutputSize =
151
+ typeof args.max_output_size === 'number' && Number.isFinite(args.max_output_size)
152
+ ? Math.min(1_000_000, Math.max(1_000, Math.trunc(args.max_output_size)))
153
+ : CALL_TOOL_CHAIN_MAX_OUTPUT;
154
+ const { result, logs } = await client.callToolChain(code, timeout);
155
+ // Bound the payload: an external session has no ambient workspace, so an
156
+ // oversized result spills to the shared store and we return only a ref —
157
+ // parity with the in-process agent's `call_tool_chain`.
158
+ if (JSON.stringify({ success: true, result, logs }).length <= maxOutputSize) {
159
+ return toCallToolResult({ success: true, result, logs });
160
+ }
161
+ const fullJson = JSON.stringify({ result, logs }, null, 2);
162
+ if (!spill) {
163
+ return toCallToolResult({
164
+ success: true,
165
+ truncated: true,
166
+ // Bytes, not chars: the spill branch reports the store's byte count,
167
+ // and `result_bytes` must mean one thing across both paths.
168
+ result_bytes: utf8ByteLength(fullJson),
169
+ message:
170
+ `Result+logs payload was ${fullJson.length} characters (exceeded max_output_size of ${maxOutputSize}), ` +
171
+ 'and this server has no spill store to park it in. Re-run a narrower chain that returns only what you ' +
172
+ 'need, or raise max_output_size.',
173
+ });
174
+ }
175
+ const { ref, bytes } = await spill.write(fullJson);
176
+ return toCallToolResult({
177
+ success: true,
178
+ truncated: true,
179
+ result_ref: ref,
180
+ result_bytes: bytes,
181
+ 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.`,
182
+ });
183
+ } catch (err) {
184
+ return toolError(`The "${name}" tool failed: ${describeToolFailure(err)}`);
185
+ }
186
+ }
@@ -0,0 +1,200 @@
1
+ import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
2
+ import type { JsonSchema, Tool as UtcpTool } from '@utcp/sdk';
3
+
4
+ /** A tool discovered from a UTCP manual, flattened into what an MCP surface advertises. */
5
+ export interface ProxiedTool {
6
+ utcpName: string;
7
+ mcpName: string;
8
+ description: string;
9
+ inputSchema: JsonSchema;
10
+ /** The UTCP manual this tool came from (the `<manual>` in `<manual>.<tool>`),
11
+ * used to look up the manual's declared per-user credentials before dispatch. */
12
+ manualName: string;
13
+ }
14
+
15
+ /**
16
+ * MCP tool-name grammar (also the Anthropic API's), and a length bound. A
17
+ * remote MCP server can expose a tool whose flattened name breaks this — too
18
+ * long, or an illegal char the `<manual>_<name>` flattening didn't remove — and
19
+ * an MCP client (or the model API behind it) rejects the ENTIRE `tools/list`
20
+ * response when a single entry is non-conforming. That makes EVERY tool vanish
21
+ * the moment one bad tool from a newly-added server enters the catalog, with no
22
+ * server-side error (the rejection is the client's). `toListedTool` isolates it
23
+ * per tool: drop the offender (logged), normalize an odd schema, keep the rest.
24
+ */
25
+ const MCP_TOOL_NAME_RE = /^[a-zA-Z0-9_-]+$/;
26
+ // The Anthropic API caps a tool name at 128 chars — but the MCP CLIENT (Claude
27
+ // Code, claude.ai) prepends `mcp__<server>__` (≈20+ chars) before sending it,
28
+ // and that FULL name is what the 128 applies to. So budget for the prefix here,
29
+ // or a long `googlecalendar_…` name we pass gets the whole request 400'd. This
30
+ // is deliberately conservative; a dropped tool is logged so it's diagnosable.
31
+ const MCP_TOOL_NAME_MAX = 100;
32
+
33
+ /** A discovered tool as an MCP listing entry, or null if its name can't be listed. */
34
+ export function toListedTool(tool: ProxiedTool): McpTool | null {
35
+ if (!MCP_TOOL_NAME_RE.test(tool.mcpName) || tool.mcpName.length > MCP_TOOL_NAME_MAX) {
36
+ console.warn(
37
+ `[mcp] dropping tool "${tool.mcpName}" from the listing — not a valid MCP tool name ` +
38
+ `(must match ${MCP_TOOL_NAME_RE} and be ≤${MCP_TOOL_NAME_MAX} chars). ` +
39
+ 'One non-conforming tool would otherwise make the whole toolset disappear on the client.',
40
+ );
41
+ return null;
42
+ }
43
+ // MCP requires an object inputSchema. A remote server's schema that isn't a
44
+ // plain object (or omits `type: 'object'`) can invalidate the whole response,
45
+ // so normalize it — keeping any declared properties — rather than pass it
46
+ // through verbatim.
47
+ const raw = tool.inputSchema;
48
+ let inputSchema: Record<string, unknown> =
49
+ raw && typeof raw === 'object' && !Array.isArray(raw)
50
+ ? { type: 'object', ...(raw as Record<string, unknown>) }
51
+ : { type: 'object', properties: {} };
52
+ // Sanitize the schema into what the Anthropic tool validator accepts. A
53
+ // remote server that emits a construct the validator rejects — Google's
54
+ // gmail/calendar use `$ref`/`$defs` AND OpenAPI `format` values like
55
+ // `int32`/`byte` — makes the CLIENT reject the ENTIRE tools/list response,
56
+ // so all tools vanish and nothing registers. Sanitizing per-tool means one
57
+ // odd server can't blank the whole toolset.
58
+ inputSchema = sanitizeInputSchema(inputSchema) as Record<string, unknown>;
59
+ // The MCP/Anthropic validator requires the top-level `type` to be exactly
60
+ // "object" and (for the Anthropic API) `properties` to be present. Force both
61
+ // so a remote schema that declared something else — or a union like
62
+ // `["object","null"]` — can't reject the whole tools/list.
63
+ inputSchema.type = 'object';
64
+ // An array passes `typeof === 'object'` but is not a property map, so it
65
+ // must coerce like any other non-object or it poisons the whole listing.
66
+ if (
67
+ typeof inputSchema.properties !== 'object' ||
68
+ inputSchema.properties === null ||
69
+ Array.isArray(inputSchema.properties)
70
+ ) {
71
+ inputSchema.properties = {};
72
+ }
73
+ return {
74
+ name: tool.mcpName,
75
+ description: tool.description,
76
+ inputSchema: inputSchema as McpTool['inputSchema'],
77
+ };
78
+ }
79
+
80
+ /** JSON-Schema string `format` values the Anthropic tool validator accepts. */
81
+ const SUPPORTED_SCHEMA_FORMATS = new Set([
82
+ 'date-time',
83
+ 'time',
84
+ 'date',
85
+ 'duration',
86
+ 'email',
87
+ 'hostname',
88
+ 'uri',
89
+ 'ipv4',
90
+ 'ipv6',
91
+ 'uuid',
92
+ ]);
93
+
94
+ /**
95
+ * Make a remote server's JSON Schema safe for the Anthropic tool validator:
96
+ * - inline local `$ref` pointers (`#/$defs/...`, `#/definitions/...`) and drop
97
+ * the now-unreferenced `$defs`/`definitions` blocks (the API restricts `$ref`
98
+ * and MCP clients converting our schemas reject it outright);
99
+ * - drop non-standard `format` values (OpenAPI's `int32`/`byte`/… — only the
100
+ * JSON-Schema-standard formats above are accepted; `format` is advisory, so
101
+ * 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.
105
+ */
106
+ export function sanitizeInputSchema(schema: unknown): unknown {
107
+ const root = schema;
108
+ const resolvePointer = (pointer: string): unknown => {
109
+ if (!pointer.startsWith('#/')) return undefined;
110
+ let node: unknown = root;
111
+ for (const partRaw of pointer.slice(2).split('/')) {
112
+ const part = partRaw.replace(/~1/g, '/').replace(/~0/g, '~');
113
+ if (!node || typeof node !== 'object') return undefined;
114
+ node = (node as Record<string, unknown>)[part];
115
+ }
116
+ return node;
117
+ };
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).
122
+ 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));
125
+ if (!node || typeof node !== 'object') return node;
126
+ const obj = node as Record<string, unknown>;
127
+ if (!isPropertyMap && typeof obj.$ref === 'string') {
128
+ const target = resolvePointer(obj.$ref);
129
+ // JSON Schema allows siblings next to $ref; keep them, target wins ties.
130
+ const siblings: Record<string, unknown> = { ...obj };
131
+ 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 ?? {};
138
+ }
139
+ const out: Record<string, unknown> = {};
140
+ for (const [key, value] of Object.entries(obj)) {
141
+ if (isPropertyMap) {
142
+ out[key] = walk(value, depth + 1);
143
+ continue;
144
+ }
145
+ if (key === '$defs' || key === 'definitions') continue; // inlined above
146
+ // Drop a non-standard `format` (OpenAPI `int32`/`byte`/…) — the validator
147
+ // only allows the JSON-Schema-standard set; the annotation is non-load-bearing.
148
+ if (key === 'format' && (typeof value !== 'string' || !SUPPORTED_SCHEMA_FORMATS.has(value))) {
149
+ continue;
150
+ }
151
+ out[key] = walk(value, depth + 1, key === 'properties' || key === 'patternProperties');
152
+ }
153
+ return out;
154
+ };
155
+ return walk(schema, 0);
156
+ }
157
+
158
+ /**
159
+ * Flatten a discovered UTCP tool (`<manual>.<tool>`) into the advertised shape
160
+ * when MULTIPLE manuals are registered. Tools from `kbManualName` keep their
161
+ * bare name (so existing agents still call `read_file`); every other manual's
162
+ * tool is namespaced as `<manual>_<tool>` to guarantee a unique, dot-free MCP
163
+ * name.
164
+ *
165
+ * `kbManualName` is a parameter rather than a constant because the two surfaces
166
+ * that call this reach the KB through different manuals: the hosted proxy
167
+ * registers it over loopback, the local server registers the deployment's MCP
168
+ * endpoint. Whichever manual carries the core toolset is the one whose names
169
+ * must stay bare.
170
+ */
171
+ export function flattenManualTool(tool: UtcpTool, kbManualName: string): ProxiedTool {
172
+ const dot = tool.name.indexOf('.');
173
+ const manual = dot >= 0 ? tool.name.slice(0, dot) : '';
174
+ const bare = dot >= 0 ? tool.name.slice(dot + 1) : tool.name;
175
+ const mcpName = manual === kbManualName ? bare : tool.name.replace(/\./g, '_');
176
+ return {
177
+ utcpName: tool.name,
178
+ mcpName,
179
+ description: tool.description,
180
+ inputSchema: tool.inputs,
181
+ manualName: manual,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Flatten one discovered UTCP tool into the advertised shape: strip the
187
+ * `<manual>.` namespace prefix for the MCP name and keep the UTCP input schema
188
+ * verbatim (Bevel-hosted HTTP tools show their `{body}` envelope, exactly as in
189
+ * `call_tool_chain`).
190
+ */
191
+ export function flattenDiscoveredTool(prefix: string, tool: UtcpTool): ProxiedTool {
192
+ return {
193
+ utcpName: tool.name,
194
+ mcpName: tool.name.startsWith(prefix) ? tool.name.slice(prefix.length) : tool.name,
195
+ description: tool.description,
196
+ inputSchema: tool.inputs,
197
+ // `prefix` is `<manual>.`; the manual is that without the trailing dot.
198
+ manualName: prefix.endsWith('.') ? prefix.slice(0, -1) : prefix,
199
+ };
200
+ }