Conversation
Signed-off-by: Anas Khan <anxkhn28@gmail.com>
There was a problem hiding this comment.
Code Review
This pull request introduces pagination support for fetching MCP tools by replacing direct calls to session.ListTools with a new helper function listAllTools that iterates over all pages. Corresponding tests have been updated to verify pagination behavior. The review feedback suggests adding a nil check for the session parameter in the new listAllTools helper function to prevent potential nil pointer dereferences.
| func listAllTools(ctx context.Context, session *mcp.ClientSession) ([]*mcp.Tool, error) { | ||
| var tools []*mcp.Tool | ||
| for tool, err := range session.Tools(ctx, nil) { | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| tools = append(tools, tool) | ||
| } | ||
| return tools, nil | ||
| } |
There was a problem hiding this comment.
To ensure defensive programming and prevent potential nil pointer dereference panics, we should add a guard check to verify that the session parameter is not nil before calling session.Tools(ctx, nil).
| func listAllTools(ctx context.Context, session *mcp.ClientSession) ([]*mcp.Tool, error) { | |
| var tools []*mcp.Tool | |
| for tool, err := range session.Tools(ctx, nil) { | |
| if err != nil { | |
| return nil, err | |
| } | |
| tools = append(tools, tool) | |
| } | |
| return tools, nil | |
| } | |
| func listAllTools(ctx context.Context, session *mcp.ClientSession) ([]*mcp.Tool, error) { | |
| if session == nil { | |
| return nil, fmt.Errorf("mcp session is nil") | |
| } | |
| var tools []*mcp.Tool | |
| for tool, err := range session.Tools(ctx, nil) { | |
| if err != nil { | |
| return nil, err | |
| } | |
| tools = append(tools, tool) | |
| } | |
| return tools, nil | |
| } |
|
Thanks — the diagnosis is right and the tests prove it (I confirmed all three fail on 1. Bound the drain (blocking)
What to do, in
func TestListAllTools_BoundedAgainstEndlessCursor(t *testing.T) {
var pages atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req struct{ ID any `json:"id"`; Method string `json:"method"` }
_ = json.Unmarshal(body, &req)
if req.ID == nil { w.WriteHeader(http.StatusAccepted); return } // notification
var result any = map[string]any{}
switch req.Method {
case "initialize":
result = map[string]any{
"protocolVersion": "2026-07-28",
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "hostile", "version": "0"},
}
case "tools/list":
pages.Add(1)
result = map[string]any{
"tools": []any{map[string]any{"name": "x", "inputSchema": map[string]any{"type": "object"}}},
"nextCursor": "again",
"ttlMs": 60000, "cacheScope": "public", // makes the SDK serve repeats from cache
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
session, err := mcp.NewClient(&mcp.Implementation{Name: "t", Version: "0"}, nil).
Connect(ctx, &mcp.StreamableClientTransport{Endpoint: srv.URL}, nil)
if err != nil { t.Fatal(err) }
defer session.Close()
tools, err := listAllTools(ctx, session)
if err != nil { t.Fatal(err) }
if len(tools) != maxToolsPerService {
t.Fatalf("got %d tools, want cap %d", len(tools), maxToolsPerService)
}
}Without the cap this test hangs past its deadline; with it, it returns immediately. Keep it well under the 10s integration budget. 2. Keep the original tests, add the paginated ones
3. Nits
No new dependencies, everything stays in |
What's wrong
Three places assume a single
tools/listresponse contains the complete tool catalogue of an MCP backend:MCPService.Toolsininternal/node/mcp_service.gocallssession.ListTools(ctx, nil)once and caches the names it finds.SamNode.fetchToolsForRemoteServiceininternal/node/mcp_handlers.gobuilds the remote catalogue rows from that one page.SamNode.fetchRemoteToolDescriptionin the same file searches only that page before returningtool not found on peer.None of them consume
NextCursor. A backend that paginates its tool list therefore loses every tool after the first page: the names never reach the announcement keys built indiscovery_source.go,find_remote_toolsunder-reports, anddescribe_remote_toolclaims a tool does not exist even when the caller passes the correct name. With a backend page size of one, two tools are enough to trigger it, independently of the intentional announcement key cap.The existing tests could not catch this because their fake server used the default page size and returned everything in one response.
The fix
Add a small
listAllToolshelper ininternal/node/mcp_service.gothat drains the SDK'sClientSession.Toolsiterator, which followsNextCursorfor us, and propagates the first error it sees. All three call sites now use it.Because the helper returns either a complete slice or an error, the partial-result behaviour stays safe:
MCPService.Toolsreturns before writing its name cache on failure, andfetchToolsForRemoteServicestill emits its existing error row for the filtered service. Sorting, name prefixes, service filtering and session cleanup are unchanged, as is the announcement key cap. The now-unreachablelistRes == nilguards are dropped. No new dependencies.Testing
The fake MCP server helper in
internal/node/mcp_handlers_test.gogained anewFakeMCPHandlerWithOptionsvariant so a test can pass&mcp.ServerOptions{PageSize: 1};newFakeMCPHandlerkeeps its old behaviour. The discovery, remote-catalogue and describe-tool tests now run against a one-tool-per-page backend, and the describe test gained analphatool ahead ofreview_prso the tool under test lives on a later page. Each of these fails on the current code and passes with the fix.Result:
ok github.com/google/sam/internal/node 1.606s.