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"