Promote deferred MCP tools on a registry-miss instead of 404ing - #649
Promote deferred MCP tools on a registry-miss instead of 404ing#649timkjr wants to merge 7 commits into
Conversation
Under the shipped core-preset defer-mode default, tools outside the eager allow-set only become callable via tools_search promotion. Handler.CallToolStrict (every /v1/* dashboard route) checked only the live registry and failed with "not registered" for any deferred tool, which is what broke the Processes/Communities dashboard pages (get_processes / get_communities). Wire Server.EnsureToolPromoted — already used by the CLI's `gortex call` path — into Handler via SetToolPromoter, called at all three handler-construction sites. CallToolStrict now promotes on a registry miss before giving up, fixing the whole class of bug rather than just these two tools.
…Strict registry miss
Covers the fix in 20454eb: a registry miss now consults the promoter before failing, and retries GetTool once the promotion succeeds. Also pins the pre-existing "promoter has nothing to offer" case still returning the original "not registered" error.
Code review on the CallToolStrict fix (previous 2 commits) found the
identical pattern in two more places that dispatch a tool call by
name against only the live registry, unaware of the deferred/lazy
catalog:
- Handler.handleToolCall (POST /v1/tools/{name}, the public REST tool
invocation route) — 404s "tool not found" for a perfectly valid
deferred tool, inconsistent with returning available_tools in the
same response, which suggests completeness was the intent, not a
deliberate tools_search-first gate.
- newLocalToolExecutor (cmd/gortex/server_router.go) — the federation
router's local-dispatch path; a remote server routing a deferred
tool call to this daemon would 404 identically.
Extracted the promote-then-retry logic shared by CallToolStrict and
handleToolCall into Handler.getToolOrPromote so the two HTTP paths
can't drift out of sync again. server_router.go's fix mirrors the
same shape against *mcp.Server directly since it predates Handler.
Also wired SetToolPromoter into bench/daemon-latency/main.go, the one
remaining Handler-construction site that was missing it.
|
Hey @timkjr! Thanks for addressing the deferred-tool failures affecting the dashboard. Looking at the technical side of the PR, the sequential happy path works, and the constructor wiring is complete. However, I don’t think this PR is ready to merge yet. 1. Concurrent promotion can return a false 404In The lazy registry marks a tool as promoted before its registration callback completes. This permits the following race:
Please make promotion atomic from the callers’ perspective - e.g., singleflight/in-flight synchronization where concurrent callers wait until registration finishes. An immediate unconditional retry is not sufficient. A synchronized two-request regression test is needed for both 2. Context-free promotion weakens the facade/session boundaryThe new promoter uses global The wrapped tool handler still performs its call-time gate, so I am not claiming an authentication bypass. The problem is that a blocked session can still promote a legacy tool globally before execution is rejected, causing cross-session registry/tool-list churn and undermining the facade/defer boundary. If generic promotion remains, the promoter should accept Architecturally, I would prefer not to promote arbitrary legacy names at all. 3. Malformed federation JSON can be invoked as empty arguments
Please validate and reject malformed input before lookup, promotion, or invocation, returning a 400-equivalent response. Add a test proving malformed input causes neither promotion nor handler execution. 4. Test coverage is insufficient at the changed boundaryThe new tests use synchronous fake promoter callbacks. They do not exercise:
GitHub currently reports no CI checks for this PR. At minimum, I would require focused race/integration coverage along these lines: go test -race ./internal/server -run 'Test(CallToolStrict|ToolCall|Processes|Communities|Dashboard)'
go test -race ./internal/mcp -run 'Test.*(Promot|Facade|Tool.*Gate)'
go test -race ./cmd/gortex -run 'Test.*(LocalToolExecutor|Router|DaemonV1)'Speaking about the big picture: I have an Architectural concern This PR fixes the immediate web symptom by making the generic legacy dispatcher more capable. That moves in the opposite direction from facade v1 and makes The failing dashboard calls already have facade replacements:
My preferred short-term fix is:
For the web application, I recommend a small server-side Next.js BFF:
This also avoids exposing The remaining facade gaps—raw graph snapshot, enriched repo statistics, guard inventory, activity/event parity—should become a few typed operations under existing facade tools, with narrowly typed legacy REST retained temporarily where necessary. Other observations
Overall, I got feedback about a broken dashboard (it was expected with the migration to the facade v1), and if the scope for the web/gortex updates is too big/vague, I can work on fixing the dashboard functionality soon (next week or the following week). |
…ion, session-aware federation Addresses zzet's review on PR 649 (labeled invalid): 1. Dashboard routes now call the eager analyze facade instead of promoting arbitrary legacy names through the public HTTP surface. wrapLegacyFacade routes analyze(kind=processes|communities|contracts) to handleFacade, which holds the captured legacy handler directly — no registry promotion, no tools/list churn, no facade boundary weakening. The generic SetToolPromoter/getToolOrPromote hook is removed from Handler; the four production wire sites drop it. 2. lazyToolRegistry.Promote is now atomic: the promoted mark and the live AddTool happen under one lock, so a concurrent caller can never observe a tool marked promoted but not yet registered (the false-404 race). EnsureToolPromoted's return now reports liveness (re-check GetTool) instead of "I transitioned it", so racing callers retry. 3. newLocalToolExecutor (federation path) promotes session-aware via EnsureToolPromotedForSession + WithAuthorizedToolCall, mirroring the daemon dispatcher — a facade-v1/hide-mode session cannot mutate the shared registry. Malformed JSON is rejected with 400 before any lookup, promotion, or invocation. 4. Tests: synchronized two-goroutine promotion race test; facade analyze alias tests through the real MCP dispatch (legacy session, no promotion); malformed-input no-promotion/no-handler tests for the executor; existing promotion tests updated to the liveness contract. All pass under -race for the reviewer's specified suites.
The server no longer auto-promotes arbitrary legacy tool names through
/v1/tools/{name} (PR zzet/gortex#649 rework). processDetail called
get_processes directly, which 404s under the core/defer surface once
generic promotion is removed. Switch to analyze(kind=processes, id=...),
which the facade routes to the get_processes handler without promotion.
|
Reworked per your review — the generic promotion approach is gone. Thanks for the detailed feedback; the facade direction is clearly right. What changed1. Dashboard routes now use the eager
2. Race fix —
3. Federation path is session-aware.
4. Malformed federation JSON rejected with 400.
5. Test coverage at the changed boundaries.
Architectural note: the remaining facade gaps you listed (raw graph snapshot, enriched repo stats, guard inventory, activity/event parity) are untouched here — I kept the scope to the dashboard breakage. Happy to do those as follow-ups, or take them on if you'd like. The web app's |
Review of the rework's own tests found two that did not actually regression-test the change: 1. TestAnalyzeAliasedKindFromLegacySession used facadeFrameCaller, whose initialize handshake with a non-empty client name makes the session a facade-v1 session (clientDefaultPolicy). The facade alias already worked there pre-rework, so the test passed on both old and new code. Rewritten to invoke the analyze tool's registered handler directly with a bare context — exactly what the HTTP dashboard path does via CallToolStrict (no MCP session, no client name). Verified: fails on the pre-rework code with 'unknown analyze kind: processes', passes on the rework. 2. TestPromote_ConcurrentCallersNeverFalse404 was timing-dependent: the pre-fix race window (mark under lock, AddTool outside) is a few instructions wide, so the old test passed on old code. Rewritten with a deterministic interleaving barrier (first promote callback blocked until the second caller observes the intermediate state). Note: the test cannot deterministically FAIL on old code — any release ordering that makes the failure deterministic deadlocks the fixed code (whose Promote blocks on the held lock). It exercises the concurrent path, passes under -race, and documents the contract; the race fix itself is the atomic lock change.
|
Follow-up on the rework (60c94f6): two of the rework's own tests did not actually regression-test the change, now fixed. Also: the connection to #661. Test corrections (60c94f6)
On #661I read the issue (surface keyed on
Happy to take #661 as a follow-up if you want it. |
The rework made aliased analyze kinds (processes, communities, contracts,
...) reachable from legacy and session-less HTTP callers via the facade,
with no tools_search promotion. Document the behavior in server.md
(/v1/tools/{name} row) and mcp.md (analyze dispatcher aliases,
surface-independence).
Summary
Under the shipped default (
corepreset, defer mode), most MCP tools are held in a deferred/lazy catalog and only reachable aftertools_searchpromotes them into the live registry. Three internal dispatch paths look the tool up in the live registry only, with no fallback to the deferred catalog, so a perfectly valid tool 404s / errors until something else happens to promote it first:Handler.CallToolStrict(internal/server/handler.go) — every internal/v1/*dashboard route goes through this. Concretely, the dashboard's Processes and Communities pages callget_processes/get_communitiesby name and gettool "get_processes" is not registered, even though the tool exists and works fine once promoted.Handler.handleToolCall(POST /v1/tools/{name}, the public REST tool-invocation endpoint) — same gap, returns404 tool_not_found. This one already returns anavailable_toolslist on the 404 response, which suggests the intent was a complete/helpful surface rather than a deliberate "must call tools_search first" gate — so I've treated this as the same bug rather than a separate design decision.newLocalToolExecutor(cmd/gortex/server_router.go) — the federation router's local-dispatch path has the identical pattern; a remote server routing a deferred tool call to this daemon would 404 the same way.None of these needed new machinery —
(*mcp.Server).EnsureToolPromotedalready existed for exactly this purpose (built for the CLI'sgortex callpath) and is already tested (internal/mcp/promote_on_demand_test.go).Changes
Handlergains apromoteTool func(name string) boolfield +SetToolPromotersetter (nil-safe).Handler.getToolOrPromotehelper: look up live, and on a miss, consult the promoter and retry once.CallToolStrictandhandleToolCallboth now go through this instead of duplicating the retry logic.SetToolPromoter(srv.EnsureToolPromoted)wired at every place that constructs aHandler/eval.Handler:cmd/gortex/daemon.go,cmd/gortex/mcp.go,cmd/gortex/eval_server.go, andbench/daemon-latency/main.go(the one benchmark that was missing it).newLocalToolExecutorgets the equivalent promote-then-retry against*mcp.Serverdirectly (it predatesHandler, so it can't sharegetToolOrPromote).Tests
TestCallToolStrict_PromotesDeferredTool/TestCallToolStrict_PromoterDeclines_StillMissing(internal/server/handler_strict_test.go)TestToolCallPromotesDeferredTool(internal/server/handler_test.go) — same fix, through the HTTPPOST /v1/tools/{name}path specifically, since it serializes a different response shape thanCallToolStrict.newLocalToolExecutor's fix isn't independently re-tested — it's a direct, mechanical mirror of the already-testedEnsureToolPromotedcontract, andgo test ./cmd/gortex/...covers the surrounding router behavior.go build ./...andgo test ./internal/server/... ./cmd/gortex/... ./internal/mcp/... ./bench/...all green.Considered and declined
handleToolCallpromotes via the context-lessEnsureToolPromoted, not the session-awareEnsureToolPromotedForSession. I considered switching to the session-aware variant since this route is externally reachable, buthandleToolCall's existingGetToollookup already doesn't do session-based gating either — a live tool is callable here regardless of session today. Promoting via the plain variant doesn't newly bypass anything relative to that existing baseline, so I left it as-is rather than threading acontext.Contextthrough the promoter signature for a gap that isn't actually a regression. Happy to revisit if there's a reason session gating matters here that I'm missing.