Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b4269f9
feat(mcp): add versioned facade tool surface
zzet Jul 11, 2026
eb3af3e
feat(mcp): ship compact surface across agent integrations
zzet Jul 12, 2026
724436f
fix(mcp): make facade exposure and impact analysis reliable
zzet Jul 12, 2026
05c192b
fix(mcp): retain concept targets during explore ranking
zzet Jul 12, 2026
9479e16
fix(analysis): make impact bounded and stable
zzet Jul 12, 2026
aee6235
fix(mcp): harden facade routing and edits
zzet Jul 12, 2026
7bfba5c
fix(agents): complete facade integration guidance
zzet Jul 12, 2026
d4d8f1a
feat(mcp): enforce localization followup budget
zzet Jul 12, 2026
b4ff2fd
revert: remove non-portable MCP terminality gate
zzet Jul 12, 2026
edd64e0
fix(mcp): ground explore responses
zzet Jul 12, 2026
6e31ba2
fix(agents): stop localization cross-check loops
zzet Jul 12, 2026
06d33c8
fix(agents): bound localization followups
zzet Jul 12, 2026
15cc5fe
fix(mcp): align explore evidence packing
zzet Jul 12, 2026
ebeb88d
fix(mcp): broaden concept recall
zzet Jul 12, 2026
66c6586
fix(search): preserve bounded hybrid recall
zzet Jul 12, 2026
cd65faf
fix(mcp): promote structural localization evidence
zzet Jul 13, 2026
6230274
fix(mcp): simplify explore evidence gate condition
zzet Jul 13, 2026
4a38506
fix(localization): resolve Rust bounds and retain reexports
zzet Jul 13, 2026
c36c069
fix(impact): bound daemon safety analysis
zzet Jul 13, 2026
e0f43d5
fix(mcp): survive daemon reconnects
zzet Jul 13, 2026
562e159
feat(indexer): normalize retrieval metadata
zzet Jul 13, 2026
499f812
feat(mcp): make localization terminal
zzet Jul 13, 2026
44bd411
fix(daemon): bound MCP request lifetimes
zzet Jul 13, 2026
4f7f54e
feat(mcp): make localization terminal and precise
zzet Jul 13, 2026
0803e32
feat(rust): resolve bounds reexports and metadata
zzet Jul 13, 2026
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
118 changes: 105 additions & 13 deletions cmd/gortex/call.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import (
"strings"

"github.com/spf13/cobra"

"github.com/zzet/gortex/internal/daemon"
gortexmcp "github.com/zzet/gortex/internal/mcp"
)

