Skip to content
Merged
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
44 changes: 39 additions & 5 deletions src/cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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. <repoRoot>/.devkit (when launched inside a git project)
// 3. <user cache dir>/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)
}
86 changes: 86 additions & 0 deletions src/cmd/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
11 changes: 10 additions & 1 deletion src/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 17 additions & 6 deletions src/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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{
Expand Down
Loading