@bevel-software/platform-core-backend 0.3.0 → 0.3.1

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 (27) hide show
  1. package/dist/core/create-core-services.d.ts.map +1 -1
  2. package/dist/core/create-core-services.js +5 -0
  3. package/dist/core/create-core-services.js.map +1 -1
  4. package/dist/modules/auth/account.routes.d.ts +2 -2
  5. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  6. package/dist/modules/auth/account.routes.js +3 -0
  7. package/dist/modules/auth/account.routes.js.map +1 -1
  8. package/dist/modules/mcp/manual-failure-memo.d.ts +38 -0
  9. package/dist/modules/mcp/manual-failure-memo.d.ts.map +1 -0
  10. package/dist/modules/mcp/manual-failure-memo.js +65 -0
  11. package/dist/modules/mcp/manual-failure-memo.js.map +1 -0
  12. package/dist/modules/mcp/mcp.service.d.ts +12 -0
  13. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  14. package/dist/modules/mcp/mcp.service.js +37 -4
  15. package/dist/modules/mcp/mcp.service.js.map +1 -1
  16. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +3 -0
  17. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  18. package/dist/modules/secrets-vault/db-secrets-vault.service.js +28 -0
  19. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  20. package/package.json +2 -2
  21. package/src/core/create-core-services.ts +5 -0
  22. package/src/modules/auth/__tests__/account.routes.test.ts +16 -3
  23. package/src/modules/auth/account.routes.ts +8 -2
  24. package/src/modules/mcp/__tests__/manual-failure-memo.test.ts +54 -0
  25. package/src/modules/mcp/manual-failure-memo.ts +67 -0
  26. package/src/modules/mcp/mcp.service.ts +1007 -973
  27. package/src/modules/secrets-vault/db-secrets-vault.service.ts +30 -0
