Decouple app sessions from MCP transport session ids - #211
Conversation
Resolve pin/context/history keys from X-Switchboard-Session-Id first so Stateless MCP (go-sdk 1.7+) still isolates conversations when Mcp-Session-Id is empty. Keep legacy header and default fallbacks for older clients. Co-Authored-By: Crush:grok-4.5 <noreply@anthropic.com>
Serialize full app sessions including pins so hosted Redis (and other SessionStore backends) can round-trip pin/context/history across pods. Co-Authored-By: Crush:grok-4.5 <noreply@anthropic.com>
golangci-lint unused flagged the alias after all call sites moved to resolveAppSessionID / sessionIDFromMCPSession. Co-Authored-By: Crush:grok-4.5 <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Nice decoupling of app sessions from transport sessions — priority resolution, middleware wiring, and the isolation tests are solid. CI is green (build/test/lint/security/rust-sdk). A few non-blocking notes inline on client-minted session id validation, codec durability edge cases, and the shared default fallback for multi-tenant.
| if sess := sessionFromCtx(ctx); sess != nil { | ||
| return sess | ||
| } | ||
| return s.sessionStore.GetOrCreate(resolveAppSessionID(ctx, req)) |
There was a problem hiding this comment.
Client-supplied session ids flow straight into GetOrCreate with no length or charset check. Now that X-Switchboard-Session-Id is the primary key (and any unique value creates a store entry), a chatty client can mint unbounded keys and grow the in-memory map until eviction pressure/OOM — each session can also hold up to 5MB of pins.
Worth validating before the store lookup, e.g. max length + a conservative allow-list (UUID hex/dashes, or [A-Za-z0-9._:-]), and rejecting/falling back when invalid:
func normalizeAppSessionID(id string) string {
id = strings.TrimSpace(id)
if id == "" || len(id) > 128 {
return ""
}
for _, r := range id {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' || r == ':') {
return ""
}
}
return id
}That also keeps Redis/file backends from getting awkward keys once the shared store lands.
| if snap.Context == nil { | ||
| snap.Context = map[string]any{} | ||
| } | ||
| return json.Marshal(snap) |
There was a problem hiding this comment.
json.Marshal runs while s.mu is still held, and Context / Breadcrumbs / pin payloads are shared references into the live session. Correctness is fine under the RLock, but a session with large pins (up to 5MB) will block PinResult / SetContext / breadcrumbs for the whole encode.
Safer pattern for the durable-store path: deep-copy (or at least snapshot maps/slices and pin bytes) under the lock, unlock, then marshal:
s.mu.RLock()
snap := sessionSnapshot{ /* copy fields */ }
// copy Context, Breadcrumbs, Pinned entries
s.mu.RUnlock()
return json.Marshal(snap)| s.pinned = make(map[string]*PinnedResult) | ||
| } | ||
| // Recompute pinned size if the snapshot omitted it or drifted. | ||
| if s.pinnedSize == 0 && len(s.pinned) > 0 { |
There was a problem hiding this comment.
We recompute pinnedSize when it's zero, but not nextHandle. A payload that has pins (or was written by something that omitted next_handle) decodes with nextHandle == 0, so the next PinResult issues $1 and can overwrite an existing $1.
Same recovery idea as size:
if s.nextHandle == 0 && len(s.pinned) > 0 {
for h := range s.pinned {
if strings.HasPrefix(h, "$") {
if n, err := strconv.Atoi(h[1:]); err == nil && n > s.nextHandle {
s.nextHandle = n
}
}
}
}| if req != nil { | ||
| return sessionIDFromMCPSession(req.Session) | ||
| } | ||
| return defaultSessionID |
There was a problem hiding this comment.
Falling back to the shared "default" key is fine for local stdio / one-shot scripts, but on a multi-tenant HTTP deployment any client that omits X-Switchboard-Session-Id (and has no transport session id) will read/write the same pin/context/history bag — cross-conversation leak.
Not a blocker for the OSS local path, but before hosted multi-replica we probably want either: require the app header when auth identity is present, or namespace the fallback as something like default:<user/org> instead of a global key. Might be worth a brief note in the hosted follow-up list so it doesn't get lost.
Reject oversized or non-allowlisted client session keys before store lookup. Snapshot session fields under the lock before marshal, and recover nextHandle from pin keys so Decode cannot reuse $N. Co-Authored-By: Crush:grok-4.5 <noreply@anthropic.com>
|
Addressed all review notes in 8ab9312 (id validation, codec lock/snapshot + nextHandle recovery, default-fallback note for hosted). |
acmacalister
left a comment
There was a problem hiding this comment.
Re-reviewed after the follow-up commit. CI is green (build/test/lint/security/rust-sdk). Session-id validation, codec snapshot-then-marshal, and nextHandle recovery all look solid, and I don't have anything new blocking. LGTM.
Summary
X-Switchboard-Session-Idfirst so Stateless MCP still isolates conversations whenMcp-Session-Idis empty (go-sdk 1.7+).AppSessionMiddleware; keep legacyMcp-Session-Idand"default"fallbacks for older clients and one-shot scripts.Why
go-sdk 1.7 Stateless mode ignores transport session ids (SEP-2567 / sessionless MCP). Switchboard app sessions previously keyed off
ServerSession.ID(), so every request collapsed onto"default".API for clients
Send on every MCP HTTP request:
Priority: app header →
Mcp-Session-Id→ transport session id →"default".Follow-up (hosted)
SessionStorefor multi-pod mcpd