Skip to content

Decouple app sessions from MCP transport session ids - #211

Merged
daltoniam merged 4 commits into
mainfrom
feat/app-session-id-stateless
Aug 4, 2026
Merged

Decouple app sessions from MCP transport session ids#211
daltoniam merged 4 commits into
mainfrom
feat/app-session-id-stateless

Conversation

@daltoniam

Copy link
Copy Markdown
Owner

Summary

  • Resolve pin/context/history keys from X-Switchboard-Session-Id first so Stateless MCP still isolates conversations when Mcp-Session-Id is empty (go-sdk 1.7+).
  • Wrap HTTP handlers with AppSessionMiddleware; keep legacy Mcp-Session-Id and "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:

X-Switchboard-Session-Id: <uuid-per-conversation>

Priority: app header → Mcp-Session-Id → transport session id → "default".

Follow-up (hosted)

  1. Redis SessionStore for multi-pod mcpd
  2. Crush mints/sends the header
  3. Bump go-sdk to 1.7.x

daltoniam and others added 3 commits August 4, 2026 11:34
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 acmacalister left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread server/session_context.go
if sess := sessionFromCtx(ctx); sess != nil {
return sess
}
return s.sessionStore.GetOrCreate(resolveAppSessionID(ctx, req))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread server/session_codec.go
if snap.Context == nil {
snap.Context = map[string]any{}
}
return json.Marshal(snap)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread server/session_codec.go
s.pinned = make(map[string]*PinnedResult)
}
// Recompute pinned size if the snapshot omitted it or drifted.
if s.pinnedSize == 0 && len(s.pinned) > 0 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
            }
        }
    }
}

Comment thread server/session_context.go
if req != nil {
return sessionIDFromMCPSession(req.Session)
}
return defaultSessionID

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@daltoniam

Copy link
Copy Markdown
Owner Author

Addressed all review notes in 8ab9312 (id validation, codec lock/snapshot + nextHandle recovery, default-fallback note for hosted).

@acmacalister acmacalister left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@daltoniam
daltoniam merged commit ed47bba into main Aug 4, 2026
5 checks passed
@daltoniam
daltoniam deleted the feat/app-session-id-stateless branch August 4, 2026 19:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants