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.
POST /v1/tools/{name}andHandler.CallToolStrictresolve a tool with a baremcpServer.GetTooland 404 when it misses. Under the shipped default (corepreset, 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 (
newLocalToolExecutorpromotes viaEnsureToolPromotedForSession) and fixes the dashboard symptom a different way (dashboard.gonow callsanalyzewith aliased kinds instead ofget_processes/get_communitiesby name), but the direct-dispatch path ininternal/server/handler.gowas 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:317builds the router only whenservers.tomlexists — "Local-only daemons (no servers.toml) leave router=nil" — andv1.SetRouterat:422is guarded onrouter != nil.cmd/gortex/mcp.go:444(gortex server/gortex mcp --bind) never callsSetRouterat all.bench/daemon-latency/main.go:92likewise.Reproduction
A
Handlerwith no router, deferred catalog active:CallToolStrictSame 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 getstool %q is not registeredwith no hint that promotion was the missing step.Suggested fix
The ordering work already landed makes this safe:
handleToolCallnow 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.with a
promoteTool func(ctx context.Context, name string) boolfield onHandler, aSetToolPromotersetter, andSetToolPromoter(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=200withpromoted after call: true, and the three session-identity tests ininternal/server/handler_session_identity_test.gokeep passing — including the restricted-session denial, so the policy gate is preserved rather than bypassed.CallToolStrictwants the same treatment, though it has no ctx-carried session today, so it needs a decision on which promoter variant it should use.