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) {
mcp.go:494 — AddOnUnregisterSession 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).
- 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))
})
- 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:
- 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.
- 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.
Summary
A transport session that is re-registered without a fresh
initializeloses itsSessionInfoforever. From that point on every activity record it produces carries nowork_session_idand noclient_name, and its token/last-activity stats stop updating. Nothing recovers it short of the client reconnecting from scratch.This is not cosmetic:
bench/replaycorpusgroups bywork_session_id(spec 082), so it drops every such record as unattributed. On my machinecodeexecsavingreportedsessions 0and 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:
mcp.go:494—AddOnUnregisterSessioncallsRemoveSession, which deletes the wholeSessionInfo:ClientName,ClientVersion,Workspace, thepersistedflag and the cachedworkSessionID, plus theactiveProfilesentry (session_store.go:348).SetSession(session_store.go:104, writing the map at:123) has exactly one production caller —mcp.go:462, insidehooks.AddAfterInitialize.AddOnRegisterSession(mcp.go:422-430) only logs:EnsurePersisted(session_store.go:176) opens withinfo, 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"", andomitemptydrops the key from the record.GetSessionreturns nil, so the client resolver returns"", ""andwithClientInfostops stampingclient_name. The two fields die together — that pair is the fingerprint that distinguishes this from a genuinely emptyWorkSessionIdentity.UpdateSessionStatsandUpdateActivityearly-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: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
handleGetregisters the session and, with no event store configured (WithEventStoreappears nowhere in this repo), defersUnregisterSessionwhen 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 callsstorageManager.CloseInactiveSessions(30m)and never touches the in-memory map.Reproduction
Any client that reopens its listening stream with the same
Mcp-Session-Idand does not re-sendinitialize. Easiest is themcp-remotestdio 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.jsonlObserved on v0.64.0, one client on the bridge and one on direct HTTP:
The log shows
MCP session unregisteredfollowed ~1s later byMCP session registeredfor the same session id, with no interveninginitialize. Directly-connectedclaude-codesessions 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
initializeshould keep its attribution, or regain it on the next call.Suggested fix
Don't discard identity on unregister. Either:
ClientName/ClientVersion/Workspace/workSessionID, and clear it frombackgroundSessionCleanupon the real inactivity timeout, which is what themcp.go:483comment already claims happens. Attribution then survives any number of stream cycles.AddOnRegisterSessionrestore. Keep a short-lived tombstone of the removedSessionInfokeyed by session id and have the register hook re-adopt it, so a re-register withoutinitializeis repaired rather than logged.Either way, please also correct the
mcp.go:483-485comment: 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/configreturns nothing, andWorkSessionTracker.SetIdleWindow(internal/runtime/worksession.go) has no production caller.Related
Found while measuring code-execution savings for a talk; it is the reason
bench/replaycorpuscould not attribute a real recording. Same investigation produced #1203 (-baseline direct|proxyforcodeexecsaving), which is independent of this.