@@ -1,973 +1,1007 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
- import {
5
- CallToolRequestSchema,
6
- ListToolsRequestSchema,
7
- ListPromptsRequestSchema,
8
- GetPromptRequestSchema,
9
- McpError,
10
- ErrorCode,
11
- type CallToolResult,
12
- type Tool as McpTool,
13
- type Prompt,
14
- type GetPromptResult,
15
- } from '@modelcontextprotocol/sdk/types.js';
16
- import '@utcp/http'; // side effect: registers the 'http' UTCP communication protocol
17
- import '@utcp/mcp'; // side effect: registers the 'mcp' protocol (native MCP-server `.tool` sources)
18
- import {
19
- UtcpClientConfigSerializer,
20
- CallTemplateSerializer,
21
- type CallTemplate,
22
- type JsonSchema,
23
- type Tool as UtcpTool,
24
- } from '@utcp/sdk';
25
- import { CodeModeUtcpClient } from '@utcp/code-mode';
26
- import { utcpNameToTsInterfaceName, findToolByName } from '../code-mode/code-mode-names.js';
27
- import { bevelSecretsLoaderConfig } from '../secrets-vault/index.js';
28
- import { scopesCovered, type ISecretsVaultService } from '../secrets-vault/secrets-vault.contract.js';
29
- import { EXTERNAL_KB_MANUAL_NAME } from '../tool-manuals/tool-manuals.contract.js';
30
- import type { IToolManualService } from '../tool-manuals/tool-manuals.contract.js';
31
- import type { SpillStore } from '../workspace/spill-store.js';
32
- import { seedBevelHostedManualVars } from '../../shared/utcp-namespace.js';
33
- import type { McpSessionStore } from './mcp-session-store.js';
34
- import type { InternalTokenService } from '../tool-auth/internal-token.service.js';
35
-
36
- /**
37
- * Configuration for the loopback proxy. `loopbackBaseUrl` is the backend's own
38
- * address (`http://127.0.0.1:<port>`) and `manualName` is BOTH the UTCP manual
39
- * namespace the per-session client registers under AND the prefix its `${VAR}`
40
- * placeholders resolve through (`<manualName>_API_URL` / `_CONNECTION_KEY`).
41
- */
42
- export interface McpProxyOptions {
43
- loopbackBaseUrl: string;
44
- manualName: string;
45
- /** Shared spill store for oversized `call_tool_chain` results (parity with the in-process agent). */
46
- spillStore: SpillStore;
47
- /** Public web address of the frontend, for the needs-authorization setup link. */
48
- publicFrontendUrl: string;
49
- }
50
-
51
- /** Default cap on a `call_tool_chain` result's stringified size before it spills. */
52
- const CALL_TOOL_CHAIN_MAX_OUTPUT = 200_000;
53
-
54
- /**
55
- * Upper bound on one `loopbackJson` round-trip (manual list, skill fetch) so a
56
- * hung loopback can't stall `createSession`. Generous: these endpoints answer
57
- * in milliseconds; only a wedged process ever comes near it.
58
- */
59
- const LOOPBACK_TIMEOUT_MS = 15_000;
60
-
61
- /**
62
- * Lifetime of the internal token minted as an OAuth/JWT session's loopback
63
- * bearer. Longer than the session store's 4h idle eviction (see
64
- * `DEFAULT_IDLE_TTL_MS` in mcp-session-store.ts) so the credential is not the
65
- * first thing to die under a session's normal lifecycle. A continuously-active
66
- * session CAN outlive it — its tool calls then fail at the loopback and the
67
- * client recovers by re-initializing, which mints a fresh token.
68
- */
69
- const MCP_LOOPBACK_TOKEN_TTL_MS = 5 * 60 * 60 * 1000;
70
-
71
- const callTemplateSerializer = new CallTemplateSerializer();
72
-
73
- /**
74
- * Code-mode meta-tools exposed ALONGSIDE the direct tools. They let an external
75
- * agent batch many Bevel calls into one isolated-vm run (`call_tool_chain`)
76
- * instead of one MCP round-trip per call — the same efficiency our own agent
77
- * gets. `call_tool_chain`'s description carries the code-mode protocol (there is
78
- * no system prompt over MCP), so the client learns the convention from the tool
79
- * itself; `list_tools`/`tools_info` are how it discovers what to call.
80
- *
81
- * Security is identical to the direct surface: the chain runs in our isolated-vm
82
- * but calls tools over loopback with the CALLER's key against the external
83
- * catalog — internal-only tools aren't in that catalog, so a chain can't reach
84
- * them either.
85
- */
86
- const CALL_TOOL_CHAIN_DESCRIPTION = [
87
- '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).',
88
- '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.',
89
- '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.',
90
- ].join('\n\n');
91
-
92
- const CODE_MODE_META_TOOLS: McpTool[] = [
93
- {
94
- name: 'list_tools',
95
- description:
96
- 'List every UTCP tool currently registered, in TypeScript-accessible form (e.g. `KNOWLEDGE_BASE.read_file`) for use inside `call_tool_chain`.',
97
- inputSchema: { type: 'object', properties: {}, additionalProperties: false } as McpTool['inputSchema'],
98
- },
99
- {
100
- name: 'tools_info',
101
- description:
102
- 'Get full TypeScript interface definitions for named tools (names from `list_tools`). The schemas are the source of truth — do not guess shapes.',
103
- inputSchema: {
104
- type: 'object',
105
- properties: {
106
- tool_names: { type: 'array', items: { type: 'string' }, minItems: 1, description: 'Tool names to describe.' },
107
- },
108
- required: ['tool_names'],
109
- additionalProperties: false,
110
- } as McpTool['inputSchema'],
111
- },
112
- {
113
- name: 'call_tool_chain',
114
- description: CALL_TOOL_CHAIN_DESCRIPTION,
115
- inputSchema: {
116
- type: 'object',
117
- properties: {
118
- code: { type: 'string', minLength: 1, description: 'JavaScript to execute against the registered tools.' },
119
- timeout: { type: 'integer', minimum: 1000, maximum: 120000, description: 'Timeout in ms (default 30000).' },
120
- max_output_size: { type: 'integer', minimum: 1000, maximum: 1000000, description: 'Max result+logs size in chars before spilling (default 200000, max 1000000).' },
121
- },
122
- required: ['code'],
123
- additionalProperties: false,
124
- } as McpTool['inputSchema'],
125
- },
126
- ];
127
-
128
- const META_TOOL_NAMES = new Set(CODE_MODE_META_TOOLS.map((t) => t.name));
129
-
130
- /** Minimal skill shapes the prompt bridge needs (kept local so the proxy stays decoupled from `modules/skills`). */
131
- interface SkillSummary {
132
- name: string;
133
- description: string;
134
- }
135
- interface LoadedSkill extends SkillSummary {
136
- body: string;
137
- path: string;
138
- files: string[];
139
- }
140
-
141
- /** The prompt message text for a loaded skill: its instructions body + a pointer to bundled files. */
142
- function skillPromptText(skill: LoadedSkill): string {
143
- const rel = skill.files.map((f) =>
144
- f.startsWith(`${skill.path}/`) ? f.slice(skill.path.length + 1) : f,
145
- );
146
- const footer = rel.length
147
- ? `\n\n---\nSkill folder: ${skill.path}\nBundled files (fetch each with the get_skill tool: { name: "${skill.name}", file }): ${rel.join(', ')}`
148
- : '';
149
- return `${skill.body}${footer}`;
150
- }
151
-
152
- /**
153
- * The MCP server is a GENERIC proxy over the UTCP tool surface. It owns no tool
154
- * logic: per MCP session it stands up a `UtcpClient` pointed at the backend's
155
- * own `GET /api/agent/utcp` manual (over loopback, authenticated with the
156
- * caller's connection key), discovers every tool, and re-exposes each one to
157
- * the MCP client. A tool call is dispatched straight back through the same REST
158
- * endpoint the tool's UTCP `tool_call_template` names — so the agent logic,
159
- * thread continuity, and per-key token metering all live ONCE behind that REST
160
- * endpoint (`AgentAskService` for `ask`), never duplicated here.
161
- *
162
- * Dispatch always uses `callToolStreaming`, which is uniform across tool kinds:
163
- * a plain `http` tool yields exactly one chunk (its final result, emitted as
164
- * the tool result with no progress), while a `streamable_http` tool yields many
165
- * (the all-but-last become `notifications/progress`, the last is the result).
166
- * Because of that, adding — or later upgrading a tool to streaming — never
167
- * touches this file.
168
- *
169
- * Auth note: `/api/agent/*` accepts connection keys and internal tokens only.
170
- * Connection-key sessions pass the caller's own key through to the loopback;
171
- * OAuth/JWT sessions can't (their bearer would 401 there), so `createSession`
172
- * mints a least-privilege internal token for the resolved user and uses THAT
173
- * as the session's loopback bearer instead.
174
- */
175
- export class McpService {
176
- constructor(
177
- private readonly sessionStore: McpSessionStore,
178
- private readonly opts: McpProxyOptions,
179
- // The vault + manual catalog, used to check a caller's per-user credentials
180
- // before dispatching a tool. Optional so existing constructions/tests that
181
- // don't exercise the check keep working (the check is skipped when absent).
182
- private readonly secretsVault?: ISecretsVaultService,
183
- private readonly toolManuals?: IToolManualService,
184
- // Mints the loopback bearer for OAuth/JWT sessions (see class doc).
185
- // Optional for the same test-compat reason; without it those sessions
186
- // fall back to the old pass-through (and 401 at the loopback hop).
187
- private readonly internalTokens?: InternalTokenService,
188
- // Revokes an MCP OAuth access token (BevelOAuthProvider.revokeByAccessToken)
189
- // — the reset that sends an interactive session back through the browser
190
- // authorization when one of its TOOL sign-ins breaks. Optional: without
191
- // it, broken sign-ins surface only as the /connect link in the result.
192
- private readonly revokeOAuthAccess?: (bearer: string) => Promise<void>,
193
- ) {}
194
-
195
- /**
196
- * Subscribe to store-driven evictions (idle-TTL sweep, size-cap eviction) so
197
- * the route layer can drop its transport mirror. Returns an unsubscribe fn.
198
- */
199
- onSessionEvicted(listener: (sessionId: string) => void): () => void {
200
- return this.sessionStore.onEvict(listener);
201
- }
202
-
203
- /**
204
- * Build a fresh per-session (transport, server) pair. Async because tool
205
- * discovery (a loopback round-trip authenticated with `bearer`) happens here,
206
- * before the server is returned, so `tools/list` is ready the moment the
207
- * client asks. The route awaits this, then `server.connect(transport)`.
208
- */
209
- async createSession(
210
- userId: string,
211
- tokenId: string | null,
212
- bearer: string,
213
- onSessionInitialized: (sessionId: string) => void,
214
- ): Promise<{ transport: StreamableHTTPServerTransport; server: Server }> {
215
- // The loopback surface (`/api/agent/*`) accepts connection keys and
216
- // internal tokens only. A connection-key session passes the caller's own
217
- // key through (per-key metering rides on it); an OAuth/JWT session's
218
- // bearer would 401 there, so mint a least-privilege internal token for
219
- // the resolved user instead — flagged `externalProxy` so the tool-auth
220
- // verifier resolves it to `source: 'external'`: the caller IS an external
221
- // agent and must be treated like one (admitted to `start_session`/`ask`,
222
- // refused from internal-only tools). TTL covers the session store's 4h
223
- // idle eviction — an evicted session re-initializes and re-mints.
224
- const loopbackBearer =
225
- tokenId == null && this.internalTokens
226
- ? this.internalTokens.mint({ userId, externalProxy: true }, MCP_LOOPBACK_TOKEN_TTL_MS)
227
- : bearer;
228
-
229
- // The list of manuals this caller gets: the KB manual + each `.tool` they
230
- // can read. Fetched over loopback with the caller's key, so ACL filtering
231
- // lives ONCE behind the REST surface.
232
- const manuals = await this.fetchManualTemplates(loopbackBearer);
233
- const client = await this.buildClient(loopbackBearer, userId, manuals);
234
- const tools = await this.discoverTools(client, manuals);
235
- // Confirms the MCP session was built for a connecting client (vs. an
236
- // in-process agent code-mode client, which never runs createSession) and
237
- // how many tools discovery yielded before listing/filtering.
238
- console.log(
239
- `[mcp] createSession: user=${userId} tokenId=${tokenId ?? 'none'} — ` +
240
- `discovered ${tools.length} tool(s) across ${manuals.length} manual(s)`,
241
- );
242
-
243
- const transport = new StreamableHTTPServerTransport({
244
- sessionIdGenerator: () => randomUUID(),
245
- onsessioninitialized: (sessionId) => {
246
- this.sessionStore.create(sessionId, userId, tokenId);
247
- onSessionInitialized(sessionId);
248
- },
249
- onsessionclosed: (sessionId) => {
250
- this.sessionStore.delete(sessionId);
251
- },
252
- });
253
-
254
- const server = new Server(
255
- { name: 'bevel-mcp', version: '0.1.0' },
256
- { capabilities: { tools: {}, prompts: {} } },
257
- );
258
-
259
- server.setRequestHandler(ListToolsRequestSchema, async () => {
260
- // A discovered tool whose name collides with a meta-tool would be
261
- // listed but never callable (the dispatcher routes the name to the
262
- // meta-tool first), so drop it from the listing entirely.
263
- let listed = tools.filter((t) => !META_TOOL_NAMES.has(t.mcpName));
264
- // Connection-key sessions are autonomous pipelines — nobody is present
265
- // to complete a sign-in mid-run, so register ONLY the tools whose
266
- // per-user credentials are already satisfied. Interactive (OAuth/JWT)
267
- // sessions keep the full listing: their caller can configure a tool on
268
- // /connect when the call-time check points them there.
269
- if (tokenId != null) {
270
- const ready = await Promise.all(
271
- // A per-tool check that throws must NOT reject the whole list (which
272
- // would blank every tool) — fail that one tool closed and move on.
273
- listed.map((t) =>
274
- this.missingUserSecrets(userId, t).then(
275
- (missing) => missing.length === 0,
276
- () => false,
277
- ),
278
- ),
279
- );
280
- listed = listed.filter((_, i) => ready[i]);
281
- }
282
- // Validate + dedupe each discovered tool so ONE non-conforming or
283
- // duplicate-named entry can't make an MCP client reject the whole
284
- // `tools/list` (blanking every tool). Every drop is logged with a reason
285
- // so a missing tool is diagnosable instead of silent.
286
- const seen = new Set(META_TOOL_NAMES);
287
- const dropped: string[] = [];
288
- const direct: McpTool[] = [];
289
- for (const t of listed) {
290
- const entry = toListedTool(t); // logs its own reason on a name/schema drop
291
- if (!entry) {
292
- dropped.push(t.mcpName);
293
- continue;
294
- }
295
- if (seen.has(entry.name)) {
296
- dropped.push(`${entry.name} (duplicate)`);
297
- continue;
298
- }
299
- seen.add(entry.name);
300
- direct.push(entry);
301
- }
302
- // Log only when a tool was dropped (name/schema/duplicate) — that's the
303
- // anomaly worth surfacing, since a downstream client would otherwise hide
304
- // it by rejecting the whole response. The steady-state served count is
305
- // already visible once per session in the createSession log above.
306
- if (dropped.length) {
307
- console.warn(
308
- `[mcp] tools/list: serving ${CODE_MODE_META_TOOLS.length + direct.length} tool(s); ` +
309
- `dropped ${dropped.length} non-listable: ${dropped.join(', ')}`,
310
- );
311
- }
312
- return {
313
- // Code-mode meta-tools first, then every validated direct tool.
314
- tools: [...CODE_MODE_META_TOOLS, ...direct],
315
- };
316
- });
317
-
318
- server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
319
- if (META_TOOL_NAMES.has(request.params.name)) {
320
- return this.dispatchMetaTool(client, request.params.name, request.params.arguments ?? {});
321
- }
322
- const proxied = tools.find((t) => t.mcpName === request.params.name);
323
- if (!proxied) {
324
- return toolError(`Unknown tool "${request.params.name}".`);
325
- }
326
- // Stop before running a tool whose personal (user-scoped) credentials the
327
- // caller hasn't provided — return a setup link instead of a blank-credential
328
- // request that would fail opaquely at the provider.
329
- const needsAuth = await this.checkUserSecrets(userId, proxied);
330
- if (needsAuth) {
331
- // A sign-in that EXISTS but is broken (expired grant, abandoned
332
- // consent, missing scopes) on an interactive OAuth session: revoke the
333
- // agent's own grant too. Its next request then 401s, its refresh
334
- // fails, and it re-runs the browser authorization — landing the user
335
- // on /connect where the broken sign-in shows as not-connected, to
336
- // re-authorize or deselect. Never-configured tools keep the plain
337
- // link (revoking for those would loop on every poke at a tool the
338
- // user simply hasn't set up). Connection-key sessions have no grant
339
- // to reset; the revoke no-ops on non-OAuth bearers anyway.
340
- if (needsAuth.brokenSignIn && tokenId == null && this.revokeOAuthAccess) {
341
- try {
342
- await this.revokeOAuthAccess(bearer);
343
- } catch (err) {
344
- console.warn('[mcp] failed to reset the session grant for re-auth:', err);
345
- }
346
- }
347
- return needsAuth.result;
348
- }
349
- return this.dispatch(client, proxied, request, extra);
350
- });
351
-
352
- // Prompts = skills. Each skill becomes a user-callable prompt (slash command
353
- // in MCP clients). Backed by the same skill endpoints the tools use, over
354
- // loopback with the caller's key — so the default-branch catalog, access
355
- // filtering, and progressive disclosure all live ONCE behind that REST
356
- // surface, never duplicated here.
357
- server.setRequestHandler(ListPromptsRequestSchema, async () => {
358
- const skills = await this.fetchSkillList(loopbackBearer);
359
- const prompts: Prompt[] = skills.map((s) => ({
360
- name: s.name,
361
- description: s.description,
362
- arguments: [],
363
- }));
364
- return { prompts };
365
- });
366
-
367
- server.setRequestHandler(GetPromptRequestSchema, async (request): Promise<GetPromptResult> => {
368
- const skill = await this.fetchSkill(loopbackBearer, request.params.name);
369
- if (!skill) {
370
- throw new McpError(ErrorCode.InvalidParams, `Unknown skill "${request.params.name}".`);
371
- }
372
- return {
373
- description: skill.description,
374
- messages: [{ role: 'user', content: { type: 'text', text: skillPromptText(skill) } }],
375
- };
376
- });
377
-
378
- return { transport, server };
379
- }
380
-
381
- /** Loopback GET of the default-branch skill catalog (via the `list_skills` tool). */
382
- private async fetchSkillList(bearer: string): Promise<SkillSummary[]> {
383
- const res = await this.loopbackTool(bearer, 'list_skills', {});
384
- const skills = (res as { skills?: SkillSummary[] } | null)?.skills;
385
- return Array.isArray(skills) ? skills : [];
386
- }
387
-
388
- /** Loopback GET of one skill's body (via the `get_skill` tool); null if unavailable. */
389
- private async fetchSkill(bearer: string, name: string): Promise<LoadedSkill | null> {
390
- const res = (await this.loopbackTool(bearer, 'get_skill', { name })) as
391
- | { ok?: boolean; kind?: string; skill?: LoadedSkill }
392
- | null;
393
- if (res?.ok && res.kind === 'skill' && res.skill) return res.skill;
394
- return null;
395
- }
396
-
397
- /**
398
- * The ONE loopback round-trip: fetch `path` on our own REST surface with the
399
- * caller's bearer, parse JSON. Failures are logged under `label` (never the
400
- * bearer) and degrade to null — a loopback hiccup must not throw into the
401
- * MCP session. GET when `json` is absent, POST with a JSON body when present.
402
- * Bounded: a hung loopback (e.g. mid-restart) must not block `createSession`
403
- * or a prompts handler indefinitely; a timeout aborts and degrades to null
404
- * like any other failure.
405
- */
406
- private async loopbackJson(bearer: string, path: string, label: string, json?: unknown): Promise<unknown> {
407
- try {
408
- const res = await fetch(`${this.opts.loopbackBaseUrl}${path}`, {
409
- headers: {
410
- Authorization: `Bearer ${bearer}`,
411
- ...(json !== undefined ? { 'Content-Type': 'application/json' } : {}),
412
- },
413
- ...(json !== undefined ? { method: 'POST', body: JSON.stringify(json) } : {}),
414
- signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
415
- });
416
- if (!res.ok) {
417
- console.error(`[mcp] ${label} loopback failed: HTTP ${res.status} ${await res.text().catch(() => '')}`);
418
- return null;
419
- }
420
- return await res.json();
421
- } catch (err) {
422
- console.error(`[mcp] ${label} loopback threw:`, err instanceof Error ? err.message : err);
423
- return null;
424
- }
425
- }
426
-
427
- /** POST a skill tool endpoint over loopback with the caller's bearer; parsed JSON or null. */
428
- private async loopbackTool(bearer: string, tool: string, body: unknown): Promise<unknown> {
429
- return this.loopbackJson(bearer, `/api/agent/tools/${tool}`, `skill ${tool}`, body);
430
- }
431
-
432
- /**
433
- * Loopback GET of the caller's manual list (KB + accessible `.tool` manuals),
434
- * validated into `CallTemplate`s at the boundary. The KB manual is guaranteed
435
- * present (a fallback covers an unavailable endpoint); a user manual that
436
- * fails validation is dropped + logged so one bad `.tool` can't break the
437
- * session. HTTP transport loses the `CallTemplate` type, so we re-validate the
438
- * received JSON even though the producing endpoint already validated it.
439
- */
440
- private async fetchManualTemplates(bearer: string): Promise<CallTemplate[]> {
441
- // This is the REMOTE proxy, so ask for remote-capable manuals only — local-only
442
- // `.tool`s are surfaced instead via the `list_local_tools` tool in the KB manual.
443
- const body = (await this.loopbackJson(bearer, '/api/agent/all-tools?remote=true', 'all-tools')) as
444
- | { manuals?: unknown }
445
- | null;
446
- const rawManuals: unknown[] = Array.isArray(body?.manuals) ? body.manuals : [];
447
-
448
- const out: CallTemplate[] = [];
449
- let hasKb = false;
450
- for (const raw of rawManuals) {
451
- const isKb = (raw as { name?: unknown })?.name === EXTERNAL_KB_MANUAL_NAME;
452
- try {
453
- out.push(callTemplateSerializer.validateDict(raw as Record<string, unknown>));
454
- if (isKb) hasKb = true;
455
- } catch (err) {
456
- const name = String((raw as { name?: unknown })?.name ?? '');
457
- if (isKb) throw err; // the KB manual must be valid — the core toolset depends on it
458
- console.warn(`[mcp] skipping manual "${name}": ${err instanceof Error ? err.message : String(err)}`);
459
- }
460
- }
461
- // Always include the KB manual, even if `all-tools` was unavailable/regressed.
462
- if (!hasKb) out.unshift(this.kbManualTemplate());
463
- return out;
464
- }
465
-
466
- /** The KB manual's discovery template — the fallback if `all-tools` is unavailable. */
467
- private kbManualTemplate(): CallTemplate {
468
- return callTemplateSerializer.validateDict({
469
- name: EXTERNAL_KB_MANUAL_NAME,
470
- call_template_type: 'http',
471
- http_method: 'GET',
472
- url: '${API_URL}/api/agent/utcp',
473
- content_type: 'application/json',
474
- headers: { Authorization: 'Bearer ${CONNECTION_KEY}' },
475
- });
476
- }
477
-
478
- /**
479
- * One `CodeModeUtcpClient` per session. Reserved `API_URL`/`CONNECTION_KEY` are
480
- * seeded (namespaced) ONLY for Bevel-hosted manuals — the KB manual and inline
481
- * `.tool` sub-manuals, whose discovery template targets the loopback (`${API_URL}`).
482
- * Third-party http/mcp `.tool`s point at arbitrary user URLs, so we must NOT seed
483
- * them the caller's bearer: a malicious `.tool` referencing `${CONNECTION_KEY}`
484
- * would otherwise exfiltrate the caller's token to its own endpoint. Tool
485
- * `${SECRET}` refs resolve lazily via the per-user `bevel-secrets` loader.
486
- */
487
- private async buildClient(
488
- bearer: string,
489
- userId: string,
490
- manuals: CallTemplate[],
491
- ): Promise<CodeModeUtcpClient> {
492
- // Bevel-hosted manuals (KB + inline `.tool` sub-manuals, discovery url
493
- // `${API_URL}/…`) get the loopback creds; third-party `.tool` URLs are never
494
- // seeded so the bearer can't leak. Same rule the in-process agent factory uses.
495
- const variables = seedBevelHostedManualVars(manuals, this.opts.loopbackBaseUrl, bearer);
496
- const config = new UtcpClientConfigSerializer().validateDict({
497
- variables,
498
- load_variables_from: [bevelSecretsLoaderConfig(userId)],
499
- });
500
- return CodeModeUtcpClient.create(process.cwd(), config);
501
- }
502
-
503
- /**
504
- * Register EVERY manual on the client, then flatten each discovered tool into
505
- * the proxy's advertised shape. A failure registering a user `.tool` is
506
- * isolated (logged + skipped) so one broken manual never breaks the session;
507
- * the KB manual failing is fatal (the core toolset is unusable).
508
- */
509
- private async discoverTools(client: CodeModeUtcpClient, manuals: CallTemplate[]): Promise<ProxiedTool[]> {
510
- for (const m of manuals) {
511
- const isKb = m.name === EXTERNAL_KB_MANUAL_NAME;
512
- try {
513
- const result = await client.registerManual(m);
514
- if (result && result.success === false) {
515
- const errs = Array.isArray(result.errors) ? result.errors.join('; ') : 'unknown error';
516
- if (isKb) throw new Error(`Bevel tool discovery failed: ${errs}`);
517
- console.warn(`[mcp] skipping manual "${String(m.name)}": ${errs}`);
518
- }
519
- } catch (err) {
520
- // Registration (network/discovery) failure — the templates are already
521
- // validated, so this is a runtime, not a schema, problem.
522
- if (isKb) throw err;
523
- console.warn(
524
- `[mcp] skipping manual "${String(m.name)}": ${err instanceof Error ? err.message : String(err)}`,
525
- );
526
- }
527
- }
528
- const utcpTools = await client.getTools();
529
- return utcpTools.map((tool: UtcpTool) => flattenManualTool(tool));
530
- }
531
-
532
- /**
533
- * Run one tool call through `callToolStreaming` with a one-chunk lookahead:
534
- * every chunk except the last becomes a progress notification, the last is
535
- * the result. Continuity is the caller's: a tool that supports it (e.g.
536
- * `ask`) returns its `sessionId` in the result verbatim, and the caller
537
- * echoes it back per the tool's own schema — the proxy never rewrites args.
538
- */
539
- /**
540
- * If the tool declares per-user credentials the caller hasn't set, return a
541
- * needs-authorization result (naming the tool + a setup link); otherwise null
542
- * to proceed. Reads the manual's `user`-scoped variables and asks the vault,
543
- * with the SAME keys `resolve` uses, which the caller has configured — so the
544
- * check can't drift from the actual resolution. A no-op when the vault/manual
545
- * services aren't wired, or the tool's manual declares no per-user credential
546
- * (e.g. the built-in KB tools).
547
- */
548
- private async checkUserSecrets(
549
- userId: string,
550
- tool: ProxiedTool,
551
- ): Promise<{ result: CallToolResult; brokenSignIn: boolean } | null> {
552
- const missing = await this.missingUserSecrets(userId, tool);
553
- if (missing.length === 0) return null;
554
- return {
555
- result: needsAuthorizationResult(
556
- tool.mcpName,
557
- missing.map((v) => v.label ?? v.name),
558
- `${this.opts.publicFrontendUrl}/connect`,
559
- ),
560
- // At least one missing item is a sign-in the caller already HAS a row
561
- // for (dead grant, abandoned consent, or missing scopes) — the state
562
- // that warrants resetting an interactive session for re-auth, as
563
- // opposed to a tool the caller never set up at all.
564
- brokenSignIn: missing.some((v) => v.brokenSignIn),
565
- };
566
- }
567
-
568
- /**
569
- * The per-user variables of `tool`'s manual the caller has NOT satisfied.
570
- * Shared by the call-time needs-authorization check above and the
571
- * listing-time filter for connection-key sessions (which registers only
572
- * ready tools). Empty when the vault/manual services aren't wired or the
573
- * manual declares no per-user credential.
574
- */
575
- private async missingUserSecrets(
576
- userId: string,
577
- tool: ProxiedTool,
578
- ): Promise<{ name: string; label?: string | null; brokenSignIn: boolean }[]> {
579
- if (!this.secretsVault || !this.toolManuals || !tool.manualName) return [];
580
- const userVars = await this.toolManuals.userScopedKeysForManual(tool.manualName);
581
- if (userVars.length === 0) return [];
582
- const status = await this.secretsVault.statusFor(
583
- userId,
584
- userVars.map((v) => v.key),
585
- );
586
- const statusMap = new Map(status.map((s) => [s.key, s]));
587
- const missing: { name: string; label?: string | null; brokenSignIn: boolean }[] = [];
588
- for (const v of userVars) {
589
- const st = statusMap.get(v.key);
590
- // A sign-in the caller already HAS a row for but that isn't (or is no
591
- // longer) usable: dead/wiped grant, abandoned consent, missing scopes.
592
- const brokenSignIn = Boolean(v.oauth && st?.userConfigured);
593
- if (!st?.userConfigured) {
594
- missing.push({ name: v.name, label: v.label, brokenSignIn }); // no row at all → needs a value / sign-in
595
- continue;
596
- }
597
- // An OAuth-backed var whose row exists but has no token yet is NOT ready —
598
- // the user has registered but not completed sign-in. Fail closed: anything
599
- // other than a confirmed `true` (including a non-oauth row → undefined)
600
- // counts as not-yet-authorized.
601
- if (v.oauth && st.userAuthorized !== true) {
602
- missing.push({ name: v.name, label: v.label, brokenSignIn });
603
- continue;
604
- }
605
- // An OAuth-backed var the user signed in for, but whose token was granted
606
- // fewer scopes than the tool now declares, is ALSO not ready — the call would
607
- // otherwise fail opaquely at the provider. Compare the live required scopes
608
- // against the token's recorded granted scopes; an under-scoped (or unknown)
609
- // token needs re-authorization.
610
- if (v.oauth && !scopesCovered(v.oauthScopes, st.grantedScopes)) {
611
- missing.push({ name: v.name, label: v.label, brokenSignIn });
612
- }
613
- }
614
- return missing;
615
- }
616
-
617
- private async dispatch(
618
- client: CodeModeUtcpClient,
619
- tool: ProxiedTool,
620
- request: { params: { arguments?: Record<string, unknown>; _meta?: { progressToken?: string | number } } },
621
- // `sendNotification` typed loosely (`any`) so a `notifications/progress`
622
- // payload without a `progressToken` is accepted — same approach the prior
623
- // handler used; the strict ServerNotification type requires the token.
624
- extra: { sessionId?: string; sendNotification: (n: any) => Promise<void> },
625
- ): Promise<CallToolResult> {
626
- const args = request.params.arguments ?? {};
627
-
628
- const progressToken = request.params._meta?.progressToken;
629
- let prev: unknown;
630
- let hasPrev = false;
631
- let progress = 0;
632
-
633
- try {
634
- // Args pass through to UTCP verbatim — each communication protocol does
635
- // its own serialization (http reads the template's `body_field` out of the
636
- // args, mcp forwards them untouched as MCP `arguments`). The advertised
637
- // schema is the tool's UTCP `inputs` verbatim too, so what the caller
638
- // sends is already in the shape the protocol expects; any reshaping here
639
- // would be wrong for at least one protocol.
640
- for await (const chunk of client.callToolStreaming(tool.utcpName, args)) {
641
- if (hasPrev) {
642
- progress += 1;
643
- await extra
644
- .sendNotification({
645
- method: 'notifications/progress',
646
- params: {
647
- ...(progressToken !== undefined ? { progressToken } : {}),
648
- progress,
649
- message: renderProgress(prev),
650
- },
651
- })
652
- .catch((err) => console.warn('[mcp] progress notification failed:', err));
653
- }
654
- prev = chunk;
655
- hasPrev = true;
656
- }
657
- } catch (err) {
658
- return toolError(`The "${tool.mcpName}" tool failed: ${describeToolFailure(err)}`);
659
- }
660
-
661
- return toCallToolResult(prev);
662
- }
663
-
664
- /**
665
- * Handle a code-mode meta-tool. `list_tools`/`tools_info` reflect on the
666
- * session client's discovered catalog; `call_tool_chain` runs the caller's
667
- * JavaScript in the client's isolated-vm, where every tool is reachable as
668
- * `<manual>.tool(...)` and resolves over loopback with the caller's key.
669
- */
670
- private async dispatchMetaTool(
671
- client: CodeModeUtcpClient,
672
- name: string,
673
- args: Record<string, unknown>,
674
- ): Promise<CallToolResult> {
675
- try {
676
- if (name === 'list_tools') {
677
- const tools = await client.config.tool_repository.getTools();
678
- return toCallToolResult({ tools: tools.map((t) => utcpNameToTsInterfaceName(t.name)) });
679
- }
680
- if (name === 'tools_info') {
681
- const names = Array.isArray(args.tool_names) ? (args.tool_names as string[]) : [];
682
- const interfaces: string[] = [];
683
- const notFound: string[] = [];
684
- for (const n of names) {
685
- const found = await findToolByName(client, n);
686
- if (found) interfaces.push(client.toolToTypeScriptInterface(found.tool));
687
- else notFound.push(n);
688
- }
689
- return toCallToolResult({ interfaces: interfaces.join('\n\n'), not_found: notFound });
690
- }
691
- // call_tool_chain
692
- const code = typeof args.code === 'string' ? args.code : '';
693
- const timeout = typeof args.timeout === 'number' ? args.timeout : 30_000;
694
- // Clamp to [1000, 1_000_000] so a caller can't force oversized inline
695
- // output past the spill (schema bounds are advisory over a raw JSON-RPC call).
696
- const maxOutputSize =
697
- typeof args.max_output_size === 'number'
698
- ? Math.min(1_000_000, Math.max(1_000, Math.trunc(args.max_output_size)))
699
- : CALL_TOOL_CHAIN_MAX_OUTPUT;
700
- const { result, logs } = await client.callToolChain(code, timeout);
701
- // Bound the payload: an external session has no ambient workspace, so an
702
- // oversized result spills to the shared store and we return only a ref —
703
- // parity with the in-process agent's `call_tool_chain`.
704
- if (JSON.stringify({ success: true, result, logs }).length <= maxOutputSize) {
705
- return toCallToolResult({ success: true, result, logs });
706
- }
707
- const fullJson = JSON.stringify({ result, logs }, null, 2);
708
- const { ref, bytes } = await this.opts.spillStore.write(fullJson);
709
- return toCallToolResult({
710
- success: true,
711
- truncated: true,
712
- result_ref: ref,
713
- result_bytes: bytes,
714
- 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.`,
715
- });
716
- } catch (err) {
717
- return toolError(`The "${name}" tool failed: ${describeToolFailure(err)}`);
718
- }
719
- }
720
- }
721
-
722
- /** A tool discovered from the manual, flattened into what the proxy advertises. */
723
- export interface ProxiedTool {
724
- utcpName: string;
725
- mcpName: string;
726
- description: string;
727
- inputSchema: JsonSchema;
728
- /** The UTCP manual this tool came from (the `<manual>` in `<manual>.<tool>`),
729
- * used to look up the manual's declared per-user credentials before dispatch. */
730
- manualName: string;
731
- }
732
-
733
- /**
734
- * Flatten a discovered UTCP tool (`<manual>.<tool>`) into the proxy's advertised
735
- * shape when MULTIPLE manuals are registered. KB tools keep their bare name (so
736
- * existing external agents still call `read_file`); a user `.tool`'s tool is
737
- * namespaced as `<manual>_<tool>` to guarantee a unique, dot-free MCP name.
738
- */
739
- /**
740
- * MCP tool-name grammar (also the Anthropic API's), and a length bound. A
741
- * remote MCP server can expose a tool whose flattened name breaks this — too
742
- * long, or an illegal char the `<manual>_<name>` flattening didn't remove — and
743
- * an MCP client (or the model API behind it) rejects the ENTIRE `tools/list`
744
- * response when a single entry is non-conforming. That makes EVERY tool vanish
745
- * the moment one bad tool from a newly-added server enters the catalog, with no
746
- * server-side error (the rejection is the client's). `toListedTool` isolates it
747
- * per tool: drop the offender (logged), normalize an odd schema, keep the rest.
748
- */
749
- const MCP_TOOL_NAME_RE = /^[a-zA-Z0-9_-]+$/;
750
- // The Anthropic API caps a tool name at 128 chars — but the MCP CLIENT (Claude
751
- // Code, claude.ai) prepends `mcp__<server>__` (≈20+ chars) before sending it,
752
- // and that FULL name is what the 128 applies to. So budget for the prefix here,
753
- // or a long `googlecalendar_…` name we pass gets the whole request 400'd. This
754
- // is deliberately conservative; a dropped tool is logged so it's diagnosable.
755
- const MCP_TOOL_NAME_MAX = 100;
756
-
757
- /** A discovered tool as an MCP listing entry, or null if its name can't be listed. */
758
- export function toListedTool(tool: ProxiedTool): McpTool | null {
759
- if (!MCP_TOOL_NAME_RE.test(tool.mcpName) || tool.mcpName.length > MCP_TOOL_NAME_MAX) {
760
- console.warn(
761
- `[mcp] dropping tool "${tool.mcpName}" from the listing — not a valid MCP tool name ` +
762
- `(must match ${MCP_TOOL_NAME_RE} and be ≤${MCP_TOOL_NAME_MAX} chars). ` +
763
- 'One non-conforming tool would otherwise make the whole toolset disappear on the client.',
764
- );
765
- return null;
766
- }
767
- // MCP requires an object inputSchema. A remote server's schema that isn't a
768
- // plain object (or omits `type: 'object'`) can invalidate the whole response,
769
- // so normalize it — keeping any declared properties — rather than pass it
770
- // through verbatim.
771
- const raw = tool.inputSchema;
772
- let inputSchema: Record<string, unknown> =
773
- raw && typeof raw === 'object' && !Array.isArray(raw)
774
- ? { type: 'object', ...(raw as Record<string, unknown>) }
775
- : { type: 'object', properties: {} };
776
- // Sanitize the schema into what the Anthropic tool validator accepts. A
777
- // remote server that emits a construct the validator rejects — Google's
778
- // gmail/calendar use `$ref`/`$defs` AND OpenAPI `format` values like
779
- // `int32`/`byte` — makes the CLIENT reject the ENTIRE tools/list response,
780
- // so all tools vanish and nothing registers. Sanitizing per-tool means one
781
- // odd server can't blank the whole toolset.
782
- inputSchema = sanitizeInputSchema(inputSchema) as Record<string, unknown>;
783
- // The MCP/Anthropic validator requires the top-level `type` to be exactly
784
- // "object" and (for the Anthropic API) `properties` to be present. Force both
785
- // so a remote schema that declared something else — or a union like
786
- // `["object","null"]` — can't reject the whole tools/list.
787
- inputSchema.type = 'object';
788
- if (typeof inputSchema.properties !== 'object' || inputSchema.properties === null) {
789
- inputSchema.properties = {};
790
- }
791
- return {
792
- name: tool.mcpName,
793
- description: tool.description,
794
- inputSchema: inputSchema as McpTool['inputSchema'],
795
- };
796
- }
797
-
798
- /** JSON-Schema string `format` values the Anthropic tool validator accepts. */
799
- const SUPPORTED_SCHEMA_FORMATS = new Set([
800
- 'date-time',
801
- 'time',
802
- 'date',
803
- 'duration',
804
- 'email',
805
- 'hostname',
806
- 'uri',
807
- 'ipv4',
808
- 'ipv6',
809
- 'uuid',
810
- ]);
811
-
812
- /**
813
- * Make a remote server's JSON Schema safe for the Anthropic tool validator:
814
- * - inline local `$ref` pointers (`#/$defs/...`, `#/definitions/...`) and drop
815
- * the now-unreferenced `$defs`/`definitions` blocks (the API restricts `$ref`
816
- * and MCP clients converting our schemas reject it outright);
817
- * - drop non-standard `format` values (OpenAPI's `int32`/`byte`/… — only the
818
- * JSON-Schema-standard formats above are accepted; `format` is advisory, so
819
- * dropping it doesn't change tool behavior).
820
- * Depth-bounded so a recursive schema degrades to a permissive `{}` node instead
821
- * of hanging or emitting the unsupported recursion; non-local/external refs
822
- * degrade the same way. Exported for direct testing.
823
- */
824
- export function sanitizeInputSchema(schema: unknown): unknown {
825
- const root = schema;
826
- const resolvePointer = (pointer: string): unknown => {
827
- if (!pointer.startsWith('#/')) return undefined;
828
- let node: unknown = root;
829
- for (const partRaw of pointer.slice(2).split('/')) {
830
- const part = partRaw.replace(/~1/g, '/').replace(/~0/g, '~');
831
- if (!node || typeof node !== 'object') return undefined;
832
- node = (node as Record<string, unknown>)[part];
833
- }
834
- return node;
835
- };
836
- const walk = (node: unknown, depth: number): unknown => {
837
- if (depth > 20) return {}; // recursion/cycle guard — permissive fallback
838
- if (Array.isArray(node)) return node.map((item) => walk(item, depth + 1));
839
- if (!node || typeof node !== 'object') return node;
840
- const obj = node as Record<string, unknown>;
841
- if (typeof obj.$ref === 'string') {
842
- const target = resolvePointer(obj.$ref);
843
- // JSON Schema allows siblings next to $ref; keep them, target wins ties.
844
- const { $ref: _ref, ...siblings } = obj;
845
- const resolved = walk(target ?? {}, depth + 1);
846
- return resolved && typeof resolved === 'object' && !Array.isArray(resolved)
847
- ? { ...siblings, ...(resolved as Record<string, unknown>) }
848
- : Object.keys(siblings).length
849
- ? siblings
850
- : resolved ?? {};
851
- }
852
- const out: Record<string, unknown> = {};
853
- for (const [key, value] of Object.entries(obj)) {
854
- if (key === '$defs' || key === 'definitions') continue; // inlined above
855
- // Drop a non-standard `format` (OpenAPI `int32`/`byte`/…) — the validator
856
- // only allows the JSON-Schema-standard set; the annotation is non-load-bearing.
857
- if (key === 'format' && (typeof value !== 'string' || !SUPPORTED_SCHEMA_FORMATS.has(value))) {
858
- continue;
859
- }
860
- out[key] = walk(value, depth + 1);
861
- }
862
- return out;
863
- };
864
- return walk(schema, 0);
865
- }
866
-
867
- export function flattenManualTool(tool: UtcpTool): ProxiedTool {
868
- const dot = tool.name.indexOf('.');
869
- const manual = dot >= 0 ? tool.name.slice(0, dot) : '';
870
- const bare = dot >= 0 ? tool.name.slice(dot + 1) : tool.name;
871
- const mcpName = manual === EXTERNAL_KB_MANUAL_NAME ? bare : tool.name.replace(/\./g, '_');
872
- return {
873
- utcpName: tool.name,
874
- mcpName,
875
- description: tool.description,
876
- inputSchema: tool.inputs,
877
- manualName: manual,
878
- };
879
- }
880
-
881
- /**
882
- * Flatten one discovered UTCP tool into the proxy's advertised shape: strip the
883
- * `<manual>.` namespace prefix for the MCP name and keep the UTCP input schema
884
- * verbatim (Bevel-hosted HTTP tools show their `{body}` envelope, exactly as in
885
- * `call_tool_chain`).
886
- */
887
- export function flattenDiscoveredTool(prefix: string, tool: UtcpTool): ProxiedTool {
888
- return {
889
- utcpName: tool.name,
890
- mcpName: tool.name.startsWith(prefix) ? tool.name.slice(prefix.length) : tool.name,
891
- description: tool.description,
892
- inputSchema: tool.inputs,
893
- // `prefix` is `<manual>.`; the manual is that without the trailing dot.
894
- manualName: prefix.endsWith('.') ? prefix.slice(0, -1) : prefix,
895
- };
896
- }
897
-
898
- /**
899
- * Extract a human-meaningful failure message from a tool-call error. UTCP's
900
- * HTTP protocol surfaces a non-2xx as an axios-style error whose `.response.data`
901
- * is the REST endpoint's JSON body (`{ error: "..." }`). Pull that out so the
902
- * MCP caller sees the tool's real message instead of a bare "status code 500".
903
- */
904
- export function describeToolFailure(err: unknown): string {
905
- const data = (err as { response?: { data?: unknown } })?.response?.data;
906
- if (data && typeof data === 'object') {
907
- const inner = (data as { error?: unknown }).error;
908
- if (typeof inner === 'string' && inner.length > 0) return inner;
909
- }
910
- if (typeof data === 'string' && data.length > 0) return data;
911
- return err instanceof Error ? err.message : String(err);
912
- }
913
-
914
- /**
915
- * Turn a tool's final value into an MCP result:
916
- * - a tool that already returns the MCP agentic shape (`{ content: [...] }`,
917
- * each entry a real content block) is passed through unchanged;
918
- * - a bare string becomes the text content;
919
- * - anything else is JSON-stringified into one text block.
920
- *
921
- * Note we do NOT collapse a structured object down to its `text` field: doing
922
- * so silently dropped the other fields (e.g. `ask`'s `status` / `sessionId`,
923
- * the id a caller must echo back to poll or continue a conversation).
924
- * Stringifying the whole object keeps every field, so the caller always sees
925
- * the id it is responsible for echoing.
926
- *
927
- * The passthrough guard checks the entries, not just that `content` is an array:
928
- * a domain value that merely happens to carry a `content` array of non-blocks
929
- * (e.g. `{ content: ['a', 'b'] }`) is data, not an MCP result, so it falls
930
- * through to JSON-stringify and survives intact instead of being emitted as a
931
- * malformed result the client can't parse.
932
- */
933
- function isMcpContentBlock(entry: unknown): boolean {
934
- return typeof entry === 'object' && entry !== null && typeof (entry as { type?: unknown }).type === 'string';
935
- }
936
-
937
- export function toCallToolResult(value: unknown): CallToolResult {
938
- // Already in MCP agentic format — pass through untouched, but only when every
939
- // `content` entry is a real content block (has a string `type`).
940
- const content = (value as { content?: unknown })?.content;
941
- if (value && typeof value === 'object' && Array.isArray(content) && content.every(isMcpContentBlock)) {
942
- return value as CallToolResult;
943
- }
944
- const text = typeof value === 'string' ? value : JSON.stringify(value ?? null);
945
- return { content: [{ type: 'text', text: text || '(tool produced no output)' }] };
946
- }
947
-
948
- function renderProgress(chunk: unknown): string {
949
- const s = typeof chunk === 'string' ? chunk : JSON.stringify(chunk);
950
- return s.length > 500 ? s.slice(0, 497) + '...' : s;
951
- }
952
-
953
- function toolError(message: string): CallToolResult {
954
- return { isError: true, content: [{ type: 'text', text: message }] };
955
- }
956
-
957
- /**
958
- * The result returned when the caller is missing personal credentials a tool
959
- * needs. Marked `isError` so the external agent surfaces it to the person rather
960
- * than treating it as tool output. Names the tool, lists the missing items, and
961
- * gives the absolute setup-page URL so the person can provide them and retry.
962
- */
963
- export function needsAuthorizationResult(
964
- toolName: string,
965
- missing: string[],
966
- connectUrl: string,
967
- ): CallToolResult {
968
- const items = missing.join(', ');
969
- const text =
970
- `The "${toolName}" tool needs credentials you haven't set up yet: ${items}. ` +
971
- `Open ${connectUrl} to connect your accounts and enter your keys, then run the tool again.`;
972
- return { isError: true, content: [{ type: 'text', text }] };
973
- }
1
+ import { randomUUID } from 'node:crypto';
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
+ import {
5
+ CallToolRequestSchema,
6
+ ListToolsRequestSchema,
7
+ ListPromptsRequestSchema,
8
+ GetPromptRequestSchema,
9
+ McpError,
10
+ ErrorCode,
11
+ type CallToolResult,
12
+ type Tool as McpTool,
13
+ type Prompt,
14
+ type GetPromptResult,
15
+ } from '@modelcontextprotocol/sdk/types.js';
16
+ import '@utcp/http'; // side effect: registers the 'http' UTCP communication protocol
17
+ import '@utcp/mcp'; // side effect: registers the 'mcp' protocol (native MCP-server `.tool` sources)
18
+ import {
19
+ UtcpClientConfigSerializer,
20
+ CallTemplateSerializer,
21
+ type CallTemplate,
22
+ type JsonSchema,
23
+ type Tool as UtcpTool,
24
+ } from '@utcp/sdk';
25
+ import { CodeModeUtcpClient } from '@utcp/code-mode';
26
+ import { utcpNameToTsInterfaceName, findToolByName } from '../code-mode/code-mode-names.js';
27
+ import { bevelSecretsLoaderConfig } from '../secrets-vault/index.js';
28
+ import { scopesCovered, type ISecretsVaultService } from '../secrets-vault/secrets-vault.contract.js';
29
+ import { EXTERNAL_KB_MANUAL_NAME } from '../tool-manuals/tool-manuals.contract.js';
30
+ import type { IToolManualService } from '../tool-manuals/tool-manuals.contract.js';
31
+ import type { SpillStore } from '../workspace/spill-store.js';
32
+ import { seedBevelHostedManualVars } from '../../shared/utcp-namespace.js';
33
+ import type { McpSessionStore } from './mcp-session-store.js';
34
+ import type { InternalTokenService } from '../tool-auth/internal-token.service.js';
35
+ import { ManualFailureMemo } from './manual-failure-memo.js';
36
+
37
+ /**
38
+ * Configuration for the loopback proxy. `loopbackBaseUrl` is the backend's own
39
+ * address (`http://127.0.0.1:<port>`) and `manualName` is BOTH the UTCP manual
40
+ * namespace the per-session client registers under AND the prefix its `${VAR}`
41
+ * placeholders resolve through (`<manualName>_API_URL` / `_CONNECTION_KEY`).
42
+ */
43
+ export interface McpProxyOptions {
44
+ loopbackBaseUrl: string;
45
+ manualName: string;
46
+ /** Shared spill store for oversized `call_tool_chain` results (parity with the in-process agent). */
47
+ spillStore: SpillStore;
48
+ /** Public web address of the frontend, for the needs-authorization setup link. */
49
+ publicFrontendUrl: string;
50
+ }
51
+
52
+ /** Default cap on a `call_tool_chain` result's stringified size before it spills. */
53
+ const CALL_TOOL_CHAIN_MAX_OUTPUT = 200_000;
54
+
55
+ /**
56
+ * Upper bound on one `loopbackJson` round-trip (manual list, skill fetch) so a
57
+ * hung loopback can't stall `createSession`. Generous: these endpoints answer
58
+ * in milliseconds; only a wedged process ever comes near it.
59
+ */
60
+ const LOOPBACK_TIMEOUT_MS = 15_000;
61
+
62
+ /**
63
+ * Lifetime of the internal token minted as an OAuth/JWT session's loopback
64
+ * bearer. Longer than the session store's 4h idle eviction (see
65
+ * `DEFAULT_IDLE_TTL_MS` in mcp-session-store.ts) so the credential is not the
66
+ * first thing to die under a session's normal lifecycle. A continuously-active
67
+ * session CAN outlive it — its tool calls then fail at the loopback and the
68
+ * client recovers by re-initializing, which mints a fresh token.
69
+ */
70
+ const MCP_LOOPBACK_TOKEN_TTL_MS = 5 * 60 * 60 * 1000;
71
+
72
+ const callTemplateSerializer = new CallTemplateSerializer();
73
+
74
+ /**
75
+ * Code-mode meta-tools exposed ALONGSIDE the direct tools. They let an external
76
+ * agent batch many Bevel calls into one isolated-vm run (`call_tool_chain`)
77
+ * instead of one MCP round-trip per call — the same efficiency our own agent
78
+ * gets. `call_tool_chain`'s description carries the code-mode protocol (there is
79
+ * no system prompt over MCP), so the client learns the convention from the tool
80
+ * itself; `list_tools`/`tools_info` are how it discovers what to call.
81
+ *
82
+ * Security is identical to the direct surface: the chain runs in our isolated-vm
83
+ * but calls tools over loopback with the CALLER's key against the external
84
+ * catalog — internal-only tools aren't in that catalog, so a chain can't reach
85
+ * them either.
86
+ */
87
+ const CALL_TOOL_CHAIN_DESCRIPTION = [
88
+ '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).',
89
+ '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.',
90
+ '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.',
91
+ ].join('\n\n');
92
+
93
+ const CODE_MODE_META_TOOLS: McpTool[] = [
94
+ {
95
+ name: 'list_tools',
96
+ description:
97
+ 'List every UTCP tool currently registered, in TypeScript-accessible form (e.g. `KNOWLEDGE_BASE.read_file`) for use inside `call_tool_chain`.',
98
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false } as McpTool['inputSchema'],
99
+ },
100
+ {
101
+ name: 'tools_info',
102
+ description:
103
+ 'Get full TypeScript interface definitions for named tools (names from `list_tools`). The schemas are the source of truth — do not guess shapes.',
104
+ inputSchema: {
105
+ type: 'object',
106
+ properties: {
107
+ tool_names: { type: 'array', items: { type: 'string' }, minItems: 1, description: 'Tool names to describe.' },
108
+ },
109
+ required: ['tool_names'],
110
+ additionalProperties: false,
111
+ } as McpTool['inputSchema'],
112
+ },
113
+ {
114
+ name: 'call_tool_chain',
115
+ description: CALL_TOOL_CHAIN_DESCRIPTION,
116
+ inputSchema: {
117
+ type: 'object',
118
+ properties: {
119
+ code: { type: 'string', minLength: 1, description: 'JavaScript to execute against the registered tools.' },
120
+ timeout: { type: 'integer', minimum: 1000, maximum: 120000, description: 'Timeout in ms (default 30000).' },
121
+ max_output_size: { type: 'integer', minimum: 1000, maximum: 1000000, description: 'Max result+logs size in chars before spilling (default 200000, max 1000000).' },
122
+ },
123
+ required: ['code'],
124
+ additionalProperties: false,
125
+ } as McpTool['inputSchema'],
126
+ },
127
+ ];
128
+
129
+ const META_TOOL_NAMES = new Set(CODE_MODE_META_TOOLS.map((t) => t.name));
130
+
131
+ /** Minimal skill shapes the prompt bridge needs (kept local so the proxy stays decoupled from `modules/skills`). */
132
+ interface SkillSummary {
133
+ name: string;
134
+ description: string;
135
+ }
136
+ interface LoadedSkill extends SkillSummary {
137
+ body: string;
138
+ path: string;
139
+ files: string[];
140
+ }
141
+
142
+ /** The prompt message text for a loaded skill: its instructions body + a pointer to bundled files. */
143
+ function skillPromptText(skill: LoadedSkill): string {
144
+ const rel = skill.files.map((f) =>
145
+ f.startsWith(`${skill.path}/`) ? f.slice(skill.path.length + 1) : f,
146
+ );
147
+ const footer = rel.length
148
+ ? `\n\n---\nSkill folder: ${skill.path}\nBundled files (fetch each with the get_skill tool: { name: "${skill.name}", file }): ${rel.join(', ')}`
149
+ : '';
150
+ return `${skill.body}${footer}`;
151
+ }
152
+
153
+ /**
154
+ * The MCP server is a GENERIC proxy over the UTCP tool surface. It owns no tool
155
+ * logic: per MCP session it stands up a `UtcpClient` pointed at the backend's
156
+ * own `GET /api/agent/utcp` manual (over loopback, authenticated with the
157
+ * caller's connection key), discovers every tool, and re-exposes each one to
158
+ * the MCP client. A tool call is dispatched straight back through the same REST
159
+ * endpoint the tool's UTCP `tool_call_template` names — so the agent logic,
160
+ * thread continuity, and per-key token metering all live ONCE behind that REST
161
+ * endpoint (`AgentAskService` for `ask`), never duplicated here.
162
+ *
163
+ * Dispatch always uses `callToolStreaming`, which is uniform across tool kinds:
164
+ * a plain `http` tool yields exactly one chunk (its final result, emitted as
165
+ * the tool result with no progress), while a `streamable_http` tool yields many
166
+ * (the all-but-last become `notifications/progress`, the last is the result).
167
+ * Because of that, adding — or later upgrading a tool to streaming — never
168
+ * touches this file.
169
+ *
170
+ * Auth note: `/api/agent/*` accepts connection keys and internal tokens only.
171
+ * Connection-key sessions pass the caller's own key through to the loopback;
172
+ * OAuth/JWT sessions can't (their bearer would 401 there), so `createSession`
173
+ * mints a least-privilege internal token for the resolved user and uses THAT
174
+ * as the session's loopback bearer instead.
175
+ */
176
+ export class McpService {
177
+ // Circuit breaker for manuals whose credentials just failed — see the memo.
178
+ private readonly manualFailures = new ManualFailureMemo();
179
+
180
+ /**
181
+ * Invalidate remembered manual failures — wired to the secrets vault's
182
+ * mutation listener, so a just-repaired credential is retried on the very
183
+ * next session build. `null` = a shared secret changed (affects everyone).
184
+ */
185
+ clearManualFailures(userId: string | null): void {
186
+ if (userId === null) this.manualFailures.clearAll();
187
+ else this.manualFailures.clearUser(userId);
188
+ }
189
+
190
+ constructor(
191
+ private readonly sessionStore: McpSessionStore,
192
+ private readonly opts: McpProxyOptions,
193
+ // The vault + manual catalog, used to check a caller's per-user credentials
194
+ // before dispatching a tool. Optional so existing constructions/tests that
195
+ // don't exercise the check keep working (the check is skipped when absent).
196
+ private readonly secretsVault?: ISecretsVaultService,
197
+ private readonly toolManuals?: IToolManualService,
198
+ // Mints the loopback bearer for OAuth/JWT sessions (see class doc).
199
+ // Optional for the same test-compat reason; without it those sessions
200
+ // fall back to the old pass-through (and 401 at the loopback hop).
201
+ private readonly internalTokens?: InternalTokenService,
202
+ // Revokes an MCP OAuth access token (BevelOAuthProvider.revokeByAccessToken)
203
+ // — the reset that sends an interactive session back through the browser
204
+ // authorization when one of its TOOL sign-ins breaks. Optional: without
205
+ // it, broken sign-ins surface only as the /connect link in the result.
206
+ private readonly revokeOAuthAccess?: (bearer: string) => Promise<void>,
207
+ ) {}
208
+
209
+ /**
210
+ * Subscribe to store-driven evictions (idle-TTL sweep, size-cap eviction) so
211
+ * the route layer can drop its transport mirror. Returns an unsubscribe fn.
212
+ */
213
+ onSessionEvicted(listener: (sessionId: string) => void): () => void {
214
+ return this.sessionStore.onEvict(listener);
215
+ }
216
+
217
+ /**
218
+ * Build a fresh per-session (transport, server) pair. Async because tool
219
+ * discovery (a loopback round-trip authenticated with `bearer`) happens here,
220
+ * before the server is returned, so `tools/list` is ready the moment the
221
+ * client asks. The route awaits this, then `server.connect(transport)`.
222
+ */
223
+ async createSession(
224
+ userId: string,
225
+ tokenId: string | null,
226
+ bearer: string,
227
+ onSessionInitialized: (sessionId: string) => void,
228
+ ): Promise<{ transport: StreamableHTTPServerTransport; server: Server }> {
229
+ // The loopback surface (`/api/agent/*`) accepts connection keys and
230
+ // internal tokens only. A connection-key session passes the caller's own
231
+ // key through (per-key metering rides on it); an OAuth/JWT session's
232
+ // bearer would 401 there, so mint a least-privilege internal token for
233
+ // the resolved user instead — flagged `externalProxy` so the tool-auth
234
+ // verifier resolves it to `source: 'external'`: the caller IS an external
235
+ // agent and must be treated like one (admitted to `start_session`/`ask`,
236
+ // refused from internal-only tools). TTL covers the session store's 4h
237
+ // idle eviction — an evicted session re-initializes and re-mints.
238
+ const loopbackBearer =
239
+ tokenId == null && this.internalTokens
240
+ ? this.internalTokens.mint({ userId, externalProxy: true }, MCP_LOOPBACK_TOKEN_TTL_MS)
241
+ : bearer;
242
+
243
+ // The list of manuals this caller gets: the KB manual + each `.tool` they
244
+ // can read. Fetched over loopback with the caller's key, so ACL filtering
245
+ // lives ONCE behind the REST surface.
246
+ const manuals = await this.fetchManualTemplates(loopbackBearer);
247
+ const client = await this.buildClient(loopbackBearer, userId, manuals);
248
+ const tools = await this.discoverTools(client, manuals, userId);
249
+ // Confirms the MCP session was built for a connecting client (vs. an
250
+ // in-process agent code-mode client, which never runs createSession) and
251
+ // how many tools discovery yielded before listing/filtering.
252
+ console.log(
253
+ `[mcp] createSession: user=${userId} tokenId=${tokenId ?? 'none'} — ` +
254
+ `discovered ${tools.length} tool(s) across ${manuals.length} manual(s)`,
255
+ );
256
+
257
+ const transport = new StreamableHTTPServerTransport({
258
+ sessionIdGenerator: () => randomUUID(),
259
+ onsessioninitialized: (sessionId) => {
260
+ this.sessionStore.create(sessionId, userId, tokenId);
261
+ onSessionInitialized(sessionId);
262
+ },
263
+ onsessionclosed: (sessionId) => {
264
+ this.sessionStore.delete(sessionId);
265
+ },
266
+ });
267
+
268
+ const server = new Server(
269
+ { name: 'bevel-mcp', version: '0.1.0' },
270
+ { capabilities: { tools: {}, prompts: {} } },
271
+ );
272
+
273
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
274
+ // A discovered tool whose name collides with a meta-tool would be
275
+ // listed but never callable (the dispatcher routes the name to the
276
+ // meta-tool first), so drop it from the listing entirely.
277
+ let listed = tools.filter((t) => !META_TOOL_NAMES.has(t.mcpName));
278
+ // Connection-key sessions are autonomous pipelines — nobody is present
279
+ // to complete a sign-in mid-run, so register ONLY the tools whose
280
+ // per-user credentials are already satisfied. Interactive (OAuth/JWT)
281
+ // sessions keep the full listing: their caller can configure a tool on
282
+ // /connect when the call-time check points them there.
283
+ if (tokenId != null) {
284
+ const ready = await Promise.all(
285
+ // A per-tool check that throws must NOT reject the whole list (which
286
+ // would blank every tool) — fail that one tool closed and move on.
287
+ listed.map((t) =>
288
+ this.missingUserSecrets(userId, t).then(
289
+ (missing) => missing.length === 0,
290
+ () => false,
291
+ ),
292
+ ),
293
+ );
294
+ listed = listed.filter((_, i) => ready[i]);
295
+ }
296
+ // Validate + dedupe each discovered tool so ONE non-conforming or
297
+ // duplicate-named entry can't make an MCP client reject the whole
298
+ // `tools/list` (blanking every tool). Every drop is logged with a reason
299
+ // so a missing tool is diagnosable instead of silent.
300
+ const seen = new Set(META_TOOL_NAMES);
301
+ const dropped: string[] = [];
302
+ const direct: McpTool[] = [];
303
+ for (const t of listed) {
304
+ const entry = toListedTool(t); // logs its own reason on a name/schema drop
305
+ if (!entry) {
306
+ dropped.push(t.mcpName);
307
+ continue;
308
+ }
309
+ if (seen.has(entry.name)) {
310
+ dropped.push(`${entry.name} (duplicate)`);
311
+ continue;
312
+ }
313
+ seen.add(entry.name);
314
+ direct.push(entry);
315
+ }
316
+ // Log only when a tool was dropped (name/schema/duplicate) — that's the
317
+ // anomaly worth surfacing, since a downstream client would otherwise hide
318
+ // it by rejecting the whole response. The steady-state served count is
319
+ // already visible once per session in the createSession log above.
320
+ if (dropped.length) {
321
+ console.warn(
322
+ `[mcp] tools/list: serving ${CODE_MODE_META_TOOLS.length + direct.length} tool(s); ` +
323
+ `dropped ${dropped.length} non-listable: ${dropped.join(', ')}`,
324
+ );
325
+ }
326
+ return {
327
+ // Code-mode meta-tools first, then every validated direct tool.
328
+ tools: [...CODE_MODE_META_TOOLS, ...direct],
329
+ };
330
+ });
331
+
332
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
333
+ if (META_TOOL_NAMES.has(request.params.name)) {
334
+ return this.dispatchMetaTool(client, request.params.name, request.params.arguments ?? {});
335
+ }
336
+ const proxied = tools.find((t) => t.mcpName === request.params.name);
337
+ if (!proxied) {
338
+ return toolError(`Unknown tool "${request.params.name}".`);
339
+ }
340
+ // Stop before running a tool whose personal (user-scoped) credentials the
341
+ // caller hasn't provided — return a setup link instead of a blank-credential
342
+ // request that would fail opaquely at the provider.
343
+ const needsAuth = await this.checkUserSecrets(userId, proxied);
344
+ if (needsAuth) {
345
+ // A sign-in that EXISTS but is broken (expired grant, abandoned
346
+ // consent, missing scopes) on an interactive OAuth session: revoke the
347
+ // agent's own grant too. Its next request then 401s, its refresh
348
+ // fails, and it re-runs the browser authorization — landing the user
349
+ // on /connect where the broken sign-in shows as not-connected, to
350
+ // re-authorize or deselect. Never-configured tools keep the plain
351
+ // link (revoking for those would loop on every poke at a tool the
352
+ // user simply hasn't set up). Connection-key sessions have no grant
353
+ // to reset; the revoke no-ops on non-OAuth bearers anyway.
354
+ if (needsAuth.brokenSignIn && tokenId == null && this.revokeOAuthAccess) {
355
+ try {
356
+ await this.revokeOAuthAccess(bearer);
357
+ } catch (err) {
358
+ console.warn('[mcp] failed to reset the session grant for re-auth:', err);
359
+ }
360
+ }
361
+ return needsAuth.result;
362
+ }
363
+ return this.dispatch(client, proxied, request, extra);
364
+ });
365
+
366
+ // Prompts = skills. Each skill becomes a user-callable prompt (slash command
367
+ // in MCP clients). Backed by the same skill endpoints the tools use, over
368
+ // loopback with the caller's key — so the default-branch catalog, access
369
+ // filtering, and progressive disclosure all live ONCE behind that REST
370
+ // surface, never duplicated here.
371
+ server.setRequestHandler(ListPromptsRequestSchema, async () => {
372
+ const skills = await this.fetchSkillList(loopbackBearer);
373
+ const prompts: Prompt[] = skills.map((s) => ({
374
+ name: s.name,
375
+ description: s.description,
376
+ arguments: [],
377
+ }));
378
+ return { prompts };
379
+ });
380
+
381
+ server.setRequestHandler(GetPromptRequestSchema, async (request): Promise<GetPromptResult> => {
382
+ const skill = await this.fetchSkill(loopbackBearer, request.params.name);
383
+ if (!skill) {
384
+ throw new McpError(ErrorCode.InvalidParams, `Unknown skill "${request.params.name}".`);
385
+ }
386
+ return {
387
+ description: skill.description,
388
+ messages: [{ role: 'user', content: { type: 'text', text: skillPromptText(skill) } }],
389
+ };
390
+ });
391
+
392
+ return { transport, server };
393
+ }
394
+
395
+ /** Loopback GET of the default-branch skill catalog (via the `list_skills` tool). */
396
+ private async fetchSkillList(bearer: string): Promise<SkillSummary[]> {
397
+ const res = await this.loopbackTool(bearer, 'list_skills', {});
398
+ const skills = (res as { skills?: SkillSummary[] } | null)?.skills;
399
+ return Array.isArray(skills) ? skills : [];
400
+ }
401
+
402
+ /** Loopback GET of one skill's body (via the `get_skill` tool); null if unavailable. */
403
+ private async fetchSkill(bearer: string, name: string): Promise<LoadedSkill | null> {
404
+ const res = (await this.loopbackTool(bearer, 'get_skill', { name })) as
405
+ | { ok?: boolean; kind?: string; skill?: LoadedSkill }
406
+ | null;
407
+ if (res?.ok && res.kind === 'skill' && res.skill) return res.skill;
408
+ return null;
409
+ }
410
+
411
+ /**
412
+ * The ONE loopback round-trip: fetch `path` on our own REST surface with the
413
+ * caller's bearer, parse JSON. Failures are logged under `label` (never the
414
+ * bearer) and degrade to null — a loopback hiccup must not throw into the
415
+ * MCP session. GET when `json` is absent, POST with a JSON body when present.
416
+ * Bounded: a hung loopback (e.g. mid-restart) must not block `createSession`
417
+ * or a prompts handler indefinitely; a timeout aborts and degrades to null
418
+ * like any other failure.
419
+ */
420
+ private async loopbackJson(bearer: string, path: string, label: string, json?: unknown): Promise<unknown> {
421
+ try {
422
+ const res = await fetch(`${this.opts.loopbackBaseUrl}${path}`, {
423
+ headers: {
424
+ Authorization: `Bearer ${bearer}`,
425
+ ...(json !== undefined ? { 'Content-Type': 'application/json' } : {}),
426
+ },
427
+ ...(json !== undefined ? { method: 'POST', body: JSON.stringify(json) } : {}),
428
+ signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
429
+ });
430
+ if (!res.ok) {
431
+ console.error(`[mcp] ${label} loopback failed: HTTP ${res.status} ${await res.text().catch(() => '')}`);
432
+ return null;
433
+ }
434
+ return await res.json();
435
+ } catch (err) {
436
+ console.error(`[mcp] ${label} loopback threw:`, err instanceof Error ? err.message : err);
437
+ return null;
438
+ }
439
+ }
440
+
441
+ /** POST a skill tool endpoint over loopback with the caller's bearer; parsed JSON or null. */
442
+ private async loopbackTool(bearer: string, tool: string, body: unknown): Promise<unknown> {
443
+ return this.loopbackJson(bearer, `/api/agent/tools/${tool}`, `skill ${tool}`, body);
444
+ }
445
+
446
+ /**
447
+ * Loopback GET of the caller's manual list (KB + accessible `.tool` manuals),
448
+ * validated into `CallTemplate`s at the boundary. The KB manual is guaranteed
449
+ * present (a fallback covers an unavailable endpoint); a user manual that
450
+ * fails validation is dropped + logged so one bad `.tool` can't break the
451
+ * session. HTTP transport loses the `CallTemplate` type, so we re-validate the
452
+ * received JSON even though the producing endpoint already validated it.
453
+ */
454
+ private async fetchManualTemplates(bearer: string): Promise<CallTemplate[]> {
455
+ // This is the REMOTE proxy, so ask for remote-capable manuals only — local-only
456
+ // `.tool`s are surfaced instead via the `list_local_tools` tool in the KB manual.
457
+ const body = (await this.loopbackJson(bearer, '/api/agent/all-tools?remote=true', 'all-tools')) as
458
+ | { manuals?: unknown }
459
+ | null;
460
+ const rawManuals: unknown[] = Array.isArray(body?.manuals) ? body.manuals : [];
461
+
462
+ const out: CallTemplate[] = [];
463
+ let hasKb = false;
464
+ for (const raw of rawManuals) {
465
+ const isKb = (raw as { name?: unknown })?.name === EXTERNAL_KB_MANUAL_NAME;
466
+ try {
467
+ out.push(callTemplateSerializer.validateDict(raw as Record<string, unknown>));
468
+ if (isKb) hasKb = true;
469
+ } catch (err) {
470
+ const name = String((raw as { name?: unknown })?.name ?? '');
471
+ if (isKb) throw err; // the KB manual must be valid — the core toolset depends on it
472
+ console.warn(`[mcp] skipping manual "${name}": ${err instanceof Error ? err.message : String(err)}`);
473
+ }
474
+ }
475
+ // Always include the KB manual, even if `all-tools` was unavailable/regressed.
476
+ if (!hasKb) out.unshift(this.kbManualTemplate());
477
+ return out;
478
+ }
479
+
480
+ /** The KB manual's discovery template — the fallback if `all-tools` is unavailable. */
481
+ private kbManualTemplate(): CallTemplate {
482
+ return callTemplateSerializer.validateDict({
483
+ name: EXTERNAL_KB_MANUAL_NAME,
484
+ call_template_type: 'http',
485
+ http_method: 'GET',
486
+ url: '${API_URL}/api/agent/utcp',
487
+ content_type: 'application/json',
488
+ headers: { Authorization: 'Bearer ${CONNECTION_KEY}' },
489
+ });
490
+ }
491
+
492
+ /**
493
+ * One `CodeModeUtcpClient` per session. Reserved `API_URL`/`CONNECTION_KEY` are
494
+ * seeded (namespaced) ONLY for Bevel-hosted manuals — the KB manual and inline
495
+ * `.tool` sub-manuals, whose discovery template targets the loopback (`${API_URL}`).
496
+ * Third-party http/mcp `.tool`s point at arbitrary user URLs, so we must NOT seed
497
+ * them the caller's bearer: a malicious `.tool` referencing `${CONNECTION_KEY}`
498
+ * would otherwise exfiltrate the caller's token to its own endpoint. Tool
499
+ * `${SECRET}` refs resolve lazily via the per-user `bevel-secrets` loader.
500
+ */
501
+ private async buildClient(
502
+ bearer: string,
503
+ userId: string,
504
+ manuals: CallTemplate[],
505
+ ): Promise<CodeModeUtcpClient> {
506
+ // Bevel-hosted manuals (KB + inline `.tool` sub-manuals, discovery url
507
+ // `${API_URL}/…`) get the loopback creds; third-party `.tool` URLs are never
508
+ // seeded so the bearer can't leak. Same rule the in-process agent factory uses.
509
+ const variables = seedBevelHostedManualVars(manuals, this.opts.loopbackBaseUrl, bearer);
510
+ const config = new UtcpClientConfigSerializer().validateDict({
511
+ variables,
512
+ load_variables_from: [bevelSecretsLoaderConfig(userId)],
513
+ });
514
+ return CodeModeUtcpClient.create(process.cwd(), config);
515
+ }
516
+
517
+ /**
518
+ * Register EVERY manual on the client, then flatten each discovered tool into
519
+ * the proxy's advertised shape. A failure registering a user `.tool` is
520
+ * isolated (logged + skipped) so one broken manual never breaks the session;
521
+ * the KB manual failing is fatal (the core toolset is unusable).
522
+ *
523
+ * Failures are memoized per (user, manual) for a few minutes (see
524
+ * {@link ManualFailureMemo}): sessions rebuild constantly, and a manual with
525
+ * a broken credential (expired OAuth, revoked key) would otherwise re-dial
526
+ * its provider on every single build.
527
+ */
528
+ private async discoverTools(
529
+ client: CodeModeUtcpClient,
530
+ manuals: CallTemplate[],
531
+ userId: string,
532
+ ): Promise<ProxiedTool[]> {
533
+ for (const m of manuals) {
534
+ const isKb = m.name === EXTERNAL_KB_MANUAL_NAME;
535
+ const name = String(m.name);
536
+ if (!isKb) {
537
+ const recent = this.manualFailures.recentFailure(userId, name);
538
+ if (recent !== undefined) {
539
+ console.warn(`[mcp] skipping manual "${name}" (recent failure, not retried): ${recent}`);
540
+ continue;
541
+ }
542
+ }
543
+ try {
544
+ const result = await client.registerManual(m);
545
+ if (result && result.success === false) {
546
+ const errs = Array.isArray(result.errors) ? result.errors.join('; ') : 'unknown error';
547
+ if (isKb) throw new Error(`Bevel tool discovery failed: ${errs}`);
548
+ this.manualFailures.recordFailure(userId, name, errs);
549
+ console.warn(`[mcp] skipping manual "${name}": ${errs}`);
550
+ } else if (!isKb) {
551
+ this.manualFailures.clear(userId, name);
552
+ }
553
+ } catch (err) {
554
+ // Registration (network/discovery) failure — the templates are already
555
+ // validated, so this is a runtime, not a schema, problem.
556
+ if (isKb) throw err;
557
+ const message = err instanceof Error ? err.message : String(err);
558
+ this.manualFailures.recordFailure(userId, name, message);
559
+ console.warn(`[mcp] skipping manual "${name}": ${message}`);
560
+ }
561
+ }
562
+ const utcpTools = await client.getTools();
563
+ return utcpTools.map((tool: UtcpTool) => flattenManualTool(tool));
564
+ }
565
+
566
+ /**
567
+ * Run one tool call through `callToolStreaming` with a one-chunk lookahead:
568
+ * every chunk except the last becomes a progress notification, the last is
569
+ * the result. Continuity is the caller's: a tool that supports it (e.g.
570
+ * `ask`) returns its `sessionId` in the result verbatim, and the caller
571
+ * echoes it back per the tool's own schema — the proxy never rewrites args.
572
+ */
573
+ /**
574
+ * If the tool declares per-user credentials the caller hasn't set, return a
575
+ * needs-authorization result (naming the tool + a setup link); otherwise null
576
+ * to proceed. Reads the manual's `user`-scoped variables and asks the vault,
577
+ * with the SAME keys `resolve` uses, which the caller has configured — so the
578
+ * check can't drift from the actual resolution. A no-op when the vault/manual
579
+ * services aren't wired, or the tool's manual declares no per-user credential
580
+ * (e.g. the built-in KB tools).
581
+ */
582
+ private async checkUserSecrets(
583
+ userId: string,
584
+ tool: ProxiedTool,
585
+ ): Promise<{ result: CallToolResult; brokenSignIn: boolean } | null> {
586
+ const missing = await this.missingUserSecrets(userId, tool);
587
+ if (missing.length === 0) return null;
588
+ return {
589
+ result: needsAuthorizationResult(
590
+ tool.mcpName,
591
+ missing.map((v) => v.label ?? v.name),
592
+ `${this.opts.publicFrontendUrl}/connect`,
593
+ ),
594
+ // At least one missing item is a sign-in the caller already HAS a row
595
+ // for (dead grant, abandoned consent, or missing scopes) — the state
596
+ // that warrants resetting an interactive session for re-auth, as
597
+ // opposed to a tool the caller never set up at all.
598
+ brokenSignIn: missing.some((v) => v.brokenSignIn),
599
+ };
600
+ }
601
+
602
+ /**
603
+ * The per-user variables of `tool`'s manual the caller has NOT satisfied.
604
+ * Shared by the call-time needs-authorization check above and the
605
+ * listing-time filter for connection-key sessions (which registers only
606
+ * ready tools). Empty when the vault/manual services aren't wired or the
607
+ * manual declares no per-user credential.
608
+ */
609
+ private async missingUserSecrets(
610
+ userId: string,
611
+ tool: ProxiedTool,
612
+ ): Promise<{ name: string; label?: string | null; brokenSignIn: boolean }[]> {
613
+ if (!this.secretsVault || !this.toolManuals || !tool.manualName) return [];
614
+ const userVars = await this.toolManuals.userScopedKeysForManual(tool.manualName);
615
+ if (userVars.length === 0) return [];
616
+ const status = await this.secretsVault.statusFor(
617
+ userId,
618
+ userVars.map((v) => v.key),
619
+ );
620
+ const statusMap = new Map(status.map((s) => [s.key, s]));
621
+ const missing: { name: string; label?: string | null; brokenSignIn: boolean }[] = [];
622
+ for (const v of userVars) {
623
+ const st = statusMap.get(v.key);
624
+ // A sign-in the caller already HAS a row for but that isn't (or is no
625
+ // longer) usable: dead/wiped grant, abandoned consent, missing scopes.
626
+ const brokenSignIn = Boolean(v.oauth && st?.userConfigured);
627
+ if (!st?.userConfigured) {
628
+ missing.push({ name: v.name, label: v.label, brokenSignIn }); // no row at all → needs a value / sign-in
629
+ continue;
630
+ }
631
+ // An OAuth-backed var whose row exists but has no token yet is NOT ready —
632
+ // the user has registered but not completed sign-in. Fail closed: anything
633
+ // other than a confirmed `true` (including a non-oauth row → undefined)
634
+ // counts as not-yet-authorized.
635
+ if (v.oauth && st.userAuthorized !== true) {
636
+ missing.push({ name: v.name, label: v.label, brokenSignIn });
637
+ continue;
638
+ }
639
+ // An OAuth-backed var the user signed in for, but whose token was granted
640
+ // fewer scopes than the tool now declares, is ALSO not ready — the call would
641
+ // otherwise fail opaquely at the provider. Compare the live required scopes
642
+ // against the token's recorded granted scopes; an under-scoped (or unknown)
643
+ // token needs re-authorization.
644
+ if (v.oauth && !scopesCovered(v.oauthScopes, st.grantedScopes)) {
645
+ missing.push({ name: v.name, label: v.label, brokenSignIn });
646
+ }
647
+ }
648
+ return missing;
649
+ }
650
+
651
+ private async dispatch(
652
+ client: CodeModeUtcpClient,
653
+ tool: ProxiedTool,
654
+ request: { params: { arguments?: Record<string, unknown>; _meta?: { progressToken?: string | number } } },
655
+ // `sendNotification` typed loosely (`any`) so a `notifications/progress`
656
+ // payload without a `progressToken` is accepted — same approach the prior
657
+ // handler used; the strict ServerNotification type requires the token.
658
+ extra: { sessionId?: string; sendNotification: (n: any) => Promise<void> },
659
+ ): Promise<CallToolResult> {
660
+ const args = request.params.arguments ?? {};
661
+
662
+ const progressToken = request.params._meta?.progressToken;
663
+ let prev: unknown;
664
+ let hasPrev = false;
665
+ let progress = 0;
666
+
667
+ try {
668
+ // Args pass through to UTCP verbatim — each communication protocol does
669
+ // its own serialization (http reads the template's `body_field` out of the
670
+ // args, mcp forwards them untouched as MCP `arguments`). The advertised
671
+ // schema is the tool's UTCP `inputs` verbatim too, so what the caller
672
+ // sends is already in the shape the protocol expects; any reshaping here
673
+ // would be wrong for at least one protocol.
674
+ for await (const chunk of client.callToolStreaming(tool.utcpName, args)) {
675
+ if (hasPrev) {
676
+ progress += 1;
677
+ await extra
678
+ .sendNotification({
679
+ method: 'notifications/progress',
680
+ params: {
681
+ ...(progressToken !== undefined ? { progressToken } : {}),
682
+ progress,
683
+ message: renderProgress(prev),
684
+ },
685
+ })
686
+ .catch((err) => console.warn('[mcp] progress notification failed:', err));
687
+ }
688
+ prev = chunk;
689
+ hasPrev = true;
690
+ }
691
+ } catch (err) {
692
+ return toolError(`The "${tool.mcpName}" tool failed: ${describeToolFailure(err)}`);
693
+ }
694
+
695
+ return toCallToolResult(prev);
696
+ }
697
+
698
+ /**
699
+ * Handle a code-mode meta-tool. `list_tools`/`tools_info` reflect on the
700
+ * session client's discovered catalog; `call_tool_chain` runs the caller's
701
+ * JavaScript in the client's isolated-vm, where every tool is reachable as
702
+ * `<manual>.tool(...)` and resolves over loopback with the caller's key.
703
+ */
704
+ private async dispatchMetaTool(
705
+ client: CodeModeUtcpClient,
706
+ name: string,
707
+ args: Record<string, unknown>,
708
+ ): Promise<CallToolResult> {
709
+ try {
710
+ if (name === 'list_tools') {
711
+ const tools = await client.config.tool_repository.getTools();
712
+ return toCallToolResult({ tools: tools.map((t) => utcpNameToTsInterfaceName(t.name)) });
713
+ }
714
+ if (name === 'tools_info') {
715
+ const names = Array.isArray(args.tool_names) ? (args.tool_names as string[]) : [];
716
+ const interfaces: string[] = [];
717
+ const notFound: string[] = [];
718
+ for (const n of names) {
719
+ const found = await findToolByName(client, n);
720
+ if (found) interfaces.push(client.toolToTypeScriptInterface(found.tool));
721
+ else notFound.push(n);
722
+ }
723
+ return toCallToolResult({ interfaces: interfaces.join('\n\n'), not_found: notFound });
724
+ }
725
+ // call_tool_chain
726
+ const code = typeof args.code === 'string' ? args.code : '';
727
+ const timeout = typeof args.timeout === 'number' ? args.timeout : 30_000;
728
+ // Clamp to [1000, 1_000_000] so a caller can't force oversized inline
729
+ // output past the spill (schema bounds are advisory over a raw JSON-RPC call).
730
+ const maxOutputSize =
731
+ typeof args.max_output_size === 'number'
732
+ ? Math.min(1_000_000, Math.max(1_000, Math.trunc(args.max_output_size)))
733
+ : CALL_TOOL_CHAIN_MAX_OUTPUT;
734
+ const { result, logs } = await client.callToolChain(code, timeout);
735
+ // Bound the payload: an external session has no ambient workspace, so an
736
+ // oversized result spills to the shared store and we return only a ref —
737
+ // parity with the in-process agent's `call_tool_chain`.
738
+ if (JSON.stringify({ success: true, result, logs }).length <= maxOutputSize) {
739
+ return toCallToolResult({ success: true, result, logs });
740
+ }
741
+ const fullJson = JSON.stringify({ result, logs }, null, 2);
742
+ const { ref, bytes } = await this.opts.spillStore.write(fullJson);
743
+ return toCallToolResult({
744
+ success: true,
745
+ truncated: true,
746
+ result_ref: ref,
747
+ result_bytes: bytes,
748
+ 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.`,
749
+ });
750
+ } catch (err) {
751
+ return toolError(`The "${name}" tool failed: ${describeToolFailure(err)}`);
752
+ }
753
+ }
754
+ }
755
+
756
+ /** A tool discovered from the manual, flattened into what the proxy advertises. */
757
+ export interface ProxiedTool {
758
+ utcpName: string;
759
+ mcpName: string;
760
+ description: string;
761
+ inputSchema: JsonSchema;
762
+ /** The UTCP manual this tool came from (the `<manual>` in `<manual>.<tool>`),
763
+ * used to look up the manual's declared per-user credentials before dispatch. */
764
+ manualName: string;
765
+ }
766
+
767
+ /**
768
+ * Flatten a discovered UTCP tool (`<manual>.<tool>`) into the proxy's advertised
769
+ * shape when MULTIPLE manuals are registered. KB tools keep their bare name (so
770
+ * existing external agents still call `read_file`); a user `.tool`'s tool is
771
+ * namespaced as `<manual>_<tool>` to guarantee a unique, dot-free MCP name.
772
+ */
773
+ /**
774
+ * MCP tool-name grammar (also the Anthropic API's), and a length bound. A
775
+ * remote MCP server can expose a tool whose flattened name breaks this — too
776
+ * long, or an illegal char the `<manual>_<name>` flattening didn't remove — and
777
+ * an MCP client (or the model API behind it) rejects the ENTIRE `tools/list`
778
+ * response when a single entry is non-conforming. That makes EVERY tool vanish
779
+ * the moment one bad tool from a newly-added server enters the catalog, with no
780
+ * server-side error (the rejection is the client's). `toListedTool` isolates it
781
+ * per tool: drop the offender (logged), normalize an odd schema, keep the rest.
782
+ */
783
+ const MCP_TOOL_NAME_RE = /^[a-zA-Z0-9_-]+$/;
784
+ // The Anthropic API caps a tool name at 128 chars — but the MCP CLIENT (Claude
785
+ // Code, claude.ai) prepends `mcp__<server>__` (≈20+ chars) before sending it,
786
+ // and that FULL name is what the 128 applies to. So budget for the prefix here,
787
+ // or a long `googlecalendar_…` name we pass gets the whole request 400'd. This
788
+ // is deliberately conservative; a dropped tool is logged so it's diagnosable.
789
+ const MCP_TOOL_NAME_MAX = 100;
790
+
791
+ /** A discovered tool as an MCP listing entry, or null if its name can't be listed. */
792
+ export function toListedTool(tool: ProxiedTool): McpTool | null {
793
+ if (!MCP_TOOL_NAME_RE.test(tool.mcpName) || tool.mcpName.length > MCP_TOOL_NAME_MAX) {
794
+ console.warn(
795
+ `[mcp] dropping tool "${tool.mcpName}" from the listing — not a valid MCP tool name ` +
796
+ `(must match ${MCP_TOOL_NAME_RE} and be ≤${MCP_TOOL_NAME_MAX} chars). ` +
797
+ 'One non-conforming tool would otherwise make the whole toolset disappear on the client.',
798
+ );
799
+ return null;
800
+ }
801
+ // MCP requires an object inputSchema. A remote server's schema that isn't a
802
+ // plain object (or omits `type: 'object'`) can invalidate the whole response,
803
+ // so normalize it — keeping any declared properties — rather than pass it
804
+ // through verbatim.
805
+ const raw = tool.inputSchema;
806
+ let inputSchema: Record<string, unknown> =
807
+ raw && typeof raw === 'object' && !Array.isArray(raw)
808
+ ? { type: 'object', ...(raw as Record<string, unknown>) }
809
+ : { type: 'object', properties: {} };
810
+ // Sanitize the schema into what the Anthropic tool validator accepts. A
811
+ // remote server that emits a construct the validator rejects — Google's
812
+ // gmail/calendar use `$ref`/`$defs` AND OpenAPI `format` values like
813
+ // `int32`/`byte` — makes the CLIENT reject the ENTIRE tools/list response,
814
+ // so all tools vanish and nothing registers. Sanitizing per-tool means one
815
+ // odd server can't blank the whole toolset.
816
+ inputSchema = sanitizeInputSchema(inputSchema) as Record<string, unknown>;
817
+ // The MCP/Anthropic validator requires the top-level `type` to be exactly
818
+ // "object" and (for the Anthropic API) `properties` to be present. Force both
819
+ // so a remote schema that declared something else — or a union like
820
+ // `["object","null"]` — can't reject the whole tools/list.
821
+ inputSchema.type = 'object';
822
+ if (typeof inputSchema.properties !== 'object' || inputSchema.properties === null) {
823
+ inputSchema.properties = {};
824
+ }
825
+ return {
826
+ name: tool.mcpName,
827
+ description: tool.description,
828
+ inputSchema: inputSchema as McpTool['inputSchema'],
829
+ };
830
+ }
831
+
832
+ /** JSON-Schema string `format` values the Anthropic tool validator accepts. */
833
+ const SUPPORTED_SCHEMA_FORMATS = new Set([
834
+ 'date-time',
835
+ 'time',
836
+ 'date',
837
+ 'duration',
838
+ 'email',
839
+ 'hostname',
840
+ 'uri',
841
+ 'ipv4',
842
+ 'ipv6',
843
+ 'uuid',
844
+ ]);
845
+
846
+ /**
847
+ * Make a remote server's JSON Schema safe for the Anthropic tool validator:
848
+ * - inline local `$ref` pointers (`#/$defs/...`, `#/definitions/...`) and drop
849
+ * the now-unreferenced `$defs`/`definitions` blocks (the API restricts `$ref`
850
+ * and MCP clients converting our schemas reject it outright);
851
+ * - drop non-standard `format` values (OpenAPI's `int32`/`byte`/… — only the
852
+ * JSON-Schema-standard formats above are accepted; `format` is advisory, so
853
+ * dropping it doesn't change tool behavior).
854
+ * Depth-bounded so a recursive schema degrades to a permissive `{}` node instead
855
+ * of hanging or emitting the unsupported recursion; non-local/external refs
856
+ * degrade the same way. Exported for direct testing.
857
+ */
858
+ export function sanitizeInputSchema(schema: unknown): unknown {
859
+ const root = schema;
860
+ const resolvePointer = (pointer: string): unknown => {
861
+ if (!pointer.startsWith('#/')) return undefined;
862
+ let node: unknown = root;
863
+ for (const partRaw of pointer.slice(2).split('/')) {
864
+ const part = partRaw.replace(/~1/g, '/').replace(/~0/g, '~');
865
+ if (!node || typeof node !== 'object') return undefined;
866
+ node = (node as Record<string, unknown>)[part];
867
+ }
868
+ return node;
869
+ };
870
+ const walk = (node: unknown, depth: number): unknown => {
871
+ if (depth > 20) return {}; // recursion/cycle guard — permissive fallback
872
+ if (Array.isArray(node)) return node.map((item) => walk(item, depth + 1));
873
+ if (!node || typeof node !== 'object') return node;
874
+ const obj = node as Record<string, unknown>;
875
+ if (typeof obj.$ref === 'string') {
876
+ const target = resolvePointer(obj.$ref);
877
+ // JSON Schema allows siblings next to $ref; keep them, target wins ties.
878
+ const { $ref: _ref, ...siblings } = obj;
879
+ const resolved = walk(target ?? {}, depth + 1);
880
+ return resolved && typeof resolved === 'object' && !Array.isArray(resolved)
881
+ ? { ...siblings, ...(resolved as Record<string, unknown>) }
882
+ : Object.keys(siblings).length
883
+ ? siblings
884
+ : resolved ?? {};
885
+ }
886
+ const out: Record<string, unknown> = {};
887
+ for (const [key, value] of Object.entries(obj)) {
888
+ if (key === '$defs' || key === 'definitions') continue; // inlined above
889
+ // Drop a non-standard `format` (OpenAPI `int32`/`byte`/…) — the validator
890
+ // only allows the JSON-Schema-standard set; the annotation is non-load-bearing.
891
+ if (key === 'format' && (typeof value !== 'string' || !SUPPORTED_SCHEMA_FORMATS.has(value))) {
892
+ continue;
893
+ }
894
+ out[key] = walk(value, depth + 1);
895
+ }
896
+ return out;
897
+ };
898
+ return walk(schema, 0);
899
+ }
900
+
901
+ export function flattenManualTool(tool: UtcpTool): ProxiedTool {
902
+ const dot = tool.name.indexOf('.');
903
+ const manual = dot >= 0 ? tool.name.slice(0, dot) : '';
904
+ const bare = dot >= 0 ? tool.name.slice(dot + 1) : tool.name;
905
+ const mcpName = manual === EXTERNAL_KB_MANUAL_NAME ? bare : tool.name.replace(/\./g, '_');
906
+ return {
907
+ utcpName: tool.name,
908
+ mcpName,
909
+ description: tool.description,
910
+ inputSchema: tool.inputs,
911
+ manualName: manual,
912
+ };
913
+ }
914
+
915
+ /**
916
+ * Flatten one discovered UTCP tool into the proxy's advertised shape: strip the
917
+ * `<manual>.` namespace prefix for the MCP name and keep the UTCP input schema
918
+ * verbatim (Bevel-hosted HTTP tools show their `{body}` envelope, exactly as in
919
+ * `call_tool_chain`).
920
+ */
921
+ export function flattenDiscoveredTool(prefix: string, tool: UtcpTool): ProxiedTool {
922
+ return {
923
+ utcpName: tool.name,
924
+ mcpName: tool.name.startsWith(prefix) ? tool.name.slice(prefix.length) : tool.name,
925
+ description: tool.description,
926
+ inputSchema: tool.inputs,
927
+ // `prefix` is `<manual>.`; the manual is that without the trailing dot.
928
+ manualName: prefix.endsWith('.') ? prefix.slice(0, -1) : prefix,
929
+ };
930
+ }
931
+
932
+ /**
933
+ * Extract a human-meaningful failure message from a tool-call error. UTCP's
934
+ * HTTP protocol surfaces a non-2xx as an axios-style error whose `.response.data`
935
+ * is the REST endpoint's JSON body (`{ error: "..." }`). Pull that out so the
936
+ * MCP caller sees the tool's real message instead of a bare "status code 500".
937
+ */
938
+ export function describeToolFailure(err: unknown): string {
939
+ const data = (err as { response?: { data?: unknown } })?.response?.data;
940
+ if (data && typeof data === 'object') {
941
+ const inner = (data as { error?: unknown }).error;
942
+ if (typeof inner === 'string' && inner.length > 0) return inner;
943
+ }
944
+ if (typeof data === 'string' && data.length > 0) return data;
945
+ return err instanceof Error ? err.message : String(err);
946
+ }
947
+
948
+ /**
949
+ * Turn a tool's final value into an MCP result:
950
+ * - a tool that already returns the MCP agentic shape (`{ content: [...] }`,
951
+ * each entry a real content block) is passed through unchanged;
952
+ * - a bare string becomes the text content;
953
+ * - anything else is JSON-stringified into one text block.
954
+ *
955
+ * Note we do NOT collapse a structured object down to its `text` field: doing
956
+ * so silently dropped the other fields (e.g. `ask`'s `status` / `sessionId`,
957
+ * the id a caller must echo back to poll or continue a conversation).
958
+ * Stringifying the whole object keeps every field, so the caller always sees
959
+ * the id it is responsible for echoing.
960
+ *
961
+ * The passthrough guard checks the entries, not just that `content` is an array:
962
+ * a domain value that merely happens to carry a `content` array of non-blocks
963
+ * (e.g. `{ content: ['a', 'b'] }`) is data, not an MCP result, so it falls
964
+ * through to JSON-stringify and survives intact instead of being emitted as a
965
+ * malformed result the client can't parse.
966
+ */
967
+ function isMcpContentBlock(entry: unknown): boolean {
968
+ return typeof entry === 'object' && entry !== null && typeof (entry as { type?: unknown }).type === 'string';
969
+ }
970
+
971
+ export function toCallToolResult(value: unknown): CallToolResult {
972
+ // Already in MCP agentic format — pass through untouched, but only when every
973
+ // `content` entry is a real content block (has a string `type`).
974
+ const content = (value as { content?: unknown })?.content;
975
+ if (value && typeof value === 'object' && Array.isArray(content) && content.every(isMcpContentBlock)) {
976
+ return value as CallToolResult;
977
+ }
978
+ const text = typeof value === 'string' ? value : JSON.stringify(value ?? null);
979
+ return { content: [{ type: 'text', text: text || '(tool produced no output)' }] };
980
+ }
981
+
982
+ function renderProgress(chunk: unknown): string {
983
+ const s = typeof chunk === 'string' ? chunk : JSON.stringify(chunk);
984
+ return s.length > 500 ? s.slice(0, 497) + '...' : s;
985
+ }
986
+
987
+ function toolError(message: string): CallToolResult {
988
+ return { isError: true, content: [{ type: 'text', text: message }] };
989
+ }
990
+
991
+ /**
992
+ * The result returned when the caller is missing personal credentials a tool
993
+ * needs. Marked `isError` so the external agent surfaces it to the person rather
994
+ * than treating it as tool output. Names the tool, lists the missing items, and
995
+ * gives the absolute setup-page URL so the person can provide them and retry.
996
+ */
997
+ export function needsAuthorizationResult(
998
+ toolName: string,
999
+ missing: string[],
1000
+ connectUrl: string,
1001
+ ): CallToolResult {
1002
+ const items = missing.join(', ');
1003
+ const text =
1004
+ `The "${toolName}" tool needs credentials you haven't set up yet: ${items}. ` +
1005
+ `Open ${connectUrl} to connect your accounts and enter your keys, then run the tool again.`;
1006
+ return { isError: true, content: [{ type: 'text', text }] };
1007
+ }