@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.
- package/LICENSE +202 -0
- package/THIRD-PARTY-NOTICES.md +3065 -0
- package/dist/code-mode-names.d.ts +40 -0
- package/dist/code-mode-names.d.ts.map +1 -0
- package/dist/code-mode-names.js +93 -0
- package/dist/code-mode-names.js.map +1 -0
- package/dist/dispatch.d.ts +44 -0
- package/dist/dispatch.d.ts.map +1 -0
- package/dist/dispatch.js +72 -0
- package/dist/dispatch.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/meta-tools.d.ts +27 -0
- package/dist/meta-tools.d.ts.map +1 -0
- package/dist/meta-tools.js +161 -0
- package/dist/meta-tools.js.map +1 -0
- package/dist/proxied-tool.d.ts +49 -0
- package/dist/proxied-tool.d.ts.map +1 -0
- package/dist/proxied-tool.js +183 -0
- package/dist/proxied-tool.js.map +1 -0
- package/dist/results.d.ts +19 -0
- package/dist/results.d.ts.map +1 -0
- package/dist/results.js +130 -0
- package/dist/results.js.map +1 -0
- package/dist/skills.d.ts +22 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +20 -0
- package/dist/skills.js.map +1 -0
- package/dist/utcp-namespace.d.ts +30 -0
- package/dist/utcp-namespace.d.ts.map +1 -0
- package/dist/utcp-namespace.js +66 -0
- package/dist/utcp-namespace.js.map +1 -0
- package/package.json +50 -0
- package/src/code-mode-names.ts +107 -0
- package/src/dispatch.ts +88 -0
- package/src/index.ts +71 -0
- package/src/meta-tools.ts +186 -0
- package/src/proxied-tool.ts +200 -0
- package/src/results.ts +131 -0
- package/src/skills.ts +34 -0
- 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
|
+
}
|
package/src/dispatch.ts
ADDED
|
@@ -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
|
+
}
|