Carry the verified Principal into DO agent sessions#1249
Conversation
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).
Greptile SummaryThis PR fixes a behavioral gap between the in-memory and Durable Object session stores: the in-memory store passes the full authenticated
Confidence Score: 5/5Safe to merge. The change is purely additive: the new field is optional, ownership validation is untouched, and unset sessions behave identically to before. The principal-carrying logic is correct on both the write path (carrySessionInit layers token fields on top of host-resolved meta without touching core identity) and the read path (buildMcpServer receives the full SessionMeta already). The helper is extracted into its own file with four unit tests that cover carry-through, both fields together, the no-op case, and the non-clobbering invariant. The two agent handlers are symmetric and the types align structurally. No behavioral regression for sessions that omit the field. No files require special attention. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant W as Worker (agent-handler)
participant DO as McpAgentSessionDOBase
participant RS as resolveSessionMeta (host)
participant CS as carrySessionInit
participant ST as DO Storage
W->>W: authenticate(request) → Principal
W->>W: validateMcpSessionOwner (userId + orgId)
W->>W: propsForPrincipal builds McpSessionInit incl. principal
W->>DO: "fetch(request) with ctx.props = {session: McpSessionInit}"
DO->>DO: init() — first request or cold revival
DO->>RS: resolveSessionMeta(token)
RS-->>DO: SessionMeta (orgId, orgName, userId, …)
DO->>CS: carrySessionInit(resolved, token)
Note over CS: spreads resolved, then token.webOrigin and token.principal
CS-->>DO: SessionMeta + webOrigin + principal
DO->>ST: storage.put(session-meta, sessionMeta)
DO->>DO: buildMcpServer(sessionMeta, dbHandle) — sessionMeta.principal now available
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant W as Worker (agent-handler)
participant DO as McpAgentSessionDOBase
participant RS as resolveSessionMeta (host)
participant CS as carrySessionInit
participant ST as DO Storage
W->>W: authenticate(request) → Principal
W->>W: validateMcpSessionOwner (userId + orgId)
W->>W: propsForPrincipal builds McpSessionInit incl. principal
W->>DO: "fetch(request) with ctx.props = {session: McpSessionInit}"
DO->>DO: init() — first request or cold revival
DO->>RS: resolveSessionMeta(token)
RS-->>DO: SessionMeta (orgId, orgName, userId, …)
DO->>CS: carrySessionInit(resolved, token)
Note over CS: spreads resolved, then token.webOrigin and token.principal
CS-->>DO: SessionMeta + webOrigin + principal
DO->>ST: storage.put(session-meta, sessionMeta)
DO->>DO: buildMcpServer(sessionMeta, dbHandle) — sessionMeta.principal now available
Reviews (1): Last reviewed commit: "Carry the verified Principal into DO age..." | Re-trigger Greptile |




Problem
The two session stores give the host's server builder different views of the authenticated caller.
The in-memory store hands
buildServerthe fullPrincipal:The Durable Object bridge does not.
propsForPrincipalin the cloud and host-cloudflare agent handlers buildsMcpSessionInitfrom justprincipal.organizationIdandprincipal.accountId, andSessionMetacan never contain more thanMcpSessionInitcarried in. Everything else the auth seam verified (email, name, avatarUrl, roles, organizationSlug) is discarded at that boundary, even though the worker had it in hand one call earlier.So a host's
buildMcpServerstructurally cannot see the identity the request was authenticated with. A server builder that reads the principal (per-caller tool policies, audit attribution) behaves correctly on the in-memory store and silently loses identity on DO sessions.Change
Carry the principal whole across the DO boundary, the same way
webOriginis already carried:McpSessionInitandSessionMetagain one optionalprincipal?: Principalfield. No new type:Principalis already the shared authenticated-caller noun on both sides of the seam.webOrigin. That merge is extracted into a small purecarrySessionInithelper (session-meta.ts) so it is testable and so no host'sresolveSessionMetaneeds to know about either carried field.principalto the session props they already build from that same principal.Optional and additive: unset changes no behavior, and ownership validation stays keyed on userId + organizationId only.
Two semantics made explicit on the field docs:
SessionMetapersists in DO storage, so the carried principal is frozen at session create, exactly likeuserIdandorganizationIdtoday (a session does not re-authenticate). That matches the in-memory store, which also captures the principal at create.Context: we run the Cloudflare host with host-side plugins built per session in
buildMcpServer; one enforces access grants matched on the caller's email and groups, which works against the in-memory store's contract and needed this carrier on the DO path.Testing
All run on this branch, all passing:
bun run --cwd packages/hosts/cloudflare test -- src/mcp/session-meta.test.ts: newcarrySessionInittest, 4/4 (carry-through, both fields, unset leaves no phantom keys, no clobbering of host-resolved meta)bun run --cwd apps/host-cloudflare test -- src/worker.e2e.node.test.ts: workerd/miniflare e2e through the real DO session path (initialize, cross-request survival, tools/call), 8/8bun run --cwd apps/cloud test -- src/mcp-session.e2e.node.test.ts: cloud session harness, 4/4bunx turbo run typecheckon the three touched packages (24/24),bun run format:checkandbun run lintclean at the root