Skip to content

Carry the verified Principal into DO agent sessions#1249

Open
capeflow wants to merge 1 commit into
RhysSullivan:mainfrom
capeflow:mcp-session-principal-parity
Open

Carry the verified Principal into DO agent sessions#1249
capeflow wants to merge 1 commit into
RhysSullivan:mainfrom
capeflow:mcp-session-principal-parity

Conversation

@capeflow

@capeflow capeflow commented Jul 2, 2026

Copy link
Copy Markdown

Problem

The two session stores give the host's server builder different views of the authenticated caller.

The in-memory store hands buildServer the full Principal:

// packages/hosts/mcp/src/in-memory-session-store.ts
return buildServer(principal, { ...buildOptionsFor(request, () => createdSessionId), resource })

The Durable Object bridge does not. propsForPrincipal in the cloud and host-cloudflare agent handlers builds McpSessionInit from just principal.organizationId and principal.accountId, and SessionMeta can never contain more than McpSessionInit carried 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 buildMcpServer structurally 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 webOrigin is already carried:

  • McpSessionInit and SessionMeta gain one optional principal?: Principal field. No new type: Principal is already the shared authenticated-caller noun on both sides of the seam.
  • The base copies it onto the stored meta alongside webOrigin. That merge is extracted into a small pure carrySessionInit helper (session-meta.ts) so it is testable and so no host's resolveSessionMeta needs to know about either carried field.
  • The cloud and host-cloudflare agent handlers add principal to 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:

  • Snapshot at mint. SessionMeta persists in DO storage, so the carried principal is frozen at session create, exactly like userId and organizationId today (a session does not re-authenticate). That matches the in-memory store, which also captures the principal at create.
  • PII at rest. Email/name/avatarUrl land in DO storage for the session's lifetime (bounded by the session alarm). The in-memory store already holds the same data for the same lifetime, in process memory. The optional shape keeps this opt-in per host if that difference matters.

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: new carrySessionInit test, 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/8
  • bun run --cwd apps/cloud test -- src/mcp-session.e2e.node.test.ts: cloud session harness, 4/4
  • Full suites of the touched packages: hosts/cloudflare 15/15, hosts/mcp 57/57, cloud 191/191
  • bunx turbo run typecheck on the three touched packages (24/24), bun run format:check and bun run lint clean at the root

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).
@capeflow

capeflow commented Jul 2, 2026

Copy link
Copy Markdown
Author
CleanShot 2026-07-02 at 08 53 49@2x CleanShot 2026-07-02 at 08 54 12@2x CleanShot 2026-07-02 at 09 27 40@2x CleanShot 2026-07-02 at 09 32 00@2x

@capeflow capeflow marked this pull request as ready for review July 2, 2026 02:41
@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a behavioral gap between the in-memory and Durable Object session stores: the in-memory store passes the full authenticated Principal to buildServer, while the DO bridge only forwarded organizationId and accountId, silently discarding email, name, avatarUrl, roles, and slug. The fix carries the complete principal as a new optional field on McpSessionInit and SessionMeta, with the merge logic extracted into a testable carrySessionInit helper.

  • McpSessionInit and SessionMeta gain readonly principal?: Principal — additive and backward-compatible; unset changes no behavior, and ownership validation remains keyed on userId + organizationId only.
  • Both agent handlers (apps/cloud and apps/host-cloudflare) now include principal in the session props they already build from the verified caller, mirroring what the in-memory store already delivered to buildServer.
  • The base DO class delegates the carried-field merge to carrySessionInit, which ensures webOrigin and principal from the token are layered on top of host-resolved meta without letting them overwrite core identity fields.

Confidence Score: 5/5

Safe 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

Filename Overview
packages/hosts/cloudflare/src/mcp/session-meta.ts New helper that merges host-resolved SessionMeta with carried fields (webOrigin, principal) from McpSessionInit. Clean, pure, well-documented, and well-tested.
packages/hosts/cloudflare/src/mcp/session-meta.test.ts Four targeted unit tests for carrySessionInit: carry-through, both fields together, neither field (no phantom keys), and no clobbering of core identity fields. Full coverage of the helper's contract.
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts Adds principal?: Principal to McpSessionInit and SessionMeta, imports carrySessionInit, and replaces the inline spread in resolveAndStoreSessionMeta with the helper. No behavioral change for sessions that omit the field.
apps/cloud/src/mcp/agent-handler.ts Adds principal to the session props already built from the verified caller. No import change required; the type is satisfied structurally via Extract<AuthOutcome, …>["principal"].
apps/host-cloudflare/src/mcp/agent-handler.ts Same one-line addition as the cloud handler; Principal is already imported and the field addition is symmetric. Clean.

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
Loading
%%{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
Loading

Reviews (1): Last reviewed commit: "Carry the verified Principal into DO age..." | Re-trigger Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant