Identity & sessions
Identity resolution is how mcpose answers "who is making this request?" without paying the cost of a full auth round trip on every call.
On HTTP, mcpose resolves a caller identity once when a session starts, then stamps that identity on every ProxyContext in the session.
Middleware reads context.identity instead of parsing credentials itself, and audit events record the same identity.
The Identity shape
The Identity interface is the contract between your resolveIdentity hook and the rest of the proxy.
Show the Identity interface
interface Identity {
sub: string; // Stable unique identifier (e.g. OIDC sub claim)
type: 'human' | 'agent' | 'service';
displayName?: string;
roles: string[]; // Governance inputs (e.g. ['admin'])
claims: Record<string, unknown>; // Raw claims from the source (JWT payload, cert CN, ...)
resolvedAt: string; // ISO 8601 timestamp of when identity was resolved
source: 'jwt' | 'mtls' | 'apikey' | 'custom'; // Which resolver produced this identity
}sub is the stable key: audit events and subject erasure use it to identify the caller.
type records whether the caller is a human user, an AI agent, or an automated service.
roles and claims carry what your middleware needs for governance, without re-parsing the original credential.
resolvedAt is the ISO 8601 timestamp of when the identity was resolved.
source says which resolver produced the identity, so an audit trail can tell whether the caller came in via JWT, mTLS, API key, or a custom resolver.
Your resolveIdentity hook builds the whole object; mcpose stamps it as-is.
Resolution lifecycle
resolveIdentity runs once per new session, on the initialize POST that creates it.
A session that already exists never re-runs resolution: its requests reuse the stored identity.
If the resolver throws, the session is aborted with a 401.
A resolver that cannot establish who the caller is therefore fails fast at session start instead of producing an unauthenticated session.
The resolved identity is stored on the session record.
Every ProxyContext created for that session, including the initial request, carries it in context.identity.
Middleware and audit see the same identity on the first request and on every routed request after it.
validateSession: re-checking every request
HTTP sessions are keyed by mcp-session-id, and a client that holds the id can route requests into the session.
To make sure the id alone is not enough, pass validateSession:
Your hook must authenticate the presented credential and compare its subject with session.identity?.sub.
Merely checking that a token is valid allows a different valid user to reuse a leaked session ID.
import type { IncomingMessage } from 'node:http';
import type { HttpProxyOptions, Identity } from 'mcpose';
export function authenticatedSessions(
authenticate: (req: IncomingMessage) => Promise<Identity>,
): HttpProxyOptions {
return {
resolveIdentity: authenticate,
validateSession: async (req, session) => {
const caller = await authenticate(req);
return session.identity !== undefined && caller.sub === session.identity.sub;
},
};
}False or a thrown error rejects the request with HTTP 401. The host must validate credentials and their expiry; mcpose does not supply a JWT verifier.
Credential header stripping
Credential-bearing headers never reach middleware or audit logs.
authorization, proxy-authorization, cookie, set-cookie, and x-api-key are stripped from ProxyContext.headers before middleware can read them.
Audit middleware records what ProxyContext contains, so credentials stay out of the audit trail too.
resolveIdentity and validateSession are the exception: they read the raw http.IncomingMessage, so they still see the original headers.
Your resolver can parse the bearer token, cookie, or client certificate from the request directly.
Sessions on HTTP
Only an initialize POST can create a session.
A session-less GET or DELETE returns 400, because a new session can only begin with MCP initialization.
After that, mcp-session-id names the session on every routed request.
A session ends through one of three paths:
- The client sends a
DELETEfor the session. - The TTL expires (30 minutes by default; set
sessionTtlMs: Infinityto disable it). - The server shuts down.
All three flow through the same teardown path, and onSessionClosed fires on every one of them.
That is why the audit recipe wires onSessionClosed to auditHandle.closeSession: the ReplayManifest flushes no matter how the session ends.
Sessions on stdio
stdio has no session concept in core.
The stdio transport is a process-local pipe, so there is no mcp-session-id, no TTL, and no onSessionClosed.
On stdio, a session is an audit-only boundary: the concept that groups audit events and produces one replay manifest on close.
Identity resolution is HTTP-only too: resolveIdentity and validateSession live on HttpProxyOptions, and ProxyOptions has no equivalent.
Session resume
Pair eventStore with sessionRegistry to resume on a fresh process or another instance.
A SessionRecord stores the initialize parameters, identity, and original expiry deadline.
The proxy recreates the SDK transport by replaying the original initialize internally, preserving the session ID and negotiated client state.
It writes the record before returning the original initialize response.
An expired or absent record cannot resume; without a registry, a fresh process returns 404 for the old session ID.
Client DELETE and TTL expiry delete the registry entry; shutdown keeps it for restart.
All teardown paths await onSessionClosed; a failure goes to onError, and server.close() waits for hooks to settle.
SSE streams are stored under <sessionId>:<streamId>; a Last-Event-ID belonging to another session is rejected with HTTP 400.
Resume restores transport negotiation and stored SSE events, not in-flight calls, backend subscriptions, policy counters, or an in-memory audit chain. It is not full execution replay or exactly-once processing. Keep the same backend configuration, identity validation, and policy on every instance. See Redis and Postgres for setup.
Agent delegation
V3 reads params._meta["mcpose/delegation"] on tool calls, prompt fetches, and resource reads, on both stdio and HTTP.
The versioned payload contains an oldest-first chain:
{ "v": 1, "chain": [{ "sub": "agent-a", "type": "agent" }] }Each entry requires a nonempty sub and a valid identity type; optional fields are displayName, resolvedAt, and source.
The chain is unsigned attribution, never authorization.
Extracted entries always receive empty roles and claims; a host-stamped chain takes precedence.
Malformed payloads, unknown versions, more than 32 entries, and loops involving the resolved caller reject inside the pipeline with DELEGATION_INVALID.
Without a resolved stdio identity only structural validation is possible.
Core extracts delegation before metadata stripping and forwards a fresh chain containing prior hops plus the current caller.
The proxy name is separate ctx.proxy provenance and never a delegation principal.
Local handlers making their own outbound calls use serializeDelegationChain(outboundDelegationChain(ctx)) under DELEGATION_META_KEY.
Audit signs the recorded attribution; that does not retroactively authenticate prior hops.
Mapping identity to the upstream
The identity mcpose resolves describes the client calling the proxy.
The upstream connection is a separate concern: BackendConfig describes how the proxy's own client reaches the upstream server.
For an HTTP/SSE upstream, headers sets static credentials, such as an API key or bearer token, sent on every request to the upstream.
authProvider passes an OAuthClientProvider to the HTTP transport, which runs the MCP OAuth flow with interactive or browser authorization and transparent token refresh.
Both are HTTP/SSE only: in stdio mode they are ignored, because the upstream is a spawned process rather than an HTTP endpoint.