From 5bd6427c89f53ee04963b10a2cdf64ee7c9688e7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 00:42:36 -0700 Subject: [PATCH 1/2] Add tests for MCP credential handle grammar --- packages/mcp-tools/src/tool.test.ts | 68 +++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/mcp-tools/src/tool.test.ts b/packages/mcp-tools/src/tool.test.ts index 719b3b16d..57718ba40 100644 --- a/packages/mcp-tools/src/tool.test.ts +++ b/packages/mcp-tools/src/tool.test.ts @@ -1,5 +1,8 @@ import { expect, test } from "bun:test"; +import { type } from "arktype"; +import { CredentialBinding } from "@intx/types"; import type { CredentialCapability, MediatedCredential } from "@intx/types"; +import { ToolCredentialHandle } from "@intx/types/package-json"; import type { ToolCall } from "@intx/types/runtime"; import { @@ -7,6 +10,7 @@ import { MCP_LIST_SERVERS_TOOL, MCP_LIST_TOOLS_TOOL, MCP_READ_TOOL, + mcpCredentialHandle, mcpTools, readOnlyGate, } from "./tool"; @@ -244,3 +248,67 @@ test("mcp_list_tools {server} surfaces the credential resolve reason instead of globalThis.fetch = originalFetch; } }); + +test("mcpCredentialHandle mints a handle conforming to @intx/types' ToolCredentialHandle grammar", () => { + const handle = mcpCredentialHandle("exa"); + expect(handle).toBe("mcp.exa"); + const parsed = ToolCredentialHandle(handle); + expect(parsed instanceof type.errors).toBe(false); +}); + +test("a mcp-tools CredentialBinding built on the minted handle parses under @intx/types' CredentialBinding schema", () => { + const binding = { + package: "@corbits/mcp-tools", + handle: mcpCredentialHandle("exa"), + provider: "mcp:exa", + locator: "tenant" as const, + }; + const parsed = CredentialBinding(binding); + expect(parsed instanceof type.errors).toBe(false); +}); + +test("mint -> bind -> resolve round trip: mcp_call resolves credentials by the exact handle mcpCredentialHandle mints", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => + Promise.resolve( + new Response( + JSON.stringify({ + data: [{ slug: "exa", name: "Exa", url: "https://example.test/mcp" }], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + )) as unknown as typeof fetch; + try { + const boundHandle = mcpCredentialHandle("exa"); + let resolvedWithHandle: string | undefined; + const capability: CredentialCapability = { + resolve(handle: string): Promise { + resolvedWithHandle = handle; + if (handle !== boundHandle) { + return Promise.reject( + new Error(`no credential is bound to handle "${handle}"`), + ); + } + return Promise.reject(new Error("server not reachable in this fake")); + }, + }; + const bundle = mcpTools(fakeEnv(undefined, capability)); + const result = await bundle.run( + { + id: "c1", + name: MCP_CALL_TOOL, + arguments: { server: "exa", tool: "echo" }, + } satisfies ToolCall, + new AbortController().signal, + ); + // The fake rejects even a correctly-resolved handle (no MCP server is + // actually reachable here); this proves resolve() was called with + // EXACTLY the handle mcp-credential-bindings.ts mints, not that the + // call itself succeeds end to end. + expect(resolvedWithHandle).toBe(boundHandle); + expect(result.isError).toBe(true); + expect(result.content).not.toContain("no credential is bound"); + } finally { + globalThis.fetch = originalFetch; + } +}); From 7ab19de59701ce15f5b8703b459406c0417426cd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 00:42:49 -0700 Subject: [PATCH 2/2] Mint MCP credential handles as mcp., conforming to ToolCredentialHandle PR #97's render path parses a folded run's credential bindings through @intx/types' CredentialBinding schema and fails closed: mcp-credential- bindings.ts minted handle: "mcp:", but ToolCredentialHandle is /^[a-z0-9][a-z0-9._-]*$/ -- no colon. Every MCP-pinned launch would hit this at render time; any future strict CredentialBinding consumer breaks the same way. Changes the handle shape to mcp. (dots are legal) at every mint and resolve site: mcpCredentialHandle() in @corbits/mcp-tools (the resolve-key builder), apps/hub's mcp-credential-bindings.ts (the binding minter, already wired into launchFoldedRun/wakeFoldedRun on main), and the test fixtures/comments across folded-runs and sidecar that stood in for either. The mcp: PROVIDER row naming (@workbench/connections' provider table, credential-expiry-sweep.ts, mcp-server-store.ts) is untouched -- that's a separate, unconstrained namespace CredentialBinding.provider (plain string) resolves against, not the handle grammar. Stored-data note: createMcpCredentialBindingsFor is already wired into apps/hub's folded-run launch path on main, so any dev deployment that pinned @corbits/mcp-tools and actually launched before this fix may have a workflow-definition tree with the old mcp: handle baked into its credentialBindings. Nothing on main parsed that field before now, so it never failed loudly; a re-seed/re-deploy of such tenants after this change produces a conforming handle. Per repo policy, dev data gets wiped/re-seeded rather than migrated in place. --- apps/hub/src/mcp-credential-bindings.ts | 13 ++++++++----- .../src/multistep-credentials-router.test.ts | 2 +- .../src/workflow-host-wiring/supervisor.test.ts | 4 ++-- .../test/workflow-deploy-lifecycle.test.ts | 2 +- packages/folded-runs/src/launch.ts | 6 +++--- packages/folded-runs/src/types.ts | 4 ++-- packages/folded-runs/test/launch.test.ts | 4 ++-- packages/mcp-tools/src/manifest.test.ts | 2 +- packages/mcp-tools/src/tool.ts | 17 +++++++++++------ packages/mcp-tools/test/scenario.test.ts | 2 +- 10 files changed, 32 insertions(+), 24 deletions(-) diff --git a/apps/hub/src/mcp-credential-bindings.ts b/apps/hub/src/mcp-credential-bindings.ts index d4cdc6b03..b13973a20 100644 --- a/apps/hub/src/mcp-credential-bindings.ts +++ b/apps/hub/src/mcp-credential-bindings.ts @@ -2,11 +2,14 @@ // with — see `@corbits/folded-runs`' `types.ts` for why this has to be // supplied by the composition root rather than derived by the deploy-time // capability walk. `@corbits/mcp-tools`' credential handles are dynamic -// (one `mcp:` per tenant-connected server), so this builds one +// (one `mcp.` per tenant-connected server), so this builds one // `CredentialBinding` per connection `@workbench/connections`' own -// `listMcpServerConnections` lists for the tenant, keyed on the same -// `mcp:` string both the provider row name and the tool package's -// `mcpCredentialHandle(slug)` use. +// `listMcpServerConnections` lists for the tenant: `handle` mirrors the +// `mcp.` convention `@corbits/mcp-tools`' `mcpCredentialHandle(slug)` +// resolves against (conforms to `@intx/types`' `ToolCredentialHandle` +// grammar), while `provider` names the stored `mcp:` provider row +// `listMcpServerConnections` read it from — a separate, unconstrained +// namespace. import type { DB } from "@intx/db"; import type { CredentialBinding } from "@intx/types"; import type { McpCredentialBindingsFor } from "@corbits/folded-runs"; @@ -21,7 +24,7 @@ export function createMcpCredentialBindingsFor( const connections = await listMcpServerConnections(db, tenantId); return connections.map((connection): CredentialBinding => ({ package: MCP_TOOLS_PACKAGE, - handle: `mcp:${connection.slug}`, + handle: `mcp.${connection.slug}`, provider: `mcp:${connection.slug}`, locator: "tenant", })); diff --git a/apps/sidecar/src/multistep-credentials-router.test.ts b/apps/sidecar/src/multistep-credentials-router.test.ts index 56eb07ea1..82e524d64 100644 --- a/apps/sidecar/src/multistep-credentials-router.test.ts +++ b/apps/sidecar/src/multistep-credentials-router.test.ts @@ -9,7 +9,7 @@ function makeFrame(agentAddress: string) { delivery: { bindings: [ { - handle: "mcp:exa", + handle: "mcp.exa", credentialId: "cred_1", consumer: "tool:@corbits/mcp-tools", }, diff --git a/apps/sidecar/src/workflow-host-wiring/supervisor.test.ts b/apps/sidecar/src/workflow-host-wiring/supervisor.test.ts index cf3ab97c4..149dbadd8 100644 --- a/apps/sidecar/src/workflow-host-wiring/supervisor.test.ts +++ b/apps/sidecar/src/workflow-host-wiring/supervisor.test.ts @@ -103,12 +103,12 @@ test("createSidecarWorkflowSupervisor omits onSuspensionRegister when the caller // `credentialDelivery` binding to the child on the pre-trigger barrier — // but before this fix `createSidecarWorkflowSupervisor` never accepted the // field, so the child's materialRef stayed null and every -// `credentials.resolve("mcp:")` failed "no credential is bound". +// `credentials.resolve("mcp.")` failed "no credential is bound". test("createSidecarWorkflowSupervisor forwards credentialDelivery to the workflow-host supervisor", () => { const delivery = { bindings: [ { - handle: "mcp:exa", + handle: "mcp.exa", credentialId: "cred_1", consumer: "tool:@corbits/mcp-tools", }, diff --git a/apps/sidecar/test/workflow-deploy-lifecycle.test.ts b/apps/sidecar/test/workflow-deploy-lifecycle.test.ts index 920b0ed1d..3bbf1e378 100644 --- a/apps/sidecar/test/workflow-deploy-lifecycle.test.ts +++ b/apps/sidecar/test/workflow-deploy-lifecycle.test.ts @@ -324,7 +324,7 @@ describe("workflow deployment lifecycle through the deploy router", () => { const delivery = { bindings: [ { - handle: "mcp:exa", + handle: "mcp.exa", credentialId: "cred_1", consumer: "tool:@corbits/mcp-tools", }, diff --git a/packages/folded-runs/src/launch.ts b/packages/folded-runs/src/launch.ts index 6ac2a2cc2..609b0423c 100644 --- a/packages/folded-runs/src/launch.ts +++ b/packages/folded-runs/src/launch.ts @@ -208,12 +208,12 @@ export async function deployAtHead( })); // `@corbits/mcp-tools` declares no static `interchange.credentials` (its - // handles are one `mcp:` per tenant-connected server, unknown at + // handles are one `mcp.` per tenant-connected server, unknown at // package-publish time), so the deploy-time capability walk never binds // them. Mirror `ToolGrantsForPins`'s pinned-package carve-out: when the // launch pins the package, fetch the tenant's real MCP credential // bindings and fold them in alongside whatever the definition itself - // declares, so `env.credentials.resolve("mcp:")` has something to + // declares, so `env.credentials.resolve("mcp.")` has something to // find instead of failing every call closed with "not connected". const isMcpToolsPin = params.foldedBody.toolPackagePins.some( (pin) => pin.name === "@corbits/mcp-tools", @@ -301,7 +301,7 @@ export async function deployAtHead( // run's synthesized definition carries the same combined bindings // (the definition's own plus the pinned-package MCP bindings folded in // above) the delivered material was resolved against; without this the - // sidecar's `consumerBindings` finds nothing for `mcp:` and every + // sidecar's `consumerBindings` finds nothing for `mcp.` and every // resolve() fails "not connected" even though the material was // delivered. const definition = diff --git a/packages/folded-runs/src/types.ts b/packages/folded-runs/src/types.ts index 861a01000..2c8fb6be8 100644 --- a/packages/folded-runs/src/types.ts +++ b/packages/folded-runs/src/types.ts @@ -61,11 +61,11 @@ export type ToolGrantsForPins = ( /** * Derives the extra `@corbits/mcp-tools` credential bindings a folded run's * launch needs for its tenant's connected MCP servers. `mcp-tools`' handles - * are dynamic (one `mcp:` per tenant-connected server, unknown at + * are dynamic (one `mcp.` per tenant-connected server, unknown at * package-publish time), so its `package.json` declares no static * `interchange.credentials` entry the deploy-time capability walk * (`vendor/intx/workflow-deploy/src/capability-walk.ts`) could turn into a - * binding — without this port, `env.credentials.resolve("mcp:")` + * binding — without this port, `env.credentials.resolve("mcp.")` * always throws "not connected" even when the tenant's credential exists. * `deployAtHead` calls this whenever `@corbits/mcp-tools` is among a * launch's `toolPackagePins`, mirroring `ToolGrantsForPins`'s reason for diff --git a/packages/folded-runs/test/launch.test.ts b/packages/folded-runs/test/launch.test.ts index d0e651c33..6f7588ba5 100644 --- a/packages/folded-runs/test/launch.test.ts +++ b/packages/folded-runs/test/launch.test.ts @@ -914,7 +914,7 @@ describe("wakeFoldedRun", () => { describe("deployAtHead — mcp credential bindings", () => { const MCP_BINDING = { package: "@corbits/mcp-tools", - handle: "mcp:exa", + handle: "mcp.exa", provider: "mcp:exa", locator: "tenant" as const, }; @@ -940,7 +940,7 @@ describe("deployAtHead — mcp credential bindings", () => { delivery: { bindings: [ { - handle: "mcp:exa", + handle: "mcp.exa", credentialId: "cred_1", consumer: "tool:@corbits/mcp-tools", }, diff --git a/packages/mcp-tools/src/manifest.test.ts b/packages/mcp-tools/src/manifest.test.ts index 6e7df7480..f013ab2a7 100644 --- a/packages/mcp-tools/src/manifest.test.ts +++ b/packages/mcp-tools/src/manifest.test.ts @@ -2,7 +2,7 @@ // `PackageJSON` manifest schema (`vendor/intx/types/src/package-json.ts`) // and, unlike `@corbits/web-search-tools`, declares no static // `interchange.credentials` entries — see `tool.ts`'s header comment for -// why an MCP server's `mcp:` handle can't be pre-declared. +// why an MCP server's `mcp.` handle can't be pre-declared. import { expect, test } from "bun:test"; import { type } from "arktype"; import { PackageJSON } from "@intx/types/package-json"; diff --git a/packages/mcp-tools/src/tool.ts b/packages/mcp-tools/src/tool.ts index c6a5e14a0..e182ab3d6 100644 --- a/packages/mcp-tools/src/tool.ts +++ b/packages/mcp-tools/src/tool.ts @@ -10,9 +10,10 @@ // for one tool's full schema. // mcp_call({server, tool, arguments}) -- invoke one of those tools. // -// Credentials: each connected server is a `mcp:` credential -// handle (`@workbench/connections`'s MCP connector; see that package's -// registry for the provider/credential shape). The slug is dynamic +// Credentials: each connected server is a `mcp.` credential +// handle (`@workbench/connections`'s MCP connector, stored under a +// `mcp:` provider row name -- see that package's registry for the +// provider/credential shape). The slug is dynamic // tenant data, unknown at package-publish time, so this package's // `package.json` declares no static `interchange.credentials` entry // the way `@corbits/web-search-tools` declares `exa` — a declaration is @@ -59,10 +60,14 @@ export const MCP_LIST_TOOLS_TOOL = "mcp_list_tools"; export const MCP_READ_TOOL = "mcp_read"; export const MCP_CALL_TOOL = "mcp_call"; -/** `mcp:` -- the credential handle convention every connected - * MCP server binds to (see this file's header comment). */ +/** `mcp.` -- the credential handle convention every connected + * MCP server binds to (see this file's header comment). Dots, not a + * colon, so the handle conforms to `@intx/types`' `ToolCredentialHandle` + * grammar (`/^[a-z0-9][a-z0-9._-]*$/`) -- distinct from the `mcp:` + * PROVIDER row naming `@workbench/connections` stores, which has no such + * constraint. */ export function mcpCredentialHandle(slug: string): string { - return `mcp:${slug}`; + return `mcp.${slug}`; } /** Env this bundle needs beyond `BaseEnv`: the mediated-credential diff --git a/packages/mcp-tools/test/scenario.test.ts b/packages/mcp-tools/test/scenario.test.ts index d509370ba..671eeb893 100644 --- a/packages/mcp-tools/test/scenario.test.ts +++ b/packages/mcp-tools/test/scenario.test.ts @@ -37,7 +37,7 @@ afterEach(() => { /** A fake `credentials` capability shaping the same origin-pinned http * handle the real `http` credential provider would, for the single - * `mcp:notion` handle this scenario connects. */ + * `mcp.notion` handle this scenario connects. */ function fakeCredentials(): CredentialCapability { return { resolve(handle: string): Promise {