var (
Expand All @@ -20,13 +23,24 @@ var (
callFormat string
callDry bool
callQuiet bool
callLegacy bool
)

// callDaemonTool is the daemon-tool relay seam. It is indirected through a
// package var so a test can stub the daemon call (and the catalog fetch used
// for name validation) without a running daemon.
var callDaemonTool = requireDaemonTool

// callFacadeDaemonTool uses the same shell argument lowering and output path as
// callDaemonTool, but negotiates the compact facade surface for this one daemon
// connection. Kept as a separate seam so tests prove surface selection without
// a running daemon.
var callFacadeDaemonTool = func(repoPath, tool string, args map[string]any) (json.RawMessage, error) {
return requireDaemonToolWithSurface(
repoPath, tool, args, gortexmcp.FacadeSurfaceVersion, "hide",
)
}

var callCmd = &cobra.Command{
Use: "call <tool>",
Short: "Invoke any registered MCP tool by name over the daemon",
Expand All @@ -48,6 +62,14 @@ Use --dry to print the lowered argument object and the target tool without
calling the daemon (works with no daemon running). Use --format to pick the
wire format the tool renders (json|gcx|toon|text).

When <tool> is one of the compact public tool names, this command automatically
selects the compact surface for that connection and accepts the exact MCP
argument object. Other names explicitly use the legacy-compatible surface.
Use --legacy to preserve the old handler contract for shared names such as
analyze, explore, review, and ask.
Discover compact operations with:
gortex call capabilities --arg domain=read --arg operation=source --arg detail=schema

Requires a running daemon that tracks the repo (except for --dry).`,
Args: cobra.ExactArgs(1),
SilenceUsage: true, // a tool/daemon error should read cleanly, not dump usage
Expand All @@ -63,6 +85,7 @@ func init() {
callCmd.Flags().StringVar(&callFormat, "format", "json", "output / wire format forwarded to the tool: json|gcx|toon|text")
callCmd.Flags().BoolVar(&callDry, "dry", false, "print the lowered argument object and target tool without calling the daemon")
callCmd.Flags().BoolVar(&callQuiet, "quiet", false, "suppress the stderr note when calling a mutating tool")
callCmd.Flags().BoolVar(&callLegacy, "legacy", false, "use the legacy MCP contract for a shared tool name")
rootCmd.AddCommand(callCmd)
}

Expand All @@ -75,34 +98,62 @@ func runCall(cmd *cobra.Command, args []string) error {
return err
}

compactCall := gortexmcp.IsFacadeToolName(tool) && !callLegacy
formatExplicit := callFormat != "json"
if flag := cmd.Flags().Lookup("format"); flag != nil && flag.Changed {
formatExplicit = true
}
// Legacy handlers historically receive the CLI's json default. Compact
// calls mirror the supplied MCP object exactly unless --format was explicit.
if compactCall {
if formatExplicit {
if err := mergeCompactOutputFormat(argObj, callFormat); err != nil {
return err
}
}
} else if callFormat != "" {
argObj["format"] = callFormat
}
if callDry {
return printCallDry(cmd, tool, argObj)
}
facadeEffectful := compactCall && daemon.IsEffectful(tool)
if facadeEffectful && !callQuiet {
fmt.Fprintf(cmd.ErrOrStderr(), "note: %s can change %s\n", tool, describeToolEffects(daemon.EffectOf(tool)))
}

// Best-effort name validation against the live daemon's catalog. When no
// daemon is reachable this is skipped entirely so the call below returns
// the normal daemonRequiredErr instead of a confusing "unknown tool".
if cat, ok := fetchToolCatalog(callIndex); ok {
if !cat.has(tool) {
return unknownToolErr(tool, cat)
}
if cat.mutating(tool) && !callQuiet {
fmt.Fprintf(cmd.ErrOrStderr(), "note: %s writes to your working tree / graph\n", tool)
if !compactCall {
if cat, ok := fetchToolCatalog(callIndex); ok {
if !cat.has(tool) {
return unknownToolErr(tool, cat)
}
if cat.mutating(tool) && !callQuiet {
fmt.Fprintf(cmd.ErrOrStderr(), "note: %s writes to your working tree / graph\n", tool)
}
}
}

// Forward the chosen wire format to the tool. The executor pins
// format=json by default; an explicit format here overrides it.
if callFormat != "" {
argObj["format"] = callFormat
relay := callDaemonTool
if compactCall {
relay = callFacadeDaemonTool
}

raw, err := callDaemonTool(callIndex, tool, argObj)
raw, err := relay(callIndex, tool, argObj)
if err != nil {
return err
}

switch callFormat {
responseFormat := callFormat
if compactCall && !formatExplicit {
if output, ok := argObj["output"].(map[string]any); ok {
if requested, ok := output["format"].(string); ok && requested != "" {
responseFormat = requested
}
}
}
switch responseFormat {
case "gcx", "toon":
// Compact wire formats are printed verbatim — re-indenting would
// corrupt them.
Expand All @@ -113,6 +164,47 @@ func runCall(cmd *cobra.Command, args []string) error {
}
}

func describeToolEffects(effect daemon.ToolEffect) string {
var boundaries []string
if effect&daemon.EffectFilesystemWrite != 0 {
boundaries = append(boundaries, "local files")
}
if effect&daemon.EffectGraphWrite != 0 {
boundaries = append(boundaries, "the indexed graph")
}
if effect&daemon.EffectConfigWrite != 0 {
boundaries = append(boundaries, "configuration")
}
if effect&daemon.EffectSessionWrite != 0 {
boundaries = append(boundaries, "session state")
}
if effect&daemon.EffectExternalWrite != 0 {
boundaries = append(boundaries, "external systems")
}
if len(boundaries) == 0 {
return "state"
}
return strings.Join(boundaries, ", ")
}

func mergeCompactOutputFormat(args map[string]any, format string) error {
if format == "" {
return nil
}
delete(args, "format") // an explicit CLI flag normalizes the legacy spelling
raw, exists := args["output"]
if !exists || raw == nil {
args["output"] = map[string]any{"format": format}
return nil
}
output, ok := raw.(map[string]any)
if !ok {
return fmt.Errorf("compact output must be a JSON object, got %T", raw)
}
output["format"] = format
return nil
}

// printCallDry prints the lowered argument object (indented JSON) and the
// target tool name without touching the daemon.
func printCallDry(cmd *cobra.Command, tool string, argObj map[string]any) error {
Expand Down
189 changes: 189 additions & 0 deletions cmd/gortex/call_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (

"github.com/spf13/cobra"
"github.com/stretchr/testify/require"

gortexmcp "github.com/zzet/gortex/internal/mcp"
)

// newCallTestCmd builds a fresh call command bound to an out/err buffer and
Expand All @@ -23,6 +25,7 @@ func newCallTestCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) {
callFormat = "json"
callDry = false
callQuiet = false
callLegacy = false

buf := &bytes.Buffer{}
cmd := &cobra.Command{Use: "call", RunE: runCall}
Expand Down Expand Up @@ -185,6 +188,192 @@ func TestCallDry_NoDaemon(t *testing.T) {
require.Contains(t, out, `"verbose": true`)
}

func TestCall_AllCompactNamesUseFacadeRelay(t *testing.T) {
origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool
t.Cleanup(func() {
callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade
})

for _, name := range gortexmcp.FacadeToolNames() {
t.Run(name, func(t *testing.T) {
var facadeTool string
callDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) {
t.Fatalf("compact call %q touched legacy relay with %q", name, tool)
return nil, nil
}
callFacadeDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) {
facadeTool = tool
return json.RawMessage(`{"ok":true}`), nil
}

cmd, _ := newCallTestCmd(t)
require.NoError(t, runCall(cmd, []string{name}))
require.Equal(t, name, facadeTool)
})
}
}

func TestCall_HelpUsesOneClientNeutralShellContract(t *testing.T) {
help := strings.ToLower(callCmd.Long)
require.Contains(t, help, "gortex call capabilities")
require.NotContains(t, help, "facade-v1")
for _, client := range []string{"codex", "claude", "cursor", "vscode"} {
require.NotContains(t, help, client)
}
require.False(t, isKnownRootCommand("facade"), "compact calls must not create a second top-level CLI contract")
}

func TestCall_CompactRequestObjectIsForwardedUnchanged(t *testing.T) {
origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool
t.Cleanup(func() {
callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade
})
callDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
t.Fatal("compact call must not use legacy relay")
return nil, nil
}
var got map[string]any
callFacadeDaemonTool = func(_ string, tool string, args map[string]any) (json.RawMessage, error) {
require.Equal(t, "read", tool)
got = args
return json.RawMessage(`{"ok":true}`), nil
}

cmd, _ := newCallTestCmd(t)
callArgs = []string{"operation=file", `target:={"file":"internal/mcp/server.go"}`}
require.NoError(t, runCall(cmd, []string{"read"}))
require.Equal(t, "file", got["operation"])
require.Equal(t, map[string]any{"file": "internal/mcp/server.go"}, got["target"])
require.NotContains(t, got, "format")
require.NotContains(t, got, "output", "the default CLI format must not rewrite an exact compact MCP request")
}

func TestCall_CompactDefaultPreservesRequestOutputFormat(t *testing.T) {
origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool
t.Cleanup(func() { callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade })
var got map[string]any
callFacadeDaemonTool = func(_ string, _ string, args map[string]any) (json.RawMessage, error) {
got = args
return json.RawMessage("TOON|results|...\n"), nil
}

cmd, buf := newCallTestCmd(t)
callJSON = `{"operation":"symbols","query":"Server","output":{"format":"toon","limit":7}}`
require.NoError(t, runCall(cmd, []string{"search"}))
output := got["output"].(map[string]any)
require.Equal(t, "toon", output["format"])
require.Equal(t, float64(7), output["limit"])
require.Equal(t, "TOON|results|...\n", buf.String())
}

func TestCall_CompactDryRunShowsFinalExplicitFormat(t *testing.T) {
cmd, buf := newCallTestCmd(t)
callDry = true
callFormat = "gcx"
callJSON = `{"operation":"symbols","query":"Server","output":{"format":"toon"}}`
require.NoError(t, runCall(cmd, []string{"search"}))
require.Contains(t, buf.String(), `"format": "gcx"`)
require.NotContains(t, buf.String(), `"format": "toon"`)
}

func TestCall_CompactFormatMergesIntoExistingOutput(t *testing.T) {
origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool
t.Cleanup(func() {
callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade
})
callDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
t.Fatal("compact call must not use legacy relay")
return nil, nil
}
var got map[string]any
callFacadeDaemonTool = func(_ string, _ string, args map[string]any) (json.RawMessage, error) {
got = args
return json.RawMessage(`{"ok":true}`), nil
}

cmd, _ := newCallTestCmd(t)
callFormat = "gcx"
callJSON = `{"operation":"symbols","query":"Server","output":{"limit":7,"fields":"id,name","format":"toon"}}`
require.NoError(t, runCall(cmd, []string{"search"}))
output, ok := got["output"].(map[string]any)
require.True(t, ok)
require.Equal(t, "gcx", output["format"], "CLI --format must win")
require.Equal(t, float64(7), output["limit"])
require.Equal(t, "id,name", output["fields"])
require.NotContains(t, got, "format")
}

func TestCall_EffectfulCompactToolWarnsWithoutCatalog(t *testing.T) {
origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool
t.Cleanup(func() {
callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade
})
callDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
t.Fatal("compact call must not use legacy relay")
return nil, nil
}
callFacadeDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
return json.RawMessage(`{"ok":true}`), nil
}

cmd, buf := newCallTestCmd(t)
require.NoError(t, runCall(cmd, []string{"session"}))
require.Contains(t, buf.String(), "can change session state")

cmd, buf = newCallTestCmd(t)
callQuiet = true
require.NoError(t, runCall(cmd, []string{"session"}))
require.NotContains(t, buf.String(), "note:")
}

func TestCall_LegacyNameUsesLegacyRelay(t *testing.T) {
origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool
t.Cleanup(func() {
callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade
})
var called []string
callDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) {
called = append(called, tool)
if tool == "tool_profile" {
return json.RawMessage(cannedToolProfileJSON), nil
}
return json.RawMessage(`{"ok":true}`), nil
}
callFacadeDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
t.Fatal("legacy name must not use the compact relay")
return nil, nil
}

cmd, _ := newCallTestCmd(t)
require.NoError(t, runCall(cmd, []string{"search_symbols"}))
require.Equal(t, []string{"tool_profile", "search_symbols"}, called)
}

func TestCall_LegacyEscapePreservesSharedAnalyzeContract(t *testing.T) {
origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool
t.Cleanup(func() { callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade })
var called []string
callDaemonTool = func(_ string, tool string, args map[string]any) (json.RawMessage, error) {
called = append(called, tool)
if tool == "tool_profile" {
return json.RawMessage(`{"live":["analyze"],"descriptors":[{"name":"analyze","mutating":false}]}`), nil
}
require.Equal(t, "analyze", tool)
require.Equal(t, "temporal_verify", args["kind"])
return json.RawMessage(`{"ok":true}`), nil
}
callFacadeDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
t.Fatal("--legacy must not negotiate the compact surface")
return nil, nil
}

cmd, _ := newCallTestCmd(t)
callLegacy = true
callArgs = []string{"kind=temporal_verify"}
require.NoError(t, runCall(cmd, []string{"analyze"}))
require.Equal(t, []string{"tool_profile", "analyze"}, called)
}

// cannedToolProfileJSON is a minimal tool_profile response with a descriptors
// array covering a read tool and a mutating tool.
const cannedToolProfileJSON = `{
Expand Down
Loading
Loading