Repository navigation
fix(mcptoolset): give each user its own MCP session with Config.Auth - #1755
Conversation
mcptoolset kept one MCP session for every user of a toolset. With Config.Auth each user's requests carry their own token, but on the session the first user opened. A server that binds sessions to the user who created them, as the MCP spec recommends and go-sdk servers do when their verifier reports a UserID, answers other users with 403 "session user mismatch", and that error also closes the first user's session. When Auth is set, keep one session per acting user, keyed by app and user from agent.IdentityFromContext, as adk-python does by keying sessions on the request headers. Callers without an identity share one session as before. Without Auth nothing changes. Closes: google#1754
|
Hi @wolo-lab, could you take a look at this when you get a chance? CI is green. Thanks! |
wolo-lab
left a comment
There was a problem hiding this comment.
Two things need to change before this can merge: the per-user session pool needs a bound (and should not multiply sessions for user-independent credentials or retain each user's full first InvocationContext in memory), and perUserClients.Close has to keep the documented concurrency and error-return guarantees of Close.
Must change before merge:
-
Bound the per-user session pool, keep user-independent credentials on one session, and detach the connecting context from the full
InvocationContext.forUserinserts a*connectionRefresherfor every distinct(appName, userID)before connecting and never removes or evicts entries beforeClose. Three consequences compound in a long-running service:- Unbounded sessions and SSE streams: each connected entry keeps an
mcp.ClientSession, its reader goroutine, and (with go-sdk v1.8.0's default streamable transport settings) a standalone SSEGETstream open untilClose. ADK does not authenticateUserID(see theCredentialKeydoc), so in a multi-user server this grows with every distinctUserIDthe process ever sees, and entries whose initialConnectfailed also remain in the map. You offered an idle limit in the PR description — please add a bound (idle eviction, a size cap, or both) and avoid keeping entries whose initial connect failed. - Session multiplication for shared credentials:
Newswitches toperUserClientswhenevercfg.Auth != nil, including user-independent providers (auth.StaticToken,auth.APIKey,auth.ADC,auth.ServiceAccount). In a normal agent run (Runner.Run), every invocation carries aSessionwith the end user'sUserID, so 50 users calling a toolset configured withauth.StaticTokenopen 50 MCP sessions and 50 SSEGETstreams on this branch, compared to 1 onmain(82ca4c62) and 1 inadk-python, which keys sessions on a hash of the merged headers in_generate_session_key. - Pinned first
InvocationContextper user: whengetSessioncallsc.client.Connect(ctx, c.transport, nil), go-sdk'sStreamableClientTransport.Connectstoresxcontext.Detach(ctx)on the connection for the lifetime of the MCP session so the standalone SSE stream and closingDELETEretain context values. BecauseInvocationContext.Valuereadsc.params.Sessionlive, detaching the fullInvocationContextpins each user's entire first-turnInvocationContextandsession.Sessionin memory untilClose(50 of 50 retained across GC cycles in a 50-user test on this branch, versus 1 of 50 onmain).
- Unbounded sessions and SSE streams: each connected entry keeps an
-
Keep the documented concurrent
Closebehavior inperUserClients.Close. TheNewdoc promises thatClosewaits for ongoing connection setup and MCP operations to finish and is safe to call concurrently or more than once, whichconnectionRefresher.Closeenforces withcloseOnce.DoandcloseErr. InperUserClients.Close, the first caller setsp.sessions = nilunderp.muand then blocks closing each session outside the lock. A concurrent second caller seesp.sessions == niland returnsnilimmediately while the first is still waiting on in-flight connection setup orDELETErequests, and any subsequent caller getsnileven when the first call returned a close error. GuardingperUserClients.Closewithsync.Onceand caching the joinedcloseErr(matchingconnectionRefresher.Close) fixes both.
Non-blocking note outside the diff: the NewProvider doc comment in auth/gcp/provider.go still says mcptoolset's MCP session and standalone stream are opened on the context of "whichever user connected first". With per-user sessions, each user opens their own session, though that session and its standalone stream stay bound to that user's first-connect context across later invocations.
Repro for concurrent Close returning early: go test ./tool/mcptoolset -run TestAuthConcurrentCloseWaits -v
// In tool/mcptoolset, next to auth_session_test.go (reuses newUserBoundServer and perUserToken).
func TestAuthConcurrentCloseWaits(t *testing.T) {
inner := newUserBoundServer(t)
release := make(chan struct{})
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.Header.Get("Authorization"), "token-of-slow") && r.Method == http.MethodPost {
b, _ := io.ReadAll(r.Body)
if strings.Contains(string(b), `"initialize"`) {
<-release // hold the slow user's connection setup open
}
r.Body = io.NopCloser(strings.NewReader(string(b)))
}
inner.Config.Handler.ServeHTTP(w, r)
}))
t.Cleanup(front.Close)
ts, _ := mcptoolset.New(mcptoolset.Config{Endpoint: front.URL, Auth: perUserToken})
closer := ts.(interface{ Close() error })
svc := session.InMemoryService()
created, _ := svc.Create(t.Context(), &session.CreateRequest{AppName: "app", UserID: "slow"})
invCtx := icontext.NewInvocationContext(t.Context(), icontext.InvocationContextParams{Session: created.Session})
go func() { _, _ = ts.Tools(icontext.NewReadonlyContext(invCtx)) }()
time.Sleep(200 * time.Millisecond)
first := make(chan struct{})
go func() { _ = closer.Close(); close(first) }()
time.Sleep(200 * time.Millisecond)
_ = closer.Close()
select {
case <-first:
default:
t.Errorf("second Close returned while the first was still closing sessions")
}
close(release)
<-first
}Output on this branch:
--- FAIL: TestAuthConcurrentCloseWaits (0.41s)
second Close returned while the first was still closing sessions
Key MCP sessions on a digest of the headers the credential provider sets, as adk-python does in _generate_session_key, rather than on app and user. A user-independent provider such as auth.StaticToken now keeps one session for every caller instead of one per user. Close sessions idle for 10 minutes, cap the pool at 64 by evicting the least recently used idle session, and drop a session whose connect failed. Sessions in use are never evicted. Connect on a context that carries only the ADK identity, so go-sdk's detached connection context no longer keeps the first invocation in memory for the life of the session. Make Close safe to call concurrently: guard it with sync.Once and return the first call's error to every caller, as connectionRefresher does.
|
Thanks for the detailed review @wolo-lab, the repro made the Close bug easy to pin down. Pushed 22d2c0d.
Also updated the gcp NewProvider doc. I checked the new tests against the previous commit and they fail there. I kept the idle timeout and cap as constants rather than Config fields to keep the API small, happy to expose them if you'd prefer. |
…user-sessions # Conflicts: # tool/mcptoolset/set.go
wolo-lab
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround — the credentialSessions pool, sync.Once on Close, and identityOnlyContext address the concurrency and memory-retention issues from round 1, and re-running the test and lint battery is clean. Two issues in the new session-pool and context code still need fixing before merge:
Must change before merge:
-
acquirereturns a session thatevictLockedjust evicted when the same credential returns aftersessionIdleTimeout. Inacquire,s, ok := p.sessions[key]runs beforep.evictLocked(now, !ok). When the caller's own session has been idle for at leastsessionIdleTimeoutand no other caller'sacquirehas swept it in the meantime (which is always the case for a sharedauth.StaticTokensession, or when the last user is also the next user to call),evictLockeddeletessfromp.sessionsand startss.conn.Close()in the background.acquirethen checks the staleif !ok(okis stilltrue), skips creating a replacement session, and returns the deleted, closingsto the caller.TestAuthEvictsIdleSessionsdid not hit this becausebob's call on line 290 sweptalice's idle session beforealicecalled again. Also, if a concurrent burst pusheslen(p.sessions)abovemaxSessions,evictLockedevicts at most one LRU session per new-keyacquire, so the pool remains abovemaxSessionsafter the burst is idle (contrary to the comments atset.goandclient.go). Sweeping expired idle sessions before looking ups, ok := p.sessions[key]and evicting idle LRU sessions in afor len(p.sessions) >= maxSessionsloop fixes both. -
Avoid
context.Background()inidentityOnlyContext.identityOnlyContextcallsbase := context.Background()in non-test library code, whichAGENTS.mdforbids outsidemain, tests, and examples. A small unexportedcontext.Contexttype whoseDeadline,Done, andErrreturn zero values and whoseValueanswers onlyadkcontext.IdentityKeyavoidscontext.Background()while keeping the invocation detached.
Repro for self-eviction on return after sessionIdleTimeout: go test ./tool/mcptoolset -run TestAuthIdleSessionSameUserReturns -v
func TestAuthIdleSessionSameUserReturns(t *testing.T) {
mcptoolset.SetSessionBoundsForTest(t, 30*time.Millisecond, 64)
server := mcp.NewServer(&mcp.Implementation{Name: "paged_server", Version: "v1.0.0"}, &mcp.ServerOptions{PageSize: 1})
for _, name := range []string{"tool_a", "tool_b"} {
mcp.AddTool(server, &mcp.Tool{Name: name, Description: name},
func(ctx context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "ok"}}}, nil, nil
})
}
httpSrv := httptest.NewServer(mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil))
t.Cleanup(httpSrv.Close)
ts, err := mcptoolset.New(mcptoolset.Config{Endpoint: httpSrv.URL, Auth: perUserToken})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ts.(io.Closer).Close() })
alice := icontext.NewReadonlyContext(invocationAs(t, "app", "alice"))
if _, err := ts.Tools(alice); err != nil {
t.Fatalf("first Tools(): %v", err)
}
time.Sleep(60 * time.Millisecond)
if _, err := ts.Tools(alice); err != nil {
t.Fatalf("second Tools() after idle timeout: %v", err)
}
if n := mcptoolset.SessionCountForTest(ts); n != 1 {
t.Fatalf("SessionCountForTest = %d, want 1", n)
}
}Output on this branch:
--- FAIL: TestAuthIdleSessionSameUserReturns (0.07s)
second Tools() after idle timeout: failed to list MCP tools: connection closed
Round 1 items addressed in 22d2c0d; superseded by Round 2 review.
…r cap Sweep expired idle sessions before looking up the caller's key so a credential returning after the idle timeout gets a new session instead of the one just evicted. Evict idle LRU sessions in a loop so a burst that pushed the pool over maxSessions shrinks back once idle. Build the identity-only connection context on an unexported root context instead of context.Background, and check disconnected() under the pool lock in release.
|
Thanks @wolo-lab, both were real. Pushed 72f7bb4.
I also took both nits: |
wolo-lab
left a comment
There was a problem hiding this comment.
LGTM, thank you for contribution.
Linked issue
2. Describe the change:
Problem:
mcptoolsetkept one MCP session (connectionRefresher) for every user of a toolset. WithConfig.Auth, each user's requests carry their own token, but on the session whichever user connected first opened. A server that binds sessions to the user who created them answers everyone else with403 session user mismatch. The MCP spec recommends this binding, and go-sdk servers do it whenever their token verifier reports aUserID. go-sdk then treats the 403 as a broken connection and deletes the session, so the first user loses it too.Solution:
When
Config.Authis set,Newuses a newperUserClients, which keeps oneconnectionRefresherper acting user, keyed by app and user fromagent.IdentityFromContext. Each connection still connects lazily and reconnects as before. adk-python does the equivalent:MCPSessionManagerkeys sessions on a hash of the merged request headers, auth included.UserID, shares one session, which is what every caller got before. A static or service-account credential used outside an invocation behaves as it did.Auth, nothing changes: the toolset still uses oneconnectionRefresher.Closecloses every user's session, and later calls fail withmcp.ErrConnectionClosed, as they do today.mcp.Clientis created once and shared by the per-user sessions (orDefaultClient), rather than once per user.Scope:
Behavior change
With
Config.Authset, each user now gets their own MCP session. Users after the first, who gotForbiddenfrom a server that binds sessions to users, now work. Against a server that doesn't bind sessions, every user worked before, all on one session; now each opens their own. The standalone stream and the closingDELETEnow carry each user's own token instead of the first user's. WithoutAuth, nothing.Testing Plan
Unit Tests:
auth_session_test.goruns a go-sdk server behindRequireBearerToken, with a verifier that reports the user, so the server binds each session to its creator. It also records which MCP sessions each user's requests arrive on.TestAuthGivesEachUserItsOwnSession: alice, bob, alice, bob each list tools and callwhoami. Every call succeeds, the server sees the right user, and each user stays on one session.TestAuthWithoutActingUserSharesOneSession: withauth.StaticTokenand no identity on the context, callers share one session.TestAuthCloseClosesEveryUsersSession: afterClose,ToolsandRunfail withmcp.ErrConnectionClosedfor every user.With your source change reverted and your tests kept, which test fails?
TestAuthWithoutActingUserSharesOneSessionpasses either way, because it pins the unchanged behavior.With the change,
go test -race ./...passes,go test -race -count=3 ./tool/mcptoolset/...passes,gofmt/go vetare clean, andgolangci-lint run ./tool/mcptoolset/...reports 0 issues.Manual End-to-End (E2E) Tests:
runner.Newwith anllmagentusing the toolset (Endpoint+ a per-userAuthprovider), a fake model that calls the MCP tool, and the go-sdk server above logging each request. alice, bob, alice, bob, thenClose:Checklist