OAuth2 authentication layer for Memory Core MCP connections
Context
Per-model GitHub accounts (#10144 ✓ complete) make each agent a first-class graph identity — @neo-opus-ada, @neo-gemini-pro, @tobiu all seeded via ai/scripts/seedAgentIdentities.mjs. MC MCP must resolve every invocation to a single authoritative identity so that (a) ChromaDB writes carry the correct tenant tag, (b) reads filter per tenant, (c) future AgentIdentity graph edges (AUTHORED_BY, OWNED_BY) terminate on the right node.
Scope-reshape note (intake 2026-04-21, session d69ac7a0-9fe8-4416-b766-cd9edb8bee71): the ticket was drafted before a full survey of the existing auth substrate. Intake revealed that #10000 already shipped the OIDC/OAuth path for SSE transport. Original body's prescription "GitHub OAuth2 flow in Memory Core connect handshake" is partially redundant for SSE and doesn't address the real stdio gap. This body is updated to reflect the architectural reality discovered during intake, per the ticket-intake "Hypothesis vs Root Cause" challenge.
The Problem
Three distinct gaps, all reachable in one cohesive change:
- Stdio identity is never resolved. With per-agent GitHub accounts, every stdio connection needs to flow an identity through
RequestContextService so MemoryService.addMemory, mutate_frontier, and future write tools tag writes with the resolved userId. Today stdio skips the context entirely — writes fall through to single-tenant mode regardless of which agent is running.
- AgentIdentity graph-node ID isn't bound to the runtime identity. #10144 created the node type and seeded three identities, but
RequestContext today only carries {userId, username}. Services that want to build AUTHORED_BY / OWNED_BY edges at write time need the resolved agentIdentityNodeId in context.
- No preemptive anti-spoof guard. Current write schemas (
AddMemoryRequest) have no client-supplied identity field, so there is no spoof surface today. But Mailbox (#10139) will add from / recipient fields; without a server-side guard, a Gemini-harness session could claim from: '@neo-opus-ada'. The guard belongs at the callTool dispatch choke-point, fires before any write-tool service method.
The Architectural Reality
| Layer |
Stdio (today) |
SSE (today, via #10000) |
#10145 delta |
| Identity source |
❌ none |
✅ OIDC Bearer token introspection (AuthService.mjs) |
Stdio: env-var → gh api user fallback |
RequestContextService populated |
❌ no |
✅ yes (TransportService wraps /mcp in run()) |
Stdio: wrap mcpServer.connect in run() |
userId on ChromaDB writes |
❌ untagged |
✅ metadata.userId (MemoryService.mjs:92) |
Stdio: tagged once RequestContext flows |
| AgentIdentity node binding |
❌ not done |
❌ not done |
Both paths: GraphService.getNode({id: githubLogin}) → agentIdentityNodeId in context |
| Anti-spoof on write tools |
n/a (no surface) |
n/a (no surface) |
New: middleware rejects client-supplied identity-override fields |
Existing infrastructure (touch, don't invent):
ai/mcp/server/shared/services/AuthService.mjs — OIDC handler (#10000). Extend to populate agentIdentityNodeId.
ai/mcp/server/shared/services/RequestContextService.mjs — AsyncLocalStorage propagation (#10000). Extend shape with agentIdentityNodeId, source.
ai/mcp/server/memory-core/services/MemoryService.mjs — already tags writes via getUserId(); no change needed, benefits transitively.
ai/scripts/seedAgentIdentities.mjs — binding consumer (#10144).
New files:
ai/mcp/server/shared/services/StdioIdentityResolver.mjs — env-var → gh-CLI → unresolved chain.
ai/mcp/server/shared/services/AuthMiddleware.mjs — anti-spoof guard at callTool dispatch.
learn/agentos/tooling/MemoryCoreMcpAuth.md — design doc.
test/playwright/unit/ai/mcp/server/memory-core/Auth.spec.mjs — coverage.
Modified file:
ai/mcp/server/memory-core/Server.mjs — stdio branch wraps mcpServer.connect(transport) in RequestContextService.run(identity, ...).
The Fix
StdioIdentityResolver — reads NEO_AGENT_IDENTITY env-var; falls back to gh api user via child_process; returns {userId, username, agentIdentityNodeId, source: 'env-var' | 'gh-cli' | 'unresolved'}. Resolution runs once at server initAsync(); result cached on the running server instance.
- AgentIdentity graph binding — both paths (SSE
AuthService.verifyAccessToken + stdio StdioIdentityResolver) call GraphService.getNode({id: githubLogin}); attach agentIdentityNodeId to the RequestContext. Missing-node case = null (not a fatal error — an unseeded identity can still write, just without graph edges).
- Server wiring — stdio branch in
Server.mjs wraps mcpServer.connect(transport) in RequestContextService.run(identity, ...) so every subsequent callTool dispatch inherits the identity context.
- Anti-spoof middleware — thin layer at the
callTool choke-point; rejects any write-tool call where args contains fields that would override server-stamped identity (userId, agentId, githubLogin, future from, etc.). Present-day no-op on existing tools; activates once schemas grow such fields.
- Design doc —
learn/agentos/tooling/MemoryCoreMcpAuth.md — documents both paths, identity resolution chain, AgentIdentity binding, anti-spoof invariants, OAuth 2.1 spec-version pin (per epic-review Stage 5 observation on #10016).
- Playwright —
Auth.spec.mjs covers: env-var resolution → RequestContext populated; gh-CLI fallback (mocked child_process.execSync); AgentIdentity graph lookup binds agentIdentityNodeId; anti-spoof accept-path + reject-path.
Acceptance Criteria
Out of Scope
- OIDC/OAuth handshake for SSE transport — already shipped by #10000 (
AuthService.mjs); this ticket only augments its context shape
- OAuth App registration (client ID / secret provisioning) — out-of-repo ops task handled separately
- GitHub account provisioning for per-model identities (email addresses, GH accounts for Opus + Gemini) — handled externally
- Cross-tenant permission edges + integration test suite — scope of #10146
- Team vs private read filter — scope of #10010
- Migration of pre-existing untagged memories — scope of #10153 (lazy back-fill)
- Neo Agent Harness external auth — #10119 (different substrate, not blocking)
Avoided Traps
- Re-implementing OAuth2 handshake for SSE. Rejected.
AuthService.mjs already does introspection-based auth via #10000; a parallel handshake would create a second identity source of truth and diverge from the OIDC Gold Standard.
- Trusting client-supplied
userId / agent / githubLogin on write tools. Rejected. Server-stamps identity from RequestContext; middleware blocks override attempts even when schemas don't have such fields yet (defense-in-depth for #10139 additions).
- Hard-failing stdio without
NEO_AGENT_IDENTITY. Rejected. gh api user fallback preserves zero-friction local dev for humans; agents set the env-var explicitly in their harness config (Claude Code settings.json, Gemini .gemini/settings.json).
- Threading identity as an explicit parameter through every service method. Rejected per #10000 precedent. AsyncLocalStorage isolates the cross-cutting concern cleanly; the two boundaries (Transport sets, Services read) are the only code touching identity.
- Per-tool identity resolution instead of per-connection. Rejected. Resolve once at
initAsync, cache on the context. Re-resolving per callTool would hit the GitHub API N times per session.
- Conflating
agent metadata (harness profile name) with githubLogin identity. agent: 'antigravity' stays metadata in AddMemoryRequest; githubLogin: '@neo-gemini-pro' is the auth key. Never reconcile them.
Related
- Parent: #10016 (Multi-Tenant Identity & Data Privacy) — epic-review by Gemini 3.1 Pro (2026-04-21T14:28:30Z) + Claude Opus 4.7 (2026-04-21T18:22Z, this session)
- Predecessor (already shipped): #10000 (Hardened Identity Ingestion — OIDC path + RequestContextService)
- Predecessor (already shipped): #10144 (AgentIdentity node type + seed script, via PR #10162)
- Blocks: #10146 (cross-tenant perms depends on identity plumbing), #10139 (Mailbox addMessage anti-spoof guard)
- Adjacent: #10119 (different substrate — Neo Agent Harness external auth)
- Files touched:
ai/mcp/server/shared/services/{AuthService,RequestContextService,StdioIdentityResolver,AuthMiddleware}.mjs, ai/mcp/server/memory-core/Server.mjs, learn/agentos/tooling/MemoryCoreMcpAuth.md, learn/tree.json, test/playwright/unit/ai/mcp/server/memory-core/Auth.spec.mjs
Origin Session ID: 71dc3cd8-d39d-48e1-ac62-e240ca67d1a5 (ticket creation)
Reshape Session ID: d69ac7a0-9fe8-4416-b766-cd9edb8bee71 (intake reshape 2026-04-21, Claude Opus 4.7 / Claude Code)
OAuth2 authentication layer for Memory Core MCP connections
Context
Per-model GitHub accounts (#10144 ✓ complete) make each agent a first-class graph identity —
@neo-opus-ada,@neo-gemini-pro,@tobiuall seeded viaai/scripts/seedAgentIdentities.mjs. MC MCP must resolve every invocation to a single authoritative identity so that (a) ChromaDB writes carry the correct tenant tag, (b) reads filter per tenant, (c) future AgentIdentity graph edges (AUTHORED_BY,OWNED_BY) terminate on the right node.Scope-reshape note (intake 2026-04-21, session
d69ac7a0-9fe8-4416-b766-cd9edb8bee71): the ticket was drafted before a full survey of the existing auth substrate. Intake revealed that #10000 already shipped the OIDC/OAuth path for SSE transport. Original body's prescription "GitHub OAuth2 flow in Memory Core connect handshake" is partially redundant for SSE and doesn't address the real stdio gap. This body is updated to reflect the architectural reality discovered during intake, per the ticket-intake "Hypothesis vs Root Cause" challenge.The Problem
Three distinct gaps, all reachable in one cohesive change:
RequestContextServicesoMemoryService.addMemory,mutate_frontier, and future write tools tag writes with the resolveduserId. Today stdio skips the context entirely — writes fall through to single-tenant mode regardless of which agent is running.RequestContexttoday only carries{userId, username}. Services that want to buildAUTHORED_BY/OWNED_BYedges at write time need the resolvedagentIdentityNodeIdin context.AddMemoryRequest) have no client-supplied identity field, so there is no spoof surface today. But Mailbox (#10139) will addfrom/recipientfields; without a server-side guard, a Gemini-harness session could claimfrom: '@neo-opus-ada'. The guard belongs at thecallTooldispatch choke-point, fires before any write-tool service method.The Architectural Reality
AuthService.mjs)gh api userfallbackRequestContextServicepopulatedTransportServicewraps/mcpinrun())mcpServer.connectinrun()userIdon ChromaDB writesmetadata.userId(MemoryService.mjs:92)GraphService.getNode({id: githubLogin})→agentIdentityNodeIdin contextExisting infrastructure (touch, don't invent):
ai/mcp/server/shared/services/AuthService.mjs— OIDC handler (#10000). Extend to populateagentIdentityNodeId.ai/mcp/server/shared/services/RequestContextService.mjs— AsyncLocalStorage propagation (#10000). Extend shape withagentIdentityNodeId,source.ai/mcp/server/memory-core/services/MemoryService.mjs— already tags writes viagetUserId(); no change needed, benefits transitively.ai/scripts/seedAgentIdentities.mjs— binding consumer (#10144).New files:
ai/mcp/server/shared/services/StdioIdentityResolver.mjs— env-var → gh-CLI → unresolved chain.ai/mcp/server/shared/services/AuthMiddleware.mjs— anti-spoof guard atcallTooldispatch.learn/agentos/tooling/MemoryCoreMcpAuth.md— design doc.test/playwright/unit/ai/mcp/server/memory-core/Auth.spec.mjs— coverage.Modified file:
ai/mcp/server/memory-core/Server.mjs— stdio branch wrapsmcpServer.connect(transport)inRequestContextService.run(identity, ...).The Fix
StdioIdentityResolver— readsNEO_AGENT_IDENTITYenv-var; falls back togh api uservia child_process; returns{userId, username, agentIdentityNodeId, source: 'env-var' | 'gh-cli' | 'unresolved'}. Resolution runs once at serverinitAsync(); result cached on the running server instance.AuthService.verifyAccessToken+ stdioStdioIdentityResolver) callGraphService.getNode({id: githubLogin}); attachagentIdentityNodeIdto the RequestContext. Missing-node case =null(not a fatal error — an unseeded identity can still write, just without graph edges).Server.mjswrapsmcpServer.connect(transport)inRequestContextService.run(identity, ...)so every subsequentcallTooldispatch inherits the identity context.callToolchoke-point; rejects any write-tool call whereargscontains fields that would override server-stamped identity (userId,agentId,githubLogin, futurefrom, etc.). Present-day no-op on existing tools; activates once schemas grow such fields.learn/agentos/tooling/MemoryCoreMcpAuth.md— documents both paths, identity resolution chain, AgentIdentity binding, anti-spoof invariants, OAuth 2.1 spec-version pin (per epic-review Stage 5 observation on #10016).Auth.spec.mjscovers: env-var resolution → RequestContext populated; gh-CLI fallback (mockedchild_process.execSync); AgentIdentity graph lookup bindsagentIdentityNodeId; anti-spoof accept-path + reject-path.Acceptance Criteria
StdioIdentityResolverresolvesNEO_AGENT_IDENTITY→gh api user→ unresolved; unit test covers all three branchesRequestContextshape extended withagentIdentityNodeIdandsource; existinggetUserId()/getUsername()accessors unchanged (backward-compatible)Server.mjsstdio branch wrapsmcpServer.connectinRequestContextService.run()— subsequentaddMemorycalls tag writes with resolveduserIdcallToolinvocationlearn/agentos/tooling/MemoryCoreMcpAuth.md; registered inlearn/tree.jsontest/playwright/unit/ai/mcp/server/memory-core/Auth.spec.mjspasses undernpm run test-unitOut of Scope
AuthService.mjs); this ticket only augments its context shapeAvoided Traps
AuthService.mjsalready does introspection-based auth via #10000; a parallel handshake would create a second identity source of truth and diverge from the OIDC Gold Standard.userId/agent/githubLoginon write tools. Rejected. Server-stamps identity from RequestContext; middleware blocks override attempts even when schemas don't have such fields yet (defense-in-depth for #10139 additions).NEO_AGENT_IDENTITY. Rejected.gh api userfallback preserves zero-friction local dev for humans; agents set the env-var explicitly in their harness config (Claude Codesettings.json, Gemini.gemini/settings.json).initAsync, cache on the context. Re-resolving percallToolwould hit the GitHub API N times per session.agentmetadata (harness profile name) withgithubLoginidentity.agent: 'antigravity'stays metadata inAddMemoryRequest;githubLogin: '@neo-gemini-pro'is the auth key. Never reconcile them.Related
ai/mcp/server/shared/services/{AuthService,RequestContextService,StdioIdentityResolver,AuthMiddleware}.mjs,ai/mcp/server/memory-core/Server.mjs,learn/agentos/tooling/MemoryCoreMcpAuth.md,learn/tree.json,test/playwright/unit/ai/mcp/server/memory-core/Auth.spec.mjsOrigin Session ID:
71dc3cd8-d39d-48e1-ac62-e240ca67d1a5(ticket creation) Reshape Session ID:d69ac7a0-9fe8-4416-b766-cd9edb8bee71(intake reshape 2026-04-21, Claude Opus 4.7 / Claude Code)