diff --git a/.changeset/member-mcp-connections.md b/.changeset/member-mcp-connections.md new file mode 100644 index 0000000000..cfa2816cd1 --- /dev/null +++ b/.changeset/member-mcp-connections.md @@ -0,0 +1,6 @@ +--- +"@executor-js/hosted-server": patch +"@executor-js/app-templates": patch +--- + +Organization members can list, create, update and revoke their own scoped MCP connections, and mint and revoke keys on those connections, from the dashboard or with their own API key. A connection still only includes apps the member can use and personal accounts they own. An Executor app self-call keeps the calling connection's policy: it cannot widen to all apps or mint a wider key, and a narrowed connection without manage is refused. Another member's connections stay unreachable. diff --git a/apps/hosted/cloud/src/infrastructure/app-outbound.ts b/apps/hosted/cloud/src/infrastructure/app-outbound.ts index a6a809b3fa..fdca680b00 100644 --- a/apps/hosted/cloud/src/infrastructure/app-outbound.ts +++ b/apps/hosted/cloud/src/infrastructure/app-outbound.ts @@ -47,6 +47,7 @@ export const appOutboundBindings = Effect.gen(function* () { const OutboundProps = Schema.Struct({ appOutbound: Schema.NonEmptyString, hostApi: Schema.optionalKey(HostApiCheck), + callerConnection: Schema.optionalKey(Schema.String), }); /** The private network service, as the outbound sends to it. */ interface Network { @@ -98,9 +99,13 @@ export const appCredentialOutbound = Effect.gen(function* () { /** The network one app's isolates use for global `fetch`. */ outbound: Effect.gen(function* () { const exports = yield* loopback; - return (app: string, hostApi: HostApiCheck | undefined) => + return (app: string, hostApi: HostApiCheck | undefined, callerConnection?: string) => exports.default({ - props: hostApi === undefined ? { appOutbound: app } : { appOutbound: app, hostApi }, + props: { + appOutbound: app, + ...(hostApi === undefined ? {} : { hostApi }), + ...(callerConnection === undefined ? {} : { callerConnection }), + }, }); }), /** Serve a request sent by an app's isolate, or pass any other request to `handler`. */ @@ -135,6 +140,9 @@ export const appCredentialOutbound = Effect.gen(function* () { credentialFetch(source, { app: props.value.appOutbound, ...(props.value.hostApi === undefined ? {} : { hostApi: props.value.hostApi }), + ...(props.value.callerConnection === undefined + ? {} + : { callerConnection: props.value.callerConnection }), key: sealing, // Cloud's own origin is public, so it needs no exemption. egress: { refusePrivateAddresses: !privateFetch, selfOrigin: undefined }, diff --git a/apps/hosted/server/src/contracts/mcp-connections.ts b/apps/hosted/server/src/contracts/mcp-connections.ts index 51e3b80ec6..ec5711cd3f 100644 --- a/apps/hosted/server/src/contracts/mcp-connections.ts +++ b/apps/hosted/server/src/contracts/mcp-connections.ts @@ -1,8 +1,9 @@ /** - * A member's own scoped MCP connections and connected agents in one organization. Browser sessions - * manage both. An organization admin's full-authority API key or API grant may also list, create, - * update and revoke the admin's own connections, so agents can be provisioned without a browser; - * connected agents stay browser-only. + * A member's own scoped MCP connections and connected agents in one organization. A member lists, + * creates, updates and revokes their own connections, and mints and revokes keys on them, from a + * browser session or from an API key or full API grant that carries their authority. Each saved + * entry is limited to apps the member can use and accounts the member can use. Another member's + * connections are omitted and cannot be changed. Connected agents stay browser-only. */ import { AccountNotFound, @@ -93,8 +94,10 @@ export const ConnectionKeyNotFound = ApiError.define({ }); export type ConnectionKeyNotFound = typeof ConnectionKeyNotFound.Type; /** - * MCP grants, including a connection's own, cannot manage connections: the API refuses MCP tokens. - * Connections only narrow their owner's own access, so an admin key cannot widen anything. + * The API refuses MCP tokens. An Executor app self-call is the caller's managed key plus the + * connection the host attached. That connection's policy is the authority: create, update and + * mint stay within its apps, tools and runsAs accounts, with no all-apps escalation. A narrowed + * connection without manage is refused. A key's lifetime stays within 5 seconds and 90 days. */ export const HostedMcpConnections = HttpApiGroup.make("mcpConnections") .add( diff --git a/apps/hosted/server/src/implementation/mcp-connections.ts b/apps/hosted/server/src/implementation/mcp-connections.ts index 9704ab63ca..33024cb4be 100644 --- a/apps/hosted/server/src/implementation/mcp-connections.ts +++ b/apps/hosted/server/src/implementation/mcp-connections.ts @@ -1,23 +1,39 @@ /** Members manage their own connections and connected agents; apps and accounts use the same access checks as calls. */ +import { isToolSelectionSubset } from "@executor-js/authorization"; import { ConnectionAccessInvalid, + ConnectionId, ConnectionNotFound, + connectionPermissions, type Connection, type ConnectionApp, + type ConnectionPolicy, } from "@executor-js/mcp-auth/connections"; import { mcpResource } from "@executor-js/mcp-auth"; -import type { App, Executor, OwnerId } from "@executor-js/sdk/core"; -import { Effect, Option } from "effect"; +import { + callerConnectionHeader, + CurrentCaller, + type App, + type Executor, + type OwnerId, +} from "@executor-js/sdk/core"; +import { Effect, Option, Schema } from "effect"; import { HttpApiBuilder } from "effect/http-api"; +import { HttpServerRequest } from "effect/http"; import { HostedApi } from "../contracts/api.ts"; import { CurrentPrincipal, CurrentUserId } from "../contracts/auth.ts"; import { HostedExecutor } from "../contracts/executor.ts"; -import { McpAuthentication } from "../contracts/mcp.ts"; +import { McpAuthentication, type McpConnectionStore } from "../contracts/mcp.ts"; import { CurrentOrganization, OrganizationForbidden } from "../contracts/organization.ts"; import { ScheduleWakeup } from "../contracts/schedules.ts"; import { checkAccounts, currentOwner, usableApp } from "./access.ts"; import { addLink, linkedAccounts, lockedChange } from "./app-accounts.ts"; -import { policyDatabase, requireAccountAccessAs } from "./resource-policy.ts"; +import { + accountAccess, + currentResourceAuthority, + policyDatabase, + requireAccountAccessAs, +} from "./resource-policy.ts"; const owner = Effect.gen(function* () { const organization = yield* CurrentOrganization; @@ -27,11 +43,12 @@ const owner = Effect.gen(function* () { }); /** - * Who may change connections through this request. A signed-in person manages their own. Without a - * browser session the request carried a bearer that RequireOrganization accepted for `manage`, so it - * holds its user's full authority (an API key or a full API grant; MCP tokens never reach the API); - * of those, only an organization admin's or owner's may manage connections. Every bearer change is - * logged, as connections decide which accounts an agent runs as. + * Who may change connections through this request. Any current member manages their own, from a + * browser session or from a bearer that RequireOrganization accepted for `manage` (an API key or a + * full API grant; MCP tokens never reach the API). A self-call from a scoped MCP connection is + * limited to that connection's policy before this runs. The store returns and changes only that + * member's connections. Bearer changes are logged with the caller and, when the host attached + * one, the calling connection. */ const manager = ( action: "list" | "create" | "update" | "revoke" | "mint-key" | "list-keys" | "revoke-key", @@ -40,12 +57,11 @@ const manager = ( Effect.gen(function* () { const current = yield* owner; const person = yield* Effect.serviceOption(CurrentPrincipal); - if (Option.isSome(person)) return current; - const organization = yield* CurrentOrganization; - if (organization.role !== "admin" && organization.role !== "owner") - return yield* new OrganizationForbidden(); // Minting and revoking a key record their own audit line for every caller, with the key. - if (action === "create" || action === "update" || action === "revoke") + if ( + Option.isNone(person) && + (action === "create" || action === "update" || action === "revoke") + ) yield* Effect.logInfo("MCP connection changed with an API credential").pipe( Effect.annotateLogs({ "executor.audit": "mcp-connection", @@ -53,11 +69,71 @@ const manager = ( "executor.user.id": current.userId, "executor.organization.id": current.resource, ...(connection === undefined ? {} : { "executor.mcp.connection": connection }), + ...(yield* callerAudit), }), ); return current; }); +/** The host-attached calling connection, or undefined when this request did not arrive on one. */ +const callingConnection = (auth: { readonly connections: McpConnectionStore }) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const header = new Headers(request.headers).get(callerConnectionHeader); + if (header === null || header === "") return undefined; + const id = yield* Schema.decodeUnknownEffect(ConnectionId)(header).pipe( + Effect.mapError(() => new OrganizationForbidden()), + ); + const current = yield* owner; + const found = (yield* auth.connections.list(current)).find( + (connection) => connection.id === id, + ); + if (found === undefined) return yield* new OrganizationForbidden(); + return found; + }); + +/** + * A connection whose every app grants all of its tools and events may manage connections inside + * that policy. Read-only and selected tool scopes do not: they have no `manage` authority. + */ +const connectionMayManage = (policy: ConnectionPolicy) => + policy.apps.length > 0 && + policy.apps.every( + (app) => app.tools.kind === "all" && (app.events === undefined || app.events.kind === "all"), + ); + +/** The next policy is no wider than the calling connection: apps, tools, events and runsAs. */ +const withinCallingConnection = (caller: ConnectionPolicy, next: ConnectionPolicy) => + isToolSelectionSubset( + { kind: "tools", apps: connectionPermissions(caller) }, + { kind: "tools", apps: connectionPermissions(next) }, + ); + +/** + * Refuse a calling connection that cannot manage, and a change whose policy is wider than that + * connection. No calling connection keeps the member's own full authority. + */ +const requireCallingScope = (caller: Connection | undefined, next?: ConnectionPolicy) => + Effect.gen(function* () { + if (caller === undefined) return; + if (!connectionMayManage(caller.policy)) return yield* new OrganizationForbidden(); + if (next !== undefined && !withinCallingConnection(caller.policy, next)) + return yield* new OrganizationForbidden(); + }); + +/** Caller kind and the host-attached connection, for the connection audit lines. */ +const callerAudit = Effect.gen(function* () { + const caller = Option.getOrUndefined(yield* Effect.serviceOption(CurrentCaller)); + const request = yield* HttpServerRequest.HttpServerRequest; + const connection = new Headers(request.headers).get(callerConnectionHeader); + return { + ...(caller === undefined ? {} : { "executor.caller.kind": caller.kind }), + ...(connection === null || connection === "" + ? {} + : { "executor.mcp.caller_connection": connection }), + }; +}); + /** * Check one app's runs-as targets. An account target the member has not linked to any of the app's * user requirements is linked to each one its provider fills, as linking it from the app would: a @@ -101,15 +177,27 @@ const resolveApp = ( AccountNotFound: () => Effect.fail(invalid("account")), }), ); + // Group-shared accounts are usable by the group. A connection may run only as an account + // this member owns: their personal account. + const actor = yield* currentResourceAuthority.pipe( + Effect.catchTag("OrganizationForbidden", () => Effect.fail(invalid("account"))), + ); + const owned = yield* accountAccess(account.id, actor).pipe( + Effect.catchTag("OrganizationForbidden", () => Effect.fail(invalid("account"))), + ); + if (owned.ownership.kind !== "personal" || owned.ownership.user !== userId) + return yield* invalid("account"); const linked = yield* linkedAccounts(sql, { subject: userId, apps: [app.id] }); if (linked.some((link) => link.id === account.id)) return false; // Check both again under the app's link lock: either may have been revoked while the // change waited for it (see `lockedChange`). return yield* lockedChange(executor, { app: app.id, access: "use" }, (locked, actor) => Effect.gen(function* () { - yield* requireAccountAccessAs(actor, account.id, "use").pipe( + const access = yield* requireAccountAccessAs(actor, account.id, "use").pipe( Effect.catchTag("OrganizationForbidden", () => Effect.fail(invalid("account"))), ); + if (access.ownership.kind !== "personal" || access.ownership.user !== userId) + return yield* invalid("account"); return yield* Effect.forEach( requirements.filter((requirement) => requirement.provider === account.provider), (requirement) => @@ -148,7 +236,11 @@ const resolvePolicy = (apps: readonly ConnectionApp[]) => }; }); -/** RequireOrganization checks current membership; `manager` limits API credentials to admins. */ +/** + * Membership is current. A request the host marked with a calling connection may create, update + * and mint only within that connection's policy, and a narrowed connection without manage is + * refused. Key lifetime stays inside the declared 5-second to 90-day bound. + */ export const hostedMcpConnectionHandlers = HttpApiBuilder.group( HostedApi, "mcpConnections", @@ -161,12 +253,15 @@ export const hostedMcpConnectionHandlers = HttpApiBuilder.group( }); return handlers .handle("list", () => - Effect.flatMap(manager("list"), (current) => auth.connections.list(current)).pipe( - Effect.map((connections) => connections.map(view)), - ), + Effect.gen(function* () { + yield* requireCallingScope(yield* callingConnection(auth)); + return (yield* auth.connections.list(yield* manager("list"))).map(view); + }), ) .handle("create", ({ payload }) => Effect.gen(function* () { + // Refuse a wider policy before linking any account for it. + yield* requireCallingScope(yield* callingConnection(auth), { apps: payload.apps }); const current = yield* manager("create", payload.id); const policy = yield* resolvePolicy(payload.apps); return view( @@ -180,14 +275,20 @@ export const hostedMcpConnectionHandlers = HttpApiBuilder.group( ) .handle("update", ({ params, payload }) => Effect.gen(function* () { - // Check ownership before linking any account for this connection. - const current = yield* manager("update", params.connection); - const existing = yield* auth.connections.list(current); - if (!existing.some((connection) => connection.id === params.connection)) + const caller = yield* callingConnection(auth); + yield* requireCallingScope(caller); + const current = yield* owner; + const found = (yield* auth.connections.list(current)).find( + (connection) => connection.id === params.connection, + ); + if (found === undefined) return yield* new ConnectionNotFound({ connection: params.connection }); + yield* requireCallingScope(caller, found.policy); + yield* requireCallingScope(caller, { apps: payload.apps }); + const actor = yield* manager("update", params.connection); const policy = yield* resolvePolicy(payload.apps); return view( - yield* auth.connections.update(current, { + yield* auth.connections.update(actor, { id: params.connection, name: payload.name, policy, @@ -196,14 +297,33 @@ export const hostedMcpConnectionHandlers = HttpApiBuilder.group( }), ) .handle("revoke", ({ params }) => - Effect.flatMap(manager("revoke", params.connection), (current) => - auth.connections.revoke(current, params.connection), - ), + Effect.gen(function* () { + const caller = yield* callingConnection(auth); + yield* requireCallingScope(caller); + if (caller !== undefined) { + const found = (yield* auth.connections.list(yield* owner)).find( + (connection) => connection.id === params.connection, + ); + if (found !== undefined) yield* requireCallingScope(caller, found.policy); + } + return yield* auth.connections.revoke( + yield* manager("revoke", params.connection), + params.connection, + ); + }), ) .handle("mintKey", ({ params, payload }) => Effect.gen(function* () { // Only the connection's owner: the store mints only for the caller's own standing // connection, and reports any other as not found. + const caller = yield* callingConnection(auth); + yield* requireCallingScope(caller); + if (caller !== undefined) { + const found = (yield* auth.connections.list(yield* owner)).find( + (connection) => connection.id === params.connection, + ); + if (found !== undefined) yield* requireCallingScope(caller, found.policy); + } const current = yield* manager("mint-key", params.connection); const minted = yield* auth.connections.mintKey(current, { id: params.connection, @@ -221,6 +341,7 @@ export const hostedMcpConnectionHandlers = HttpApiBuilder.group( "executor.mcp.connection": params.connection, "executor.api_key.id": minted.id, "executor.api_key.expires_at": minted.expiresAt, + ...(yield* callerAudit), }), ); return { @@ -236,13 +357,32 @@ export const hostedMcpConnectionHandlers = HttpApiBuilder.group( }), ) .handle("listKeys", ({ params }) => - // Only the connection's owner: the store reports any other connection as not found. - Effect.flatMap(manager("list-keys", params.connection), (current) => - auth.connections.keys(current, params.connection), - ), + Effect.gen(function* () { + // Only the connection's owner: the store reports any other connection as not found. + const caller = yield* callingConnection(auth); + yield* requireCallingScope(caller); + if (caller !== undefined) { + const found = (yield* auth.connections.list(yield* owner)).find( + (connection) => connection.id === params.connection, + ); + if (found !== undefined) yield* requireCallingScope(caller, found.policy); + } + return yield* auth.connections.keys( + yield* manager("list-keys", params.connection), + params.connection, + ); + }), ) .handle("revokeKey", ({ params }) => Effect.gen(function* () { + const caller = yield* callingConnection(auth); + yield* requireCallingScope(caller); + if (caller !== undefined) { + const found = (yield* auth.connections.list(yield* owner)).find( + (connection) => connection.id === params.connection, + ); + if (found !== undefined) yield* requireCallingScope(caller, found.policy); + } const current = yield* manager("revoke-key", params.connection); yield* auth.connections.revokeKey(current, { id: params.connection, @@ -258,6 +398,7 @@ export const hostedMcpConnectionHandlers = HttpApiBuilder.group( "executor.organization.id": current.resource, "executor.mcp.connection": params.connection, "executor.api_key.id": params.key, + ...(yield* callerAudit), }), ); }), diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index d80888be5a..011fc63d08 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -6825,6 +6825,28 @@ export const scenarios = plan({ local: na("The shared connections page is exercised through the hosted dashboard."), }, }, + memberConnectionNoWidening: { + fixtures: "actors", + file: "mcp-connections.spec.ts", + title: + "A full member connection can create a narrower Mesh connection through the Executor app, and a scoped connection cannot create an all-apps connection or mint a wider key", + targets: { + "self-host": scheduled, + cloud: scheduled, + local: na("Local has one operator and no organization roles or managed Executor keys."), + }, + }, + memberOwnScopedConnections: { + fixtures: "actors", + file: "mcp-connections.spec.ts", + title: + "Members create, list, update and revoke their own scoped connection from the dashboard and with an API key, mint and revoke a key on it, and cannot use another member's connection, an account they do not own, or an app they cannot see", + targets: { + "self-host": scheduled, + cloud: scheduled, + local: na("Local has one operator and no organization roles or API keys."), + }, + }, localScopedConnections: { file: "local-mcp-connections.spec.ts", title: "Local scoped connections limit an OAuth client to their tools and revoke it", @@ -8496,7 +8518,7 @@ export const scenarios = plan({ fixtures: "actors", file: "connection-api-keys.spec.ts", title: - "an admin's API key creates, uses and revokes a scoped connection without a browser, and a member's key cannot", + "an API key creates, uses and revokes its user's scoped connection without a browser, and a member's key cannot see or change an admin's connection", targets: { "self-host": scheduled, cloud: scheduled, diff --git a/e2e/tests/connection-api-keys.spec.ts b/e2e/tests/connection-api-keys.spec.ts index b580ab5a34..33ba3ebf64 100644 --- a/e2e/tests/connection-api-keys.spec.ts +++ b/e2e/tests/connection-api-keys.spec.ts @@ -1,7 +1,8 @@ /** - * Agents are provisioned without a browser: an organization admin's API key creates a scoped - * connection, the same key runs at the connection's URL with only that connection's narrower - * policy, and revoking it stops those calls. Members' keys cannot manage connections. + * Agents are provisioned without a browser: an API key creates a scoped connection for its own + * user, the same key runs at the connection's URL with only that connection's narrower policy, + * and revoking it stops those calls. A member's key manages that member's connections and cannot + * see or change another member's. */ import { expect, layer } from "@effect/vitest"; import { Effect, Exit, Redacted, Schema } from "effect"; @@ -83,13 +84,14 @@ layer(HostedLive, { excludeTestServices: true })("Connection API keys", (it) => { app: scopedApp.app.id, runsAs: [{ kind: "app" }], tools: { kind: "readOnly" } }, ]; - // A member's key cannot manage connections, even its own. - const refused = yield* asKey(member, "POST", `${prefix}/mcp-connections`, { + // A member's key creates that member's own connection. + const memberCreated = yield* asKey(member, "POST", `${prefix}/mcp-connections`, { id: randomUUID(), name: "Member connection", apps: policy, }); - expect(refused.status).toBe(403); + expect(memberCreated.status, JSON.stringify(memberCreated.body)).toBe(200); + const memberOwned = yield* body(ConnectionView, memberCreated); // An admin's key creates one and lists it, with no browser session. const created = yield* asKey(admin, "POST", `${prefix}/mcp-connections`, { @@ -103,6 +105,16 @@ layer(HostedLive, { excludeTestServices: true })("Connection API keys", (it) => const listed = yield* asKey(admin, "GET", `${prefix}/mcp-connections`); expect(listed.status).toBe(200); expect(JSON.stringify(listed.body)).toContain(connection.id); + expect(JSON.stringify(listed.body)).not.toContain(memberOwned.id); + // The member's key lists only their connection and cannot revoke the admin's. + const memberListed = yield* asKey(member, "GET", `${prefix}/mcp-connections`); + expect(memberListed.status).toBe(200); + expect(JSON.stringify(memberListed.body)).toContain(memberOwned.id); + expect(JSON.stringify(memberListed.body)).not.toContain(connection.id); + expect( + (yield* asKey(member, "POST", `${prefix}/mcp-connections/${connection.id}/revoke`)) + .status, + ).toBe(404); // The same key at the connection's URL runs with only the connection's policy. const scoped = yield* mcp.connect(admin.key, "key-at-connection", { diff --git a/e2e/tests/mcp-connections.spec.ts b/e2e/tests/mcp-connections.spec.ts index bd06bc93ec..e13c3ba6ff 100644 --- a/e2e/tests/mcp-connections.spec.ts +++ b/e2e/tests/mcp-connections.spec.ts @@ -30,6 +30,7 @@ import { import { holdTools, untilHeld } from "../support/held-call.ts"; import { Target } from "../support/platform.ts"; import { appsManifest } from "../support/apps-release.ts"; +import { managementApp } from "../support/management-app.ts"; import { targetHosts } from "../support/role-hosts.ts"; const Search = Schema.Struct({ @@ -950,4 +951,414 @@ export default defineApp({ app: { accounts: { team } } }, async () => ({ tools: }).pipe(Effect.provide(McpClient.layer)), ), ); + + it.effect(scenarios.memberOwnScopedConnections.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const api = yield* Api, + actors = yield* Actors, + browser = yield* Browser; + const prefix = `/api/organizations/${actors.organization.id}`; + const hidden = yield* deployReadWriteApp; + const deployed = yield* api.request(actors.owner, "POST", `${prefix}/apps/deploy`, { + name: `Member connection app ${randomUUID().slice(0, 8)}`, + files: [ + { + path: "index.ts", + content: `import { defineApp, defineProvider, secrets, query, object, string, router } from "apps"; +const service = defineProvider({ name: "Member connection account", auth: { key: secrets({ label: "Key", fields: object({ token: string() }) }) } }); +export default defineApp({ user: { accounts: { service } } }, async () => ({ tools: router({ who: query({ input: object({}) }, async ctx => ctx.user.accounts.service.fields.token) }) }));`, + }, + appsManifest, + ], + }); + expect(deployed.status, JSON.stringify(deployed.body)).toBe(200); + const app = yield* body(App, deployed), + path = `${prefix}/apps/${app.id}`; + const accounts: string[] = []; + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* api.request(actors.owner, "DELETE", path); + for (const id of accounts) + yield* api.request(actors.owner, "DELETE", `${prefix}/accounts/${id}`); + }).pipe(Effect.orDie), + ); + const shared = yield* api.request(actors.owner, "GET", `${path}/access`); + const { revision } = yield* body(Schema.Struct({ revision: Schema.String }), shared); + expect( + (yield* api.request(actors.owner, "PATCH", `${path}/access`, { + revision, + audience: { kind: "everyone" }, + })).status, + ).toBe(200); + const connectAccount = (actor: typeof actors.owner, token: string) => + Effect.gen(function* () { + const started = yield* api.request( + actor, + "POST", + connectionsPath(path, "user", "service"), + { destination: { kind: "personal" } }, + ); + expect(started.status, JSON.stringify(started.body)).toBe(200); + const submitted = yield* api.request( + actor, + "POST", + `${prefix}/connections/${(yield* body(Resource, started)).id}/submit`, + { method: "key", label: `${token} account`, fields: { token } }, + ); + expect(submitted.status, JSON.stringify(submitted.body)).toBe(200); + const account = (yield* body(Resource, submitted)).id; + accounts.push(account); + return account; + }); + const ownerAccount = yield* connectAccount(actors.owner, "owner"); + const memberAccount = yield* connectAccount(actors.member, "member"); + const ownerName = `Owner seat ${randomUUID().slice(0, 6)}`; + const memberName = `Member seat ${randomUUID().slice(0, 6)}`; + const renamed = `${memberName} renamed`; + const save = (actor: typeof actors.owner, apps: readonly unknown[], name: string) => + api.request(actor, "POST", `${prefix}/mcp-connections`, { + id: randomUUID(), + name, + apps, + }); + const ownedApps = [ + { app: app.id, runsAs: [{ kind: "account", id: memberAccount }], tools: { kind: "all" } }, + ]; + const ownerSaved = yield* save( + actors.owner, + [ + { + app: app.id, + runsAs: [{ kind: "account", id: ownerAccount }], + tools: { kind: "all" }, + }, + ], + ownerName, + ); + expect(ownerSaved.status, JSON.stringify(ownerSaved.body)).toBe(200); + const ownerConnection = yield* body(ConnectionView, ownerSaved); + const created = yield* save(actors.member, ownedApps, memberName); + expect(created.status, JSON.stringify(created.body)).toBe(200); + const connection = yield* body(ConnectionView, created); + expect(connection.policy.apps).toEqual(ownedApps); + const memberListed = yield* api.request(actors.member, "GET", `${prefix}/mcp-connections`); + expect(memberListed.status, JSON.stringify(memberListed.body)).toBe(200); + const listed = yield* body(Schema.Array(ConnectionView), memberListed); + expect(listed.map((item) => item.id)).toContain(connection.id); + expect(listed.map((item) => item.id)).not.toContain(ownerConnection.id); + const updated = yield* api.request( + actors.member, + "PUT", + `${prefix}/mcp-connections/${connection.id}`, + { name: renamed, apps: ownedApps }, + ); + expect(updated.status, JSON.stringify(updated.body)).toBe(200); + expect((yield* body(ConnectionView, updated)).name).toBe(renamed); + expect( + (yield* api.request( + actors.member, + "PUT", + `${prefix}/mcp-connections/${ownerConnection.id}`, + { + name: renamed, + apps: ownedApps, + }, + )).status, + ).toBe(404); + expect( + (yield* api.request( + actors.member, + "POST", + `${prefix}/mcp-connections/${ownerConnection.id}/revoke`, + )).status, + ).toBe(404); + + yield* browser.login(actors.member); + yield* browser.use("Open Connections", (page) => + page.goto(`/org/${actors.organization.slug}/connect`), + ); + yield* browser.use("The member's connection is listed", (page) => + page.getByRole("listitem", { name: renamed, exact: true }).waitFor(), + ); + expect( + yield* browser.use("Another member's connection is not listed", (page) => + page.getByRole("listitem", { name: ownerName, exact: true }).count(), + ), + ).toBe(0); + + const issued = yield* api.request(actors.member, "POST", "/api/auth/api-key/create", { + name: "Member connection key", + }); + expect(issued.status).toBe(200); + const token = yield* body( + Schema.Struct({ key: Schema.RedactedFromValue(Schema.String), id: Schema.String }), + issued, + ); + yield* Effect.addFinalizer(() => + api + .request(actors.member, "POST", "/api/auth/api-key/delete", { keyId: token.id }) + .pipe(Effect.ignore), + ); + const bearer = { + authorization: `Bearer ${Redacted.value(token.key)}`, + }; + const asKey = (method: "GET" | "POST" | "PUT", route: string, data?: unknown) => + Effect.gen(function* () { + return yield* api.request(yield* api.session(), method, route, data, bearer); + }); + const keyList = yield* asKey("GET", `${prefix}/mcp-connections`); + expect(keyList.status).toBe(200); + const keyViews = yield* body(Schema.Array(ConnectionView), keyList); + expect(keyViews.map((item) => item.id)).toContain(connection.id); + expect(keyViews.map((item) => item.id)).not.toContain(ownerConnection.id); + const keyUpdated = yield* asKey("PUT", `${prefix}/mcp-connections/${connection.id}`, { + name: renamed, + apps: ownedApps, + }); + expect(keyUpdated.status, JSON.stringify(keyUpdated.body)).toBe(200); + const keysPath = `${prefix}/mcp-connections/${connection.id}/keys`; + const minted = yield* asKey("POST", keysPath, { expiresInSeconds: 3600 }); + expect(minted.status, JSON.stringify(minted.body)).toBe(200); + const KeySummary = Schema.Struct({ + id: Schema.String, + name: Schema.NullOr(Schema.String), + start: Schema.NullOr(Schema.String), + createdAt: Schema.String, + expiresAt: Schema.NullOr(Schema.String), + lastRequest: Schema.NullOr(Schema.String), + }); + const key = yield* body(Schema.Struct({ id: Schema.String }), minted); + const listedKeys = yield* asKey("GET", keysPath); + expect(listedKeys.status, JSON.stringify(listedKeys.body)).toBe(200); + const keys = yield* body(Schema.Array(KeySummary), listedKeys); + expect(keys.map((item) => item.id)).toContain(key.id); + expect((yield* asKey("POST", `${keysPath}/${key.id}/revoke`)).status).toBeLessThan(300); + const keysAfter = yield* asKey("GET", keysPath); + expect(keysAfter.status, JSON.stringify(keysAfter.body)).toBe(200); + expect( + (yield* body(Schema.Array(KeySummary), keysAfter)).map((item) => item.id), + ).not.toContain(key.id); + const keyOwned = yield* asKey("POST", `${prefix}/mcp-connections`, { + id: randomUUID(), + name: `Key seat ${randomUUID().slice(0, 6)}`, + apps: ownedApps, + }); + expect(keyOwned.status, JSON.stringify(keyOwned.body)).toBe(200); + expect((yield* body(ConnectionView, keyOwned)).policy.apps).toEqual(ownedApps); + + const hiddenAttempt = yield* asKey("POST", `${prefix}/mcp-connections`, { + id: randomUUID(), + name: "Hidden app", + apps: [{ app: hidden.app.id, runsAs: [{ kind: "app" }], tools: { kind: "all" } }], + }); + expect(hiddenAttempt.status, JSON.stringify(hiddenAttempt.body)).toBe(400); + expect(hiddenAttempt.body).toMatchObject({ + _tag: "ConnectionAccessInvalid", + reason: "app", + }); + const borrowed = yield* asKey("POST", `${prefix}/mcp-connections`, { + id: randomUUID(), + name: "Borrowed account", + apps: [ + { + app: app.id, + runsAs: [{ kind: "account", id: ownerAccount }], + tools: { kind: "all" }, + }, + ], + }); + expect(borrowed.status, JSON.stringify(borrowed.body)).toBe(400); + expect(borrowed.body).toMatchObject({ + _tag: "ConnectionAccessInvalid", + reason: "account", + }); + expect(yield* linkedAccountIds(actors.member, path)).toEqual([memberAccount]); + const foreign = [ + yield* asKey("PUT", `${prefix}/mcp-connections/${ownerConnection.id}`, { + name: renamed, + apps: ownedApps, + }), + yield* asKey("POST", `${prefix}/mcp-connections/${ownerConnection.id}/revoke`), + yield* asKey("POST", `${prefix}/mcp-connections/${ownerConnection.id}/keys`, { + expiresInSeconds: 3600, + }), + yield* asKey( + "POST", + `${prefix}/mcp-connections/${ownerConnection.id}/keys/${key.id}/revoke`, + ), + ]; + for (const response of foreign) { + expect(response.status, JSON.stringify(response.body)).toBe(404); + expect(response.body).toMatchObject({ _tag: "ConnectionNotFound" }); + } + + const group = { + name: "Members cannot", + description: "", + memberIds: [] as const, + }; + for (const response of [ + yield* api.request(actors.member, "POST", `${prefix}/groups`, group), + yield* asKey("POST", `${prefix}/groups`, group), + ]) { + expect(response.status, JSON.stringify(response.body)).toBe(403); + expect(response.body).toMatchObject({ _tag: "OrganizationForbidden" }); + } + + expect( + (yield* asKey("POST", `${prefix}/mcp-connections/${connection.id}/revoke`)).status, + ).toBeLessThan(300); + const afterRevoke = yield* asKey("GET", `${prefix}/mcp-connections`); + expect(afterRevoke.status, JSON.stringify(afterRevoke.body)).toBe(200); + expect( + (yield* body(Schema.Array(ConnectionView), afterRevoke)).map((item) => item.id), + ).not.toContain(connection.id); + const ownerListed = yield* api.request(actors.owner, "GET", `${prefix}/mcp-connections`); + expect(ownerListed.status, JSON.stringify(ownerListed.body)).toBe(200); + expect( + (yield* body(Schema.Array(ConnectionView), ownerListed)).map((item) => item.id), + ).toContain(ownerConnection.id); + }), + ), + ); + + it.effect(scenarios.memberConnectionNoWidening.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const api = yield* Api, + actors = yield* Actors, + mcp = yield* McpClient; + const prefix = `/api/organizations/${actors.organization.id}`; + const { app: executor, account: executorAccount } = yield* managementApp(actors.member); + const mesh = yield* deployReadWriteApp; + const meshPath = `${prefix}/apps/${mesh.app.id}`; + const access = yield* body( + Schema.Struct({ revision: Schema.String }), + yield* api.request(actors.owner, "GET", `${meshPath}/access`), + ); + expect( + (yield* api.request(actors.owner, "PATCH", `${meshPath}/access`, { + revision: access.revision, + audience: { kind: "everyone" }, + })).status, + ).toBe(200); + const executorEntry = { + app: executor.id, + runsAs: [{ kind: "account", id: executorAccount }], + tools: { kind: "all" }, + }; + const meshEntry = { + app: mesh.app.id, + runsAs: [{ kind: "app" }], + tools: { kind: "all" }, + }; + const save = (name: string, apps: readonly unknown[]) => + Effect.gen(function* () { + const created = yield* api.request(actors.member, "POST", `${prefix}/mcp-connections`, { + id: randomUUID(), + name, + apps, + }); + expect(created.status, JSON.stringify(created.body)).toBe(200); + return yield* body(ConnectionView, created); + }); + const full = yield* save(`Full ${randomUUID().slice(0, 6)}`, [executorEntry, meshEntry]); + const scoped = yield* save(`Scoped ${randomUUID().slice(0, 6)}`, [executorEntry]); + const narrowed = yield* save(`Narrow ${randomUUID().slice(0, 6)}`, [ + { ...executorEntry, tools: { kind: "selected", names: ["mcpConnections.create"] } }, + ]); + const clients: string[] = []; + yield* revokeClientGrants(actors.member, () => clients); + const agent = (connection: typeof full) => + Effect.gen(function* () { + const consent = yield* consentTo(actors.member, connection.url); + clients.push(consent.clientId); + expect(consent.status, JSON.stringify(consent)).toBe(200); + const client = yield* mcp.connect(yield* consent.tokens, connection.id, { + connection: connection.id, + }); + const tool = `tools[${JSON.stringify(executor.slug)}]`; + const run = (step: string, code: string) => + client + .use(step, (session, signal) => + session.callTool({ name: "execute", arguments: { code } }, undefined, { signal }), + ) + .pipe( + Effect.flatMap((result) => + Schema.decodeUnknownEffect( + Schema.Struct({ + status: Schema.Literal("completed"), + execution: Schema.Struct({ + ok: Schema.Literal(true), + value: Schema.Struct({ + ok: Schema.Boolean, + error: Schema.optional(Schema.Struct({ status: Schema.Number })), + value: Schema.optional(Schema.Unknown), + }), + }), + }), + )(result.structuredContent), + ), + ); + return { run, tool }; + }); + const invoke = (tool: string, method: string, input: unknown) => + `try { return { ok: true, value: await ${tool}.${method}(${JSON.stringify(input)}) }; } catch (error) { return { ok: false, error: JSON.parse(error.message) }; }`; + const fullAgent = yield* agent(full); + const meshId = randomUUID(); + const created = yield* fullAgent.run( + "A full connection creates a narrower Mesh connection", + invoke(fullAgent.tool, "mcpConnections.create", { + body: { id: meshId, name: "Mesh seat", apps: [meshEntry] }, + }), + ); + expect(created.execution.value.ok, JSON.stringify(created.execution.value)).toBe(true); + const minted = yield* fullAgent.run( + "A full connection mints a key inside its scope", + invoke(fullAgent.tool, "mcpConnections.mintKey", { + path: { connection: meshId }, + body: { expiresInSeconds: 3600 }, + }), + ); + expect(minted.execution.value.ok, JSON.stringify(minted.execution.value)).toBe(true); + + const scopedAgent = yield* agent(scoped); + const widened = yield* scopedAgent.run( + "A scoped connection cannot create an all-apps connection", + invoke(scopedAgent.tool, "mcpConnections.create", { + body: { + id: randomUUID(), + name: "All apps", + apps: [executorEntry, meshEntry], + }, + }), + ); + expect(widened.execution.value).toMatchObject({ ok: false, error: { status: 403 } }); + const widerKey = yield* scopedAgent.run( + "A scoped connection cannot mint a key for a wider connection", + invoke(scopedAgent.tool, "mcpConnections.mintKey", { + path: { connection: full.id }, + body: { expiresInSeconds: 90 * 24 * 60 * 60 }, + }), + ); + expect(widerKey.execution.value).toMatchObject({ ok: false, error: { status: 403 } }); + + const narrowAgent = yield* agent(narrowed); + const unmanaged = yield* narrowAgent.run( + "A narrowed connection without manage is refused", + invoke(narrowAgent.tool, "mcpConnections.create", { + body: { + id: randomUUID(), + name: "Still narrow", + apps: [executorEntry], + }, + }), + ); + expect(unmanaged.execution.value).toMatchObject({ ok: false, error: { status: 403 } }); + }).pipe(Effect.provide(Layer.mergeAll(McpOAuth.layer, McpClient.layer))), + ), + ); }); diff --git a/packages/app-templates/executor/skills/app-authoring/accounts.md b/packages/app-templates/executor/skills/app-authoring/accounts.md index 841383d9c1..d721da7230 100644 --- a/packages/app-templates/executor/skills/app-authoring/accounts.md +++ b/packages/app-templates/executor/skills/app-authoring/accounts.md @@ -645,16 +645,22 @@ connection. The connection's `runsAs` names the account the agent's calls use, s that account in `ctx.user.accounts`, and `ctx.caller.provenance` names the connection for logs. A connection can only narrow its owner's own access: it never grants another person's account. -An organization admin creates connections without a browser through the routes under -`/api/organizations/:organization/mcp-connections` (`list`, `create`, `update`, `revoke`), with -their own API key or a full-access API grant, or in the dashboard. Members manage their own from -the dashboard. Every change an API credential makes is logged. - -These routes are deliberately not tools. Every MCP caller is an agent (`ctx.caller.kind` is -`"agent"`, even when a person drives the client), and the API refuses MCP tokens here, so an agent -cannot create, widen or revoke connections. A tool limited to human owners and admins would never -be callable over MCP, so connection management stays with people in the dashboard and with admin -API keys. +A member creates, lists, updates and revokes their own connections, and mints and revokes keys on +them, from the dashboard or with their own API key or full API grant. The routes are +`/api/organizations/:organization/mcp-connections` (`list`, `create`, `update`, `revoke`, and +`.../keys`). The API refuses MCP tokens, so an agent does not call them with its grant. The +Executor app can call them with the member's managed key. That self-call carries the MCP +connection that invoked it, attached by the host. Authority is that connection's policy, not the +key's full grant. + +A connection may create, update or mint only within its own scope: each app, tool, event and +`runsAs` account on the new connection is already on the calling connection. It cannot escalate +to all apps or to a wider key. A key's lifetime stays within 5 seconds and 90 days. A narrowed +connection that does not grant every tool of its apps has no `manage` authority and is refused +with 403. A connection that grants every tool of its apps can create a narrower one, such as a +dedicated connection for one agent. `runsAs` names a personal account that member owns. A +group-shared account is refused, even when the member can use it. Every change an API credential +makes is logged with the caller kind and, for a self-call, the calling connection. An agent then connects to the connection's `url` (`/mcp?connection=`) with an OAuth grant, or with its owner's API key: at that URL the key runs with only the connection's apps, tools and diff --git a/packages/app-templates/src/implementation/executor-skill-digests.gen.ts b/packages/app-templates/src/implementation/executor-skill-digests.gen.ts index 8a5bba9d1f..d9264a8cd0 100644 --- a/packages/app-templates/src/implementation/executor-skill-digests.gen.ts +++ b/packages/app-templates/src/implementation/executor-skill-digests.gen.ts @@ -3,7 +3,7 @@ /** SHA-256 of each file the Executor app publishes as a skill, keyed by `/`. */ export const executorSkillDigests: Readonly> = { "app-authoring/SKILL.md": "87118b0f1b98968a64fd6ba64dce39d1fde6c78f781f34ba85063ef4e44355a3", - "app-authoring/accounts.md": "6876d5f25ab7ab458b248f5a12fec70baa9b3cbddf8894674bce7f6ab890c884", + "app-authoring/accounts.md": "58cad0c32c94d719c50c63b53e798c0b6eae79a48f1942cc953a565ec73f9f91", "app-authoring/deploy.md": "01938ca5b8a23c872bc6233c6c941c055adfb59ce2aca0744e873438f11095ff", "app-authoring/events.md": "8f7465e35c6c94b06978b89680181df60c741225de3ba8158f22f76ee7e91a5a", "app-authoring/integrations.md": "3f9cecb591465a71c95c98046dab960750000c05a900fde3550aac4ea3a0171a", diff --git a/packages/sdk/src/contracts/caller-connection.ts b/packages/sdk/src/contracts/caller-connection.ts new file mode 100644 index 0000000000..01aff9ce19 --- /dev/null +++ b/packages/sdk/src/contracts/caller-connection.ts @@ -0,0 +1,6 @@ +/** + * Header the app outbound sets when it places a managed credential for an invocation that arrived + * on a scoped MCP connection. The host seals the connection id into the credential handle; app + * code cannot choose it. The API trusts the header only as that bound id. + */ +export const callerConnectionHeader = "x-executor-caller-connection"; diff --git a/packages/sdk/src/contracts/index.ts b/packages/sdk/src/contracts/index.ts index 15a7a5ac81..bab7609f27 100644 --- a/packages/sdk/src/contracts/index.ts +++ b/packages/sdk/src/contracts/index.ts @@ -21,6 +21,7 @@ export * from "./declarations.ts"; export * from "./storage.ts"; export * from "./runtime.ts"; export * from "./blobs.ts"; +export * from "./caller-connection.ts"; export * from "./schedules.ts"; export * from "./scheduler.ts"; diff --git a/packages/sdk/src/contracts/workerd-host.ts b/packages/sdk/src/contracts/workerd-host.ts index 64ac0750fc..4316258c9a 100644 --- a/packages/sdk/src/contracts/workerd-host.ts +++ b/packages/sdk/src/contracts/workerd-host.ts @@ -40,6 +40,8 @@ export const WorkerInvocation = Schema.Struct({ approval: Schema.optionalKey(TrustedToolApproval), /** Present only for builds that declare callerIdentity; see `callerContext`. */ caller: Schema.optionalKey(Caller), + /** The MCP connection the call arrived on. The runner seals it; the app bundle never sees it. */ + callerConnection: Schema.optionalKey(Schema.String), approvedBy: Schema.optionalKey(ApprovedBy), replay: Schema.optionalKey(WorkflowReplay), deadline: Schema.optionalKey(InvocationDeadline), diff --git a/packages/sdk/src/implementation/app-runner.ts b/packages/sdk/src/implementation/app-runner.ts index 90c1ee065e..223791c8ac 100644 --- a/packages/sdk/src/implementation/app-runner.ts +++ b/packages/sdk/src/implementation/app-runner.ts @@ -96,7 +96,11 @@ export interface AppRunnerHost { * credential handles the runner seals with `credentialKey`; see credential-handles.ts. Bound to * a host API revision, it refuses that API's document unless it has the revision. */ - readonly outbound: (app: string, hostApi: HostApiCheck | undefined) => Fetcher; + readonly outbound: ( + app: string, + hostApi: HostApiCheck | undefined, + callerConnection?: string, + ) => Fetcher; /** Seals the secret fields of providers that declare hosts. App code never holds it. */ readonly credentialKey: Effect.Effect; /** @@ -146,6 +150,11 @@ export interface AppInvocation { readonly approval?: typeof TrustedToolApproval.Type; /** Sent only to builds that declare callerIdentity; see `callerContext`. */ readonly caller?: Caller; + /** + * The MCP connection this call arrived on. Sealed into managed credentials; never sent to the + * app bundle. Set beside `caller` so a build that does not receive `caller` still cannot widen. + */ + readonly callerConnection?: string; readonly approvedBy?: ApprovedBy; readonly replay?: typeof WorkflowReplay.Type; readonly deadline?: typeof InvocationDeadline.Type; @@ -390,7 +399,14 @@ class ColdStartFailed extends Schema.TaggedError()("ColdStartFa export const appWorker = ( invocation: Pick< AppInvocation, - "app" | "build" | "database" | "sharedRuntime" | "command" | "accounts" | "hostApi" + | "app" + | "build" + | "database" + | "sharedRuntime" + | "command" + | "accounts" + | "hostApi" + | "callerConnection" >, ) => // Requirement names are unique across scopes, so one record names the account set. @@ -401,10 +417,10 @@ export const appWorker = ( Effect.map((selection) => { // A Worker's network is bound when it loads, so each host API revision has its own app // Worker and its own facet Worker. The facet's database is the supervisor's, whichever loads. - const identity = - invocation.hostApi === undefined - ? selection - : `${selection}:${invocation.hostApi.revision}`; + // The network binding is fixed when the Worker loads, and it carries the calling connection. + const identity = [selection, invocation.hostApi?.revision, invocation.callerConnection] + .filter((part) => part !== undefined && part !== "") + .join(":"); return { identity, mode: runsInFacet(invocation) ? ("facet" as const) : ("worker" as const), @@ -428,9 +444,14 @@ const bundleAccounts = ( app: string, key: Effect.Effect, accounts: HostAccounts, + callerConnection?: string, ): Effect.Effect => { if (protocol.sealedCredentials) - return sealAccounts(accounts, { app, key }).pipe(Effect.map(protocol.accounts)); + return sealAccounts(accounts, { + app, + key, + ...(callerConnection === undefined ? {} : { callerConnection }), + }).pipe(Effect.map(protocol.accounts)); const unsealed = unsealedAccounts(accounts); return unsealed === undefined ? failed("managed") : Effect.succeed(protocol.accounts(unsealed)); }; @@ -867,7 +888,13 @@ export const makeAppRunner = (host: AppRunnerHost) => { const refused = protocol.refuse(invocation.command); if (refused !== undefined) return { ok: false, error: refused }; const accountsFor = (resolved: HostAccounts) => - bundleAccounts(protocol, invocation.app, host.credentialKey, resolved); + bundleAccounts( + protocol, + invocation.app, + host.credentialKey, + resolved, + invocation.callerConnection ?? invocation.caller?.provenance?.connectionId, + ); const accounts = yield* accountsFor(invocation.accounts); const body = protocol.invocation({ command: invocation.command, @@ -959,7 +986,11 @@ export const makeAppRunner = (host: AppRunnerHost) => { const reply = yield* start(name, load, { body, headers: invocation.headers, - globalOutbound: host.outbound(invocation.app, invocation.hostApi), + globalOutbound: host.outbound( + invocation.app, + invocation.hostApi, + invocation.callerConnection ?? invocation.caller?.provenance?.connectionId, + ), elicit: capabilities.elicit, controls: capabilities.controls, ...(capabilities.workflow === undefined diff --git a/packages/sdk/src/implementation/credential-handles.ts b/packages/sdk/src/implementation/credential-handles.ts index c2602f2352..008b618072 100644 --- a/packages/sdk/src/implementation/credential-handles.ts +++ b/packages/sdk/src/implementation/credential-handles.ts @@ -37,6 +37,7 @@ */ import { Clock, Effect, Option, Result, Schema } from "effect"; import { Base64, Hex } from "effect/encoding"; +import { callerConnectionHeader } from "../contracts/caller-connection.ts"; import { NetworkRefused, type Placement, @@ -96,6 +97,11 @@ const Sealed = Schema.Struct({ exposed: Schema.optionalKey(Schema.Array(Schema.String)), /** A managed account's value; see `placeManaged`. Only placed handles carry it. */ managed: Schema.optionalKey(Schema.Literal(true)), + /** + * The MCP connection whose invocation sealed this handle, when the call arrived on one. The + * outbound copies it onto the host API request. App code never supplies it. + */ + callerConnection: Schema.optionalKey(Schema.String), }); type Sealed = typeof Sealed.Type; @@ -202,7 +208,12 @@ const sealValue = ( const sealAccount = ( { managed, ...account }: HostAccount, - context: { readonly app: string; readonly key: CryptoKey; readonly expires: number }, + context: { + readonly app: string; + readonly key: CryptoKey; + readonly expires: number; + readonly callerConnection?: string; + }, ): Effect.Effect => Effect.gen(function* () { const hosts = account.provider.hosts; @@ -232,6 +243,9 @@ const sealAccount = ( exposed: [...exposed].sort(), ...(field === undefined ? {} : { field }), ...(managed === undefined ? {} : { managed }), + ...(managed === undefined || context.callerConnection === undefined + ? {} + : { callerConnection: context.callerConnection }), }), }); const fields = yield* Effect.forEach(Object.entries(account.fields), ([name, value]) => @@ -266,7 +280,12 @@ export const unsealedAccounts = (accounts: HostAccounts): ResolvedAccounts | und */ export const sealAccounts = ( accounts: HostAccounts, - context: { readonly app: string; readonly key: Effect.Effect }, + context: { + readonly app: string; + readonly key: Effect.Effect; + /** The invoking MCP connection, sealed into managed credentials for this call. */ + readonly callerConnection?: string; + }, ): Effect.Effect => Effect.gen(function* () { const unsealed = unsealedAccounts(accounts); @@ -279,6 +298,9 @@ export const sealAccounts = ( app: context.app, key: yield* context.key, expires: (yield* Clock.currentTimeMillis) + handleLifetime, + ...(context.callerConnection === undefined + ? {} + : { callerConnection: context.callerConnection }), }; const sealSlot = (selected: Selected): Effect.Effect => isMany(selected) @@ -703,6 +725,12 @@ export interface CredentialOutbound { readonly egress: AppEgress; /** Send the rewritten request on the host's network. It rejects when that network fails. */ readonly send: (request: Request) => Promise; + /** + * The MCP connection this invocation arrived on. The runner binds it into the app's network, + * which is fixed when the Worker loads, so each connection has its own Worker. Requests to this + * host's own origin carry it; app code cannot choose a different one. + */ + readonly callerConnection?: string; /** The host API revision the invocation's cache belongs to; see `hostApiDocument`. */ readonly hostApi?: HostApiCheck; } @@ -794,6 +822,13 @@ const scrubbed = (error: unknown, values: ReadonlyMap) => { return copy; }; +/** Replace any connection id app code wrote with the one this Worker was loaded for. */ +const stampCaller = (headers: Headers, outbound: CredentialOutbound) => { + headers.delete(callerConnectionHeader); + if (outbound.callerConnection !== undefined) + headers.set(callerConnectionHeader, outbound.callerConnection); +}; + const forward = async (request: Request, outbound: CredentialOutbound): Promise => { const url = new URL(request.url); const send = (sent: Request) => outbound.send(sent).catch(sendFailed(request.signal)); @@ -808,15 +843,19 @@ const forward = async (request: Request, outbound: CredentialOutbound): Promise< ...[...request.headers].flatMap(([, value]) => handlesWithin(value)), ...(body?.complete === true ? handlesIn(body.text) : []), ]); - if (found.size === 0) - return send( + if (found.size === 0) { + const headers = new Headers(request.headers); + stampCaller(headers, outbound); + const sent = body === undefined - ? request + ? new Request(request, { headers }) : new Request(request, { + headers, body: body.complete ? body.text : body.stream, ...(body.complete ? {} : { duplex: "half" }), - }), - ).then(unmarked); + }); + return send(sent).then(unmarked); + } // TRACE asks the service to send the request back, credentials included. if (request.method.toUpperCase() === "TRACE") return networkRefusalResponse( @@ -895,6 +934,37 @@ const forward = async (request: Request, outbound: CredentialOutbound): Promise< ? replaceAll(body.text, encoded(anywhere, encodings[kind ?? "text"])) : undefined; if (text !== undefined) headers.delete("content-length"); + const callerConnections = [ + ...new Set( + [...opened.values()].flatMap((sealed) => + sealed.managed === true && sealed.callerConnection !== undefined + ? [sealed.callerConnection] + : [], + ), + ), + ]; + // A managed credential is the host's. Drop any connection id app code wrote, then attach the + // one sealed for this invocation. Two managed credentials from different connections cannot + // share a request. + stampCaller(headers, outbound); + if ([...opened.values()].some((sealed) => sealed.managed === true)) { + if (callerConnections.length > 1) { + const provider = [...opened.values()].find((sealed) => sealed.managed === true)?.provider; + return networkRefusalResponse( + new NetworkRefused({ + host: url.host, + refusal: { + reason: "credential_placement", + provider: provider ?? "managed", + location: callerConnectionHeader, + }, + }), + ); + } + headers.delete(callerConnectionHeader); + const [callerConnection] = callerConnections; + if (callerConnection !== undefined) headers.set(callerConnectionHeader, callerConnection); + } const response = await send( new Request(replaceAll(placed.success.href, encoded(anywhere, encodings.url)), { method: request.method, diff --git a/packages/sdk/src/implementation/workerd-client.ts b/packages/sdk/src/implementation/workerd-client.ts index 40066e7827..1108c6cde6 100644 --- a/packages/sdk/src/implementation/workerd-client.ts +++ b/packages/sdk/src/implementation/workerd-client.ts @@ -236,8 +236,10 @@ export const connectedWorkerdApps = (blobs: BlobStorage, transport: WorkerdTrans Effect.gen(function* () { // A workflow body runs in the apps Worker's own workflow engine, never through a call. if (capabilities.workflow !== undefined) return yield* protocolFailure(); + const callerConnection = invocation.caller?.provenance?.connectionId; const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(WorkerInvocation))({ ...invocation, + ...(callerConnection === undefined ? {} : { callerConnection }), elicitation: capabilities.elicit !== null, workflowControls: capabilities.controls !== null, }).pipe(Effect.mapError(protocolFailure)); diff --git a/packages/sdk/src/implementation/workerd-entry.ts b/packages/sdk/src/implementation/workerd-entry.ts index 2e9725312a..23808756e2 100644 --- a/packages/sdk/src/implementation/workerd-entry.ts +++ b/packages/sdk/src/implementation/workerd-entry.ts @@ -148,6 +148,7 @@ const buildFailed = (stage: RuntimeBuildFailed["stage"], cause: unknown) => const OutboundProps = Schema.Struct({ app: Schema.NonEmptyString, hostApi: Schema.optionalKey(HostApiCheck), + callerConnection: Schema.optionalKey(Schema.String), }); type OutboundProps = typeof OutboundProps.Type; /** Handles are sealed with a key derived from the secret only this host and its runner share. */ @@ -170,6 +171,7 @@ export class AppOutbound extends WorkerEntrypoint { return credentialFetch(request, { app: props.app, ...(props.hostApi === undefined ? {} : { hostApi: props.hostApi }), + ...(props.callerConnection === undefined ? {} : { callerConnection: props.callerConnection }), key: await Effect.runPromise(credentials(this.env)), // Refuse a private destination by name, before the public-only network refuses its // address with an error app code cannot tell apart from any other connection failure. @@ -189,9 +191,13 @@ const OutboundExports = Schema.Struct({ /** The loopback binding to `AppOutbound` that workerd supplies on every context's exports. */ const appOutbound = (context: { readonly exports: unknown }) => - (app: string, hostApi: HostApiCheck | undefined): Fetcher => + (app: string, hostApi: HostApiCheck | undefined, callerConnection?: string): Fetcher => Schema.decodeUnknownSync(OutboundExports)(context.exports).AppOutbound({ - props: hostApi === undefined ? { app } : { app, hostApi }, + props: { + app, + ...(hostApi === undefined ? {} : { hostApi }), + ...(callerConnection === undefined ? {} : { callerConnection }), + }, }); const rpcOptions = { onSendError: () => new Error("App runtime request failed") }; const json = Schema.decodeUnknownSync(Schema.Json);