Repository navigation
adk/mcp: defer tool discovery until invocation - #3560
Conversation
1e94a41 to
14fe593
Compare
EItanya
left a comment
There was a problem hiding this comment.
🤖 AI-generated review. I looked through them to make sure they weren't crazy and they all seem worth looking at.
Comment left by Claude on behalf of @EItanya.
Reviewed at 14fe59397bbddb01103294af0b0e5f105219d4a2.
Making startup and resume independent of MCP discovery is the right goal. I think most of the new machinery can go away, though. mcptoolset already connects lazily ("MCP session is created lazily on the first request to LLM"), so the startup half is mostly a deletion. The added complexity is all in getting MCP Apps _meta classification, and the ADK and go-sdk can give us that without a second connection or a cache.
1. Token passthrough: every caller is limited to the first caller's tool list
mcpAppToolset.classify caches the classification (a StringPredicate of tool names) on the toolset for the life of the process. That classification is made with the first caller's credentials. Every later caller's tool list is then filtered by those names. If a passthrough server returns different tools per token, later users only get the overlap. On main, passthrough servers had no name filter, so this is a regression for them. The cache also never refreshes, so tools a server adds later stay hidden until the runtime restarts.
Repro: one shared MCP session, with a server that filters tools/list by the request's Authorization header. Alice gets [common, board]. Bob should also get admin_only, but gets [common, board].
Repro test (fails at 14fe593; drop into go/adk/pkg/mcp)
package mcp
import (
"context"
"net/http"
"net/http/httptest"
"testing"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/require"
"github.com/kagent-dev/kagent/go/adk/pkg/models"
)
// perRequestAuthServer filters tools/list per request Authorization header on one shared MCP session.
func perRequestAuthServer(t *testing.T) *httptest.Server {
s := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "p", Version: "1"}, nil)
h := func(context.Context, *mcpsdk.CallToolRequest, map[string]any) (*mcpsdk.CallToolResult, map[string]any, error) {
return nil, nil, nil
}
mcpsdk.AddTool(s, &mcpsdk.Tool{Name: "common"}, h)
mcpsdk.AddTool(s, &mcpsdk.Tool{Name: "board", Meta: mcpsdk.Meta{"ui": map[string]any{"resourceUri": "ui://b"}}}, h)
mcpsdk.AddTool(s, &mcpsdk.Tool{Name: "refresh", Meta: mcpsdk.Meta{"ui": map[string]any{"resourceUri": "ui://b", "visibility": []any{"app"}}}}, h)
mcpsdk.AddTool(s, &mcpsdk.Tool{Name: "admin_only"}, h)
s.AddReceivingMiddleware(func(next mcpsdk.MethodHandler) mcpsdk.MethodHandler {
return func(ctx context.Context, method string, req mcpsdk.Request) (mcpsdk.Result, error) {
res, err := next(ctx, method, req)
if lr, ok := res.(*mcpsdk.ListToolsResult); ok && req.GetExtra().Header.Get("Authorization") != "Bearer bob" {
out := lr.Tools[:0:0]
for _, tl := range lr.Tools {
if tl.Name != "admin_only" {
out = append(out, tl)
}
}
lr.Tools = out
}
return res, err
}
})
srv := httptest.NewServer(mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { return s }, nil))
t.Cleanup(srv.Close)
t.Cleanup(srv.CloseClientConnections)
return srv
}
func TestProbePRCachesFirstCallerToolList(t *testing.T) {
srv := perRequestAuthServer(t)
ts, err := initializeToolSet(t.Context(), mcpServerParams{URL: srv.URL, ServerType: "http", TokenPassthrough: true}, nil)
require.NoError(t, err)
names := func(tok string) []string {
ctx := WithDiscoveryScope(context.WithValue(t.Context(), models.BearerTokenKey, tok))
tools, err := ts.Tools(testReadonlyContext{Context: ctx})
require.NoError(t, err)
var out []string
for _, tl := range tools {
out = append(out, tl.Name())
}
return out
}
require.ElementsMatch(t, []string{"common", "board"}, names("alice"))
require.ElementsMatch(t, []string{"common", "board", "admin_only"}, names("bob")) // fails: admin_only missing
}go test ./adk/pkg/mcp/ -run TestProbePRCachesFirstCallerToolList -count=1
2. Classify from ADK's own tools/list instead of a separate discovery path
Right now classification opens its own connection and repeats tools/list with its own pagination and cursor-loop check. For stdio servers that starts a second process. The classify loop (mutex, wait channel, retry loop, omitted-check before and after locking) and WithDiscoveryScope exist only to share and cache that extra call. WithDiscoveryScope is also a new public API passed implicitly through ctx, and every runner.Run caller has to remember to add it.
Everything needed for a stateless version already exists:
mcptoolset.Config.Clientlets us pass our own*mcpsdk.Client. This PR already does.(*mcpsdk.Client).AddSendingMiddlewaresees the raw*ListToolsResult,_metaincluded, for ADK's owntools/listcalls, including every page. It runs with the caller'sctx, so results can be collected per call even though the MCP session is shared.- ADK rebuilds the tool list before every model step (
toolProcessor), andProcessRequestputs each tool object intoreq.Tools[name].scopedToolinbaggage.goalready uses this to swap in a wrapper.
Sketch:
func newMCPAppClient() *mcpsdk.Client {
c := mcpsdk.NewClient(impl, &mcpsdk.ClientOptions{Capabilities: mcpUIClientCapabilities()})
c.AddSendingMiddleware(func(next mcpsdk.MethodHandler) mcpsdk.MethodHandler {
return func(ctx context.Context, method string, req mcpsdk.Request) (mcpsdk.Result, error) {
res, err := next(ctx, method, req)
if kinds, ok := ctx.Value(toolKindsKey{}).(*toolKinds); ok {
if list, ok := res.(*mcpsdk.ListToolsResult); ok {
kinds.record(list.Tools) // name -> mcpToolKindOf(t.Meta)
}
}
return res, err
}
})
return c
}
func (m *mcpAppToolset) Tools(ctx adkagent.ReadonlyContext) ([]tool.Tool, error) {
kinds := newToolKinds()
tools, err := m.inner.Tools(readonlyValuesContext{ctx, context.WithValue(ctx, toolKindsKey{}, kinds)})
if err != nil {
return nil, err
}
visible := tools[:0:0]
for _, t := range tools {
switch kind, ok := kinds.get(t.Name()); {
case !ok || kind == mcpToolKindAppInternal:
continue // never classified, so hidden
case kind == mcpToolKindApp:
visible = append(visible, appTool{t.(functionTool)}) // re-registers itself in req.Tools, like scopedTool
default:
visible = append(visible, t)
}
}
return visible, nil
}
// Before-model callback: no toolset closure, no shared lookups.
if _, ok := req.Tools[part.FunctionResponse.Name].(appTool); ok { compact(...) }The configured tool allowlist can stay as tool.FilterToolset on the inner toolset.
This removes:
- the second connection (and second stdio process), plus the hand-written pagination and cursor guard
- the cache, mutex, wait channel and retry loop
WithDiscoveryScope, including its wiring inexecutor.goand the oneshot exampleMCPAppToolNamesFromToolsets
It also fixes #1 by construction, because classification comes from each caller's own response. It stays fail-closed, since a tool can't be exposed without its _meta having been read. I prototyped the middleware against the real mcptoolset with token passthrough: Alice gets [common, board] with board detected as an app tool and the app-only refresh hidden, and Bob also gets admin_only. I did not build the appTool wrapper or the compaction callback end to end.
One separate decision: today the PR leaves a server out for the rest of an execution when classification fails, but an ADK discovery error after classification still fails the run. With one merged call, this becomes a single policy question: should an unreachable MCP server fail the run or be skipped? If skipping is wanted, it's a few lines in mcpAppToolset.Tools. I'd suggest making that its own change rather than bundling it here.
3. Small cleanup
MakeMCPAppModelResultCallback is now only used by tests. Production uses the new private version. The redesign above removes the need for either.
Checks run
- The PR's tests for
adk/pkg/mcp,adk/pkg/agentandadk/pkg/a2a/...pass with-race -count=2. - The repro above fails at
14fe593. - The middleware prototype passes.
8b67375 to
40903f8
Compare
This isn't a concern because:
Fixed
Fixed |
40903f8 to
138a67f
Compare
\# Description Slow MCP discovery can exceed router timeouts and fail agent resumes, holding worker reservations and capacity while startup waits. Separate classification also duplicates MCP connections and paginated listings. This fixes it by: - Deferring HTTP, SSE, and stdio MCP connections until invocation preparation. - Classifying and filtering ADK's own discovery responses before the SDK caches them, preserving server TTLs and catalog notification handling. - Omitting failed toolsets for the execution regardless of prior discovery success, keeping healthy tools available and retrying on later executions. - Serializing discovery and classification publication per toolset, allowing queued invocations to cancel without interrupting the active listing. - Preserving MCP App result compaction, rich stored results, credentials, approvals, and tracing while clearing app names reclassified as ordinary. - Covering cached filtering, catalog refresh, reclassification, concurrent discovery, later outages, pagination, cancellation, and slow runtime restores. \# Change Type ``` /kind fix ``` \# Changelog ```release-note MCP discovery no longer delays startup or resume and caches filtered tools safely. ``` \# Additional Notes Signed-off-by: Shashank Ram <[email protected]>
138a67f to
4a3ec29
Compare
EItanya
left a comment
There was a problem hiding this comment.
🤖 AI-generated review. I looked through them to make sure they weren't crazy and they all seem worth looking at.
Comment left by Claude on behalf of @EItanya.
Follow-up on 4a3ec2907e0b8023291d663b5ffad8f1d0bb2210.
Thanks for switching to middleware. Filtering each raw tools/list page there, so the SDK caches already-filtered results, is a better approach than what I sketched: my per-call version would have hidden every tool on SDK cache hits, which skip middleware. Agreed on passthrough too. With one execution per Actor, per-caller visibility is a policy question, not an SDK one.
A few things still look like more machinery than this change needs:
1. Omitting failed toolsets is still a policy change
Thanks for removing the sticky classified flag. A failed listing is now omitted for the execution whether or not that toolset worked before, and I confirmed that on 4a3ec29 with the same repro. That fixes the inconsistency.
It is still a behavior change from main, though, where a discovery error fails the step. Whether an unreachable server should be skipped or fail the run seems worth deciding in its own PR. I'd drop WithDiscoveryScope and the omission logic here, so this PR only defers discovery.
2. The lock and extra state guard a case that doesn't happen
listingMu serializes every Tools() call on a toolset, held across the network call, to stop overlapping listings from mixing app classifications. The Go runtime admits one execution per Actor, and each agent and subagent builds its own toolsets, so overlapping listings on one toolset effectively don't occur. The only thing that needs to survive SDK cache hits is which names are app tools, for compaction.
Suggestion: have the middleware write name → isApp into one map per toolset on each fresh listing, and have the compaction callback read it. Remove listingMu and the second app-name map.
3. Cursor rewriting is out of scope
ADK's connectionRefresher.ListTools has no pagination-cycle check, and main has the same gap. Encoding every visited cursor into the opaque cursor (which grows with each page) and rebuilding the outgoing ClientRequest is a lot to carry in a PR about deferring discovery. I'd report it to ADK upstream or fix it separately.
4. Docs
The new mcp.md section mostly describes internals: locking, cursor encoding, and when app names are published. I'd trim it to what users and operators see. Discovery is lazy, app-only tools are hidden, and app results are compacted for the model.
With 1–3, discovery.go should shrink to roughly the middleware plus one map, and most of discovery_test.go with it.
Checks: the PR's tests for adk/pkg/mcp, adk/pkg/agent and adk/pkg/a2a/... pass with -race -count=2. Checked on 4a3ec29.
Signed-off-by: Shashank Ram <[email protected]>
I’d keep this behavior here. Discovery now happens during invocation preparation, and an unavailable MCP server should not fail the entire execution when healthy tools remain usable. Failed toolsets stay hidden for that execution and are retried in the next. Cancellation still propagates.
Removed
Removed cursor rewriting and cycle guards. Pagination now uses ADK and the SDK’s existing implementation. Cycle detection can be handled separately upstream.
Shortened the documentation around lazy discovery, tool visibility, execution-scoped failure handling, SDK caching, and model-result compaction. Removed the locking and cursor implementation details. |
# Description Slow MCP discovery can exceed router timeouts and fail agent resumes, holding worker reservations and capacity while startup waits. Separate classification also duplicates MCP connections and paginated listings. This fixes it by: - Deferring HTTP, SSE, and stdio MCP connections until invocation preparation. - Classifying and filtering ADK's own discovery responses before the SDK caches them, preserving server TTLs and catalog notification handling. - Omitting failed toolsets for the execution regardless of prior discovery success, keeping healthy tools available and retrying on later executions without exposing unclassified tools. - Serializing discovery and classification publication per toolset, with cancellation support for queued invocations. - Preserving MCP App result compaction through a single callback, alongside rich stored results, credentials, approvals, and tracing. Tools reclassified as ordinary keep their real output; removed app names remain available for historical compaction. - Documenting discovery, SDK caching, pagination guards, and failure handling in `docs/architecture/mcp.md`. # Change Type ``` /kind fix ``` # Testing - Added unit coverage for zero startup requests, one paginated discovery, filtered SDK cache hits, TTL expiry, catalog notifications, partial failures, execution-scoped retries after initial and later failures, cancellation, concurrent discovery, reclassification, and stdio startup. - Added app-result coverage for cached tools, approvals, and historical results after catalog removal, preserving the original rich payloads. - Added E2E coverage for slow MCP discovery across repeated resumes and initial discovery failure and recovery; extended app-history E2E coverage to verify SDK caching across model steps and rediscovery after restore. # Changelog ```release-note MCP discovery no longer delays startup or resume and caches filtered tools safely. ``` --------- Signed-off-by: Shashank Ram <[email protected]> (cherry picked from commit ccb2dd3) Signed-off-by: Eitan Yarmush <[email protected]>
# Description Slow MCP discovery can exceed router timeouts and fail agent resumes, holding worker reservations and capacity while startup waits. Separate classification also duplicates MCP connections and paginated listings. This fixes it by: - Deferring HTTP, SSE, and stdio MCP connections until invocation preparation. - Classifying and filtering ADK's own discovery responses before the SDK caches them, preserving server TTLs and catalog notification handling. - Omitting failed toolsets for the execution regardless of prior discovery success, keeping healthy tools available and retrying on later executions without exposing unclassified tools. - Serializing discovery and classification publication per toolset, with cancellation support for queued invocations. - Preserving MCP App result compaction through a single callback, alongside rich stored results, credentials, approvals, and tracing. Tools reclassified as ordinary keep their real output; removed app names remain available for historical compaction. - Documenting discovery, SDK caching, pagination guards, and failure handling in `docs/architecture/mcp.md`. # Change Type ``` /kind fix ``` # Testing - Added unit coverage for zero startup requests, one paginated discovery, filtered SDK cache hits, TTL expiry, catalog notifications, partial failures, execution-scoped retries after initial and later failures, cancellation, concurrent discovery, reclassification, and stdio startup. - Added app-result coverage for cached tools, approvals, and historical results after catalog removal, preserving the original rich payloads. - Added E2E coverage for slow MCP discovery across repeated resumes and initial discovery failure and recovery; extended app-history E2E coverage to verify SDK caching across model steps and rediscovery after restore. # Changelog ```release-note MCP discovery no longer delays startup or resume and caches filtered tools safely. ``` --------- Signed-off-by: Shashank Ram <[email protected]> (cherry picked from commit ccb2dd3) Signed-off-by: Eitan Yarmush <[email protected]>
Backports seven PRs from `main` to `release/v1.0.x` for the 1.0 release. Each is cherry-picked with `-x` in its original merge order, and none needed changes. Every file these commits touch now matches `main` exactly. | PR | Commit | Why | | --- | --- | --- | | kagent-dev#3558 | `2e2acf58c` | fix(ci): restore upgrade tests from RC3 | | kagent-dev#3545 | `7c9c52105` | fix(ui): show when the Substrate lists were read, not a running age | | kagent-dev#3395 | `aaf1ca882` | feat(session): revert crashed runtimes through RevertSession (needed for a customer) | | kagent-dev#3562 | `5c490d83b` | refactor: remove helpers orphaned by the KMCP removal (low risk; keeps the branch close to `main`) | | kagent-dev#3563 | `c6adf72f9` | refactor(api): move MCP header resolution out of `v1alpha3`, so the 1.0 API package describes only data | | kagent-dev#3340 | `347acbe55` | fix(controller): move a ModelConfig condition's transition time only on a status change (labeled `cherrpick-1.0`) | | kagent-dev#3560 | `ccb2dd350` | adk/mcp: defer tool discovery until invocation, so slow MCP discovery no longer fails agent resumes | Please use **Rebase and merge** so each backported PR stays its own commit on the release branch. Validation before kagent-dev#3560 was added (CI covers the full set): - `go build ./...` and `go vet ./...` - `go test -race` with envtest for `api`, `core/cli`, `core/internal/{controller,database,grpcserver,mcp,service,substrate}`, `core/pkg/migrations`, and `pkg/logging` - `make -C go lint`: 0 issues - UI `tsc --noEmit` and `substrateReadAge.test.tsx` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Eitan Yarmush <[email protected]> Signed-off-by: Nicholas Bucher <[email protected]> Signed-off-by: Ada Lundhe <[email protected]> Signed-off-by: timflannagan <[email protected]> Signed-off-by: QuentinBisson <[email protected]> Signed-off-by: Shashank Ram <[email protected]> Co-authored-by: Nicholas Bucher <[email protected]> Co-authored-by: Ada Lündhé <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Tim Flannagan <[email protected]> Co-authored-by: Quentin Bisson <[email protected]> Co-authored-by: Shashank <[email protected]>
Description
Slow MCP discovery can exceed router timeouts and fail agent resumes,
holding worker reservations and capacity while startup waits. Separate
classification also duplicates MCP connections and paginated listings.
This fixes it by:
them, preserving server TTLs and catalog notification handling.
keeping healthy tools available and retrying on later executions without exposing
unclassified tools.
support for queued invocations.
stored results, credentials, approvals, and tracing. Tools reclassified as ordinary
keep their real output; removed app names remain available for historical compaction.
docs/architecture/mcp.md.Change Type
Testing
filtered SDK cache hits, TTL expiry, catalog notifications, partial failures,
execution-scoped retries after initial and later failures, cancellation, concurrent
discovery, reclassification, and stdio startup.
after catalog removal, preserving the original rich payloads.
discovery failure and recovery; extended app-history E2E coverage to verify SDK
caching across model steps and rediscovery after restore.
Changelog