From 6e452445db2f656127f2186e3f5cc3dd016570bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Nov 2026 06:59:37 +0000 Subject: [PATCH 1/2] Bind a scoped MCP connection even when the app does not receive caller. A connection key's Executor app self-call uses the caller's full managed key. The calling connection was taken only from caller, which a build that does not declare callerIdentity never receives, so create and mint ran with that full key for org admins too. The connection now travels beside caller, and the app worker is named by it so a warm worker from another connection cannot serve the call. Co-authored-by: shuv --- .changeset/connection-key-scope.md | 5 + e2e/test-plan.ts | 11 + e2e/tests/mcp-connections.spec.ts | 210 ++++++++++++++++++ packages/sdk/src/contracts/runtime.ts | 5 + packages/sdk/src/implementation/app-runner.ts | 5 +- .../sdk/src/implementation/app-runtime.ts | 6 + .../src/implementation/remote-app-runner.ts | 3 + packages/sdk/src/implementation/tools.ts | 11 + .../sdk/src/implementation/workerd-client.ts | 3 +- 9 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 .changeset/connection-key-scope.md diff --git a/.changeset/connection-key-scope.md b/.changeset/connection-key-scope.md new file mode 100644 index 0000000000..abcb9565c7 --- /dev/null +++ b/.changeset/connection-key-scope.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +A scoped MCP connection stays scoped when its key calls the Executor app. The calling connection is bound onto the app invocation even when that build does not receive `caller`, and a warm app worker loaded for another connection cannot serve the call. Create, update, and mint through that self-call stay inside the connection, including for an organization admin. diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index 011fc63d08..970e0faa5d 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -6836,6 +6836,17 @@ export const scenarios = plan({ local: na("Local has one operator and no organization roles or managed Executor keys."), }, }, + adminConnectionKeyNoWidening: { + fixtures: "actors", + file: "mcp-connections.spec.ts", + title: + "An org admin's connection key cannot widen a connection or mint outside its scope, on the API or through the Executor app", + 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 e13c3ba6ff..89f05c8f4a 100644 --- a/e2e/tests/mcp-connections.spec.ts +++ b/e2e/tests/mcp-connections.spec.ts @@ -1361,4 +1361,214 @@ export default defineApp({ user: { accounts: { service } } }, async () => ({ too }).pipe(Effect.provide(Layer.mergeAll(McpOAuth.layer, McpClient.layer))), ), ); + + it.effect(scenarios.adminConnectionKeyNoWidening.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: executorApp, account: executorAccount } = yield* managementApp(actors.admin); + 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: executorApp.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.admin, "POST", `${prefix}/mcp-connections`, { + id: randomUUID(), + name, + apps, + }); + expect(created.status, JSON.stringify(created.body)).toBe(200); + return yield* body(ConnectionView, created); + }); + const scoped = yield* save(`Scoped ${randomUUID().slice(0, 6)}`, [executorEntry]); + const wide = yield* save(`Wide ${randomUUID().slice(0, 6)}`, [executorEntry, meshEntry]); + // Another member's connections stay unreachable, including an admin's. + expect( + (yield* api.request(actors.member, "POST", `${prefix}/mcp-connections/${wide.id}/keys`, { + expiresInSeconds: 3600, + })).status, + ).toBe(404); + + const minted = yield* api.request( + actors.admin, + "POST", + `${prefix}/mcp-connections/${scoped.id}/keys`, + { name: "Scoped connection key", expiresInSeconds: 3600 }, + ); + expect(minted.status, JSON.stringify(minted.body)).toBe(200); + const key = yield* body( + Schema.Struct({ + id: Schema.String, + key: Schema.RedactedFromValue(Schema.String), + }), + minted, + ); + const bearer = { + authorization: `Bearer ${Redacted.value(key.key)}`, + "x-executor-organization": actors.organization.id, + }; + const asKey = (method: "GET" | "POST", route: string, data?: unknown) => + Effect.gen(function* () { + return yield* api.request(yield* api.session(), method, route, data, bearer); + }); + // The connection key is the bearer on the hosted API, with no Executor app self-call. + for (const response of [ + yield* asKey("POST", `${prefix}/mcp-connections`, { + id: randomUUID(), + name: "Wider than the key", + apps: [executorEntry, meshEntry], + }), + yield* asKey("POST", `${prefix}/mcp-connections/${wide.id}/keys`, { + expiresInSeconds: 3600, + }), + yield* asKey("GET", `${prefix}/mcp-connections/${wide.id}/keys`), + ]) { + expect(response.status, JSON.stringify(response.body)).toBe(403); + expect(response.body).toMatchObject({ _tag: "ConnectionKeyRefused" }); + } + + // Warm the Executor app from an unscoped key first. A later scoped call must not reuse + // that worker's outbound, which has no calling connection. + const issued = yield* api.request(actors.admin, "POST", "/api/auth/api-key/create", { + name: "Admin full key", + }); + expect(issued.status).toBe(200); + const fullKey = yield* body( + Schema.Struct({ key: Schema.RedactedFromValue(Schema.String), id: Schema.String }), + issued, + ); + yield* Effect.addFinalizer(() => + api + .request(actors.admin, "POST", "/api/auth/api-key/delete", { keyId: fullKey.id }) + .pipe(Effect.ignore), + ); + const tool = `tools[${JSON.stringify(executorApp.slug)}]`; + const invoke = (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 Program = Schema.Struct({ + ok: Schema.Boolean, + error: Schema.optional(Schema.Struct({ code: Schema.String, status: Schema.Number })), + value: Schema.optional(Schema.Unknown), + }); + const run = ( + secret: typeof key.key, + connection: string | undefined, + step: string, + code: string, + ) => + Effect.gen(function* () { + const client = yield* mcp.connect(secret, step, { + organization: actors.organization.id, + ...(connection === undefined ? {} : { connection }), + }); + const result = yield* executor(client)(step, code); + return yield* Schema.decodeUnknownEffect(Program)(result.execution.value); + }); + const warmed = yield* run( + fullKey.key, + undefined, + "An unscoped key lists connections through the Executor app", + invoke("mcpConnections.list", {}), + ); + expect(warmed.ok, JSON.stringify(warmed)).toBe(true); + + const widened = yield* run( + key.key, + scoped.id, + "A scoped connection key cannot create a wider connection", + invoke("mcpConnections.create", { + body: { + id: randomUUID(), + name: "Wider than the connection", + apps: [executorEntry, meshEntry], + }, + }), + ); + expect(widened, JSON.stringify(widened)).toMatchObject({ + ok: false, + error: { status: 403, code: "OrganizationForbidden" }, + }); + const outside = yield* run( + key.key, + scoped.id, + "A scoped connection key cannot mint a key outside its scope", + invoke("mcpConnections.mintKey", { + path: { connection: wide.id }, + body: { expiresInSeconds: 3600 }, + }), + ); + expect(outside, JSON.stringify(outside)).toMatchObject({ + ok: false, + error: { status: 403, code: "OrganizationForbidden" }, + }); + const listed = yield* run( + key.key, + scoped.id, + "A scoped connection key cannot list keys outside its scope", + invoke("mcpConnections.listKeys", { path: { connection: wide.id } }), + ); + expect(listed, JSON.stringify(listed)).toMatchObject({ + ok: false, + error: { status: 403, code: "OrganizationForbidden" }, + }); + + // A connection that grants every tool of its apps can still narrow and mint inside it. + const full = yield* save(`Full ${randomUUID().slice(0, 6)}`, [executorEntry, meshEntry]); + const fullMinted = yield* api.request( + actors.admin, + "POST", + `${prefix}/mcp-connections/${full.id}/keys`, + { expiresInSeconds: 3600 }, + ); + expect(fullMinted.status, JSON.stringify(fullMinted.body)).toBe(200); + const fullConnectionKey = yield* body( + Schema.Struct({ key: Schema.RedactedFromValue(Schema.String) }), + fullMinted, + ); + const meshId = randomUUID(); + const narrowed = yield* run( + fullConnectionKey.key, + full.id, + "A full connection key creates a narrower connection", + invoke("mcpConnections.create", { + body: { id: meshId, name: "Mesh seat", apps: [meshEntry] }, + }), + ); + expect(narrowed.ok, JSON.stringify(narrowed)).toBe(true); + const mintedInside = yield* run( + fullConnectionKey.key, + full.id, + "A full connection key mints inside its scope", + invoke("mcpConnections.mintKey", { + path: { connection: meshId }, + body: { expiresInSeconds: 3600 }, + }), + ); + expect(mintedInside.ok, JSON.stringify(mintedInside)).toBe(true); + }).pipe(Effect.provide(McpClient.layer)), + ), + ); }); diff --git a/packages/sdk/src/contracts/runtime.ts b/packages/sdk/src/contracts/runtime.ts index 944884a003..1932a2afb7 100644 --- a/packages/sdk/src/contracts/runtime.ts +++ b/packages/sdk/src/contracts/runtime.ts @@ -532,6 +532,11 @@ export interface Runtime { /** Decides the storage mode before the app runs; the host verifies it. */ readonly kind?: "query" | "mutation"; readonly input: Json; + /** + * The MCP connection this call arrived on. Stays on the invocation when the build does not + * declare `callerIdentity`, so the outbound still binds that connection. + */ + readonly callerConnection?: string; } & HostContext, ) => Effect.Effect; /** diff --git a/packages/sdk/src/implementation/app-runner.ts b/packages/sdk/src/implementation/app-runner.ts index 223791c8ac..c7dfc70c2c 100644 --- a/packages/sdk/src/implementation/app-runner.ts +++ b/packages/sdk/src/implementation/app-runner.ts @@ -407,6 +407,7 @@ export const appWorker = ( | "accounts" | "hostApi" | "callerConnection" + | "caller" >, ) => // Requirement names are unique across scopes, so one record names the account set. @@ -418,7 +419,9 @@ export const appWorker = ( // 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. // The network binding is fixed when the Worker loads, and it carries the calling connection. - const identity = [selection, invocation.hostApi?.revision, invocation.callerConnection] + // A warm worker loaded for another connection, or for none, must not serve this call. + const calling = invocation.callerConnection ?? invocation.caller?.provenance?.connectionId; + const identity = [selection, invocation.hostApi?.revision, calling] .filter((part) => part !== undefined && part !== "") .join(":"); return { diff --git a/packages/sdk/src/implementation/app-runtime.ts b/packages/sdk/src/implementation/app-runtime.ts index 99a7e6bc52..c2760609e1 100644 --- a/packages/sdk/src/implementation/app-runtime.ts +++ b/packages/sdk/src/implementation/app-runtime.ts @@ -169,6 +169,8 @@ export const appRuntime = (host: AppRuntimeHost) => readonly database: boolean; readonly sharedRuntime: boolean; readonly observeRevision?: (revision: number) => void; + /** The MCP connection this call arrived on, including when `caller` is not sent. */ + readonly callerConnection?: string; } & HostContext, command: HostRequest, output: Schema.Decoder, @@ -185,6 +187,9 @@ export const appRuntime = (host: AppRuntimeHost) => ); const build = input.build; const run = yield* InvocationRun; + // Bound beside `caller`: a build that does not declare callerIdentity never receives + // `caller`, and the connection id on it would otherwise never reach the outbound. + const callerConnection = input.callerConnection ?? input.caller?.provenance?.connectionId; const invocation: AppInvocation = { app: input.app, build, @@ -195,6 +200,7 @@ export const appRuntime = (host: AppRuntimeHost) => // Workers RPC structured-clones its arguments; Effect headers carry a prototype it rejects. headers: Object.fromEntries(Object.entries(yield* traceHeaders)), ...(input.hostApi === undefined ? {} : { hostApi: input.hostApi }), + ...(callerConnection === undefined ? {} : { callerConnection }), ...(input.approval === undefined ? {} : { approval: input.approval }), ...(input.caller === undefined ? {} : { caller: input.caller }), ...(input.approvedBy === undefined ? {} : { approvedBy: input.approvedBy }), diff --git a/packages/sdk/src/implementation/remote-app-runner.ts b/packages/sdk/src/implementation/remote-app-runner.ts index 8fe8696715..00916f2845 100644 --- a/packages/sdk/src/implementation/remote-app-runner.ts +++ b/packages/sdk/src/implementation/remote-app-runner.ts @@ -370,8 +370,11 @@ export const remoteAppRunner = (remote: RemoteAppRunner new AbortController()), (controller) => Effect.sync(() => controller.abort()), ); + const callerConnection = + invocation.callerConnection ?? invocation.caller?.provenance?.connectionId; const encoded = yield* Schema.encodeEffect(encodedInvocation)({ ...invocation, + ...(callerConnection === undefined ? {} : { callerConnection }), elicitation: capabilities.elicit !== null, workflowControls: capabilities.controls !== null, }).pipe(Effect.mapError(() => new RuntimeProtocolFailed())); diff --git a/packages/sdk/src/implementation/tools.ts b/packages/sdk/src/implementation/tools.ts index 5ebb958ba4..f08c129644 100644 --- a/packages/sdk/src/implementation/tools.ts +++ b/packages/sdk/src/implementation/tools.ts @@ -291,6 +291,15 @@ export const callerContext = ( } : {}; +/** + * The verified MCP connection, sent even when {@link callerContext} omits `caller`. The runner + * seals it into the outbound; the app bundle never sees it. + */ +const boundCallerConnection = (caller: Caller | undefined) => { + const connection = caller?.provenance?.connectionId; + return connection === undefined ? {} : { callerConnection: connection }; +}; + /** Background work names no organization itself; the app's organization owner is the one it runs in. */ const withOrganization = (caller: C, owner: string): C => caller.organizationId !== null || !owner.startsWith("organization:") @@ -1707,6 +1716,7 @@ export const makeTools = ( ...(kind === undefined ? {} : { kind }), input: args, ...callerContext(state), + ...boundCallerConnection(state.caller), ...(options?.elicitation === undefined ? {} : { elicitation: options.elicitation }), }); }).pipe( @@ -1907,6 +1917,7 @@ export const makeTools = ( input: originalInput, approval: { tool: saved.tool, input: saved.input }, ...callerContext(state, approver), + ...boundCallerConnection(state.caller), ...(options?.elicitation === undefined ? {} : { elicitation: options.elicitation }), diff --git a/packages/sdk/src/implementation/workerd-client.ts b/packages/sdk/src/implementation/workerd-client.ts index 1108c6cde6..1dcc97643e 100644 --- a/packages/sdk/src/implementation/workerd-client.ts +++ b/packages/sdk/src/implementation/workerd-client.ts @@ -236,7 +236,8 @@ 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 callerConnection = + invocation.callerConnection ?? invocation.caller?.provenance?.connectionId; const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(WorkerInvocation))({ ...invocation, ...(callerConnection === undefined ? {} : { callerConnection }), From a1f2ffee5ec388b7e3d62b03280bf9e423cc568a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Nov 2026 07:53:38 +0000 Subject: [PATCH 2/2] Bind the calling connection on nested tool calls and facet outbound. A scoped key's Executor app could call tools.call with the managed key. That API request ignored the calling connection, so the nested invocation was unbound and create, mint, and out-of-scope app calls succeeded. The organization API now narrows to that connection and stamps it on the caller. Facet workers get the same connection on their outbound. Co-authored-by: shuv --- .changeset/connection-key-scope.md | 4 +- apps/hosted/server/src/app-management.ts | 3 + .../server/src/implementation/caller.ts | 14 +++- .../server/src/implementation/organization.ts | 80 +++++++++++++++++-- .../hosted/server/src/implementation/tools.ts | 10 +++ e2e/tests/mcp-connections.spec.ts | 40 ++++++++++ packages/app-data/src/cloudflare.ts | 16 +++- packages/sdk/src/implementation/app-runner.ts | 3 + .../src/implementation/credential-handles.ts | 17 ++-- packages/sdk/src/implementation/workflows.ts | 6 +- 10 files changed, 173 insertions(+), 20 deletions(-) diff --git a/.changeset/connection-key-scope.md b/.changeset/connection-key-scope.md index abcb9565c7..c0a592e7c8 100644 --- a/.changeset/connection-key-scope.md +++ b/.changeset/connection-key-scope.md @@ -1,5 +1,7 @@ --- "@executor-js/sdk": patch +"@executor-js/hosted-server": patch +"@executor-js/app-data": patch --- -A scoped MCP connection stays scoped when its key calls the Executor app. The calling connection is bound onto the app invocation even when that build does not receive `caller`, and a warm app worker loaded for another connection cannot serve the call. Create, update, and mint through that self-call stay inside the connection, including for an organization admin. +A scoped MCP connection stays scoped when its key calls the Executor app, including a nested `tools.call`. The API narrows that request to the calling connection and stamps the connection on the caller, so a further app invocation stays bound. Facet apps get the same connection on their outbound. Create, update, and mint stay inside the connection, including for an organization admin. diff --git a/apps/hosted/server/src/app-management.ts b/apps/hosted/server/src/app-management.ts index a5a1fc03fe..015272e1ef 100644 --- a/apps/hosted/server/src/app-management.ts +++ b/apps/hosted/server/src/app-management.ts @@ -17,6 +17,7 @@ import { AppIdentity, AppGitAccess, AppAccessDenied } from "@executor-js/app-man import { Effect, Layer, Schema } from "effect"; import { Base64 } from "effect/encoding"; import { ApiAuthentication, Authentication } from "./contracts/auth.ts"; +import { McpAuthentication } from "./contracts/mcp.ts"; import { OrganizationTombstones } from "./contracts/organization-removal.ts"; import { OrganizationReference, @@ -70,6 +71,7 @@ export const hostedAppAccess = Layer.effect( const api = yield* ApiAuthentication; const auth = yield* Authentication; const tombstones = yield* OrganizationTombstones; + const mcp = yield* McpAuthentication; return (response, { endpoint }) => withOrganizationRequest( (namespace) => @@ -113,6 +115,7 @@ export const hostedAppAccess = Layer.effect( Effect.provideService(Authentication, auth), Effect.provideService(ApiAuthentication, api), Effect.provideService(OrganizationTombstones, tombstones), + Effect.provideService(McpAuthentication, mcp), ); }), ); diff --git a/apps/hosted/server/src/implementation/caller.ts b/apps/hosted/server/src/implementation/caller.ts index 7d5a0087d2..5ebef90315 100644 --- a/apps/hosted/server/src/implementation/caller.ts +++ b/apps/hosted/server/src/implementation/caller.ts @@ -31,6 +31,8 @@ export const apiCaller = ( readonly email?: string | null | undefined; readonly access: Pick; readonly key?: { readonly id: string } | undefined; + /** The scoped MCP connection the host attached to this request, when it arrived on one. */ + readonly connectionId?: string | undefined; }, issuer: string, ): Caller => ({ @@ -40,9 +42,15 @@ export const apiCaller = ( email: grant.email ?? null, organizationId: grant.access.organization, channel: "api", - ...(grant.key === undefined + ...(grant.key === undefined && grant.connectionId === undefined ? {} - : { provenance: { trusted: false, clientId: `apikey:${grant.key.id}` } }), + : { + provenance: { + trusted: false, + ...(grant.key === undefined ? {} : { clientId: `apikey:${grant.key.id}` }), + ...(grant.connectionId === undefined ? {} : { connectionId: grant.connectionId }), + }, + }), }); /** A signed-in person, in the dashboard or an app's own UI. */ @@ -51,6 +59,7 @@ export const personCaller = ( organization: string, issuer: string, channel: "ui" | "dashboard", + connectionId?: string, ): Caller => ({ kind: "human", subject: person.userId, @@ -58,6 +67,7 @@ export const personCaller = ( email: person.email ?? null, organizationId: organization, channel, + ...(connectionId === undefined ? {} : { provenance: { trusted: false as const, connectionId } }), }); /** A person who answered an approval request on the browser page or in the dashboard. */ diff --git a/apps/hosted/server/src/implementation/organization.ts b/apps/hosted/server/src/implementation/organization.ts index 88d4ee129d..6fc3a274e0 100644 --- a/apps/hosted/server/src/implementation/organization.ts +++ b/apps/hosted/server/src/implementation/organization.ts @@ -11,11 +11,20 @@ import { import { RequiredAction, CurrentAuthorization } from "../contracts/authorization.ts"; import { fullAuthority, + isToolSelectionSubset, permitsAction, permitsApp, permittedAppIds, + type AuthorizationPolicy, + type ToolSelection, } from "@executor-js/authorization"; -import { AppId, CurrentCaller } from "@executor-js/sdk/core"; +import { AppId, callerConnectionHeader, CurrentCaller } from "@executor-js/sdk/core"; +import { + ConnectionId, + connectionPermissions, + type ConnectionPolicy, +} from "@executor-js/mcp-auth/connections"; +import { McpAuthentication } from "../contracts/mcp.ts"; import { apiCaller, personCaller } from "./caller.ts"; import { Context } from "effect"; import { currentResourceAuthority, visibleAccountsAs, visibleAppsAs } from "./resource-policy.ts"; @@ -106,6 +115,43 @@ const refuseRemoved = (organization: OrganizationId) => Effect.flatMap((gone) => (gone ? Effect.fail(new OrganizationForbidden()) : Effect.void)), ); +/** The connection's tools, no wider than the credential's own selection. */ +const narrowToConnection = (grant: ToolSelection, policy: ConnectionPolicy): ToolSelection => { + const bound: ToolSelection = { kind: "tools", apps: [...connectionPermissions(policy)] }; + if (grant.kind === "all" || isToolSelectionSubset(grant, bound)) return bound; + if (isToolSelectionSubset(bound, grant)) return grant; + return { + kind: "tools", + apps: bound.apps.filter((item) => + isToolSelectionSubset(grant, { kind: "tools", apps: [item] }), + ), + }; +}; + +/** + * The host-attached calling connection, when this request arrived on one. A missing or foreign id + * is refused. The credential's actions stay; its tools narrow to that connection, and the caller + * carries the id so a nested app call binds the same connection. + */ +const scopedAuthority = (userId: string, organization: string, policy: AuthorizationPolicy) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const header = new Headers(request.headers).get(callerConnectionHeader); + if (header === null || header === "") return { policy, connectionId: undefined }; + const id = yield* Schema.decodeUnknownEffect(ConnectionId)(header).pipe( + Effect.mapError(() => new OrganizationForbidden()), + ); + const store = yield* McpAuthentication; + const found = (yield* store.connections.list({ userId, resource: organization })).find( + (connection) => connection.id === id, + ); + if (found === undefined) return yield* new OrganizationForbidden(); + return { + policy: { ...policy, tools: narrowToConnection(policy.tools, found.policy) }, + connectionId: found.id, + }; + }); + /** Access resolves only the explicit route ID or slug. Shared session preferences never participate. */ export const withOrganizationRequest = ( response: ( @@ -134,23 +180,32 @@ export const withOrganizationRequest = ( callerSpanAttributes(grant.access.organization, grant.userId), ); yield* refuseRemoved(grant.access.organization); - if (!permitsAction(grant.policy, action)) return yield* new OrganizationForbidden(); + const scoped = yield* scopedAuthority(grant.userId, grant.access.organization, grant.policy); + if (!permitsAction(scoped.policy, action)) return yield* new OrganizationForbidden(); if (params.app !== undefined) { const app = yield* Schema.decodeUnknownEffect(AppId)(params.app).pipe( Effect.mapError(() => new OrganizationForbidden()), ); - if (!permitsApp(grant.policy, app)) return yield* new OrganizationForbidden(); + if (!permitsApp(scoped.policy, app)) return yield* new OrganizationForbidden(); } if (grant.key !== undefined) yield* Effect.annotateCurrentSpan("executor.api_key.id", grant.key.id); const scope = Effect.succeed(publishingScope(grant.organizationSlug)); return (yield* response(scope).pipe( Effect.tapCause(ErrorReporter.report), - Effect.provideService(CurrentAuthorization, grant.policy), + Effect.provideService(CurrentAuthorization, scoped.policy), Effect.provideService(CurrentOrganization, grant.access), Effect.provideService(CurrentOrganizationNamespace, scope), Effect.provideService(CurrentUserId, grant.userId), - Effect.provideService(CurrentCaller, apiCaller(grant, api.issuer)), + Effect.provideService( + CurrentCaller, + apiCaller( + scoped.connectionId === undefined + ? grant + : { ...grant, connectionId: scoped.connectionId }, + api.issuer, + ), + ), Effect.provideService(CurrentUsage, { source: "api" }), )).pipe(HttpServerResponse.setHeader("cache-control", "no-store")); } @@ -179,6 +234,13 @@ export const withOrganizationRequest = ( owner: organizationOwner(organization), role: membership.role, }; + const scoped = yield* scopedAuthority(principal.userId, organization, fullAuthority); + if (scoped.connectionId !== undefined && params.app !== undefined) { + const app = yield* Schema.decodeUnknownEffect(AppId)(params.app).pipe( + Effect.mapError(() => new OrganizationForbidden()), + ); + if (!permitsApp(scoped.policy, app)) return yield* new OrganizationForbidden(); + } const scope = auth.organizationSlug(headers, organization).pipe(Effect.map(publishingScope)); return (yield* response(scope).pipe( Effect.tapCause(ErrorReporter.report), @@ -187,11 +249,13 @@ export const withOrganizationRequest = ( Effect.provideService(CurrentUserId, principal.userId), Effect.provideService( CurrentCaller, - personCaller(principal, organization, auth.origin, "dashboard"), + scoped.connectionId === undefined + ? personCaller(principal, organization, auth.origin, "dashboard") + : personCaller(principal, organization, auth.origin, "dashboard", scoped.connectionId), ), Effect.provideService(CurrentUsage, { source: "dashboard" }), Effect.provideService(CurrentPrincipal, principal), - Effect.provideService(CurrentAuthorization, fullAuthority), + Effect.provideService(CurrentAuthorization, scoped.policy), )).pipe(HttpServerResponse.setHeader("cache-control", "no-store")); }); @@ -202,6 +266,7 @@ export const requireOrganizationLive = Layer.effect( const auth = yield* Authentication; const api = yield* ApiAuthentication; const tombstones = yield* OrganizationTombstones; + const mcp = yield* McpAuthentication; return (response, { endpoint, group }) => withOrganizationRequest( () => { @@ -223,6 +288,7 @@ export const requireOrganizationLive = Layer.effect( Effect.provideService(Authentication, auth), Effect.provideService(ApiAuthentication, api), Effect.provideService(OrganizationTombstones, tombstones), + Effect.provideService(McpAuthentication, mcp), ); }), ); diff --git a/apps/hosted/server/src/implementation/tools.ts b/apps/hosted/server/src/implementation/tools.ts index ea6dda8af8..9e27fc7484 100644 --- a/apps/hosted/server/src/implementation/tools.ts +++ b/apps/hosted/server/src/implementation/tools.ts @@ -29,6 +29,7 @@ import { HttpApiBuilder } from "effect/http-api"; import { HostedApi } from "../contracts/api.ts"; import { HostedExecutor } from "../contracts/executor.ts"; import { CurrentUserId, Forbidden } from "../contracts/auth.ts"; +import { OrganizationForbidden } from "../contracts/organization.ts"; import { CurrentAuthorization } from "../contracts/authorization.ts"; import { browserOnly, @@ -218,6 +219,15 @@ export const callTool = (input: Parameters[0]) => result.status === "approval-required" ? Effect.fail(approvalRequired(result)) : Effect.succeed(result.value), + ).pipe( + // A nested app call that the calling connection refused must stay a 403, not a tool 502. + Effect.mapError((error) => + error._tag === "ToolCallFailed" && + error.response?.status === 403 && + error.response.code === "OrganizationForbidden" + ? new OrganizationForbidden() + : error, + ), ); /** * The signed-in person's own run from the dashboard. Its approval is saved with this issuer, so only diff --git a/e2e/tests/mcp-connections.spec.ts b/e2e/tests/mcp-connections.spec.ts index 89f05c8f4a..eda284a776 100644 --- a/e2e/tests/mcp-connections.spec.ts +++ b/e2e/tests/mcp-connections.spec.ts @@ -1568,6 +1568,46 @@ export default defineApp({ user: { accounts: { service } } }, async () => ({ too }), ); expect(mintedInside.ok, JSON.stringify(mintedInside)).toBe(true); + + // Nested tools.call: the Executor app calls the API again. The connection must still bind. + const nested = (step: string, app: string, body: unknown) => + run(key.key, scoped.id, step, invoke("tools.call", { path: { app }, body })); + const nestedWiden = yield* nested("nestedWiden", executorApp.id, { + tool: "mcpConnections.create", + input: { + body: { + id: randomUUID(), + name: "Nested wider", + apps: [executorEntry, meshEntry], + }, + }, + }); + expect(nestedWiden, JSON.stringify(nestedWiden)).toMatchObject({ + ok: false, + error: { status: 403, code: "OrganizationForbidden" }, + }); + const nestedMint = yield* nested("nestedMint", executorApp.id, { + tool: "mcpConnections.mintKey", + input: { + path: { connection: wide.id }, + body: { expiresInSeconds: 3600 }, + }, + }); + expect(nestedMint, JSON.stringify(nestedMint)).toMatchObject({ + ok: false, + error: { status: 403, code: "OrganizationForbidden" }, + }); + const directMesh = yield* nested("directMesh", mesh.app.id, { + tool: "read", + input: {}, + }); + expect(directMesh, JSON.stringify(directMesh)).toMatchObject({ + ok: false, + error: { status: 403, code: "OrganizationForbidden" }, + }); + const afterNested = yield* api.request(actors.admin, "GET", `${prefix}/mcp-connections`); + expect(afterNested.status, JSON.stringify(afterNested.body)).toBe(200); + expect(JSON.stringify(afterNested.body)).not.toContain("Nested wider"); }).pipe(Effect.provide(McpClient.layer)), ), ); diff --git a/packages/app-data/src/cloudflare.ts b/packages/app-data/src/cloudflare.ts index 02200632b6..8efad66aa1 100644 --- a/packages/app-data/src/cloudflare.ts +++ b/packages/app-data/src/cloudflare.ts @@ -48,6 +48,8 @@ export const FacetInvocation = Schema.Struct({ */ concurrent: Schema.Boolean, headers: Schema.Record(Schema.String, Schema.String), + /** The MCP connection this call arrived on. The facet's outbound is bound to it. */ + callerConnection: Schema.optionalKey(Schema.String), }); /** * The supervisor attaches the revision the invocation observed, whether the invocation @@ -218,7 +220,11 @@ export const makeFacetSupervisor = ( * cache belongs to, when it reads its host's API. It substitutes credential handles and applies * the host's routing rules. */ - globalOutbound: (app: string, hostApi: HostApiCheck | undefined) => Fetcher, + globalOutbound: ( + app: string, + hostApi: HostApiCheck | undefined, + callerConnection?: string, + ) => Fetcher, /** * Whether this object is the supervisor named for `app`, from its own ID (for example * `state.id.equals(namespace.idFromName(app))`). Its storage is that app's, so it refuses an @@ -432,7 +438,7 @@ export const makeFacetSupervisor = ( }); const select = ( name: string, - invocation: Pick, + invocation: Pick, load: () => Promise, starts: FacetStarts, ) => @@ -452,7 +458,11 @@ export const makeFacetSupervisor = ( compatibilityDate: "2026-07-30", // The strictly-public flag would override this outbound and bypass it. compatibilityFlags: ["nodejs_compat", "no_brotli_content_encoding"], - globalOutbound: globalOutbound(invocation.app, invocation.hostApi), + globalOutbound: globalOutbound( + invocation.app, + invocation.hostApi, + invocation.callerConnection, + ), }; }); return { class: worker.getDurableObjectClass("ExecutorAppData") }; diff --git a/packages/sdk/src/implementation/app-runner.ts b/packages/sdk/src/implementation/app-runner.ts index c7dfc70c2c..99026f8b42 100644 --- a/packages/sdk/src/implementation/app-runner.ts +++ b/packages/sdk/src/implementation/app-runner.ts @@ -774,6 +774,8 @@ export const makeAppRunner = (host: AppRunnerHost) => { Effect.gen(function* () { const id = crypto.randomUUID(); const { command } = invocation; + const callerConnection = + invocation.callerConnection ?? invocation.caller?.provenance?.connectionId; const result = yield* target .invoke( { @@ -782,6 +784,7 @@ export const makeAppRunner = (host: AppRunnerHost) => { identity, cacheNamespace: appCacheNamespace(invocation.build, invocation.hostApi), ...(invocation.hostApi === undefined ? {} : { hostApi: invocation.hostApi }), + ...(callerConnection === undefined ? {} : { callerConnection }), body, headers: invocation.headers, write: diff --git a/packages/sdk/src/implementation/credential-handles.ts b/packages/sdk/src/implementation/credential-handles.ts index 008b618072..30f851d3a0 100644 --- a/packages/sdk/src/implementation/credential-handles.ts +++ b/packages/sdk/src/implementation/credential-handles.ts @@ -948,9 +948,9 @@ const forward = async (request: Request, outbound: CredentialOutbound): Promise< // 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( + const provider = [...opened.values()].find((sealed) => sealed.managed === true)?.provider; + const mismatch = () => + networkRefusalResponse( new NetworkRefused({ host: url.host, refusal: { @@ -960,10 +960,15 @@ const forward = async (request: Request, outbound: CredentialOutbound): Promise< }, }), ); - } + if (callerConnections.length > 1) return mismatch(); + const [sealedConnection] = callerConnections; + // The worker was loaded for one connection. A managed handle from another connection, or + // one that dropped it, must not replace that bind. + if (outbound.callerConnection !== undefined && sealedConnection !== outbound.callerConnection) + return mismatch(); headers.delete(callerConnectionHeader); - const [callerConnection] = callerConnections; - if (callerConnection !== undefined) headers.set(callerConnectionHeader, callerConnection); + const bound = outbound.callerConnection ?? sealedConnection; + if (bound !== undefined) headers.set(callerConnectionHeader, bound); } const response = await send( new Request(replaceAll(placed.success.href, encoded(anywhere, encodings.url)), { diff --git a/packages/sdk/src/implementation/workflows.ts b/packages/sdk/src/implementation/workflows.ts index 7d9a5c9fb4..7fa0ada8c7 100644 --- a/packages/sdk/src/implementation/workflows.ts +++ b/packages/sdk/src/implementation/workflows.ts @@ -219,7 +219,11 @@ export const makeWorkflowRuns = ( "engine", true, ); - /** The run's captured version, scope and accounts, checked against the actor it started for. */ + /** + * The run's captured version, scope and accounts, checked against the actor it started for. + * A workflow body is the host's own work. It does not carry the MCP connection that enqueued + * it, so a step's API calls are not that connection's self-call. + */ const captured = (row: typeof StoredRun.Type) => snapshot(db, { app: row.app,