From e0cf645359640fbaa241f5f93a8e2bb73a33eb3f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Nov 2026 04:02:02 +0000 Subject: [PATCH 1/2] Let members manage their own scoped MCP connections. A member's API key was refused with OrganizationForbidden on mcpConnections create and list unless the member was an admin. Members can now list, create, update, and revoke their own connections, and mint and revoke keys on them. Apps and accounts stay limited to what that member can use, and another member's connections stay unreachable. Co-authored-by: shuv --- .changeset/member-mcp-connections.md | 5 + .../server/src/contracts/mcp-connections.ts | 9 +- .../src/implementation/mcp-connections.ts | 17 +- e2e/test-plan.ts | 13 +- e2e/tests/connection-api-keys.spec.ts | 24 +- e2e/tests/mcp-connections.spec.ts | 272 ++++++++++++++++++ 6 files changed, 320 insertions(+), 20 deletions(-) create mode 100644 .changeset/member-mcp-connections.md diff --git a/.changeset/member-mcp-connections.md b/.changeset/member-mcp-connections.md new file mode 100644 index 0000000000..b6a5607c2a --- /dev/null +++ b/.changeset/member-mcp-connections.md @@ -0,0 +1,5 @@ +--- +"@executor-js/hosted-server": 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 and accounts that member can use, and another member's connections stay unreachable. Creating a group and other administrator actions stay limited to admins. diff --git a/apps/hosted/server/src/contracts/mcp-connections.ts b/apps/hosted/server/src/contracts/mcp-connections.ts index 51e3b80ec6..fa5d8ef342 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, diff --git a/apps/hosted/server/src/implementation/mcp-connections.ts b/apps/hosted/server/src/implementation/mcp-connections.ts index 9704ab63ca..82ecc2d278 100644 --- a/apps/hosted/server/src/implementation/mcp-connections.ts +++ b/apps/hosted/server/src/implementation/mcp-connections.ts @@ -27,10 +27,10 @@ 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 + * 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). The store returns and changes only that + * member's connections. Apps and accounts are checked the same way as a call. Bearer changes are * logged, as connections decide which accounts an agent runs as. */ const manager = ( @@ -40,12 +40,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", diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index d80888be5a..0167900e06 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -6825,6 +6825,17 @@ export const scenarios = plan({ local: na("The shared connections page is exercised through the hosted dashboard."), }, }, + 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 +8507,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..ca458d6927 100644 --- a/e2e/tests/mcp-connections.spec.ts +++ b/e2e/tests/mcp-connections.spec.ts @@ -950,4 +950,276 @@ 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); + }), + ), + ); }); From 9fc23fcd81599366bcc0d78898635f7138c6fb1d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Nov 2026 05:43:37 +0000 Subject: [PATCH 2/2] Keep a scoped connection from widening through the Executor app. The app's self-call uses the member's full key. The host now binds the calling MCP connection to that request, and create, update, and mint stay inside that connection's apps, tools, and runsAs accounts. A narrowed connection without manage is refused. runsAs accepts only a personal account the member owns. Co-authored-by: shuv --- .changeset/member-mcp-connections.md | 3 +- .../cloud/src/infrastructure/app-outbound.ts | 12 +- .../server/src/contracts/mcp-connections.ts | 6 +- .../src/implementation/mcp-connections.ts | 190 +++++++++++++++--- e2e/test-plan.ts | 11 + e2e/tests/mcp-connections.spec.ts | 139 +++++++++++++ .../executor/skills/app-authoring/accounts.md | 26 ++- .../executor-skill-digests.gen.ts | 2 +- .../sdk/src/contracts/caller-connection.ts | 6 + packages/sdk/src/contracts/index.ts | 1 + packages/sdk/src/contracts/workerd-host.ts | 2 + packages/sdk/src/implementation/app-runner.ts | 49 ++++- .../src/implementation/credential-handles.ts | 84 +++++++- .../sdk/src/implementation/workerd-client.ts | 2 + .../sdk/src/implementation/workerd-entry.ts | 10 +- 15 files changed, 485 insertions(+), 58 deletions(-) create mode 100644 packages/sdk/src/contracts/caller-connection.ts diff --git a/.changeset/member-mcp-connections.md b/.changeset/member-mcp-connections.md index b6a5607c2a..cfa2816cd1 100644 --- a/.changeset/member-mcp-connections.md +++ b/.changeset/member-mcp-connections.md @@ -1,5 +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 and accounts that member can use, and another member's connections stay unreachable. Creating a group and other administrator actions stay limited to admins. +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 fa5d8ef342..ec5711cd3f 100644 --- a/apps/hosted/server/src/contracts/mcp-connections.ts +++ b/apps/hosted/server/src/contracts/mcp-connections.ts @@ -94,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 82ecc2d278..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; @@ -29,9 +45,10 @@ const owner = Effect.gen(function* () { /** * 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). The store returns and changes only that - * member's connections. Apps and accounts are checked the same way as a call. Bearer changes are - * logged, as connections decide which accounts an agent runs as. + * 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", @@ -52,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 @@ -100,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) => @@ -147,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", @@ -160,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( @@ -179,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, @@ -195,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, @@ -220,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 { @@ -235,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, @@ -257,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 0167900e06..011fc63d08 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -6825,6 +6825,17 @@ 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", diff --git a/e2e/tests/mcp-connections.spec.ts b/e2e/tests/mcp-connections.spec.ts index ca458d6927..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({ @@ -1222,4 +1223,142 @@ export default defineApp({ user: { accounts: { service } } }, async () => ({ too }), ), ); + + 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);