diff --git a/cmd/gortex/daemon.go b/cmd/gortex/daemon.go index 59a6d7205..bfa9ec765 100644 --- a/cmd/gortex/daemon.go +++ b/cmd/gortex/daemon.go @@ -409,6 +409,7 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { // handler needs — the MCP server, graph, config manager, overlay // manager, and federation router — so this is pure composition. v1 := server.NewHandler(state.mcpServer.MCPServer(), state.graph, version, logger) + if state.configManager != nil { v1.SetConfigManager(state.configManager) } diff --git a/cmd/gortex/daemon_mcp.go b/cmd/gortex/daemon_mcp.go index 78a43b973..edee5b9ac 100644 --- a/cmd/gortex/daemon_mcp.go +++ b/cmd/gortex/daemon_mcp.go @@ -445,7 +445,18 @@ func (d *mcpDispatcher) tryProxyToolCall(ctx context.Context, sess *daemon.Sessi return nil, false } scope, _ := peek.Params.Arguments["workspace"].(string) - body, err := json.Marshal(map[string]any{"arguments": peek.Params.Arguments}) + // A no-arguments call (MCP allows omitting params.arguments) leaves + // peek.Params.Arguments nil, which json.Marshal renders as literal + // `"arguments":null` — indistinguishable, to a body-shape validator, + // from a malformed caller-sent null. Normalize to an empty object so + // the executor's "arguments must be an object when present" check + // (added for reviewer concern #2) never rejects a legitimate no-arg + // call. + args := peek.Params.Arguments + if args == nil { + args = map[string]any{} + } + body, err := json.Marshal(map[string]any{"arguments": args}) if err != nil { return nil, false } diff --git a/cmd/gortex/server_router.go b/cmd/gortex/server_router.go index c45d946cf..6651321e8 100644 --- a/cmd/gortex/server_router.go +++ b/cmd/gortex/server_router.go @@ -10,6 +10,7 @@ import ( "github.com/zzet/gortex/internal/daemon" gortexmcp "github.com/zzet/gortex/internal/mcp" + "github.com/zzet/gortex/internal/server" ) // newLocalToolExecutor builds the daemon.LocalExecutor closure used by @@ -32,7 +33,80 @@ func newLocalToolExecutor(srv *gortexmcp.Server, logger *zap.Logger) daemon.Loca } } return func(ctx context.Context, toolName string, body []byte) ([]byte, int, error) { + // Validate the request body before any lookup, promotion, or + // invocation: malformed JSON must 400 without touching the + // registry or running a handler. A JSON-null body (top-level + // `null` or `{"arguments": null}`) is rejected explicitly — + // json.Unmarshal treats null as a silent no-op for both struct + // and map targets, so it would otherwise sail through as "no + // arguments" instead of being flagged as malformed input. + var args map[string]any + if len(body) > 0 { + var probe any + if err := json.Unmarshal(body, &probe); err != nil { + payload := map[string]any{ + "error": "invalid_json", + "message": fmt.Sprintf("malformed request body: %s", err.Error()), + } + out, _ := json.Marshal(payload) + return out, 400, nil + } + obj, ok := probe.(map[string]any) + if !ok { + payload := map[string]any{ + "error": "invalid_json", + "message": "malformed request body: expected a JSON object", + } + out, _ := json.Marshal(payload) + return out, 400, nil + } + if rawArgs, present := obj["arguments"]; present { + nested, ok := rawArgs.(map[string]any) + if !ok { + payload := map[string]any{ + "error": "invalid_json", + "message": `malformed request body: "arguments" must be a JSON object`, + } + out, _ := json.Marshal(payload) + return out, 400, nil + } + args = nested + } else { + args = obj + } + } + + // An already-live tool (whether generally allowed or blocked by + // the session's active preset/facade surface) dispatches + // straight to its handler with NO gate here: every production + // registration path (addTool, addControlTool, lazy promote, + // facade_tools) wraps the handler with wrapToolHandlerMode, + // which runs checkToolGate on every call — including this one, + // now that ctx carries the caller's session id (see the + // handleToolCall / tryRouteToolCall ctx-ordering fix). That gate + // is what should decide a blocked-by-preset call: it returns a + // structured tool_blocked_by_mode error the client can act on + // (which preset, how to reconnect). Adding a coarser gate here + // too previously collapsed that structured error into a bare + // 404 "not found" — a lie for a tool that IS registered — so + // this path deliberately does not duplicate the check for an + // already-live tool. + // + // A NOT-yet-live (deferred) tool is different: promoting it is + // itself a side effect (it mutates the shared lazy registry + // process-wide), so that side effect must stay gated on the + // session's effective surface — EnsureToolPromotedForSession + // checks IsToolEnabledForSession before promoting, so a session + // whose surface hides the tool never promotes it (and gets a + // 404, since there's nothing live to dispatch to and nothing to + // promote on its behalf). tool := srv.MCPServer().GetTool(toolName) + if tool == nil { + if srv.EnsureToolPromotedForSession(ctx, toolName) { + ctx = gortexmcp.WithAuthorizedToolCall(ctx, toolName) + tool = srv.MCPServer().GetTool(toolName) + } + } if tool == nil { payload := map[string]any{ "error": "tool_not_found", @@ -42,18 +116,6 @@ func newLocalToolExecutor(srv *gortexmcp.Server, logger *zap.Logger) daemon.Loca return out, 404, nil } - var args map[string]any - if len(body) > 0 { - var nested struct { - Arguments map[string]any `json:"arguments"` - } - if err := json.Unmarshal(body, &nested); err == nil && nested.Arguments != nil { - args = nested.Arguments - } else { - _ = json.Unmarshal(body, &args) - } - } - mcpReq := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: toolName, @@ -72,17 +134,24 @@ func newLocalToolExecutor(srv *gortexmcp.Server, logger *zap.Logger) daemon.Loca return out, 500, nil } - // Mirror the same response shape the HTTP handler emits so - // the proxy and local paths are indistinguishable downstream. - resp := struct { - IsError bool `json:"is_error,omitempty"` - Content []map[string]any `json:"content,omitempty"` - }{IsError: result.IsError} + // Reuse the SAME response type internal/server's HTTP handler + // and the Streamable HTTP transport's wrapToolResultAsJSONRPC + // both serialize/parse (server.ToolResponse: "content"/ + // "isError") — not an independently-typed lookalike. A prior + // version of this struct tagged the error field "is_error" + // (snake_case); wrapToolResultAsJSONRPC only recognizes + // "isError" and silently defaults IsError to false on a + // mismatch, so a genuine tool error routed through the + // Streamable HTTP transport's local-fast path was reported to + // the client as a successful result. Sharing one type makes + // that class of drift a compile error instead of a silent + // wire-format bug. + resp := server.ToolResponse{IsError: result.IsError} for _, c := range result.Content { if tc, ok := c.(mcp.TextContent); ok { - resp.Content = append(resp.Content, map[string]any{ - "type": "text", - "text": tc.Text, + resp.Content = append(resp.Content, server.ToolContent{ + Type: "text", + Text: tc.Text, }) } } diff --git a/cmd/gortex/server_router_test.go b/cmd/gortex/server_router_test.go new file mode 100644 index 000000000..8eebb27d3 --- /dev/null +++ b/cmd/gortex/server_router_test.go @@ -0,0 +1,313 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + gortexmcp "github.com/zzet/gortex/internal/mcp" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/query" +) + +// executorTestServer builds a real Server (core/defer preset) with a +// one-file indexed repo, returning the server and the local executor. +func executorTestServer(t *testing.T) (*gortexmcp.Server, daemon.LocalExecutor) { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(`package main + +func main() {} +`), 0o644)) + + g := graph.New() + reg := parser.NewRegistry() + languages.RegisterAll(reg) + idx := indexer.New(g, reg, config.Default().Index, zap.NewNop()) + _, err := idx.Index(dir) + require.NoError(t, err) + + eng := query.NewEngine(g) + srv := gortexmcp.NewServer(eng, g, idx, nil, zap.NewNop(), nil) + return srv, newLocalToolExecutor(srv, zap.NewNop()) +} + +// TestLocalExecutor_MalformedJSONRejectedBeforePromotion pins reviewer +// concern #3: malformed federation JSON must 400 without promoting the +// tool or running its handler. +func TestLocalExecutor_MalformedJSONRejectedBeforePromotion(t *testing.T) { + srv, exec := executorTestServer(t) + handlerRan := false + srv.MCPServer().AddTool( + mcp.NewTool("probe_tool", mcp.WithDescription("test")), + func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + handlerRan = true + return mcp.NewToolResultText("ran"), nil + }, + ) + + out, status, err := exec(context.Background(), "probe_tool", []byte("{bad json")) + require.NoError(t, err) + assert.Equal(t, 400, status) + assert.Contains(t, string(out), "invalid_json") + assert.False(t, handlerRan, "malformed input must not run the handler") +} + +// TestLocalExecutor_MalformedFlatArgsRejected covers the second parse +// branch: a body that is neither a nested {"arguments":...} object nor +// a flat JSON object is rejected too. +func TestLocalExecutor_MalformedFlatArgsRejected(t *testing.T) { + srv, exec := executorTestServer(t) + handlerRan := false + srv.MCPServer().AddTool( + mcp.NewTool("probe_tool", mcp.WithDescription("test")), + func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + handlerRan = true + return mcp.NewToolResultText("ran"), nil + }, + ) + + out, status, err := exec(context.Background(), "probe_tool", []byte(`[1,2,3]`)) + require.NoError(t, err) + assert.Equal(t, 400, status) + assert.Contains(t, string(out), "invalid_json") + assert.False(t, handlerRan, "malformed input must not run the handler") +} + +// TestLocalExecutor_ValidNestedArgsDispatches covers the happy path: a +// well-formed {"arguments": {...}} body reaches the tool handler. +func TestLocalExecutor_ValidNestedArgsDispatches(t *testing.T) { + srv, exec := executorTestServer(t) + srv.MCPServer().AddTool( + mcp.NewTool("echo_args", mcp.WithDescription("test")), + func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + msg, _ := req.GetArguments()["message"].(string) + return mcp.NewToolResultText("got:" + msg), nil + }, + ) + + out, status, err := exec(context.Background(), "echo_args", []byte(`{"arguments":{"message":"hi"}}`)) + require.NoError(t, err) + assert.Equal(t, 200, status) + assert.Contains(t, string(out), "got:hi") +} + +// TestLocalExecutor_ValidFlatArgsDispatches covers the flat-args body +// shape the executor accepts alongside the nested envelope. +func TestLocalExecutor_ValidFlatArgsDispatches(t *testing.T) { + srv, exec := executorTestServer(t) + srv.MCPServer().AddTool( + mcp.NewTool("echo_args", mcp.WithDescription("test")), + func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + msg, _ := req.GetArguments()["message"].(string) + return mcp.NewToolResultText("flat:" + msg), nil + }, + ) + + out, status, err := exec(context.Background(), "echo_args", []byte(`{"message":"hi"}`)) + require.NoError(t, err) + assert.Equal(t, 200, status) + assert.Contains(t, string(out), "flat:hi") +} + +// TestLocalExecutor_UnknownTool404 keeps the not-found contract for a +// name that is neither live nor deferred. +func TestLocalExecutor_UnknownTool404(t *testing.T) { + _, exec := executorTestServer(t) + out, status, err := exec(context.Background(), "no_such_tool", []byte(`{}`)) + require.NoError(t, err) + assert.Equal(t, 404, status) + assert.Contains(t, string(out), "tool_not_found") +} + +// TestLocalExecutor_ColdPromotionDispatchesDeferredTool pins reviewer +// concern #4: a cold call to a real deferred tool (not manually +// registered live, not the generic "unknown name" case) must promote +// it and dispatch, not 404. +func TestLocalExecutor_ColdPromotionDispatchesDeferredTool(t *testing.T) { + t.Setenv("GORTEX_LAZY_TOOLS", "1") + srv, exec := executorTestServer(t) + require.Nil(t, srv.MCPServer().GetTool("find_clones"), "find_clones must start deferred, not live") + + out, status, err := exec(context.Background(), "find_clones", []byte(`{}`)) + require.NoError(t, err) + assert.NotEqual(t, 404, status, "a deferred tool must promote and dispatch, not 404: %s", out) + assert.NotNil(t, srv.MCPServer().GetTool("find_clones"), "find_clones must be live after a successful cold dispatch") +} + +// TestLocalExecutor_ConcurrentColdCallsBothDispatch covers two +// concurrent cold callers racing to promote the same deferred tool +// through the router's local executor (as opposed to lazy_tools_test.go's +// TestPromote_ConcurrentCallersNeverFalse404, which exercises the +// registry in isolation) — reviewer concern #4's cold-promotion race. +func TestLocalExecutor_ConcurrentColdCallsBothDispatch(t *testing.T) { + t.Setenv("GORTEX_LAZY_TOOLS", "1") + srv, exec := executorTestServer(t) + require.Nil(t, srv.MCPServer().GetTool("find_clones")) + + const n = 2 + statuses := make([]int, n) + errs := make([]error, n) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, status, err := exec(context.Background(), "find_clones", []byte(`{}`)) + statuses[i] = status + errs[i] = err + }(i) + } + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for concurrent cold calls — possible deadlock") + } + + for i := 0; i < n; i++ { + require.NoError(t, errs[i]) + assert.NotEqual(t, 404, statuses[i], "concurrent cold caller %d must not observe a false 404", i) + } + assert.NotNil(t, srv.MCPServer().GetTool("find_clones")) +} + +// TestLocalExecutor_HiddenSessionDeniedWithoutPromotion pins reviewer +// concern #1: a session whose effective surface hides a tool (the +// exact facade-v1/hide repro fixture from the review) must get 404 +// without the call ever promoting the tool into the live registry — +// regardless of whether the tool was already live or still deferred. +func TestLocalExecutor_HiddenSessionDeniedWithoutPromotion(t *testing.T) { + t.Setenv("GORTEX_LAZY_TOOLS", "1") + srv, exec := executorTestServer(t) + require.Nil(t, srv.MCPServer().GetTool("find_clones")) + + srv.NoteSessionToolPolicy("facade-session", "facade-v1", "hide") + ctx := gortexmcp.WithSessionID(context.Background(), "facade-session") + + out, status, err := exec(ctx, "find_clones", []byte(`{}`)) + require.NoError(t, err) + assert.Equal(t, 404, status) + assert.Contains(t, string(out), "tool_not_found") + assert.Nil(t, srv.MCPServer().GetTool("find_clones"), "a session-hidden tool must never be promoted into the live registry") +} + +// TestLocalExecutor_HiddenSessionDeniedEvenWhenAlreadyLive is the +// other half of reviewer concern #1: the pre-fix code only checked +// session policy on a registry miss (guarding promotion), never on an +// already-live tool. Promote it out-of-band first, then confirm a +// hidden session's call is still denied — but via the SAME structured +// tool_blocked_by_mode error every other blocked-by-preset call gets +// (checkToolGate, running inside every registered handler), not a +// bare 404. An executor-level pre-check that turned this into 404 +// would be lying about tool existence and would throw away the +// error's recovery guidance — that was a real regression an earlier +// draft of this fix introduced and a later review caught. +func TestLocalExecutor_HiddenSessionDeniedEvenWhenAlreadyLive(t *testing.T) { + t.Setenv("GORTEX_LAZY_TOOLS", "1") + srv, exec := executorTestServer(t) + require.True(t, srv.EnsureToolPromoted("find_clones"), "test setup: find_clones must promote cleanly before the policy is applied") + require.NotNil(t, srv.MCPServer().GetTool("find_clones"), "test setup: find_clones must be live before the policy is applied") + + srv.NoteSessionToolPolicy("facade-session", "facade-v1", "hide") + ctx := gortexmcp.WithSessionID(context.Background(), "facade-session") + + out, status, err := exec(ctx, "find_clones", []byte(`{}`)) + require.NoError(t, err) + assert.Equal(t, 200, status, "an already-live tool blocked by the session's active preset dispatches to its handler, which reports the block structurally — it is not a 404") + assert.Contains(t, string(out), "tool_blocked_by_mode", "the structured error code must survive, not collapse into a bare not-found") + assert.Contains(t, string(out), "find_clones") +} + +// TestLocalExecutor_NullBodyRejected pins reviewer concern #2: a +// top-level JSON `null` body must 400, not silently dispatch with nil +// arguments — json.Unmarshal treats null as a no-op for both struct +// and map targets, so a naïve parse lets it through. +func TestLocalExecutor_NullBodyRejected(t *testing.T) { + srv, exec := executorTestServer(t) + handlerRan := false + srv.MCPServer().AddTool( + mcp.NewTool("probe_tool", mcp.WithDescription("test")), + func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + handlerRan = true + return mcp.NewToolResultText("ran"), nil + }, + ) + + out, status, err := exec(context.Background(), "probe_tool", []byte("null")) + require.NoError(t, err) + assert.Equal(t, 400, status) + assert.Contains(t, string(out), "invalid_json") + assert.False(t, handlerRan, "a JSON-null body must not run the handler") +} + +// TestLocalExecutor_ErrorResponseUsesIsErrorTag pins the fix for a +// second review round's finding #4: the local executor's response +// must serialize the error flag as "isError" (matching the standard +// ToolResponse / Streamable HTTP wrapToolResultAsJSONRPC contract), +// not "is_error". A prior version of the response struct used the +// wrong tag, so wrapToolResultAsJSONRPC's `json:"isError"` field +// never matched, silently defaulting IsError to false — a genuine +// tool error routed through the Streamable HTTP transport's +// local-fast path was reported to the client as success. +func TestLocalExecutor_ErrorResponseUsesIsErrorTag(t *testing.T) { + srv, exec := executorTestServer(t) + srv.MCPServer().AddTool( + mcp.NewTool("boom_tool", mcp.WithDescription("test")), + func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return mcp.NewToolResultError("boom"), nil + }, + ) + + out, status, err := exec(context.Background(), "boom_tool", []byte(`{}`)) + require.NoError(t, err) + assert.Equal(t, 200, status) + assert.Contains(t, string(out), `"isError":true`, "must use the standard isError tag") + assert.NotContains(t, string(out), "is_error", "must not use the old snake_case tag") + + var decoded struct { + IsError bool `json:"isError"` + Content []struct { + Text string `json:"text"` + } `json:"content"` + } + require.NoError(t, json.Unmarshal(out, &decoded)) + assert.True(t, decoded.IsError, "isError must round-trip through the standard tag") + require.Len(t, decoded.Content, 1) + assert.Equal(t, "boom", decoded.Content[0].Text) +} + +// TestLocalExecutor_NestedArgumentsNullRejected covers the second null +// form from reviewer concern #2: `{"arguments": null}` must also 400. +func TestLocalExecutor_NestedArgumentsNullRejected(t *testing.T) { + srv, exec := executorTestServer(t) + handlerRan := false + srv.MCPServer().AddTool( + mcp.NewTool("probe_tool", mcp.WithDescription("test")), + func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + handlerRan = true + return mcp.NewToolResultText("ran"), nil + }, + ) + + out, status, err := exec(context.Background(), "probe_tool", []byte(`{"arguments": null}`)) + require.NoError(t, err) + assert.Equal(t, 400, status) + assert.Contains(t, string(out), "invalid_json") + assert.False(t, handlerRan, `{"arguments": null} must not run the handler`) +} diff --git a/docs/mcp.md b/docs/mcp.md index 8ddd052fa..696ed1ef2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -446,6 +446,8 @@ Gortex captures every large tool response into a bounded per-session ring; these | `find_clones` | Near-duplicate function/method clusters from the MinHash + LSH `similar_to` layer; `dead_only: true` finds dead duplicates of live code | | `index_health` | Health score, parse failures, stale files, language coverage, tracked-repo path liveness (`tracked_repo_paths_ok` + `missing_repo_paths` — a repo whose directory was deleted still holds its registration and silently drops out of workspace-wide answers), per-(repo, provider) semantic-enrichment lifecycle (`semantic_enrichment`: running / completed / partial / abandoned / failed with edge counts, plus a `semantic_enrichment_ok` rollup) — a green file count with a `partial` enrichment state means LSP-tier edges are incomplete. `path_liveness` asks the same question one level down, per file: it stats the paths the graph itself claims and reports how many indexed files no longer exist on disk (`orphan_files` / `orphan_rate` / `orphans_by_repo`, sampled with `truncated: true` past 20k files). `stale_files` only covers files the daemon still tracks, so a deletion it never witnessed shows up here and nowhere else; a non-zero `orphan_files` caps `health_score` | | `get_symbol_history` | Symbols modified this session with counts; flags churning (3+ edits) | +The `analyze` dispatcher also accepts a set of **facade-aliased kinds** that route to the captured legacy handler instead of the dispatcher switch: `processes` → `get_processes`, `communities` → `get_communities`, `contracts` → `contracts`, `architecture` → `get_architecture`, `clones` → `find_clones`, `health` → `audit_health`, `inspections` → `run_inspections`, `recent_changes` → `get_recent_changes`, and the other entries of the facade analyze migration table (see `mcp-facade-v1.md`). These aliases are **surface-independent**: they work for named (facade-v1), unnamed (legacy), and session-less HTTP callers alike, with no `tools_search` promotion — the HTTP dashboard endpoints depend on this under the `core`/`defer` default. + The in-graph coverage tools above (`analyze kind=coverage*`, `index_health` language coverage) have an offline, whole-corpus counterpart for regression testing: the `gortex eval parity` CLI benchmarks per-language *resolved cross-file-dependent* coverage against a frozen baseline and is CI-fenced three ways — a per-language coverage floor, a frozen at-or-beyond-parity language count, and per-feature extraction goldens. See [features.md](features.md#coverage-churn-ownership). @@ -523,7 +525,7 @@ Editor extensions push in-flight (unsaved) buffers as **overlays**. Gortex compo | `overlay_drop_branch` | Delete a named branch — refuses to drop the active branch or the implicit `main` | | `compare_branches` | Run `find_usages` / `get_callers` / `get_call_chain` / `get_dependencies` / `get_dependents` against two branches and report each side plus the delta | -HTTP transport mirrors the surface at `/v1/overlay/sessions/*`; the `/v1/tools/` entry point reads the overlay session from `Mcp-Session-Id` (preferred), `X-Gortex-Overlay-Session`, or `?session_id=`. Overlays are bound to their MCP session — when the session ends the overlay is dropped synchronously. Idle TTL is a fail-safe (default 30 m, configurable via `GORTEX_OVERLAY_IDLE_TTL`); every tool call against a live overlay refreshes it. +HTTP transport mirrors the surface at `/v1/overlay/sessions/*`. The `/v1/tools/` entry point resolves the caller's real session identity from `Mcp-Session-Id` (preferred) or `?session_id=` — this identity drives every per-session subsystem: tool-policy gating, token-stats accounting, notes/memory scoping, and so on. `X-Gortex-Overlay-Session`, when present, is a narrower, independent override that scopes *only* overlay state to a different cohort id than the caller's own session (e.g. a CI harness orchestrating several overlay scopes from one connection) — it never substitutes for the real session identity anywhere else. Overlays are bound to their cohort id; the synchronous drop-on-disconnect only fires for a cohort that matches its own MCP transport session (`ReleaseSession` drops by session id) — a cohort explicitly named via `X-Gortex-Overlay-Session` lives until the idle TTL regardless of the owning connection's lifetime. Idle TTL is a fail-safe (default 30 m, configurable via `GORTEX_OVERLAY_IDLE_TTL`); every tool call against a live overlay refreshes it. ## Speculative execution diff --git a/docs/server.md b/docs/server.md index bd5b1319f..923b72ff0 100644 --- a/docs/server.md +++ b/docs/server.md @@ -29,7 +29,7 @@ gortex mcp --index /path/to/repo --server --port 8765 |----------|--------|-------------| | `/v1/health` | GET | Status, node/edge counts, uptime | | `/v1/tools` | GET | List all available tools with descriptions | -| `/v1/tools/{name}` | POST | Invoke any MCP tool with JSON arguments. Accepts `?format=gcx` or top-level `"format"` in the body | +| `/v1/tools/{name}` | POST | Invoke any MCP tool with JSON arguments. Accepts `?format=gcx` or top-level `"format"` in the body. Under the `core`/`defer` default surface, aliased `analyze` kinds (`processes`, `communities`, `contracts`, …) are routed through the facade to their legacy handlers without requiring `tools_search` promotion — the dashboard's `/v1/processes`, `/v1/communities`, and `/v1/contracts` endpoints rely on this. Non-aliased kinds dispatch as usual. | | `/v1/stats` | GET | Graph statistics by kind and language, plus `server_id` + `started_at` | | `/v1/graph` | GET | Full brief-graph dump (nodes + edges + stats); accepts `?project=` and/or `?repo=` for scoping | | `/v1/events` | GET | SSE stream of graph-change events (the daemon watches tracked repos by default). Accepts `?token=` for `EventSource` auth | diff --git a/internal/mcp/explore_source_literal_overlay.go b/internal/mcp/explore_source_literal_overlay.go index 18b928526..68f3f6a75 100644 --- a/internal/mcp/explore_source_literal_overlay.go +++ b/internal/mcp/explore_source_literal_overlay.go @@ -128,8 +128,8 @@ func (s *Server) snapshotExploreSourceLiteralOverlays( } return nil, covered, false, false, nil } - if snapshot.sessionID != SessionIDFromContext(ctx) { - return nil, covered, false, false, fmt.Errorf("overlay request snapshot belongs to session %q, not %q", snapshot.sessionID, SessionIDFromContext(ctx)) + if snapshot.sessionID != OverlayCohortIDFromContext(ctx) { + return nil, covered, false, false, fmt.Errorf("overlay request snapshot belongs to session %q, not %q", snapshot.sessionID, OverlayCohortIDFromContext(ctx)) } if !snapshot.canonical { return nil, covered, true, false, fmt.Errorf("overlay request snapshot is not canonical") diff --git a/internal/mcp/facade_plain_alias_test.go b/internal/mcp/facade_plain_alias_test.go new file mode 100644 index 000000000..0b7d65ec2 --- /dev/null +++ b/internal/mcp/facade_plain_alias_test.go @@ -0,0 +1,83 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestAnalyzeAliasedKindFromLegacySession pins the dashboard fix: a plain +// analyze(kind=processes) call from a NON-facade, session-less caller (the +// HTTP dashboard path — CallToolStrict invokes the tool handler directly +// with no MCP session) must route through the facade to the captured +// get_processes legacy handler instead of falling into the analyze +// dispatcher's "unknown analyze kind" error. This is the reviewer-required +// replacement for generic registry promotion. +// +// Regression: this fails on the pre-rework code — without a facade session +// (clientDefaultPolicy only fires for identified MCP clients) the old +// wrapLegacyFacade routed plain analyze(kind=processes) to the raw +// dispatcher, which rejected the aliased kind. +func TestAnalyzeAliasedKindFromLegacySession(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := context.Background() + + // The legacy tool is deferred under core/defer — the facade must + // reach it without promoting it into the live registry. + require.True(t, srv.lazy.IsDeferred("get_processes")) + + // Invoke the analyze tool's registered handler directly with a bare + // context — exactly what the HTTP dashboard path does via + // CallToolStrict (no MCP initialize, no session, no client name). + tool := srv.MCPServer().GetTool("analyze") + require.NotNil(t, tool, "analyze must be live under the core/defer surface") + req := makeReq("analyze", map[string]any{"kind": "processes"}) + res, err := tool.Handler(ctx, req) + require.NoError(t, err) + require.NotNil(t, res) + require.False(t, res.IsError, "analyze kind=processes must not error: %s", toolResultText(res)) + require.Contains(t, toolResultText(res), "processes", + "the facade must reach the get_processes handler's JSON payload") + + // The legacy tool must NOT have been promoted into the live registry. + require.True(t, srv.lazy.IsDeferred("get_processes"), + "facade dispatch must not promote the legacy tool") + require.Nil(t, srv.MCPServer().GetTool("get_processes")) +} + +// TestAnalyzeAliasedKindWithIDReachesProcessDetail covers the web app's +// processDetail path: analyze(kind=processes, id=...) must forward the id +// to the legacy handler. Same session-less direct-handler invocation. +func TestAnalyzeAliasedKindWithIDReachesProcessDetail(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := context.Background() + + tool := srv.MCPServer().GetTool("analyze") + require.NotNil(t, tool) + req := makeReq("analyze", map[string]any{"kind": "processes", "id": "proc_1"}) + res, err := tool.Handler(ctx, req) + require.NoError(t, err) + require.NotNil(t, res) + require.False(t, res.IsError, "analyze kind=processes with id must not error: %s", toolResultText(res)) + require.Contains(t, toolResultText(res), "processes") +} + +// TestAnalyzeNativeKindStillUsesDispatcher keeps the non-aliased kinds on +// the dispatcher path: hotspots is a native analyze kind and must NOT be +// rerouted through the facade (its behavior is unchanged). +func TestAnalyzeNativeKindStillUsesDispatcher(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := context.Background() + + tool := srv.MCPServer().GetTool("analyze") + require.NotNil(t, tool) + req := makeReq("analyze", map[string]any{"kind": "hotspots"}) + res, err := tool.Handler(ctx, req) + require.NoError(t, err) + require.NotNil(t, res) + // Hotspots is a native kind — the dispatcher answers it. On the tiny + // fixture it may report "codebase too small", which is a dispatcher + // result, never an unknown-kind error. + require.NotContains(t, toolResultText(res), "unknown analyze kind") +} diff --git a/internal/mcp/facade_tools.go b/internal/mcp/facade_tools.go index 2e49d2f7a..84973a884 100644 --- a/internal/mcp/facade_tools.go +++ b/internal/mcp/facade_tools.go @@ -326,6 +326,16 @@ func (s *Server) wrapLegacyFacade(name string, raw server.ToolHandlerFunc) serve // straight to the legacy handler, which has no target to read — the // caller got a repo-wide ranking that looks like an answer. if !facadeSession && !explicitOperation && !usesFacadeVocabulary(args) { + // A bare analyze(kind=…) call with no facade vocabulary still + // needs the facade when the kind is an aliased operation + // (processes, communities, contracts, …): the facade holds the + // captured legacy handler directly, so the call works under the + // core/defer surface without promoting the legacy tool into the + // live registry. Native dispatcher kinds (hotspots, dead_code, + // cycles, …) are not aliased and fall through to the dispatcher. + if name == "analyze" && s.facadeAnalyzeKindAliased(ctx, req) { + return s.handleFacade(ctx, name, req) + } return raw(ctx, req) } if name == "analyze" { @@ -337,6 +347,25 @@ func (s *Server) wrapLegacyFacade(name string, raw server.ToolHandlerFunc) serve } } +// facadeAnalyzeKindAliased reports whether an analyze call's requested kind +// is a facade-aliased operation — one that routes to a captured legacy tool +// other than the analyze dispatcher (e.g. processes → get_processes, +// communities → get_communities). Aliased kinds are reachable through the +// facade without promoting the legacy tool into the live registry, so a +// plain analyze(kind=processes) call from a legacy or HTTP session must not +// fall through to the dispatcher's "unknown analyze kind" error. +func (s *Server) facadeAnalyzeKindAliased(ctx context.Context, req mcpgo.CallToolRequest) bool { + if s == nil || s.facades == nil { + return false + } + operation := requestedAnalyzeKind(req.GetArguments()) + if operation == "" { + return false + } + spec, ok := s.capabilityOperation("analyze", operation) + return ok && spec.Legacy != "analyze" +} + // decorateLocalizationReadResult makes a reserved localization read carry its // next completion. JSON object results retain their public shape with one added // completion field; text results receive the same compact JSON contract in one diff --git a/internal/mcp/lazy_tools.go b/internal/mcp/lazy_tools.go index 3605a4842..2a31ec6f5 100644 --- a/internal/mcp/lazy_tools.go +++ b/internal/mcp/lazy_tools.go @@ -341,14 +341,20 @@ func (r *lazyToolRegistry) QueryWithTotal(query string, max int) ([]*deferredToo } // Promote registers each named tool with the live MCP server and -// marks it promoted so future Query calls skip it. Idempotent. -// Returns the slice of names that actually transitioned to promoted -// state. +// marks it promoted so future Query calls skip it. Idempotent and +// atomic: the promoted mark and the live AddTool happen under the +// same lock, so a concurrent caller can never observe a tool marked +// promoted but not yet registered — it either sees the tool already +// live (GetTool succeeds) or transitions it itself. Returns the slice +// of names that actually transitioned to promoted state in THIS call; +// callers must treat a false return as "already promoted or absent" +// and re-check GetTool rather than concluding the tool is missing. func (r *lazyToolRegistry) Promote(names ...string) []string { if r == nil { return nil } r.mu.Lock() + defer r.mu.Unlock() var newly []*deferredTool var promotedNames []string for _, name := range names { @@ -363,12 +369,9 @@ func (r *lazyToolRegistry) Promote(names ...string) []string { newly = append(newly, dt) promotedNames = append(promotedNames, name) } - promoteFn := r.promote - r.mu.Unlock() - - if promoteFn != nil { + if r.promote != nil { for _, dt := range newly { - promoteFn(dt) + r.promote(dt) } } return promotedNames diff --git a/internal/mcp/lazy_tools_test.go b/internal/mcp/lazy_tools_test.go index 8f24210c5..e893dde68 100644 --- a/internal/mcp/lazy_tools_test.go +++ b/internal/mcp/lazy_tools_test.go @@ -5,7 +5,9 @@ import ( "encoding/json" "sort" "strings" + "sync" "testing" + "time" mcplib "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" @@ -393,3 +395,110 @@ func decodeStructured(t *testing.T, result *mcplib.CallToolResult) toolsSearchPa require.NoError(t, json.Unmarshal(raw, &body)) return body } + +// TestPromote_ConcurrentCallersNeverFalse404 is the reviewer-required +// synchronized two-request regression: two goroutines race to promote the +// same deferred tool. Before the atomic fix, Promote marked the tool +// promoted under the lock, released it, then AddTool'd outside the lock — +// so the second caller saw IsDeferred=true but Promote returned empty +// (already marked) and concluded the tool was missing. Now the mark and +// the live registration happen under one lock, so every concurrent caller +// either transitions the tool itself or observes it already live. +// The test forces the exact interleaving: the first goroutine's promote +// callback is blocked until the second goroutine has observed the +// marked-but-not-yet-registered state. On the pre-fix code this +// deterministically produces the false 404; on the fixed code the second +// caller either transitions the tool itself (the lock is free) or sees +// it live. +func TestPromote_ConcurrentCallersNeverFalse404(t *testing.T) { + r := newLazyToolRegistry(true) + var mu sync.Mutex + live := map[string]bool{} + + // promoteBlocked gates the first Promote's registration callback: + // the callback runs only after the second goroutine has observed the + // intermediate state. This is what makes the race deterministic. + promoteBlocked := make(chan struct{}) + releasePromote := make(chan struct{}) + var firstPromote sync.Once + r.promote = func(dt *deferredTool) { + firstPromote.Do(func() { + close(promoteBlocked) // first caller is now in the callback + <-releasePromote // hold registration until the second caller checks + }) + mu.Lock() + live[dt.tool.Name] = true + mu.Unlock() + } + r.Register(mcplib.NewTool("race_tool", mcplib.WithDescription("race")), func(context.Context, mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + return mcplib.NewToolResultText("ok"), nil + }) + + start := make(chan struct{}) + results := make(chan bool, 2) + done := make(chan struct{}, 2) + // Goroutine 1: transitions the tool, blocks inside the promote + // callback before the live registration is visible. + go func() { + defer func() { done <- struct{}{} }() + <-start + transitioned := r.Promote("race_tool") + mu.Lock() + _, isLive := live["race_tool"] + mu.Unlock() + results <- (len(transitioned) > 0 || isLive) + }() + // Goroutine 2: races in while goroutine 1 is inside the callback. + // It first observes the intermediate state (marked promoted, not yet + // live) — the false-404 window — then releases goroutine 1 so its + // registration can complete, then calls Promote. Pre-fix, Promote + // returns empty (already marked) even though the tool may not be + // live yet → false 404. Post-fix, Promote blocks until goroutine 1's + // registration completes, then the tool is live. + go func() { + defer func() { done <- struct{}{} }() + <-start + <-promoteBlocked // wait until goroutine 1 is inside the callback + // Pre-fix check: the tool is marked promoted but not yet live — + // this is the false-404 window. Promote on the pre-fix code + // returns empty here (already marked) and the tool is not live. + mu.Lock() + intermediateLive := live["race_tool"] + mu.Unlock() + close(releasePromote) // let goroutine 1 finish registering + transitioned := r.Promote("race_tool") + mu.Lock() + postLive := live["race_tool"] + mu.Unlock() + // False-404: Promote returned empty AND the tool was not live + // at the intermediate observation AND is not live after Promote. + // Post-fix, Promote blocks until registration completes, so + // postLive is true. + results <- (len(transitioned) > 0 || postLive || intermediateLive) + }() + + // Release both callers simultaneously so the interleaving above is + // actually exercised, then collect both verdicts under a bounded + // timeout — a hang here means the fix regressed to a deadlock, not + // a silently-green test. + close(start) + timeout := time.After(5 * time.Second) + for i := 0; i < 2; i++ { + select { + case ok := <-results: + assert.True(t, ok, "concurrent caller must never observe a false 404 (tool marked promoted but never live)") + case <-timeout: + t.Fatal("timed out waiting for concurrent Promote callers — possible deadlock in the fixed implementation") + } + } + + // Both goroutines must actually exit (not leaked) before the test + // returns; give them a bounded window past their result send. + for i := 0; i < 2; i++ { + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for a goroutine to exit — possible leak") + } + } +} diff --git a/internal/mcp/overlay_view.go b/internal/mcp/overlay_view.go index 54fe4a276..e23f4e8ae 100644 --- a/internal/mcp/overlay_view.go +++ b/internal/mcp/overlay_view.go @@ -196,7 +196,7 @@ func (s *Server) snapshotOverlayRequestForCtx(ctx context.Context) (*overlayRequ if s == nil || s.overlays == nil { return nil, nil } - sessionID := SessionIDFromContext(ctx) + sessionID := OverlayCohortIDFromContext(ctx) if sessionID == "" { return nil, nil } @@ -230,8 +230,8 @@ func (s *Server) prepareOverlayRequest(ctx context.Context) (context.Context, *g return ctx, nil, nil } snapshot, ok := overlayRequestSnapshotFromContext(ctx) - if ok && snapshot.sessionID != SessionIDFromContext(ctx) { - return ctx, nil, fmt.Errorf("overlay request snapshot belongs to session %q, not %q", snapshot.sessionID, SessionIDFromContext(ctx)) + if ok && snapshot.sessionID != OverlayCohortIDFromContext(ctx) { + return ctx, nil, fmt.Errorf("overlay request snapshot belongs to session %q, not %q", snapshot.sessionID, OverlayCohortIDFromContext(ctx)) } if !ok { if OverlayViewFromContext(ctx) != nil { @@ -352,7 +352,7 @@ func (s *Server) buildOverlayViewForCtx(ctx context.Context) (*graph.OverlaidVie return nil, fmt.Errorf("overlay request snapshot is not canonical") } files := snapshot.files - sessID := SessionIDFromContext(ctx) + sessID := OverlayCohortIDFromContext(ctx) // Drift check up front for every overlay that carries a BaseSHA. // We do it here, before parsing, so a stale overlay never costs diff --git a/internal/mcp/promote_on_demand_test.go b/internal/mcp/promote_on_demand_test.go index 30327ccb4..5cf8c9503 100644 --- a/internal/mcp/promote_on_demand_test.go +++ b/internal/mcp/promote_on_demand_test.go @@ -35,9 +35,12 @@ func TestEnsureToolPromoted_MakesDeferredToolCallable(t *testing.T) { // promotion is tracked separately and reflected by the live registry.) require.Contains(t, srv.mcpServer.ListTools(), tool, "promoted tool must appear in the live tools/list") - // Idempotent: a second promote is a no-op — Promote returns only the names - // that newly transitioned, so an already-promoted tool yields false. - require.False(t, srv.EnsureToolPromoted(tool), "promoting an already-promoted tool must be a no-op") + // Idempotent: a second promote is a no-op on the registry, but the + // return value reports liveness — the tool is still live, so it + // returns true. Callers use this as "re-check GetTool", never as + // "I transitioned it" (the pre-race contract that caused false 404s + // when a concurrent caller did the transition). + require.True(t, srv.EnsureToolPromoted(tool), "an already-promoted tool is still live") } // TestEnsureToolPromoted_NoopCases covers the guards: a live tool, an unknown diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 36c5ed81e..8e6482406 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -3176,6 +3176,11 @@ func (s *Server) attachLazyRegistry() { // without a discovery round-trip. It is a no-op (returns false) when there is // no lazy registry or the tool is live, absent, or already promoted; a hidden // (hide-mode) tool is never deferred, so this never bypasses the hide gate. +// +// The return value reports whether the tool is now live in the registry — +// promoted by this call OR already promoted by a concurrent caller. It is +// false only when the name is absent or not deferred. Callers must treat a +// true return as "re-check GetTool", never as "I transitioned it". func (s *Server) EnsureToolPromoted(name string) bool { if s == nil || s.lazy == nil || name == "" { return false @@ -3183,7 +3188,8 @@ func (s *Server) EnsureToolPromoted(name string) bool { if !s.lazy.IsDeferred(name) { return false } - return len(s.lazy.Promote(name)) > 0 + s.lazy.Promote(name) + return s.MCPServer().GetTool(name) != nil } // EnsureToolPromotedForSession is the per-connection promote-on-demand entry diff --git a/internal/mcp/session_ctx.go b/internal/mcp/session_ctx.go index dc71dd229..7b64ffac6 100644 --- a/internal/mcp/session_ctx.go +++ b/internal/mcp/session_ctx.go @@ -21,6 +21,16 @@ type sessionCtxKey struct{} // calling MCPServer.HandleMessage, giving every tool handler access // to the per-session state without touching the handler signature. // +// This is the ONE universal session identity: sessionFor, +// effectiveSessionPolicy (and therefore every tool-policy gate), +// tokenStatsFor, the agent registry, diagnostics/health/readiness/ +// stale-refs subscriptions, localization state, query logging, and +// notes/memory scoping all key off SessionIDFromContext. It MUST be +// the caller's real transport/MCP session id (Mcp-Session-Id, or the +// stdio server's implicit single session) — never overridden by an +// unrelated selector. See WithOverlayCohortID for the one narrow +// exception (overlay snapshot binding) that is allowed to diverge. +// // An empty id is treated as "no session" and returns ctx unchanged — // that's the path the embedded stdio server takes, where there's only // one implicit session. @@ -44,6 +54,56 @@ func SessionIDFromContext(ctx context.Context) string { return "" } +// overlayCohortCtxKey carries an explicit override for which overlay +// cohort a request's overlay-scoped calls should bind to, when that +// differs from the caller's real session id (see WithOverlayCohortID). +// Unexported: use WithOverlayCohortID / OverlayCohortIDFromContext. +type overlayCohortCtxKey struct{} + +// WithOverlayCohortID returns a context carrying an overlay-cohort +// override distinct from the request's real session id +// (SessionIDFromContext). Only the overlay subsystem's own accessors +// (overlaySessionID, snapshotOverlayRequestForCtx, +// prepareOverlayRequest, buildOverlayViewForCtx, and the simulate / +// explore-literal-overlay call sites) consult this — every other +// SessionIDFromContext caller (policy, token stats, notes, agent +// registry, diagnostics subscriptions, ...) is intentionally +// unaffected by it. +// +// This exists for callers that legitimately want to scope overlay +// state to a cohort id that differs from their own transport session +// (e.g. a CI harness that orchestrates several overlay scopes from +// one connection) WITHOUT that selection also silently redirecting +// every other per-session subsystem to the wrong identity — which is +// exactly what happened when a single header-precedence value fed +// both purposes: a session's own tool-policy gate could be bypassed +// by pairing a restricted Mcp-Session-Id with a permissive +// X-Gortex-Overlay-Session. +// +// An empty id returns ctx unchanged; OverlayCohortIDFromContext then +// falls back to SessionIDFromContext, so a caller that never sets +// this behaves byte-identically to before this type existed. +func WithOverlayCohortID(ctx context.Context, id string) context.Context { + if id == "" { + return ctx + } + return context.WithValue(ctx, overlayCohortCtxKey{}, id) +} + +// OverlayCohortIDFromContext returns the overlay-cohort override +// attached via WithOverlayCohortID, or SessionIDFromContext(ctx) when +// none was set — the common case, where overlay state binds to the +// caller's own session exactly as it always has. +func OverlayCohortIDFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + if id, ok := ctx.Value(overlayCohortCtxKey{}).(string); ok && id != "" { + return id + } + return SessionIDFromContext(ctx) +} + // sessionCWDCtxKey carries the session's working directory. The // daemon's MCP dispatcher stashes it alongside the session ID so tool // handlers can resolve — and enforce — the workspace boundary for the diff --git a/internal/mcp/streamable/transport.go b/internal/mcp/streamable/transport.go index 56aea8070..f34b20203 100644 --- a/internal/mcp/streamable/transport.go +++ b/internal/mcp/streamable/transport.go @@ -474,16 +474,36 @@ func (t *Transport) tryRouteToolCall(r *http.Request, state SessionState, frame // the local executor's nested-arguments unmarshal path (see // cmd/gortex/server_router.go newLocalToolExecutor) finds them. // This matches cmd/gortex/daemon_mcp.go:tryProxyToolCall exactly. + // A missing `arguments` key AND an explicit JSON `null` both mean + // "no arguments" at the MCP layer (params.arguments is optional); + // normalize both to `{}` so the executor's "arguments must be an + // object when present" check (added for reviewer concern #2) never + // rejects a legitimate no-arg call. rawArgs := envelope.Params.Arguments - if len(rawArgs) == 0 { + if len(rawArgs) == 0 || strings.TrimSpace(string(rawArgs)) == "null" { rawArgs = json.RawMessage(`{}`) } body, err := json.Marshal(map[string]json.RawMessage{"arguments": rawArgs}) if err != nil { return nil, 0, false } + // Attach the session id AND cwd to ctx before the routing decision — + // the local-fast path (Decide -> RouteToolCall -> callLocal -> + // newLocalToolExecutor) threads this ctx straight into the + // session-policy gate and the handler itself. Session id alone + // isn't enough: localDispatch below (and the daemon dispatcher's + // tryProxyToolCall) also attach WithSessionCWD, because handlers + // use it as a workspace boundary — without it, a session in + // workspace A could see workspace B's nodes on this routed path. + ctx := r.Context() + if state.ID != "" { + ctx = gortexmcp.WithSessionID(ctx, state.ID) + } + if cwd != "" { + ctx = gortexmcp.WithSessionCWD(ctx, cwd) + } decision := daemon.NewProxyDecision(func() *daemon.Router { return t.router }) - outcome := decision.Decide(r.Context(), daemon.RouteInputs{ + outcome := decision.Decide(ctx, daemon.RouteInputs{ ToolName: envelope.Params.Name, Body: body, Cwd: cwd, diff --git a/internal/mcp/tool_profile.go b/internal/mcp/tool_profile.go index 31e1f9ef8..682f0f450 100644 --- a/internal/mcp/tool_profile.go +++ b/internal/mcp/tool_profile.go @@ -91,16 +91,29 @@ func (s *Server) sessionLiveToolNames(ctx context.Context) []string { // registeredToolNames returns the complete catalog behind this server: both // the currently registered MCP tools and the lazy registry's cold tools. +// +// Read order matters: lazyToolRegistry.Promote holds its lock for the +// entire mark-and-register transition (see lazy_tools.go), so +// DeferredNames' RLock cannot return mid-promotion — it either observes a +// name still deferred, or observes it already excluded because Promote +// (registration included) has fully completed. Reading DeferredNames +// FIRST and ListTools SECOND therefore guarantees no false miss: a name +// excluded from the first read is guaranteed live by the time of the +// second (registration only ever moves deferred -> live, never back). +// Reading ListTools first (the previous order) raced a concurrent +// Promote: ListTools could snapshot before AddTool ran and DeferredNames +// could snapshot after the name was marked promoted, missing the name in +// both and misclassifying a legitimately live tool as "absent". func (s *Server) registeredToolNames() []string { names := make(map[string]bool) - for name := range s.mcpServer.ListTools() { - names[name] = true - } if s.lazy != nil { for _, name := range s.lazy.DeferredNames() { names[name] = true } } + for name := range s.mcpServer.ListTools() { + names[name] = true + } out := make([]string, 0, len(names)) for name := range names { diff --git a/internal/mcp/tools_overlay.go b/internal/mcp/tools_overlay.go index 04c5976f5..8cf2d4db3 100644 --- a/internal/mcp/tools_overlay.go +++ b/internal/mcp/tools_overlay.go @@ -83,11 +83,13 @@ func (s *Server) registerOverlayTools() { s.registerOverlayBranchTools() } -// overlaySessionID returns the calling MCP session ID, or a structured -// MCP error result when no session is on the context. Used by every +// overlaySessionID returns the calling request's overlay cohort id — +// SessionIDFromContext by default, or the X-Gortex-Overlay-Session +// override when one was set (see WithOverlayCohortID) — or a +// structured MCP error result when neither is present. Used by every // overlay_* handler. func (s *Server) overlaySessionID(ctx context.Context) (string, *mcp.CallToolResult) { - id := SessionIDFromContext(ctx) + id := OverlayCohortIDFromContext(ctx) if id == "" { return "", mcp.NewToolResultError("overlay tools require an MCP session — connect via the daemon or set X-Mcp-Session-Id") } diff --git a/internal/mcp/tools_overlay_diff.go b/internal/mcp/tools_overlay_diff.go index d1964f70c..225766034 100644 --- a/internal/mcp/tools_overlay_diff.go +++ b/internal/mcp/tools_overlay_diff.go @@ -47,7 +47,7 @@ func (s *Server) handleCompareWithOverlay(ctx context.Context, req mcp.CallToolR if s.overlays == nil { return mcp.NewToolResultError("overlay support is not enabled on this server"), nil } - if SessionIDFromContext(ctx) == "" { + if OverlayCohortIDFromContext(ctx) == "" { return mcp.NewToolResultError("compare_with_overlay requires an MCP session; connect via the daemon or set Mcp-Session-Id"), nil } ctx, view, viewErr := s.prepareOverlayRequest(ctx) diff --git a/internal/mcp/tools_simulate.go b/internal/mcp/tools_simulate.go index 945bdb9ee..d0ac4c92a 100644 --- a/internal/mcp/tools_simulate.go +++ b/internal/mcp/tools_simulate.go @@ -326,7 +326,7 @@ func (s *Server) buildSimulation(ctx context.Context, edits []lsp.WorkspaceEdit, current := map[string]daemon.OverlayFile{} if inherit { - if sessID := SessionIDFromContext(ctx); sessID != "" && s.overlays != nil && s.overlays.Has(sessID) { + if sessID := OverlayCohortIDFromContext(ctx); sessID != "" && s.overlays != nil && s.overlays.Has(sessID) { if err := ctx.Err(); err != nil { return nil, err } @@ -1125,7 +1125,7 @@ func (s *Server) persistSimulationOverlay(ctx context.Context, sim *simulation) if s.overlays == nil { return "", errors.New("overlay support is not enabled on this server") } - sessID := SessionIDFromContext(ctx) + sessID := OverlayCohortIDFromContext(ctx) if sessID == "" { return "", nil } diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index 77db06f0a..b3cefc202 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -217,10 +217,12 @@ func (h *Handler) handleRepos(w http.ResponseWriter, _ *http.Request) { // When `session_id` is supplied, the session is registered under that // ID instead of a freshly minted one — this is how an MCP client binds // its overlay session to its MCP session ID, so subsequent tools/call -// frames from the same MCP session automatically see the overlay -// (the MCP tool middleware reads SessionIDFromContext and resolves the -// overlay by that ID). Idempotent: registering twice with the same -// (id, workspace) tuple is a no-op; mismatched workspaces return 409. +// frames from the same MCP session automatically see the overlay (the +// MCP tool middleware reads OverlayCohortIDFromContext, which resolves +// to the real session id unless a caller explicitly overrides it via +// X-Gortex-Overlay-Session). Idempotent: registering twice with the +// same (id, workspace) tuple is a no-op; mismatched workspaces return +// 409. // // Response: {"session_id": "...", "workspace_id": "..."}. func (h *Handler) handleOverlayRegister(w http.ResponseWriter, r *http.Request) { @@ -493,7 +495,7 @@ func categorizeProcess(entry string) string { } func (h *Handler) handleProcesses(w http.ResponseWriter, r *http.Request) { - raw, err := h.CallToolStrict(r.Context(), "get_processes", map[string]any{}) + raw, err := h.CallToolStrict(r.Context(), "analyze", map[string]any{"kind": "processes"}) if err != nil { WriteJSONError(w, http.StatusInternalServerError, err.Error()) return @@ -636,7 +638,7 @@ type contractLocation struct { } func (h *Handler) handleContracts(w http.ResponseWriter, r *http.Request) { - raw, err := h.CallToolStrict(r.Context(), "contracts", map[string]any{"action": "list"}) + raw, err := h.CallToolStrict(r.Context(), "analyze", map[string]any{"kind": "contracts", "action": "list"}) if err != nil { WriteJSONError(w, http.StatusInternalServerError, err.Error()) return @@ -746,7 +748,7 @@ func (h *Handler) handleContracts(w http.ResponseWriter, r *http.Request) { // counts and render a per-contract diff panel. func (h *Handler) handleContractsValidate(w http.ResponseWriter, r *http.Request) { - raw, err := h.CallToolStrict(r.Context(), "contracts", map[string]any{"action": "validate"}) + raw, err := h.CallToolStrict(r.Context(), "analyze", map[string]any{"kind": "contracts", "action": "validate"}) if err != nil { WriteJSONError(w, http.StatusInternalServerError, err.Error()) return @@ -1138,7 +1140,7 @@ type communityEntry struct { } func (h *Handler) handleCommunities(w http.ResponseWriter, r *http.Request) { - raw, err := h.CallToolStrict(r.Context(), "get_communities", map[string]any{}) + raw, err := h.CallToolStrict(r.Context(), "analyze", map[string]any{"kind": "communities"}) if err != nil { WriteJSONError(w, http.StatusInternalServerError, err.Error()) return @@ -1596,7 +1598,7 @@ func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) { // Top processes for the inline preview. The full list is on the // Processes page; here we cap at 6 so the dashboard stays compact. - if raw, err := h.CallToolStrict(ctx, "get_processes", map[string]any{}); err != nil { + if raw, err := h.CallToolStrict(ctx, "analyze", map[string]any{"kind": "processes"}); err != nil { h.logger.Warn("dashboard: get_processes failed; processes section will be empty", zap.Error(err)) } else if raw != "" { diff --git a/internal/server/handler.go b/internal/server/handler.go index 311375cc4..7616c62b1 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -250,7 +250,10 @@ func (h *Handler) peekRouteContext(body []byte, r *http.Request) (scope, cwd str if cwd == "" { // HTTP clients without an explicit cwd in the body can pass // it via header — matches the daemon's session-cwd plumbing. - cwd = r.Header.Get("X-Gortex-Cwd") + // Streamable HTTP transport trims this same header at every + // read site; match that so a padded value doesn't silently + // become the session's workspace boundary. + cwd = strings.TrimSpace(r.Header.Get("X-Gortex-Cwd")) } return scope, cwd } @@ -381,16 +384,62 @@ func (h *Handler) handleToolCall(w http.ResponseWriter, r *http.Request) { return } - // If a Router is wired, peek the body for `workspace` / `cwd` - // overrides and let the router - // decide local vs remote. Local path falls through to the + // Session identity for the HTTP transport. `Mcp-Session-Id` (set + // by mcp-go's Streamable HTTP client) or the `?session_id=` query + // fallback (for curl / integration tests) is the caller's REAL + // session id — this is what gortexmcp.WithSessionID carries, and + // it MUST be the caller's actual identity: it drives + // effectiveSessionPolicy / every tool-policy gate, token-stats + // accounting, the agent registry, notes/memory scoping, and + // diagnostics subscriptions (see session_ctx.go). It must never be + // substituted with an unrelated selector — doing so previously let + // a session's own tool-policy gate be bypassed by pairing a + // restricted Mcp-Session-Id with a permissive + // X-Gortex-Overlay-Session, since both fed the same context value. + // + // `X-Gortex-Overlay-Session` is a NARROWER, separate override: it + // lets a caller scope overlay state (only) to a cohort id that + // differs from their own session — e.g. a CI harness orchestrating + // several overlay scopes from one connection. It flows through + // gortexmcp.WithOverlayCohortID, which only the overlay subsystem's + // own accessors consult (overlay.go::wrapToolHandler and friends); + // every other per-session subsystem keeps reading the real session + // id above, untouched by this header. + // + // Both attachments MUST happen before the router decision below: + // the router's local-fast path threads ctx straight through to the + // in-process tool dispatch (including the deferred-tool promotion + // gate), so attaching either only after routing would leave that + // path evaluating the daemon's default surface instead of the + // caller's actual session policy. + ctx := r.Context() + if sid := firstNonEmpty(r.Header.Get("Mcp-Session-Id"), r.URL.Query().Get("session_id")); sid != "" { + ctx = gortexmcp.WithSessionID(ctx, sid) + } + if cohort := r.Header.Get("X-Gortex-Overlay-Session"); cohort != "" { + ctx = gortexmcp.WithOverlayCohortID(ctx, cohort) + } + + // Peek the body for `workspace` / `cwd` overrides. `cwd` is + // attached to ctx as the session's workspace boundary + // (gortexmcp.WithSessionCWD) regardless of whether a router is + // wired — the direct in-process dispatch below needs it exactly + // as much as the router's local-fast path does; a tool that reads + // the session's workspace boundary from context otherwise sees + // nothing and enforces no boundary at all. + scope, cwd := h.peekRouteContext(body, r) + if cwd != "" { + ctx = gortexmcp.WithSessionCWD(ctx, cwd) + } + + // If a Router is wired, let it decide local vs remote using the + // scope/cwd peeked above. Local path falls through to the // existing in-process tool dispatch below; remote path returns // the proxied response verbatim. Only the proxy short-circuits // — local routing reuses the legacy code so downstream features // (combo / frecency / session state) keep working unchanged. if h.router != nil && h.decision != nil { - scope, cwd := h.peekRouteContext(body, r) - outcome := h.decision.Decide(r.Context(), daemon.RouteInputs{ + outcome := h.decision.Decide(ctx, daemon.RouteInputs{ ToolName: toolName, Body: body, Cwd: cwd, @@ -425,21 +474,38 @@ func (h *Handler) handleToolCall(w http.ResponseWriter, r *http.Request) { return } + // Parse via an `any` probe rather than unmarshalling straight into + // ToolRequest: a JSON `null` body (or `{"arguments": null}`) is a + // silent no-op for both a struct and a map target in Go, so the + // previous req-then-flat-fallback shape let a null body through + // with nil arguments instead of 400ing (reviewer concern #2 — this + // direct-dispatch path is a second producer of the same defect + // fixed in cmd/gortex/server_router.go's newLocalToolExecutor). var args map[string]any var bodyFormat string if len(body) > 0 { - var req ToolRequest - if err := json.Unmarshal(body, &req); err != nil { - if err2 := json.Unmarshal(body, &args); err2 != nil { - WriteJSONError(w, http.StatusBadRequest, fmt.Sprintf("malformed JSON: %s", err.Error())) + var probe any + if err := json.Unmarshal(body, &probe); err != nil { + WriteJSONError(w, http.StatusBadRequest, fmt.Sprintf("malformed JSON: %s", err.Error())) + return + } + obj, ok := probe.(map[string]any) + if !ok { + WriteJSONError(w, http.StatusBadRequest, "malformed JSON: expected a JSON object") + return + } + if f, ok := obj["format"].(string); ok { + bodyFormat = f + } + if rawArgs, present := obj["arguments"]; present { + nested, ok := rawArgs.(map[string]any) + if !ok { + WriteJSONError(w, http.StatusBadRequest, `malformed JSON: "arguments" must be a JSON object`) return } + args = nested } else { - args = req.Arguments - bodyFormat = req.Format - if args == nil { - _ = json.Unmarshal(body, &args) - } + args = obj } } @@ -463,26 +529,6 @@ func (h *Handler) handleToolCall(w http.ResponseWriter, r *http.Request) { }, } - // Overlay session binding for the HTTP transport. The standard - // `Mcp-Session-Id` header (set by mcp-go's Streamable HTTP - // client) is preferred; a gortex-specific - // `X-Gortex-Overlay-Session` header takes precedence when - // callers want to scope an overlay to a session ID that differs - // from their MCP transport session (e.g. a CI harness that - // orchestrates several overlay scopes from one connection). A - // `?session_id=` query parameter is the final fallback so curl / - // integration tests can attach overlays without setting HTTP - // headers. The session ID flows through gortexmcp.WithSessionID - // so the MCP overlay middleware (overlay.go::wrapToolHandler) - // finds the right overlay snapshot. - ctx := r.Context() - if sid := firstNonEmpty( - r.Header.Get("X-Gortex-Overlay-Session"), - r.Header.Get("Mcp-Session-Id"), - r.URL.Query().Get("session_id"), - ); sid != "" { - ctx = gortexmcp.WithSessionID(ctx, sid) - } result, err := tool.Handler(ctx, mcpReq) if err != nil { h.logger.Error("tool call failed", diff --git a/internal/server/handler_session_identity_test.go b/internal/server/handler_session_identity_test.go new file mode 100644 index 000000000..deb826f4a --- /dev/null +++ b/internal/server/handler_session_identity_test.go @@ -0,0 +1,157 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + gortexmcp "github.com/zzet/gortex/internal/mcp" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/query" +) + +// realServerTestHandler builds a Handler backed by a REAL gortexmcp.Server +// (not the bare mark3labs mcpserver.MCPServer newTestHandler uses), so +// session-policy gating (checkToolGate / effectiveSessionPolicy) is +// actually exercised. Needed for tests pinning the session-identity / +// overlay-cohort separation, which lives entirely in that machinery. +func realServerTestHandler(t *testing.T) (*Handler, *gortexmcp.Server) { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(`package main + +func main() {} +`), 0o644)) + + g := graph.New() + reg := parser.NewRegistry() + languages.RegisterAll(reg) + idx := indexer.New(g, reg, config.Default().Index, zap.NewNop()) + _, err := idx.Index(dir) + require.NoError(t, err) + + eng := query.NewEngine(g) + srv := gortexmcp.NewServer(eng, g, idx, nil, zap.NewNop(), nil) + h := NewHandler(srv.MCPServer(), g, "0.0.1-test", zap.NewNop()) + return h, srv +} + +// TestToolCall_OverlayHeaderDoesNotOverridePolicySession pins the fix for +// the reviewer's finding: X-Gortex-Overlay-Session must scope ONLY overlay +// state to a different cohort id — it must never substitute for +// Mcp-Session-Id when evaluating the caller's tool-policy. Before the fix, +// handleToolCall picked one winner (the overlay header, when present) and +// fed it into gortexmcp.WithSessionID, so a restricted session paired with +// a permissive overlay-cohort override had its policy silently bypassed. +// +// Setup: session "restricted" is facade-v1/hide (blocks find_clones, +// pre-promoted here since handler.go's direct-dispatch path doesn't +// promote deferred tools — a documented, separate, pre-existing gap). +// Session "overlay-open" has no policy noted at all (unrestricted). The +// request carries Mcp-Session-Id: restricted and +// X-Gortex-Overlay-Session: overlay-open. If the overlay header were +// still hijacking policy evaluation, the call would succeed under +// overlay-open's unrestricted policy; with the fix, "restricted"'s +// hide-mode policy applies and the call is blocked. +func TestToolCall_OverlayHeaderDoesNotOverridePolicySession(t *testing.T) { + t.Setenv("GORTEX_LAZY_TOOLS", "1") + h, srv := realServerTestHandler(t) + require.Nil(t, srv.MCPServer().GetTool("find_clones"), "test setup: find_clones must start deferred") + require.True(t, srv.EnsureToolPromoted("find_clones"), "test setup: find_clones must promote cleanly") + require.NotNil(t, srv.MCPServer().GetTool("find_clones"), "test setup: find_clones must be live before the policy is applied") + + srv.NoteSessionToolPolicy("restricted", "facade-v1", "hide") + // "overlay-open" intentionally gets no NoteSessionToolPolicy call — + // it is the permissive/default session a pre-fix bug would wrongly + // evaluate policy against. + + req := httptest.NewRequest(http.MethodPost, "/v1/tools/find_clones", strings.NewReader("{}")) + req.Header.Set("Mcp-Session-Id", "restricted") + req.Header.Set("X-Gortex-Overlay-Session", "overlay-open") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "a blocked-by-preset call still dispatches to the handler, which reports the block structurally") + var resp ToolResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + require.True(t, resp.IsError, "restricted session's hide-mode policy must apply despite the overlay-cohort override naming a permissive session") + require.Len(t, resp.Content, 1) + assert.Contains(t, resp.Content[0].Text, "tool_blocked_by_mode", + "expected the structured block error from restricted's policy, not overlay-open's unrestricted one") +} + +// TestToolCall_OverlayHeaderScopesOverlayState is the positive +// counterpart to the test above: it proves X-Gortex-Overlay-Session +// actually does something, rather than only proving it doesn't leak +// into policy. Without this, WithOverlayCohortID could be deleted +// entirely and the negative test would still pass (the header would +// simply be ignored). Pushes an overlay file under cohort +// "overlay-open" via the real overlay_push tool while authenticating +// as a different Mcp-Session-Id, then confirms the file landed under +// the cohort id, not the caller's own session id. +func TestToolCall_OverlayHeaderScopesOverlayState(t *testing.T) { + h, srv := realServerTestHandler(t) + srv.SetOverlayManager(daemon.NewOverlayManager(30 * time.Minute)) + + body := `{"arguments":{"path":"scratch.go","content":"package main\n"}}` + req := httptest.NewRequest(http.MethodPost, "/v1/tools/overlay_push", strings.NewReader(body)) + req.Header.Set("Mcp-Session-Id", "caller-session") + req.Header.Set("X-Gortex-Overlay-Session", "overlay-open") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp ToolResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + require.False(t, resp.IsError, "overlay_push must succeed: %+v", resp) + + files, err := srv.OverlayManager().Files("overlay-open") + require.NoError(t, err, "the push must land under the overlay-cohort id") + require.Contains(t, files, "scratch.go") + + _, err = srv.OverlayManager().Files("caller-session") + assert.Error(t, err, "the push must NOT land under the caller's own session id") +} + +// TestToolCall_SessionCWDReachesHandler pins the other local half of the +// same finding: handleToolCall must attach the resolved cwd to ctx via +// gortexmcp.WithSessionCWD so a tool handler can read (and enforce) the +// session's workspace boundary. Before the fix, cwd was computed +// (peekRouteContext) and used only to build the router's RouteInputs — +// never attached to ctx — so any tool consulting +// gortexmcp.SessionCWDFromContext saw nothing on this path. +func TestToolCall_SessionCWDReachesHandler(t *testing.T) { + h, srv := realServerTestHandler(t) + var observedCWD string + srv.MCPServer().AddTool( + mcp.NewTool("spy_cwd", mcp.WithDescription("reports the session cwd from context")), + func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + observedCWD = gortexmcp.SessionCWDFromContext(ctx) + return mcp.NewToolResultText("ok"), nil + }, + ) + + req := httptest.NewRequest(http.MethodPost, "/v1/tools/spy_cwd", strings.NewReader("{}")) + req.Header.Set("X-Gortex-Cwd", "/repo/A") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "/repo/A", observedCWD, "the session's resolved cwd must reach the tool handler via context") +} diff --git a/internal/server/handler_strict_test.go b/internal/server/handler_strict_test.go index ce8e6c733..c949ff7ef 100644 --- a/internal/server/handler_strict_test.go +++ b/internal/server/handler_strict_test.go @@ -37,6 +37,67 @@ func TestCallToolStrict_MissingTool(t *testing.T) { assert.Contains(t, err.Error(), "not registered") } +// TestCallToolStrict_AnalyzeAliasedKindRoutesThroughFacade pins the +// dashboard fix: a deferred legacy tool (get_processes under the core/defer +// surface) is reachable via the eager `analyze` facade's aliased kind +// (processes → get_processes). CallToolStrict must dispatch the analyze +// handler, whose facade wrapper routes the aliased kind to the captured +// legacy handler — no registry promotion involved. +func TestCallToolStrict_AnalyzeAliasedKindRoutesThroughFacade(t *testing.T) { + g := graph.New() + srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test", + mcpserver.WithToolCapabilities(false), + ) + h := NewHandler(srv, g, "0.0.1-test", zap.NewNop()) + + // Register an eager `analyze` tool whose handler is the facade + // wrapper. The wrapper must route kind=processes to the captured + // legacy handler even though the legacy tool is NOT in the live + // registry (deferred under core/defer). + legacyCalled := false + srv.AddTool( + mcp.NewTool("analyze", mcp.WithDescription("dispatcher"), + mcp.WithString("kind", mcp.Required())), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + kind, _ := req.GetArguments()["kind"].(string) + if kind == "processes" { + legacyCalled = true + return mcp.NewToolResultText(`{"processes":[]}`), nil + } + return mcp.NewToolResultError("unknown analyze kind: " + kind), nil + }, + ) + + text, err := h.CallToolStrict(context.Background(), "analyze", map[string]any{"kind": "processes"}) + require.NoError(t, err) + assert.True(t, legacyCalled, "analyze kind=processes must reach the legacy handler") + assert.Contains(t, text, `"processes"`) +} + +// TestCallToolStrict_UnknownKindStillErrors keeps the dispatcher's +// unknown-kind error for non-aliased kinds — the facade must not swallow +// them into a silent empty result. +func TestCallToolStrict_UnknownKindStillErrors(t *testing.T) { + g := graph.New() + srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test", + mcpserver.WithToolCapabilities(false), + ) + h := NewHandler(srv, g, "0.0.1-test", zap.NewNop()) + + srv.AddTool( + mcp.NewTool("analyze", mcp.WithDescription("dispatcher"), + mcp.WithString("kind", mcp.Required())), + func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + kind, _ := req.GetArguments()["kind"].(string) + return mcp.NewToolResultError("unknown analyze kind: " + kind), nil + }, + ) + + _, err := h.CallToolStrict(context.Background(), "analyze", map[string]any{"kind": "bogus_kind"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown analyze kind") +} + // TestCallToolStrict_ToolErrorResult promotes an MCP IsError=true result to // a Go error. This is the contract that handleContracts depends on to surface // 5xx instead of pretending the call succeeded with empty content. @@ -117,8 +178,13 @@ func TestHandleContracts_ToolError_500(t *testing.T) { mcpserver.WithToolCapabilities(false), ) srv.AddTool( - mcp.NewTool("contracts", mcp.WithDescription("contracts stub")), - func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + mcp.NewTool("analyze", mcp.WithDescription("dispatcher"), + mcp.WithString("kind", mcp.Required())), + func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + kind, _ := req.GetArguments()["kind"].(string) + if kind != "contracts" { + return mcp.NewToolResultError("unknown analyze kind: " + kind), nil + } return mcp.NewToolResultError(`project not found: "gortex" (available: )`), nil }, ) @@ -147,8 +213,13 @@ func TestHandleContracts_Success_200(t *testing.T) { mcpserver.WithToolCapabilities(false), ) srv.AddTool( - mcp.NewTool("contracts", mcp.WithDescription("contracts stub")), - func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + mcp.NewTool("analyze", mcp.WithDescription("dispatcher"), + mcp.WithString("kind", mcp.Required())), + func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + kind, _ := req.GetArguments()["kind"].(string) + if kind != "contracts" { + return mcp.NewToolResultError("unknown analyze kind: " + kind), nil + } payload := `{"by_repo":{"alpha":{"contracts":{"http":[{"id":"GET /foo","type":"http","role":"provider","symbol_id":"alpha/x.go::H","file_path":"alpha/x.go","line":10,"repo_prefix":"alpha"}]},"total":1}}}` return mcp.NewToolResultText(payload), nil }, diff --git a/internal/server/handler_test.go b/internal/server/handler_test.go index 056549391..0701fbefc 100644 --- a/internal/server/handler_test.go +++ b/internal/server/handler_test.go @@ -146,6 +146,39 @@ func TestToolCallUnknownTool(t *testing.T) { assert.Contains(t, available, "echo") } +// TestToolCallAnalyzeAliasedKindRoutesThroughFacade pins the HTTP-facing +// contract: POST /v1/tools/analyze with kind=processes reaches the facade +// (which routes to the captured legacy handler) without any registry +// promotion. This is the dashboard's /v1/processes path under core/defer. +func TestToolCallAnalyzeAliasedKindRoutesThroughFacade(t *testing.T) { + h := newTestHandler(t) + legacyCalled := false + h.mcpServer.AddTool( + mcp.NewTool("analyze", mcp.WithDescription("dispatcher"), + mcp.WithString("kind", mcp.Required())), + func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + kind, _ := req.GetArguments()["kind"].(string) + if kind == "processes" { + legacyCalled = true + return mcp.NewToolResultText(`{"processes":[]}`), nil + } + return mcp.NewToolResultError("unknown analyze kind: " + kind), nil + }, + ) + + req := httptest.NewRequest(http.MethodPost, "/v1/tools/analyze", + strings.NewReader(`{"arguments":{"kind":"processes"}}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, legacyCalled, "analyze kind=processes must reach the legacy handler") + var resp ToolResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + require.Len(t, resp.Content, 1) + assert.Contains(t, resp.Content[0].Text, `"processes"`) +} + func TestToolCallMalformedJSON(t *testing.T) { h := newTestHandler(t) req := httptest.NewRequest(http.MethodPost, "/v1/tools/echo", strings.NewReader("{bad"))