Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions apps/hub/src/mcp-credential-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<slug>` per tenant-connected server), so this builds one
// (one `mcp.<slug>` per tenant-connected server), so this builds one
// `CredentialBinding` per connection `@workbench/connections`' own
// `listMcpServerConnections` lists for the tenant, keyed on the same
// `mcp:<slug>` string both the provider row name and the tool package's
// `mcpCredentialHandle(slug)` use.
// `listMcpServerConnections` lists for the tenant: `handle` mirrors the
// `mcp.<slug>` convention `@corbits/mcp-tools`' `mcpCredentialHandle(slug)`
// resolves against (conforms to `@intx/types`' `ToolCredentialHandle`
// grammar), while `provider` names the stored `mcp:<slug>` 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";
Expand All @@ -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",
}));
Expand Down
2 changes: 1 addition & 1 deletion apps/sidecar/src/multistep-credentials-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ function makeFrame(agentAddress: string) {
delivery: {
bindings: [
{
handle: "mcp:exa",
handle: "mcp.exa",
credentialId: "cred_1",
consumer: "tool:@corbits/mcp-tools",
},
Expand Down
4 changes: 2 additions & 2 deletions apps/sidecar/src/workflow-host-wiring/supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<slug>")` failed "no credential is bound".
// `credentials.resolve("mcp.<slug>")` 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",
},
Expand Down
2 changes: 1 addition & 1 deletion apps/sidecar/test/workflow-deploy-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down
6 changes: 3 additions & 3 deletions packages/folded-runs/src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,12 @@ export async function deployAtHead(
}));

// `@corbits/mcp-tools` declares no static `interchange.credentials` (its
// handles are one `mcp:<slug>` per tenant-connected server, unknown at
// handles are one `mcp.<slug>` 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:<slug>")` has something to
// declares, so `env.credentials.resolve("mcp.<slug>")` 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",
Expand Down Expand Up @@ -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:<slug>` and every
// sidecar's `consumerBindings` finds nothing for `mcp.<slug>` and every
// resolve() fails "not connected" even though the material was
// delivered.
const definition =
Expand Down
4 changes: 2 additions & 2 deletions packages/folded-runs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<slug>` per tenant-connected server, unknown at
* are dynamic (one `mcp.<slug>` 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:<slug>")`
* binding — without this port, `env.credentials.resolve("mcp.<slug>")`
* 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
Expand Down
4 changes: 2 additions & 2 deletions packages/folded-runs/test/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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",
},
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-tools/src/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<slug>` handle can't be pre-declared.
// why an MCP server's `mcp.<slug>` handle can't be pre-declared.
import { expect, test } from "bun:test";
import { type } from "arktype";
import { PackageJSON } from "@intx/types/package-json";
Expand Down
68 changes: 68 additions & 0 deletions packages/mcp-tools/src/tool.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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 {
MCP_CALL_TOOL,
MCP_LIST_SERVERS_TOOL,
MCP_LIST_TOOLS_TOOL,
MCP_READ_TOOL,
mcpCredentialHandle,
mcpTools,
readOnlyGate,
} from "./tool";
Expand Down Expand Up @@ -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<MediatedCredential> {
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;
}
});
17 changes: 11 additions & 6 deletions packages/mcp-tools/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<slug>` 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.<slug>` credential
// handle (`@workbench/connections`'s MCP connector, stored under a
// `mcp:<slug>` 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
Expand Down Expand Up @@ -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:<slug>` -- the credential handle convention every connected
* MCP server binds to (see this file's header comment). */
/** `mcp.<slug>` -- 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:<slug>`
* 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
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-tools/test/scenario.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MediatedCredential> {
Expand Down
Loading