Skip to content

fix(mcptoolset): give each user its own MCP session with Config.Auth - #1755

Merged
wolo-lab merged 4 commits into
google:mainfrom
Vivek1106-04:fix/mcptoolset-per-user-sessions
Oct 30, 2026
Merged

wolo-lab merged 4 commits into
google:mainfrom
Vivek1106-04:fix/mcptoolset-per-user-sessions

Conversation

@Vivek1106-04

Copy link
Copy Markdown
Contributor

Linked issue

2. Describe the change:

Problem:

mcptoolset kept one MCP session (connectionRefresher) for every user of a toolset. With Config.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 with 403 session user mismatch. The MCP spec recommends this binding, and go-sdk servers do it whenever their token verifier reports a UserID. go-sdk then treats the 403 as a broken connection and deletes the session, so the first user loses it too.

Solution:

When Config.Auth is set, New uses a new perUserClients, which keeps one connectionRefresher per acting user, keyed by app and user from agent.IdentityFromContext. Each connection still connects lazily and reconnects as before. adk-python does the equivalent: MCPSessionManager keys sessions on a hash of the merged request headers, auth included.

  • A caller with no identity, or an empty 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.
  • Without Auth, nothing changes: the toolset still uses one connectionRefresher.
  • Close closes every user's session, and later calls fail with mcp.ErrConnectionClosed, as they do today.
  • The default mcp.Client is created once and shared by the per-user sessions (orDefaultClient), rather than once per user.

Scope:

  • Sessions stay open until the toolset is closed: one session, and one standalone SSE stream, per user that has used the toolset. I didn't add an idle limit, to keep this change small. Happy to add one if you'd like.

Behavior change

With Config.Auth set, each user now gets their own MCP session. Users after the first, who got Forbidden from 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 closing DELETE now carry each user's own token instead of the first user's. Without Auth, nothing.

Testing Plan

Unit Tests:

  • All unit tests pass locally.

auth_session_test.go runs a go-sdk server behind RequireBearerToken, 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 call whoami. Every call succeeds, the server sees the right user, and each user stays on one session.
  • TestAuthWithoutActingUserSharesOneSession: with auth.StaticToken and no identity on the context, callers share one session.
  • TestAuthCloseClosesEveryUsersSession: after Close, Tools and Run fail with mcp.ErrConnectionClosed for every user.

With your source change reverted and your tests kept, which test fails?

--- FAIL: TestAuthGivesEachUserItsOwnSession (0.01s)
    auth_session_test.go:141: Tools() as "bob": failed to list MCP tools: calling "tools/list": sending "tools/list": Forbidden
--- FAIL: TestAuthCloseClosesEveryUsersSession (0.00s)
    auth_session_test.go:196: Tools() failed: failed to list MCP tools: calling "tools/list": sending "tools/list": Forbidden

TestAuthWithoutActingUserSharesOneSession passes 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 vet are clean, and golangci-lint run ./tool/mcptoolset/... reports 0 issues.

Manual End-to-End (E2E) Tests:

runner.New with an llmagent using the toolset (Endpoint + a per-user Auth provider), a fake model that calls the MCP tool, and the go-sdk server above logging each request. alice, bob, alice, bob, then Close:

main:
-- bob asks whoami
   SERVER tools/list   auth="Bearer token-of-bob"   mcp-session=RWRWTTNGFVZJJJCEWK4CGLMONR   <- alice's session
   SERVER DELETE       auth="Bearer token-of-alice" mcp-session=RWRWTTNGFVZJJJCEWK4CGLMONR
   ERR ... calling "tools/list": sending "tools/list": Forbidden

this PR:
-- alice asks whoami
   SERVER tools/call   auth="Bearer token-of-alice" mcp-session=7E4ENBSJUXWWM6QCKINENLWM6Z
   alice got: tool said: {"output":"server saw Bearer token-of-alice"}
-- bob asks whoami
   SERVER initialize   auth="Bearer token-of-bob"   mcp-session=
   SERVER tools/call   auth="Bearer token-of-bob"   mcp-session=GN3TOIEDJLUEB4AWXTLJZNQDCN
   bob got: tool said: {"output":"server saw Bearer token-of-bob"}
-- alice asks whoami   (same session 7E4E..., works)
-- bob asks whoami     (same session GN3T..., works)
-- closing toolset
   SERVER DELETE       auth="Bearer token-of-alice" mcp-session=7E4ENBSJUXWWM6QCKINENLWM6Z
   SERVER DELETE       auth="Bearer token-of-bob"   mcp-session=GN3TOIEDJLUEB4AWXTLJZNQDCN

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

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
@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

Hi @wolo-lab, could you take a look at this when you get a chance? CI is green. Thanks!

@wolo-lab
wolo-lab self-requested a review October 28, 2026 19:24

