Identifying users
Contents
By default, the SDK attributes MCP events to a generated session ID (ses_…). On 2025-11-25, this normally follows the protocol session. On 2026-07-28, conversation IDs are on by default. Calls share a session only when the agent echoes the handle. The SDK does not know the person behind the session.
Use identify to associate calls with a person.
How attribution works
For each event, the SDK picks distinct_id in this order:
- The distinct ID returned by your
identifycallback, if it returned an identity. - The MCP session id (
ses_…). - The literal string
"anonymous".
Before identify returns a user, events use the session ID. Later events use the user ID. See Events before identification for what happens to the earlier events.
Anonymous sessions and person profiles
Events for sessions with no resolved identity are sent with $process_person_profile: false. This keeps anonymous MCP sessions from each creating a person profile (which would inflate your person count and billing). Once identify resolves an identity for a session, person processing stays on and the events create/update that user's profile as normal.
Configure identify
identify (WithIdentity in Go) is a callback that returns an identity or nothing. It runs on each request. In TypeScript, Python, and Ruby, identify can also be a static identity instead of a callback. Except in Go and Rust, the SDK caches the identity per session and merges each new result into it. Go and Rust have no cache. In Go, the resolver runs once for each captured event. In Rust, it runs once per request, so a failed call's $mcp_tool_call and $exception share one result.
In TypeScript, read headers with getRequestHeaders. The two MCP SDK majors store them in different locations, and reading extra directly can return undefined and cause anonymous attribution. See reading request headers. Python has the same helper, get_request_headers. In Go, go-sdk gives you req.Extra.Header and req.Extra.TokenInfo. In Ruby, extra["headers"] holds only user-agent, x-anthropic-client, mcp-session-id, and mcp-protocol-version, so identify can't read an Authorization header. Look the user up from the session your app authenticated, or use a custom dispatcher, which takes distinct_id:, set_properties:, and groups: on each capture.
An identity has three fields, named in each language's style:
- The distinct ID becomes the event's
distinct_id. - Person properties are written to
$set, such as name, email, or plan.$setupdates the person profile but isn't kept on the stored event, so query these as person properties. - Groups, a map of group type to group key, are added to every event as
$groups.
When the callback returns an identity, the SDK:
- Uses the distinct ID as the event's
distinct_id. - Adds the person properties as
$seton every event except$exception(Rust includes$exception), and the groups as$groupson every event. - Caches the identity per session, merging person properties across requests. The Go and Rust SDKs have no cache.
The TypeScript, Python, Go, and Rust SDKs don't send a standalone $identify event. The Ruby SDK still sends one when the identity for a session changes.
Events before identification
An MCP session can emit events before authentication completes. For example, $mcp_initialize may occur before you identify the user. These events keep the session ID as distinct_id, without a person profile.
No SDK sets $anon_distinct_id, so PostHog doesn't merge these earlier events into the person. Resolve identity as early as you can, ideally on the first request of the session, so all of a session's events reach the person.
When not to call identify
- Internal tools without per-user auth. If your MCP server doesn't authenticate end users (e.g. a single-tenant internal server behind a VPN), leave
identifyunset. Session-scoped attribution is fine. - Bots and crawlers. Returning a junk identity for unauthenticated traffic dilutes your person count. Return
nullfor traffic you can't identify. Those events stay session-scoped, unless an earlier request on the same session was identified. Except in Go, the cached identity then still applies.
Querying by identified user
Once identification is wired up, anything that filters on person.properties.* or groups by distinct_id works as expected:





