From 26cc0580c954dd8936789ac8555801e2f571dba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20B=C3=BChringer?= <93698155+capeflow@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:47:26 +0700 Subject: [PATCH] Carry the verified Principal into DO agent sessions The in-memory session store hands buildServer the full Principal, but the Durable Object bridge forwards only organizationId and accountId into McpSessionInit, so a host's buildMcpServer cannot see the identity the request was authenticated with. Carry the principal whole, like webOrigin: one optional field on McpSessionInit and SessionMeta, copied onto the stored meta by the base (extracted as carrySessionInit with tests), and forwarded by the cloud and host-cloudflare agent handlers from the principal they already hold. Optional and additive: unset changes no behavior, ownership validation stays keyed on userId + organizationId, and the carried principal is a snapshot at session mint (a session does not re-authenticate). --- apps/cloud/src/mcp/agent-handler.ts | 3 ++ apps/host-cloudflare/src/mcp/agent-handler.ts | 3 ++ .../src/mcp/agent-session-durable-object.ts | 19 +++++-- .../cloudflare/src/mcp/session-meta.test.ts | 54 +++++++++++++++++++ .../hosts/cloudflare/src/mcp/session-meta.ts | 17 ++++++ 5 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 packages/hosts/cloudflare/src/mcp/session-meta.test.ts create mode 100644 packages/hosts/cloudflare/src/mcp/session-meta.ts diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 239215920..9a2be7057 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -105,6 +105,9 @@ const propsForPrincipal = ( elicitationMode: readElicitationMode(request), resource, webOrigin: new URL(request.url).origin, + // The whole verified caller, so the session DO rebuilds its runtime + // with the same identity the in-memory store passes to buildServer. + principal, }, propagation, }; diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index 1c82479df..12d8fd0f1 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -96,6 +96,9 @@ const propsForPrincipal = ( // resource. resource: defaultMcpResource, webOrigin: new URL(request.url).origin, + // The whole verified caller, so the session DO rebuilds its runtime + // with the same identity the in-memory store passes to buildServer. + principal, }, propagation, }; diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index a2c8fdff1..3cb0c6b62 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -14,7 +14,7 @@ import { PAUSED_APPROVAL_TIMEOUT_MS, type PausedExecutionHooks, } from "@executor-js/host-mcp/tool-server"; -import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; +import { defaultMcpResource, type McpResource, type Principal } from "@executor-js/host-mcp"; import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; import { @@ -22,6 +22,7 @@ import { decideSessionAlarm, pausedLeaseExtensionLog, } from "./session-alarm-policy"; +import { carrySessionInit } from "./session-meta"; export type IncomingTraceHeaders = IncomingPropagationHeaders; @@ -33,6 +34,11 @@ export interface McpSessionInit { * `/mcp/toolkits/` toolkit), so the tool catalog is scoped to it. */ readonly resource: McpResource; readonly webOrigin?: string; + /** The verified caller, carried whole so the DO can rebuild its runtime with + * the same identity the in-memory session store already passes to + * `buildServer`. Optional and additive: unset changes no behavior, and + * ownership validation stays keyed on userId + organizationId. */ + readonly principal?: Principal; } export interface McpSessionProps extends Record { @@ -89,6 +95,12 @@ export interface SessionMeta { * `buildMcpServer` scopes the tool catalog to it. */ readonly resource: McpResource; readonly webOrigin?: string; + /** The verified caller (carried from {@link McpSessionInit}), persisted with + * the meta so a cold isolate rebuilds the runtime with the same identity. + * A snapshot at session mint, same lifetime semantics as userId and + * organizationId (a session does not re-authenticate). Optional; ownership + * validation still keys on userId + organizationId only. */ + readonly principal?: Principal; } export interface BuiltMcpServer { @@ -341,10 +353,7 @@ export abstract class McpAgentSessionDOBase< const self = this; return Effect.gen(function* () { const resolved = yield* self.resolveSessionMeta(token); - const sessionMeta: SessionMeta = { - ...resolved, - ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}), - }; + const sessionMeta = carrySessionInit(resolved, token); yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( Effect.withSpan("mcp.session.save_meta"), ); diff --git a/packages/hosts/cloudflare/src/mcp/session-meta.test.ts b/packages/hosts/cloudflare/src/mcp/session-meta.test.ts new file mode 100644 index 000000000..0696b7a96 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/session-meta.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@effect/vitest"; + +import type { Principal } from "@executor-js/host-mcp"; + +import type { SessionMeta } from "./agent-session-durable-object"; +import { carrySessionInit } from "./session-meta"; + +const resolved: SessionMeta = { + organizationId: "org-1", + organizationName: "Org One", + userId: "acct-1", + elicitationMode: "model", + resource: { kind: "default" }, +}; + +const principal: Principal = { + accountId: "acct-1", + organizationId: "org-1", + organizationName: "Org One", + email: "person@example.com", + name: "Person", + avatarUrl: null, + roles: ["admin"], +}; + +describe("carrySessionInit", () => { + it("carries the verified principal onto the stored meta", () => { + const meta = carrySessionInit(resolved, { principal }); + expect(meta.principal).toEqual(principal); + }); + + it("carries webOrigin and principal together", () => { + const meta = carrySessionInit(resolved, { + webOrigin: "https://executor.example.com", + principal, + }); + expect(meta.webOrigin).toBe("https://executor.example.com"); + expect(meta.principal).toEqual(principal); + }); + + it("leaves both unset when the init carries neither", () => { + const meta = carrySessionInit(resolved, {}); + expect(meta).toEqual(resolved); + expect("principal" in meta).toBe(false); + expect("webOrigin" in meta).toBe(false); + }); + + it("does not let carried fields clobber host-resolved meta", () => { + const meta = carrySessionInit(resolved, { principal }); + expect(meta.organizationId).toBe("org-1"); + expect(meta.userId).toBe("acct-1"); + expect(meta.resource).toEqual({ kind: "default" }); + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/session-meta.ts b/packages/hosts/cloudflare/src/mcp/session-meta.ts new file mode 100644 index 000000000..a4edd0580 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/session-meta.ts @@ -0,0 +1,17 @@ +import type { McpSessionInit, SessionMeta } from "./agent-session-durable-object"; + +/** + * Merge the host-resolved meta with the fields the base carries verbatim from + * `McpSessionInit`: `webOrigin` and the verified `principal`. Carrying them + * here (rather than in every host's `resolveSessionMeta`) keeps each host's + * resolver lossless without it knowing about either field, and mirrors what + * the in-memory session store gives `buildServer`: the whole principal. + */ +export const carrySessionInit = ( + resolved: SessionMeta, + token: Pick, +): SessionMeta => ({ + ...resolved, + ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}), + ...(token.principal ? { principal: token.principal } : {}), +});