Skip to content

Session attribution is lost permanently after an unregister: work_session_id and client_name go null and never come back #1205

Description

@algis-dumbris

Summary

A transport session that is re-registered without a fresh initialize loses its SessionInfo forever. From that point on every activity record it produces carries no work_session_id and no client_name, and its token/last-activity stats stop updating. Nothing recovers it short of the client reconnecting from scratch.

This is not cosmetic: bench/replaycorpus groups by work_session_id (spec 082), so it drops every such record as unattributed. On my machine codeexecsaving reported sessions 0 and computed nothing over a perfectly good 73-sub-call recording until I hand-stamped a work-session id into the export.

Mechanism

Exactly one place writes the in-memory session map, and exactly one place removes from it:

$ grep -rn "SetSession(\|RemoveSession(" --include='*.go' internal/ | grep -v _test
internal/server/mcp.go:462:      sessionStore.SetSession(sessionID, clientName, clientVersion, hasRoots, hasSampling, experimental)
internal/server/mcp.go:494:      sessionStore.RemoveSession(sessionID)
internal/server/session_store.go:104:func (s *SessionStore) SetSession(...)
internal/server/session_store.go:348:func (s *SessionStore) RemoveSession(sessionID string) {
  1. mcp.go:494AddOnUnregisterSession calls RemoveSession, which deletes the whole SessionInfo: ClientName, ClientVersion, Workspace, the persisted flag and the cached workSessionID, plus the activeProfiles entry (session_store.go:348).
  2. Nothing puts it back. SetSession (session_store.go:104, writing the map at :123) has exactly one production caller — mcp.go:462, inside hooks.AddAfterInitialize. AddOnRegisterSession (mcp.go:422-430) only logs:
hooks.AddOnRegisterSession(func(ctx context.Context, sess mcpserver.ClientSession) {
    sessionID := sess.SessionID()
    // Just log the registration - client info and capabilities will be set by OnAfterInitialize
    logger.Info("MCP session registered", zap.String("session_id", sessionID))
})
  1. So every downstream read misses:
    • EnsurePersisted (session_store.go:176) opens with info, ok := s.sessions[sessionID]; if !ok || s.storageManager == nil { return "" } — it can never mint or cache a work session again.
    • WorkSessionID (session_store.go:250) returns "".
    • ActivityService.resolveWorkSession (internal/runtime/activity_service.go:157) stamps "", and omitempty drops the key from the record.
    • GetSession returns nil, so the client resolver returns "", "" and withClientInfo stops stamping client_name. The two fields die together — that pair is the fingerprint that distinguishes this from a genuinely empty WorkSessionIdentity.
    • UpdateSessionStats and UpdateActivity early-return on !persisted, so token counts and last-activity are dropped too.

WorkSessionTracker.Resolve / WorkSessionIdentity.isEmpty (internal/runtime/worksession.go) are never even reached. The identity is fine; the record of it is gone.

The stale comment that licenses it

mcp.go:483-485:

// NOTE: This hook may NOT be called for Streamable HTTP transport because HTTP is stateless
// and has no persistent connection. For HTTP transport, we rely on inactivity timeout
// cleanup (see runtime.backgroundSessionCleanup).

That holds for a POST-only client. It does not hold for a client that keeps a GET listening stream: in mcp-go v0.57.0 handleGet registers the session and, with no event store configured (WithEventStore appears nowhere in this repo), defers UnregisterSession when that stream ends. And the fallback the comment points at does not do what it says — runtime.backgroundSessionCleanup (internal/runtime/lifecycle.go:211, :321) only calls storageManager.CloseInactiveSessions(30m) and never touches the in-memory map.

Reproduction

Any client that reopens its listening stream with the same Mcp-Session-Id and does not re-send initialize. Easiest is the mcp-remote stdio bridge, which is the documented Claude Desktop path (internal/connect/clients.go:266):

{ "mcpServers": { "mcpproxy": { "command": "npx", "args": ["-y", "mcp-remote", "http://127.0.0.1:8080/mcp"] } } }

Make some tool calls, wait for a stream cycle, make more, then:

mcpproxy activity export --format json --output /tmp/a.jsonl

Observed on v0.64.0, one client on the bridge and one on direct HTTP:

100 recent records: 100 with no work_session_id, 100 with no client_name
$ grep -c "MCP session unregistered" ~/Library/Logs/mcpproxy/main.log   # 63
$ grep -c "MCP client initialized"   ~/Library/Logs/mcpproxy/main.log   # 155

The log shows MCP session unregistered followed ~1s later by MCP session registered for the same session id, with no intervening initialize. Directly-connected claude-code sessions in the same daemon keep both fields, which isolates it to the re-register path rather than to identity resolution.

Expected

A session that is re-registered without a fresh initialize should keep its attribution, or regain it on the next call.

Suggested fix

Don't discard identity on unregister. Either:

  1. Preferred — make unregister a soft close. Mark the entry closed (stop stats, release storage) but keep ClientName/ClientVersion/Workspace/workSessionID, and clear it from backgroundSessionCleanup on the real inactivity timeout, which is what the mcp.go:483 comment already claims happens. Attribution then survives any number of stream cycles.
  2. Or — let AddOnRegisterSession restore. Keep a short-lived tombstone of the removed SessionInfo keyed by session id and have the register hook re-adopt it, so a re-register without initialize is repaired rather than logged.

Either way, please also correct the mcp.go:483-485 comment: the hook is called for Streamable HTTP whenever the client holds a GET stream, and the cleanup it defers to does not restore or remove in-memory sessions.

There's no config surface to work around this today — grep -riE 'work_session|idle_window|session_idle' internal/config returns nothing, and WorkSessionTracker.SetIdleWindow (internal/runtime/worksession.go) has no production caller.

Related

Found while measuring code-execution savings for a talk; it is the reason bench/replaycorpus could not attribute a real recording. Same investigation produced #1203 (-baseline direct|proxy for codeexecsaving), which is independent of this.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions