diff --git a/src/cmd/mcp.go b/src/cmd/mcp.go index 6676284..3459439 100644 --- a/src/cmd/mcp.go +++ b/src/cmd/mcp.go @@ -16,16 +16,24 @@ var mcpCmd = &cobra.Command{ Use: "mcp", Short: "Start MCP server on stdio", Long: "Launch the devkit engine as an MCP server communicating via JSON-RPC over stdin/stdout.", + // MCP must boot even when Claude Code's working directory is outside a + // git repo — otherwise the JSON-RPC initialize handshake never completes + // and the user only sees -32000. Tool calls that genuinely need git + // state can check repoRoot themselves and return a structured error. + Annotations: map[string]string{"allow_no_git": "true"}, RunE: func(cmd *cobra.Command, args []string) error { - dataDir := os.Getenv("CLAUDE_PLUGIN_DATA") - if dataDir == "" { - dataDir = filepath.Join(repoRoot, ".devkit") + dataDir, err := resolveMCPDataDir(repoRoot) + if err != nil { + return fmt.Errorf("resolving MCP data dir: %w", err) } pluginRoot := os.Getenv("CLAUDE_PLUGIN_ROOT") - workflowDir := filepath.Join(repoRoot, "workflows") - if pluginRoot != "" { + workflowDir := "" + switch { + case pluginRoot != "": workflowDir = filepath.Join(pluginRoot, "workflows") + case repoRoot != "": + workflowDir = filepath.Join(repoRoot, "workflows") } srv, err := devkitmcp.NewServer(repoRoot, dataDir, workflowDir) @@ -42,6 +50,32 @@ var mcpCmd = &cobra.Command{ }, } +// resolveMCPDataDir picks where the MCP server stores its state DB. Order: +// 1. CLAUDE_PLUGIN_DATA (explicit override from the harness) +// 2. /.devkit (when launched inside a git project) +// 3. /devkit (fallback for non-git cwds, e.g. running CC +// from $HOME — the symptom that produced issue #105) +// +// The directory is created on demand so the caller can rely on it existing. +func resolveMCPDataDir(repoRoot string) (string, error) { + dir := os.Getenv("CLAUDE_PLUGIN_DATA") + if dir == "" { + if repoRoot != "" { + dir = filepath.Join(repoRoot, ".devkit") + } else { + cache, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("locating user cache dir: %w", err) + } + dir = filepath.Join(cache, "devkit") + } + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("creating %s: %w", dir, err) + } + return dir, nil +} + func init() { rootCmd.AddCommand(mcpCmd) } diff --git a/src/cmd/mcp_test.go b/src/cmd/mcp_test.go index e54028b..2f9aa1d 100644 --- a/src/cmd/mcp_test.go +++ b/src/cmd/mcp_test.go @@ -155,3 +155,89 @@ func leadingBytes(b []byte, n int) []byte { } return b[:n] } + +// TestMCPInitializeOutsideGitRepo is a regression test for issue #105. +// Before the fix, devkit mcp aborted at startup whenever the cwd was not +// inside a git repo — the JSON-RPC initialize handshake never completed +// and Claude Code only surfaced the opaque `-32000` server error. The MCP +// server must boot regardless of cwd; tools that genuinely need git state +// return structured errors on call, not by exiting at boot. +func TestMCPInitializeOutsideGitRepo(t *testing.T) { + if testing.Short() { + t.Skip("skipping subprocess build in short mode") + } + + bin := buildDevkitBinary(t) + + initReq := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":` + + `{"protocolVersion":"2024-11-05","capabilities":{},` + + `"clientInfo":{"name":"regression-test","version":"0.0.0"}}}` + "\n" + + // Resolve CLAUDE_PLUGIN_ROOT to this repo so workflows still load — + // in production the launcher always sets this env var. The point of + // this test is that the cwd is OUTSIDE any git repo. + repoRoot, err := filepath.Abs("../..") + if err != nil { + t.Fatalf("resolve repo root: %v", err) + } + + nonGitDir := t.TempDir() + if _, err := os.Stat(filepath.Join(nonGitDir, ".git")); err == nil { + t.Fatalf("tempdir unexpectedly contained .git — test premise broken") + } + + cmd := exec.Command(bin, "mcp") + cmd.Dir = nonGitDir + cmd.Env = append(os.Environ(), + "CLAUDE_PLUGIN_ROOT="+repoRoot, + "CLAUDE_PLUGIN_DATA="+t.TempDir(), + ) + cmd.Stdin = strings.NewReader(initReq) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + t.Fatalf("start devkit mcp: %v", err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + select { + case <-done: + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + <-done + t.Fatalf("devkit mcp did not exit within 10s\nstdout: %q\nstderr: %q", stdout.String(), stderr.String()) + } + + if strings.Contains(stderr.String(), "not inside a git repo") { + t.Fatalf("devkit mcp aborted with git-repo check — boot must tolerate non-git cwds\nstderr: %q", stderr.String()) + } + + out := stdout.Bytes() + if len(out) == 0 { + t.Fatalf("devkit mcp wrote nothing to stdout — initialize handshake never completed\nstderr: %q", stderr.String()) + } + + dec := json.NewDecoder(bytes.NewReader(out)) + sawInitResp := false + for { + var msg map[string]any + err := dec.Decode(&msg) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("stdout contains non-JSON content: %v\nstdout: %q\nstderr: %q", err, out, stderr.String()) + } + if id, ok := msg["id"]; ok { + if n, ok := id.(float64); ok && n == 1 { + sawInitResp = true + } + } + } + if !sawInitResp { + t.Fatalf("did not see initialize response on stdout — handshake failed outside a git repo\nstdout: %q\nstderr: %q", out, stderr.String()) + } +} diff --git a/src/cmd/root.go b/src/cmd/root.go index a7a18d7..1d9c243 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -25,9 +25,18 @@ var rootCmd = &cobra.Command{ Short: "Deterministic orchestration for AI agent workflows", Long: "Go CLI harness for devkit — deterministic loop control, process management, and unattended runs.", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + allowNoGit := cmd.Annotations["allow_no_git"] == "true" + root, err := findRepoRoot() if err != nil { - return fmt.Errorf("not inside a git repo — run devkit from a project directory") + if !allowNoGit { + return fmt.Errorf("not inside a git repo — run devkit from a project directory") + } + // Subcommand opts out of the git requirement (e.g. `devkit mcp`, + // which serves workflows that don't need git state). Leave + // repoRoot empty and let the subcommand pick its own data dir. + repoRoot = "" + return nil } repoRoot = root diff --git a/src/mcp/server.go b/src/mcp/server.go index 164c616..ce51e97 100644 --- a/src/mcp/server.go +++ b/src/mcp/server.go @@ -21,9 +21,16 @@ type Server struct { } // NewServer creates a devkit MCP server. +// +// Only dataDir is strictly required (the server needs somewhere to persist +// workflow state). repoRoot and workflowDir may be empty — the server still +// boots and answers the MCP initialize handshake, so the client doesn't see +// an opaque -32000. Individual tool calls that need git state or workflow +// definitions are responsible for returning a structured error when the +// required input is missing. See issue #105. func NewServer(repoRoot, dataDir, workflowDir string) (*Server, error) { - if repoRoot == "" || dataDir == "" || workflowDir == "" { - return nil, fmt.Errorf("repoRoot, dataDir, and workflowDir must be non-empty") + if dataDir == "" { + return nil, fmt.Errorf("dataDir must be non-empty") } dbPath := filepath.Join(dataDir, "devkit.db") @@ -32,10 +39,14 @@ func NewServer(repoRoot, dataDir, workflowDir string) (*Server, error) { return nil, fmt.Errorf("open db: %w", err) } - principles, err := LoadPrinciples(workflowDir) - if err != nil { - fmt.Fprintf(os.Stderr, "warning: could not load principles: %v\n", err) - principles = map[string][]string{} + principles := map[string][]string{} + if workflowDir != "" { + loaded, err := LoadPrinciples(workflowDir) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not load principles: %v\n", err) + } else { + principles = loaded + } } return &Server{