@wolo-lab wolo-lab left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

  1. Bound the per-user session pool, keep user-independent credentials on one session, and detach the connecting context from the full InvocationContext. forUser inserts a *connectionRefresher for every distinct (appName, userID) before connecting and never removes or evicts entries before Close. 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 SSE GET stream open until Close. ADK does not authenticate UserID (see the CredentialKey doc), so in a multi-user server this grows with every distinct UserID the process ever sees, and entries whose initial Connect failed 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: New switches to perUserClients whenever cfg.Auth != nil, including user-independent providers (auth.StaticToken, auth.APIKey, auth.ADC, auth.ServiceAccount). In a normal agent run (Runner.Run), every invocation carries a Session with the end user's UserID, so 50 users calling a toolset configured with auth.StaticToken open 50 MCP sessions and 50 SSE GET streams on this branch, compared to 1 on main (82ca4c62) and 1 in adk-python, which keys sessions on a hash of the merged headers in _generate_session_key.
    • Pinned first InvocationContext per user: when getSession calls c.client.Connect(ctx, c.transport, nil), go-sdk's StreamableClientTransport.Connect stores xcontext.Detach(ctx) on the connection for the lifetime of the MCP session so the standalone SSE stream and closing DELETE retain context values. Because InvocationContext.Value reads c.params.Session live, detaching the full InvocationContext pins each user's entire first-turn InvocationContext and session.Session in memory until Close (50 of 50 retained across GC cycles in a 50-user test on this branch, versus 1 of 50 on main).
  2. Keep the documented concurrent Close behavior in perUserClients.Close. The New doc promises that Close waits for ongoing connection setup and MCP operations to finish and is safe to call concurrently or more than once, which connectionRefresher.Close enforces with closeOnce.Do and closeErr. In perUserClients.Close, the first caller sets p.sessions = nil under p.mu and then blocks closing each session outside the lock. A concurrent second caller sees p.sessions == nil and returns nil immediately while the first is still waiting on in-flight connection setup or DELETE requests, and any subsequent caller gets nil even when the first call returned a close error. Guarding perUserClients.Close with sync.Once and caching the joined closeErr (matching connectionRefresher.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

Comment thread tool/mcptoolset/auth_session_test.go Resolved
Comment thread tool/mcptoolset/auth_session_test.go Resolved
Comment thread tool/mcptoolset/client.go Resolved
Comment thread tool/mcptoolset/set.go Resolved
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.
@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review @wolo-lab, the repro made the Close bug easy to pin down. Pushed 22d2c0d.

  1. Sessions are now keyed on a hash of the headers the credential provider sets, same idea as _generate_session_key in adk-python. So StaticToken/APIKey/ADC stay on one session no matter how many users call it, and per-user providers still get one each. The pool closes sessions after 10 minutes idle and caps at 64 (evicting the least recently used idle one), never touching sessions that are in use. A session whose first connect fails is dropped. For the pinned InvocationContext, it now connects on a context that only carries the ADK identity (cancelled with the caller's), so the session doesn't hold onto the invocation. There's a weak-pointer test for that.

  2. Close uses sync.Once and caches the error like connectionRefresher, and also waits for evicted sessions still closing. Your repro is in as TestAuthConcurrentCloseWaits.

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
@Vivek1106-04
Vivek1106-04 requested a review from wolo-lab October 29, 2026 03:53

@wolo-lab wolo-lab left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

  1. acquire returns a session that evictLocked just evicted when the same credential returns after sessionIdleTimeout. In acquire, s, ok := p.sessions[key] runs before p.evictLocked(now, !ok). When the caller's own session has been idle for at least sessionIdleTimeout and no other caller's acquire has swept it in the meantime (which is always the case for a shared auth.StaticToken session, or when the last user is also the next user to call), evictLocked deletes s from p.sessions and starts s.conn.Close() in the background. acquire then checks the stale if !ok (ok is still true), skips creating a replacement session, and returns the deleted, closing s to the caller. TestAuthEvictsIdleSessions did not hit this because bob's call on line 290 swept alice's idle session before alice called again. Also, if a concurrent burst pushes len(p.sessions) above maxSessions, evictLocked evicts at most one LRU session per new-key acquire, so the pool remains above maxSessions after the burst is idle (contrary to the comments at set.go and client.go). Sweeping expired idle sessions before looking up s, ok := p.sessions[key] and evicting idle LRU sessions in a for len(p.sessions) >= maxSessions loop fixes both.

  2. Avoid context.Background() in identityOnlyContext. identityOnlyContext calls base := context.Background() in non-test library code, which AGENTS.md forbids outside main, tests, and examples. A small unexported context.Context type whose Deadline, Done, and Err return zero values and whose Value answers only adkcontext.IdentityKey avoids context.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

Comment thread tool/mcptoolset/client.go Resolved
Comment thread auth/gcp/provider.go Resolved
@wolo-lab
wolo-lab dismissed their stale review October 29, 2026 13:31

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.
@Vivek1106-04

Copy link
Copy Markdown
Contributor Author

Thanks @wolo-lab, both were real. Pushed 72f7bb4.

  1. acquire now sweeps expired idle sessions before looking up the key, so a credential coming back after the idle timeout gets a fresh session. Making room for a new key now loops, evicting LRU idle sessions until the pool is under maxSessions again, so a burst no longer leaves it over the cap. I added your repro as TestAuthIdleSessionSameUserReturns, plus deterministic pool-level tests for both cases in client_test.go.
  2. identityOnlyContext now builds on a small unexported root context that only answers adkcontext.IdentityKey, so there's no context.Background() in library code.

I also took both nits: disconnected() is checked under p.mu in release, and the provider.go doc mentions the initialize handshake.

@Vivek1106-04
Vivek1106-04 requested a review from wolo-lab October 29, 2026 15:23

@wolo-lab wolo-lab left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, thank you for contribution.

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.

mcptoolset: with Config.Auth, only one user at a time can use the toolset

2 participants