Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/gortex/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
54 changes: 42 additions & 12 deletions cmd/gortex/server_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,49 @@ 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.
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 {
payload := map[string]any{
"error": "invalid_json",
"message": fmt.Sprintf("malformed request body: %s", err.Error()),
}
out, _ := json.Marshal(payload)
return out, 400, nil
}
if nested.Arguments != nil {
args = nested.Arguments
} else if err := json.Unmarshal(body, &args); 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
}
}

tool := srv.MCPServer().GetTool(toolName)
if tool == nil {
// Promote-on-demand, session-aware: a deferred/lazy tool
// (the defer-mode tools_search split, the shipped core-preset
// default) is not in the live registry until promoted. Mirror
// the daemon dispatcher: check the effective session surface
// before touching the process-global lazy registry so a
// facade-v1 / hide-mode session cannot mutate it, then mark
// the call authorized so the MCP surface filter recognises it
// (the per-call gate inside the handler still decides).
if srv.IsToolEnabledForSession(ctx, toolName) && 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",
Expand All @@ -42,18 +84,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,
Expand Down
132 changes: 132 additions & 0 deletions cmd/gortex/server_router_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package main

import (
"context"
"os"
"path/filepath"
"testing"

"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")
}
2 changes: 2 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<t>` for `EventSource` auth |
Expand Down
83 changes: 83 additions & 0 deletions internal/mcp/facade_plain_alias_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
29 changes: 29 additions & 0 deletions internal/mcp/facade_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand All @@ -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
Expand Down
19 changes: 11 additions & 8 deletions internal/mcp/lazy_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading