diff --git a/README.md b/README.md index 5fcb540..ea998ab 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,17 @@ Point it at a codebase. It parses the code, discovers the structure, and lets yo ```bash git clone https://github.com/agentic-research/mache.git cd mache && task build && task install -mache serve . # ← long-running daemon on :7532 — keep it running (use a 2nd terminal) -claude mcp add --transport http mache http://localhost:7532/mcp +mache init --global # installs the keepalive HTTP daemon (:7532) + registers detected editors ``` -That's the 30-second path for a **Go** codebase. Two things new users hit: +That's the 30-second path for a **Go** codebase. `mache init --global` installs a per-user supervisor (launchd on macOS, systemd `--user` on Linux) that keeps the shared mache HTTP daemon alive on `localhost:7532` and registers it with Claude Code and any detected editors — no terminal to babysit. Then `mache init` (no flag) inside a project records what that project serves. -- `mache serve` is a **daemon** — it must stay running, or the client gets `connection refused`. Background it or run it in another terminal. +Two things new users hit: + +- **HTTP is the canonical transport.** One shared daemon serves every project, routing per session via the MCP roots protocol. `--stdio` exists only as an escape hatch for CI / sandboxes / headless agents and is never registered for editor use (see [ADR-0022](docs/adr/0022-mcp-transport-canonical.md)). - For **Rust / Python / TypeScript**, point mache at a [ley-line-open](https://github.com/agentic-research/ley-line-open)-built `.db` instead of a directory (`mache serve ./code.db`) to get accurate `find_callers` + `get_type_info` + `get_diagnostics`. A bare directory uses the built-in tree-sitter tier, which is tuned for Go. -For the full first-run flow — source choice (directory vs `.db` vs live hot-swap), HTTP vs stdio, `--scope`, Claude Desktop, mount as filesystem, write-back, schema inference, troubleshooting — see [GETTING-STARTED.md](GETTING-STARTED.md). +For the full first-run flow — source choice (directory vs `.db` vs live hot-swap), the `--stdio` escape hatch, `--scope`, Claude Desktop, mount as filesystem, write-back, schema inference, troubleshooting — see [GETTING-STARTED.md](GETTING-STARTED.md). ## What it gives an agent @@ -85,7 +86,7 @@ The graph is the same on either path; MCP and the filesystem are two ways to tal | Capability | Status | | --------------------------------------- | ------------------------------------------------------------------------------------------------ | | Tree-sitter parsing (28 langs) | Stable | -| MCP server (17 tools, stdio + HTTP) | Stable | +| MCP server (17 tools, HTTP canonical) | Stable | | Cross-repo serve (`--mount NAME=PATH`) | Stable (find_callers federates; find_callees stays per-mount for now) | | Cross-references (callers/callees) | Stable | | `find_smells` (9 structural rules) | Stable. `fan_out_skew` is qualifier-aware via ley-line-open `BindingRecord.qualifier` | diff --git a/cmd/config.go b/cmd/config.go index 226b382..e061a35 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -155,15 +155,17 @@ func evalOrAbs(p string) (string, error) { } } -// mcpServerEntry is the mache entry written into MCP config files. -type mcpServerEntry struct { - Command string `json:"command"` - Args []string `json:"args"` +// httpServerEntry is the canonical MCP client entry for mache: a pointer to +// the shared Streamable HTTP daemon (see ADR-0022). Onboarding never registers +// a stdio command — `mache serve --stdio` is an explicit CI/sandbox escape +// hatch, not an editor-registered transport. +func httpServerEntry() map[string]any { + return map[string]any{"type": "http", "url": macheHTTPURL} } // writeClaudeMCPConfig writes or merges a mache entry into .claude/mcp.json. // Uses map[string]any as root type to preserve unknown top-level keys. -func writeClaudeMCPConfig(projectDir, macheCommand string) error { +func writeClaudeMCPConfig(projectDir, _ string) error { claudeDir := filepath.Join(projectDir, ".claude") if err := os.MkdirAll(claudeDir, 0o755); err != nil { return fmt.Errorf("create .claude dir: %w", err) @@ -185,10 +187,7 @@ func writeClaudeMCPConfig(projectDir, macheCommand string) error { servers = make(map[string]any) } - servers["mache"] = mcpServerEntry{ - Command: macheCommand, - Args: []string{"serve"}, - } + servers["mache"] = httpServerEntry() root["mcpServers"] = servers out, err := json.MarshalIndent(root, "", " ") @@ -293,9 +292,7 @@ func detectEditors(binaryPath string) []editorConfig { ConfigPath: filepath.Join(home, ".config", "zed", "settings.json"), ServerKey: "context_servers", SharedConfig: true, - EntryFunc: func(bp string) map[string]any { - return map[string]any{"source": "custom", "command": bp, "args": []string{"serve"}} - }, + EntryFunc: func(string) map[string]any { return httpServerEntry() }, }, } @@ -312,17 +309,15 @@ func detectEditors(binaryPath string) []editorConfig { Name: "VS Code", ConfigPath: vscodePath, ServerKey: "servers", - EntryFunc: func(bp string) map[string]any { - return map[string]any{"type": "stdio", "command": bp, "args": []string{"serve"}} - }, + EntryFunc: func(string) map[string]any { return httpServerEntry() }, }) } return editors } -func mcpServersEntry(binaryPath string) map[string]any { - return map[string]any{"command": binaryPath, "args": []string{"serve"}} +func mcpServersEntry(string) map[string]any { + return httpServerEntry() } // registerEditorMCP upserts mache into an editor's MCP config file. @@ -383,7 +378,10 @@ func registerClaudeCodeCLIImpl(binaryPath string) bool { } // Remove first (may fail if not registered — that's fine) _ = exec.Command(claudePath, "mcp", "remove", "-s", "user", "mache").Run() - err = exec.Command(claudePath, "mcp", "add", "--scope", "user", "mache", "--", binaryPath, "serve").Run() + // Register the canonical Streamable HTTP endpoint (ADR-0022), not a stdio + // command. binaryPath is unused now — the daemon is shared, not spawned + // per client. + err = exec.Command(claudePath, "mcp", "add", "--scope", "user", "--transport", "http", "mache", macheHTTPURL).Run() return err == nil } diff --git a/cmd/config_test.go b/cmd/config_test.go index d17d4b0..f48c4f6 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -278,9 +278,10 @@ func TestWriteClaudeMCPConfig_Fresh(t *testing.T) { require.NoError(t, json.Unmarshal(data, &root)) servers := root["mcpServers"].(map[string]any) mache := servers["mache"].(map[string]any) - assert.Equal(t, "mache", mache["command"]) - args := mache["args"].([]any) - assert.Equal(t, "serve", args[0]) + // Canonical: HTTP endpoint, not a stdio command (ADR-0022). + assert.Equal(t, "http", mache["type"]) + assert.Equal(t, macheHTTPURL, mache["url"]) + assert.NotContains(t, mache, "command") } func TestWriteClaudeMCPConfig_MergeExisting(t *testing.T) { @@ -305,7 +306,7 @@ func TestWriteClaudeMCPConfig_MergeExisting(t *testing.T) { assert.Contains(t, servers, "mache") mache := servers["mache"].(map[string]any) - assert.Equal(t, "/usr/local/bin/mache", mache["command"]) + assert.Equal(t, macheHTTPURL, mache["url"]) } func TestWriteClaudeMCPConfig_PreservesUnknownKeys(t *testing.T) { @@ -384,7 +385,7 @@ func TestRegisterEditorMCP_Fresh(t *testing.T) { require.NoError(t, json.Unmarshal(data, &root)) servers := root["mcpServers"].(map[string]any) mache := servers["mache"].(map[string]any) - assert.Equal(t, "/usr/local/bin/mache", mache["command"]) + assert.Equal(t, macheHTTPURL, mache["url"]) } func TestRegisterEditorMCP_MergeExisting(t *testing.T) { diff --git a/cmd/daemon_agent.go b/cmd/daemon_agent.go new file mode 100644 index 0000000..d48a8f9 --- /dev/null +++ b/cmd/daemon_agent.go @@ -0,0 +1,191 @@ +package cmd + +import ( + "encoding/xml" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// Canonical MCP transport endpoint. mache serves Streamable HTTP on this +// address (serve.go default), and onboarding (`mache init`) registers clients +// against it. stdio is a deliberate escape hatch (`mache serve --stdio`) for +// CI / sandbox / headless use — it is never registered. See ADR-0022. +const ( + macheHTTPListen = "localhost:7532" + macheHTTPURL = "http://localhost:7532/mcp" + launchAgentLabel = "com.agentic-research.mache" +) + +// daemonAgentAutoload gates the launchctl/systemctl load step. Tests set it +// false to exercise plist/unit writing without a real supervisor side effect. +var daemonAgentAutoload = true + +// logf writes a progress line, discarding the write error (best-effort output). +func logf(w io.Writer, format string, a ...any) { _, _ = fmt.Fprintf(w, format, a...) } + +// xmlText escapes s for use as plist element text. os.Executable() can +// resolve to a path with XML-significant characters (&, <, >) or quotes — e.g. +// an app bundle under "/Applications/Mache Tools/" — which would otherwise +// produce a malformed plist. +func xmlText(s string) string { + var b strings.Builder + _ = xml.EscapeText(&b, []byte(s)) + return b.String() +} + +// systemdQuote double-quotes s for a systemd ExecStart argument so a binary +// path containing spaces (or quotes/backslashes) is treated as one argument +// rather than split on whitespace. systemd honors \" and \\ inside double +// quotes. +func systemdQuote(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return `"` + s + `"` +} + +// launchAgentPlist renders the macOS LaunchAgent plist that keepalives the +// shared mache HTTP daemon, so the endpoint registered by `mache init` is +// answerable without anyone running `mache serve` by hand. +// +// KeepAlive only restarts on failure (SuccessfulExit=false) and ThrottleInterval +// guards against a tight crash-loop if the daemon can't start (e.g. stale +// ~/.mache state — mache-823d91). Pure function so it can be unit-tested. +func launchAgentPlist(binPath, logPath string) string { + return fmt.Sprintf(` + + + + Label + %s + ProgramArguments + + %s + serve + --http + %s + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 10 + StandardOutPath + %s + StandardErrorPath + %s + + +`, launchAgentLabel, xmlText(binPath), macheHTTPListen, xmlText(logPath), xmlText(logPath)) +} + +// systemdUserUnit renders the Linux systemd --user service that keepalives the +// shared mache HTTP daemon. Pure function so it can be unit-tested. +func systemdUserUnit(binPath string) string { + return fmt.Sprintf(`[Unit] +Description=mache MCP HTTP daemon (Streamable HTTP on %s) +After=network.target + +[Service] +ExecStart=%s serve --http %s +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=default.target +`, macheHTTPListen, systemdQuote(binPath), macheHTTPListen) +} + +// installDaemonAgent writes and loads a per-user supervisor that keeps the +// shared mache HTTP daemon alive, so the canonical endpoint is always +// answerable. Best-effort: it reports what it did but never fails init — a +// user can always run `mache serve --http` by hand. Idempotent. +func installDaemonAgent(w io.Writer, binPath string) { + switch runtime.GOOS { + case "darwin": + installLaunchAgent(w, binPath) + case "linux": + installSystemdUnit(w, binPath) + default: + logf(w, " [daemon] no supervisor for %s — run `mache serve --http %s` to start the daemon.\n", runtime.GOOS, macheHTTPListen) + } +} + +func installLaunchAgent(w io.Writer, binPath string) { + home, err := os.UserHomeDir() + if err != nil { + logf(w, " [daemon] could not resolve home dir: %v — start manually: mache serve --http %s\n", err, macheHTTPListen) + return + } + agentDir := filepath.Join(home, "Library", "LaunchAgents") + logPath := filepath.Join(home, "Library", "Logs", "mache.log") + plistPath := filepath.Join(agentDir, launchAgentLabel+".plist") + + if err := os.MkdirAll(agentDir, 0o755); err != nil { + logf(w, " [daemon] could not create %s: %v\n", agentDir, err) + return + } + if err := os.WriteFile(plistPath, []byte(launchAgentPlist(binPath, logPath)), 0o644); err != nil { + logf(w, " [daemon] could not write %s: %v\n", plistPath, err) + return + } + logf(w, " [daemon] wrote %s\n", plistPath) + + if !daemonAgentAutoload { + return + } + // Reload: bootout (ignore failure if not loaded) then bootstrap. + if launchctl, err := exec.LookPath("launchctl"); err == nil { + target := fmt.Sprintf("gui/%d", os.Getuid()) + _ = exec.Command(launchctl, "bootout", target+"/"+launchAgentLabel).Run() + if err := exec.Command(launchctl, "bootstrap", target, plistPath).Run(); err != nil { + logf(w, " [daemon] plist installed; load it with: launchctl bootstrap %s %s\n", target, plistPath) + return + } + logf(w, " [daemon] loaded — mache HTTP daemon will keepalive on %s\n", macheHTTPListen) + return + } + logf(w, " [daemon] plist installed; launchctl not found — load it after restart.\n") +} + +func installSystemdUnit(w io.Writer, binPath string) { + home, err := os.UserHomeDir() + if err != nil { + logf(w, " [daemon] could not resolve home dir: %v — start manually: mache serve --http %s\n", err, macheHTTPListen) + return + } + unitDir := filepath.Join(home, ".config", "systemd", "user") + unitPath := filepath.Join(unitDir, "mache.service") + + if err := os.MkdirAll(unitDir, 0o755); err != nil { + logf(w, " [daemon] could not create %s: %v\n", unitDir, err) + return + } + if err := os.WriteFile(unitPath, []byte(systemdUserUnit(binPath)), 0o644); err != nil { + logf(w, " [daemon] could not write %s: %v\n", unitPath, err) + return + } + logf(w, " [daemon] wrote %s\n", unitPath) + + if !daemonAgentAutoload { + return + } + if systemctl, err := exec.LookPath("systemctl"); err == nil { + _ = exec.Command(systemctl, "--user", "daemon-reload").Run() + if err := exec.Command(systemctl, "--user", "enable", "--now", "mache.service").Run(); err != nil { + logf(w, " [daemon] unit installed; enable it with: systemctl --user enable --now mache.service\n") + return + } + logf(w, " [daemon] enabled — mache HTTP daemon will keepalive on %s\n", macheHTTPListen) + return + } + logf(w, " [daemon] unit installed; systemctl not found — enable it manually.\n") +} diff --git a/cmd/daemon_agent_test.go b/cmd/daemon_agent_test.go new file mode 100644 index 0000000..243c3d4 --- /dev/null +++ b/cmd/daemon_agent_test.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "bytes" + "encoding/xml" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLaunchAgentPlist_ShapeAndArgs(t *testing.T) { + plist := launchAgentPlist("/usr/local/bin/mache", "/tmp/mache.log") + + assert.Contains(t, plist, "serve") + assert.Contains(t, plist, "--http") + assert.Contains(t, plist, macheHTTPListen) + assert.NotContains(t, plist, "--stdio") + // Crash-loop guard. + assert.Contains(t, plist, "ThrottleInterval") +} + +func TestSystemdUserUnit_ExecStart(t *testing.T) { + unit := systemdUserUnit("/usr/local/bin/mache") + + // Path is double-quoted so systemd treats it as one argument. + assert.Contains(t, unit, `ExecStart="/usr/local/bin/mache" serve --http `+macheHTTPListen) + assert.Contains(t, unit, "Restart=on-failure") + assert.NotContains(t, unit, "--stdio") +} + +// TestLaunchAgentPlist_EscapesSpecialChars guards against a nonstandard install +// path (os.Executable can resolve under e.g. "/Applications/Mache Tools/") +// producing a malformed plist. +func TestLaunchAgentPlist_EscapesSpecialChars(t *testing.T) { + bin := "/Applications/Mache & Tools/ache" + plist := launchAgentPlist(bin, "/tmp/a&b.log") + + // Must be well-formed XML end to end. + dec := xml.NewDecoder(strings.NewReader(plist)) + for { + _, err := dec.Token() + if err == io.EOF { + break + } + require.NoError(t, err, "rendered plist must be valid XML") + } + + // XML-significant chars from the path are escaped, not raw. + assert.Contains(t, plist, "&") + assert.Contains(t, plist, "<m>ache") + assert.NotContains(t, plist, "ache") + // The space survives (legal in element text) so the path is preserved. + assert.Contains(t, plist, "Mache & Tools") +} + +func TestSystemdUserUnit_QuotesSpacedPath(t *testing.T) { + unit := systemdUserUnit("/Applications/Mache Tools/mache") + assert.Contains(t, unit, `ExecStart="/Applications/Mache Tools/mache" serve --http `+macheHTTPListen) +} + +func TestInstallDaemonAgent_WritesSupervisorFile(t *testing.T) { + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skipf("no supervisor on %s", runtime.GOOS) + } + home := t.TempDir() + t.Setenv("HOME", home) + + origAutoload := daemonAgentAutoload + daemonAgentAutoload = false // write the file, don't run launchctl/systemctl + t.Cleanup(func() { daemonAgentAutoload = origAutoload }) + + buf := new(bytes.Buffer) + installDaemonAgent(buf, "/usr/local/bin/mache") + + var want string + switch runtime.GOOS { + case "darwin": + want = filepath.Join(home, "Library", "LaunchAgents", launchAgentLabel+".plist") + case "linux": + want = filepath.Join(home, ".config", "systemd", "user", "mache.service") + } + data, err := os.ReadFile(want) + require.NoError(t, err, "supervisor file should be written") + assert.Contains(t, string(data), "serve") + assert.Contains(t, string(data), macheHTTPListen) + assert.True(t, strings.Contains(buf.String(), want), "output should mention the written path") +} diff --git a/cmd/init.go b/cmd/init.go index 0910998..b3f2266 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -56,13 +56,19 @@ func execInit(w io.Writer, macheCmd string, opts initOpts) error { } func execInitGlobal(w io.Writer, macheCmd string) error { - _, _ = fmt.Fprintln(w, "Registering mache MCP server with detected editors...") + _, _ = fmt.Fprintln(w, "Installing the shared mache HTTP daemon (keepalive supervisor)...") + _, _ = fmt.Fprintln(w) + + installDaemonAgent(w, macheCmd) + + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "Registering mache MCP server (Streamable HTTP) with detected editors...") _, _ = fmt.Fprintln(w) registerAllEditors(w, macheCmd) _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "mache is now available as an MCP server. Restart your editor to activate.") + _, _ = fmt.Fprintf(w, "mache is now available as an MCP server at %s. Restart your editor to activate.\n", macheHTTPURL) _, _ = fmt.Fprintln(w, "Run 'mache init' (without --global) in a project to configure what it serves.") return nil } @@ -123,7 +129,8 @@ func execInitProject(w io.Writer, macheCmd string, opts initOpts) error { } _, _ = fmt.Fprintf(w, " Source: %s\n", opts.Source) _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "Run 'mache serve' to start the MCP server.") + _, _ = fmt.Fprintf(w, "This project is registered against the shared mache HTTP daemon (%s).\n", macheHTTPURL) + _, _ = fmt.Fprintln(w, "If you haven't already, run 'mache init --global' once to install and start it.") return nil } diff --git a/cmd/init_test.go b/cmd/init_test.go index 998a977..33c5eeb 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -37,7 +37,8 @@ func TestInit_CreatesFiles(t *testing.T) { mcpData, err := os.ReadFile(filepath.Join(dir, ".claude", "mcp.json")) require.NoError(t, err) assert.Contains(t, string(mcpData), "mache") - assert.Contains(t, string(mcpData), "serve") + assert.Contains(t, string(mcpData), macheHTTPURL) + assert.NotContains(t, string(mcpData), `"serve"`) // Check .claude/CLAUDE.md claudeMD, err := os.ReadFile(filepath.Join(dir, ".claude", "CLAUDE.md")) @@ -97,6 +98,11 @@ func TestInit_Global(t *testing.T) { claudeCLIRegister = func(string) bool { return false } t.Cleanup(func() { claudeCLIRegister = orig }) + // Don't load a real launchd/systemd agent during the test — just write files. + origAutoload := daemonAgentAutoload + daemonAgentAutoload = false + t.Cleanup(func() { daemonAgentAutoload = origAutoload }) + // Create a fake .cursor dir so registerEditorMCP finds it require.NoError(t, os.MkdirAll(filepath.Join(dir, ".cursor"), 0o755)) @@ -108,7 +114,8 @@ func TestInit_Global(t *testing.T) { mcpData, err := os.ReadFile(filepath.Join(dir, ".cursor", "mcp.json")) require.NoError(t, err) assert.Contains(t, string(mcpData), "mache") - assert.Contains(t, string(mcpData), "serve") + assert.Contains(t, string(mcpData), macheHTTPURL) + assert.NotContains(t, string(mcpData), `"serve"`) // No .mache.json should be created in global mode _, err = os.Stat(filepath.Join(dir, ConfigFileName)) diff --git a/cmd/serve.go b/cmd/serve.go index 2cc7299..c9c0487 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -27,13 +27,20 @@ var serveCmd = &cobra.Command{ Use: "serve [data-source]", Short: "Serve a Mache graph as an MCP server", Long: `Starts an MCP (Model Context Protocol) server that exposes the graph -as tools. By default starts a Streamable HTTP server on localhost:7532. -Use --stdio for subprocess mode (client manages lifecycle). +as tools. + +Streamable HTTP is the canonical transport (default, localhost:7532): one +shared daemon serves every project, routing per session via the MCP roots +protocol. 'mache init' registers clients against it. See ADR-0022. + +--stdio is an explicit escape hatch for CI, sandboxes, and headless/cron +agents where no shared daemon should run. It is NOT registered by 'mache init' +and should not be used for interactive editor setups. Examples: - mache serve ./data.db # HTTP on localhost:7532 (default) + mache serve ./data.db # HTTP on localhost:7532 (canonical) mache serve --http :9000 ./data.db # HTTP on custom port (all interfaces) - mache serve --stdio ./data.db # stdio (subprocess mode) + mache serve --stdio ./data.db # stdio escape hatch (CI / sandbox) claude mcp add --transport http mache http://localhost:7532/mcp`, Args: cobra.MaximumNArgs(1), RunE: runServe, @@ -52,7 +59,7 @@ var ( func init() { serveCmd.Flags().StringVarP(&serveSchema, "schema", "s", "", "Path to topology schema") serveCmd.Flags().StringVar(&serveHTTP, "http", "localhost:7532", "Listen address for Streamable HTTP transport") - serveCmd.Flags().BoolVar(&serveStdio, "stdio", false, "Use stdio transport instead of HTTP (for subprocess mode)") + serveCmd.Flags().BoolVar(&serveStdio, "stdio", false, "Escape-hatch stdio transport for CI/sandbox/headless use (HTTP is canonical; never registered by `mache init`)") serveCmd.Flags().StringVar(&servePath, "path", "", "Base directory for project detection (defaults to current working directory)") serveCmd.Flags().StringVar(&serveRepo, "repo", "", "Git repo URL to clone and serve (ephemeral: cleaned up on exit)") serveCmd.Flags().StringVar(&serveControl, "control", "", "Path to ley-line control block (reads from arena, enables hot-swap)") diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ea89b76..75e84e4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -57,7 +57,7 @@ graph TD subgraph "System Interface" NFS["NFS Server
(go-nfs / billy)"] - MCP["MCP Server
(stdio or Streamable HTTP)"] + MCP["MCP Server
(Streamable HTTP; stdio escape hatch)"] Tools["User Tools
ls, cat, grep, MCP clients"] end @@ -117,7 +117,7 @@ With `--infer`, the schema itself can be derived automatically: the `lattice` pa - **`GraphFS`** — NFS filesystem via `go-nfs`/`billy`. Adapts the `Graph` interface to `billy.Filesystem`. The only mount backend (the earlier FUSE backend was removed in v0.7.0; see ADR-0006). - **`_project_files/`** — Non-AST files (READMEs, configs, docs) encountered during tree-sitter ingestion are routed into a separate `_project_files/` tree via `ingestRawFileUnder()`. This preserves access to supporting files without polluting the AST-derived structure. - **Friendly-name grouping** — `ProjectAST` in the lattice package maps raw tree-sitter node types to intuitive container directory names: `function_declaration` → `functions/`, `class_definition` → `classes/`, `type_declaration` → `types/`, etc. Language-specific containment rules nest methods inside classes for Python/TypeScript. -- **MCP Server** (`cmd/serve.go` entry; tool registration in `cmd/serve_handlers.go::registerMCPTools`; `lazyGraph` defined in `cmd/serve_registry.go`) — `mache serve` exposes any graph as an MCP (Model Context Protocol) server. Two transports: stdio (default, client spawns mache as subprocess) and Streamable HTTP (`--http :PORT`, mache runs as an independent always-on process with stateful sessions). Seventeen tools wrap the `Graph` interface: `list_directory`, `read_file`, `find_callers`, `find_callees`, `find_definition`, `search`, `semantic_search`, `get_communities`, `get_overview`, `get_type_info`, `get_diagnostics`, `get_impact`, `get_architecture`, `get_diagram`, `resolve_ref`, `find_smells`, and `write_file`. Several are conditional on backend capabilities (e.g., `search` requires `QueryRefs`, `write_file` requires `writeBacker`, LSP tools require `_lsp*` tables produced by ley-line-open). Uses `mark3labs/mcp-go` with lazy graph initialization for instant health-check response. No filesystem mount needed. +- **MCP Server** (`cmd/serve.go` entry; tool registration in `cmd/serve_handlers.go::registerMCPTools`; `lazyGraph` defined in `cmd/serve_registry.go`) — `mache serve` exposes any graph as an MCP (Model Context Protocol) server. Streamable HTTP is the canonical transport (`--http :PORT`, default `localhost:7532`): one always-on shared process with stateful sessions, routing each client to its project via the MCP roots protocol, registered by `mache init` (see ADR-0022). `--stdio` (client spawns mache as a subprocess) remains as an explicit escape hatch for CI / sandbox / headless use and is never registered for editor onboarding. Seventeen tools wrap the `Graph` interface: `list_directory`, `read_file`, `find_callers`, `find_callees`, `find_definition`, `search`, `semantic_search`, `get_communities`, `get_overview`, `get_type_info`, `get_diagnostics`, `get_impact`, `get_architecture`, `get_diagram`, `resolve_ref`, `find_smells`, and `write_file`. Several are conditional on backend capabilities (e.g., `search` requires `QueryRefs`, `write_file` requires `writeBacker`, LSP tools require `_lsp*` tables produced by ley-line-open). Uses `mark3labs/mcp-go` with lazy graph initialization for instant health-check response. No filesystem mount needed. - **Community Detection** (`internal/graph/community.go`) — Louvain modularity optimization on the refs graph. Projects the bipartite token→nodeID refs into a unipartite co-reference graph (edge weight = shared tokens), then iteratively moves nodes between communities to maximize modularity. Also provides `ConnectedComponents` as a simpler baseline. Exposed via the `get_communities` MCP tool. ## Canonical Views & Capnp Event Log diff --git a/docs/adr/0022-mcp-transport-canonical.md b/docs/adr/0022-mcp-transport-canonical.md new file mode 100644 index 0000000..8521bd4 --- /dev/null +++ b/docs/adr/0022-mcp-transport-canonical.md @@ -0,0 +1,57 @@ +# ADR-0022: Streamable HTTP is the canonical MCP transport; stdio is an escape hatch + +Date: 2026-06-26 +Status: Accepted +Bead: `mache-60dc86` (thread `mcp-transport-canonical/onboarding`) +Relates to: ADR-0006 (pure-go-mcp-first), ADR-0010 (hosted mache architecture) +Breaking: shipped in **v0.10.0** + +## Context + +`mache serve` grew two MCP transports — stdio (`--stdio`) and Streamable HTTP (`--http`, default `localhost:7532`). The default flipped from stdio to HTTP (HTTP shares one daemon across sessions and avoids per-client FD leaks), but the migration was never finished, leaving three loose ends that read as "mache MCP feels broken": + +1. **Onboarding still wired stdio.** `mache init` registered a `type: stdio` client entry whose command was a bare `mache serve` — which now starts an *HTTP listener* and never speaks JSON-RPC on stdout. `mache serve` branches purely on the `--stdio` bool (`cmd/serve.go`), with no stdin/TTY autodetection, so the stdio handshake could not complete. The only setup that actually worked was the *manual* `claude mcp add --transport http …`, i.e. `mache init` was not the working path. +1. **No daemon lifecycle.** Nothing started or kept the HTTP daemon alive, so a registered `http://localhost:7532/mcp` returned `connection refused` until someone ran `mache serve` by hand in a spare terminal. +1. **Two registration paths, no canonical one** — and the "official" one (`init`) was the broken one. + +The instinct "just get rid of stdio" is half-right: the mess is not that two transports exist, it is the half-finished migration. stdio is still the correct transport for CI, sandboxes, and headless/cron agents where no shared daemon should run (an interactively-authenticated HTTP daemon may be absent there). + +The HTTP model is sound, not a hack: a single daemon resolves each client's project per session via the MCP **roots** protocol (`cmd/serve_registry.go::resolveSession` calls `ListRoots`, caching session → workspace-root), plus hosted (`?repo=`) and repo-clone modes. One daemon genuinely serves every project. + +## Decision + +**Streamable HTTP is the canonical MCP transport. stdio is demoted to an explicit escape hatch.** "Get rid of stdio" means remove it as a *path/default*, not as a *capability*. + +### D.1 — Onboarding registers HTTP only + +`mache init` registers the shared endpoint, never a stdio command: + +- Claude Code CLI: `claude mcp add --scope user --transport http mache http://localhost:7532/mcp`. +- File-based clients (`.claude/mcp.json`, Cursor, Windsurf, Gemini, Zed, VS Code): `{ "type": "http", "url": "http://localhost:7532/mcp" }`. + +The mache binary path is no longer part of any client entry — the daemon is shared, not spawned per client. + +### D.2 — `mache init --global` installs a keepalive supervisor + +So the registered endpoint is answerable without anyone running `mache serve`: + +- macOS: a `~/Library/LaunchAgents/com.agentic-research.mache.plist` LaunchAgent (`RunAtLoad`, `KeepAlive` on failure, `ThrottleInterval` to avoid crash-loops). +- Linux: a `~/.config/systemd/user/mache.service` unit (`Restart=on-failure`). + +Both run `mache serve --http localhost:7532`. Install is best-effort and never fails `init`; the supervisor *load* step is test-overridable (`daemonAgentAutoload`) so unit tests exercise file generation without a real side effect. + +This is the achievable slice of the lifecycle work. Full **on-demand socket activation** (spawn only on first connect) and the daemon-reliability hardening (stale `~/.mache` state, singleton-socket clobber) remain in `mache-605d08` / `mache-823d91`. + +### D.3 — `--stdio` stays, demoted + +The flag is unchanged in behavior but reframed in `serve --help`, README, and ARCHITECTURE as the CI/sandbox/headless escape hatch. It is never emitted by any registration path. `server.json` still advertises stdio as an available transport because it remains a real capability. + +## Consequences + +- **Breaking (v0.10.0):** existing stdio-based mache registrations stop matching how `mache init` writes config; re-run `mache init --global`. Anyone who pointed a client at `mache serve` as a stdio command must switch to the HTTP endpoint (or pass `--stdio` explicitly for a genuine subprocess use). +- One canonical onboarding path; `init` ↔ `serve` defaults no longer contradict. +- mache is now a managed per-user daemon on the dev box, matching the "one shared daemon" intent of ADR-0010. + +## Not decided here + +On-demand socket activation and the `~/.mache` daemon-reliability rot (`mache-823d91`) are tracked separately in decade `mcp-transport-canonical`. This ADR ships the canonicalization + a keepalive supervisor, not the full on-demand lifecycle. diff --git a/internal/buildinfo/version.txt b/internal/buildinfo/version.txt index ac39a10..78bc1ab 100644 --- a/internal/buildinfo/version.txt +++ b/internal/buildinfo/version.txt @@ -1 +1 @@ -0.9.0 +0.10.0 diff --git a/melange.yaml b/melange.yaml index 3f53d5f..f6b0c6a 100644 --- a/melange.yaml +++ b/melange.yaml @@ -12,7 +12,7 @@ package: # Keep in sync with internal/buildinfo/version.txt — `task version:check` # asserts they agree. The binary's version comes from the embedded # buildinfo, not from this field. - version: 0.9.0 + version: 0.10.0 epoch: 0 description: "Structural code intelligence — graph projection of source as MCP tools" copyright: diff --git a/server.json b/server.json index 7cfb93c..a25ab3d 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.agentic-research/mache", "title": "mache", "description": "Project source trees, SQLite, and JSON as a structured filesystem with MCP code-intelligence tools.", - "version": "0.9.0", + "version": "0.10.0", "repository": { "url": "https://github.com/agentic-research/mache", "source": "github"