diff --git a/.changeset/connection-key-scope.md b/.changeset/connection-key-scope.md new file mode 100644 index 0000000000..c0a592e7c8 --- /dev/null +++ b/.changeset/connection-key-scope.md @@ -0,0 +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, 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/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..eda284a776 100644 --- a/e2e/tests/mcp-connections.spec.ts +++ b/e2e/tests/mcp-connections.spec.ts @@ -1361,4 +1361,254 @@ 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); + + // 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/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..99026f8b42 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 { @@ -771,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( { @@ -779,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/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/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/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 }), 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,