Skip to content

Deferred tools 404 on POST /v1/tools/{name} and CallToolStrict when no router is wired #706

Description

@zzet

POST /v1/tools/{name} and Handler.CallToolStrict resolve a tool with a bare mcpServer.GetTool and 404 when it misses. Under the shipped default (core preset, defer mode) most tools live in the deferred catalog, so any of them 404s on these paths until something else promotes it first.

This is the gap PR #649 originally set out to close on this endpoint. That PR's final shape fixes the federation/router path (newLocalToolExecutor promotes via EnsureToolPromotedForSession) and fixes the dashboard symptom a different way (dashboard.go now calls analyze with aliased kinds instead of get_processes / get_communities by name), but the direct-dispatch path in internal/server/handler.go was left as-is.

Why a default install takes it

The REST surface only promotes when a router happens to be wired, and on an ordinary single-machine install it isn't:

  • cmd/gortex/daemon.go:317 builds the router only when servers.toml exists — "Local-only daemons (no servers.toml) leave router=nil" — and v1.SetRouter at :422 is guarded on router != nil.
  • cmd/gortex/mcp.go:444 (gortex server / gortex mcp --bind) never calls SetRouter at all.
  • bench/daemon-latency/main.go:92 likewise.

Reproduction

A Handler with no router, deferred catalog active:

t.Setenv("GORTEX_LAZY_TOOLS", "1")
h, srv := realServerTestHandler(t)          // internal/server/handler_session_identity_test.go
require.Nil(t, srv.MCPServer().GetTool("find_clones"))

req := httptest.NewRequest(http.MethodPost, "/v1/tools/find_clones", strings.NewReader(`{"arguments":{}}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
status=404  error="tool_not_found"
promoted after call: false

CallToolStrict

Same shape, and nothing is broken today only because every call site in the tree passes "analyze", which is always live. It is a general-purpose internal API, so the next caller that names a deferred tool gets tool %q is not registered with no hint that promotion was the missing step.

Suggested fix

The ordering work already landed makes this safe: handleToolCall now attaches the caller's real session id to ctx before dispatch, so promoting through the session-aware variant is gated exactly like the router path — a session whose surface hides the tool still gets denied and the tool stays unpromoted.

tool := h.mcpServer.GetTool(toolName)
if tool == nil && h.promoteTool != nil && h.promoteTool(ctx, toolName) {
    tool = h.mcpServer.GetTool(toolName)
}

with a promoteTool func(ctx context.Context, name string) bool field on Handler, a SetToolPromoter setter, and SetToolPromoter(srv.EnsureToolPromotedForSession) at the four construction sites (daemon.go, mcp.go, eval_server.go, bench/daemon-latency/main.go).

Verified locally: with that in place the reproduction above returns status=200 with promoted after call: true, and the three session-identity tests in internal/server/handler_session_identity_test.go keep passing — including the restricted-session denial, so the policy gate is preserved rather than bypassed.

CallToolStrict wants the same treatment, though it has no ctx-carried session today, so it needs a decision on which promoter variant it should use.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions