From 133bf0f8184aad1b15ad4d90b55f14d1a5ea1996 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 17 Feb 2026 21:25:07 -0600 Subject: [PATCH] Fix MCP task tools access for Codex and other non-Claude executors The TaskYou MCP server config (taskyou_complete, taskyou_get_project_context, etc.) was only written to ~/.claude.json inside runClaude/runClaudeResume, so non-Claude executors (Codex, Gemini, etc.) never got MCP tools configured. Changes: - Move writeWorkflowMCPConfig call from runClaude/runClaudeResume to executeTask so it runs for ALL executors (centralized) - Add writeWorktreeMCPConfig that writes taskyou MCP server to .mcp.json in the worktree directory, which non-Claude CLIs read for MCP discovery - Handle symlinked .mcp.json by breaking the symlink before writing (preserves the project's original file) - Add comprehensive tests for writeWorktreeMCPConfig Co-Authored-By: Claude Opus 4.6 --- internal/executor/executor.go | 81 +++++++++++-- internal/executor/executor_test.go | 184 +++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 10 deletions(-) diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 2478ec6d..63a68fd6 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -876,6 +876,16 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { } e.events.EmitTaskWorktreeReady(task) + // Setup TaskYou MCP server so all executors can use taskyou_* tools + // This writes to both ~/.claude.json (for Claude Code, no approval needed) + // and .mcp.json in the worktree (for Codex, Gemini, and other CLI tools) + if err := writeWorkflowMCPConfig(workDir, task.ID); err != nil { + e.logger.Warn("could not setup TaskYou MCP config in claude.json", "error", err) + } + if err := writeWorktreeMCPConfig(workDir, task.ID); err != nil { + e.logger.Warn("could not setup TaskYou MCP config in .mcp.json", "error", err) + } + // Prepare attachments (write to .claude/attachments for seamless access) attachmentPaths, cleanupAttachments := e.prepareAttachments(task.ID, workDir) defer cleanupAttachments() @@ -1969,11 +1979,7 @@ func (e *Executor) runClaude(ctx context.Context, task *db.Task, workDir, prompt e.logger.Warn("could not setup Claude hooks", "error", err) } // Note: we don't clean up hooks config immediately - it needs to persist for the session - - // Setup TaskYou MCP server in ~/.claude.json so Claude can use taskyou_* tools - if err := writeWorkflowMCPConfig(workDir, task.ID); err != nil { - e.logger.Warn("could not setup TaskYou MCP config", "error", err) - } + // Note: TaskYou MCP config is already set up by executeTask() before calling this method // Create a temp file for the prompt (avoids quoting issues) promptFile, err := os.CreateTemp("", "task-prompt-*.txt") @@ -2145,11 +2151,7 @@ func (e *Executor) runClaudeResume(ctx context.Context, task *db.Task, workDir, if err != nil { e.logger.Warn("could not setup Claude hooks", "error", err) } - - // Setup TaskYou MCP server in ~/.claude.json so Claude can use taskyou_* tools - if err := writeWorkflowMCPConfig(workDir, task.ID); err != nil { - e.logger.Warn("could not setup TaskYou MCP config", "error", err) - } + // Note: TaskYou MCP config is already set up by executeTask() before calling this method // Create a temp file for the feedback (avoids quoting issues) feedbackFile, err := os.CreateTemp("", "task-feedback-*.txt") @@ -3748,6 +3750,65 @@ func writeWorkflowMCPConfig(worktreePath string, taskID int64) error { return nil } +// writeWorktreeMCPConfig writes the TaskYou MCP server configuration to .mcp.json in the worktree. +// This enables non-Claude executors (Codex, Gemini, etc.) to use TaskYou tools, since they read +// MCP server config from .mcp.json rather than ~/.claude.json. +// If .mcp.json already exists (e.g., symlinked from the project), it is resolved to a regular file +// and the taskyou server is merged in. The task ID is always updated to match the current task. +func writeWorktreeMCPConfig(worktreePath string, taskID int64) error { + mcpFile := filepath.Join(worktreePath, ".mcp.json") + + // Get the path to the task executable + taskExecutable, err := os.Executable() + if err != nil { + taskExecutable = "task" + } + + // Build the TaskYou MCP server config (same format as writeWorkflowMCPConfig) + taskyouServer := map[string]interface{}{ + "type": "stdio", + "command": taskExecutable, + "args": []string{"mcp-server", "--task-id", fmt.Sprintf("%d", taskID)}, + } + + // Read existing .mcp.json (resolving symlinks) + var config map[string]interface{} + if data, err := os.ReadFile(mcpFile); err == nil { + if err := json.Unmarshal(data, &config); err != nil { + config = make(map[string]interface{}) + } + } else { + config = make(map[string]interface{}) + } + + // Get or create mcpServers map + mcpServers, ok := config["mcpServers"].(map[string]interface{}) + if !ok { + mcpServers = make(map[string]interface{}) + } + + // Add/update the taskyou server + mcpServers["taskyou"] = taskyouServer + config["mcpServers"] = mcpServers + + // If the file is a symlink, remove it first so we write a regular file + // (we don't want to modify the project's shared .mcp.json) + if target, err := os.Readlink(mcpFile); err == nil && target != "" { + os.Remove(mcpFile) + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("marshal .mcp.json: %w", err) + } + + if err := os.WriteFile(mcpFile, data, 0644); err != nil { + return fmt.Errorf("write .mcp.json: %w", err) + } + + return nil +} + // copyMCPConfig copies the MCP server configuration from the source project to the worktree // in the claude.json file so that Claude Code in the worktree has the same MCP servers available. func copyMCPConfig(configPath, srcDir, dstDir string) error { diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 7ba4d4bf..25b8b25b 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -1672,3 +1672,187 @@ func TestWriteWorkflowMCPConfig(t *testing.T) { } }) } + +func TestWriteWorktreeMCPConfig(t *testing.T) { + // Helper to read .mcp.json and return the taskyou server config + readWorktreeMCP := func(t *testing.T, worktreePath string) map[string]interface{} { + t.Helper() + data, err := os.ReadFile(filepath.Join(worktreePath, ".mcp.json")) + if err != nil { + t.Fatalf("failed to read .mcp.json: %v", err) + } + var config map[string]interface{} + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("failed to parse .mcp.json: %v", err) + } + mcpServers, ok := config["mcpServers"].(map[string]interface{}) + if !ok { + t.Fatal("expected mcpServers key in .mcp.json") + } + taskyou, ok := mcpServers["taskyou"].(map[string]interface{}) + if !ok { + t.Fatal("expected taskyou key in mcpServers") + } + return taskyou + } + + t.Run("creates .mcp.json with taskyou config", func(t *testing.T) { + worktreePath := t.TempDir() + taskID := int64(42) + + err := writeWorktreeMCPConfig(worktreePath, taskID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + taskyou := readWorktreeMCP(t, worktreePath) + + if taskyou["type"] != "stdio" { + t.Errorf("taskyou type = %v, want stdio", taskyou["type"]) + } + + args, ok := taskyou["args"].([]interface{}) + if !ok { + t.Fatal("expected args array in taskyou config") + } + if len(args) != 3 || args[0] != "mcp-server" || args[1] != "--task-id" || args[2] != "42" { + t.Errorf("taskyou args = %v, want [mcp-server --task-id 42]", args) + } + }) + + t.Run("merges into existing .mcp.json", func(t *testing.T) { + worktreePath := t.TempDir() + taskID := int64(99) + + // Create existing .mcp.json with another server + existingConfig := map[string]interface{}{ + "mcpServers": map[string]interface{}{ + "github": map[string]interface{}{ + "type": "stdio", + "command": "gh-mcp", + }, + }, + } + existingData, _ := json.MarshalIndent(existingConfig, "", " ") + if err := os.WriteFile(filepath.Join(worktreePath, ".mcp.json"), existingData, 0644); err != nil { + t.Fatal(err) + } + + err := writeWorktreeMCPConfig(worktreePath, taskID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify both servers exist + data, err := os.ReadFile(filepath.Join(worktreePath, ".mcp.json")) + if err != nil { + t.Fatalf("failed to read .mcp.json: %v", err) + } + var config map[string]interface{} + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("failed to parse .mcp.json: %v", err) + } + mcpServers := config["mcpServers"].(map[string]interface{}) + + // Check github server preserved + github, ok := mcpServers["github"].(map[string]interface{}) + if !ok { + t.Fatal("expected github server to be preserved") + } + if github["command"] != "gh-mcp" { + t.Errorf("github command = %v, want gh-mcp", github["command"]) + } + + // Check taskyou added + if _, ok := mcpServers["taskyou"].(map[string]interface{}); !ok { + t.Fatal("expected taskyou server to be added") + } + }) + + t.Run("replaces symlink with regular file", func(t *testing.T) { + worktreePath := t.TempDir() + projectDir := t.TempDir() + taskID := int64(55) + + // Create .mcp.json in project directory + projectMCP := map[string]interface{}{ + "mcpServers": map[string]interface{}{ + "existing": map[string]interface{}{ + "type": "stdio", + "command": "existing-cmd", + }, + }, + } + projectData, _ := json.MarshalIndent(projectMCP, "", " ") + projectMCPFile := filepath.Join(projectDir, ".mcp.json") + if err := os.WriteFile(projectMCPFile, projectData, 0644); err != nil { + t.Fatal(err) + } + + // Create symlink in worktree -> project + worktreeMCPFile := filepath.Join(worktreePath, ".mcp.json") + if err := os.Symlink(projectMCPFile, worktreeMCPFile); err != nil { + t.Fatal(err) + } + + err := writeWorktreeMCPConfig(worktreePath, taskID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify it's no longer a symlink + info, err := os.Lstat(worktreeMCPFile) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Error("expected .mcp.json to be a regular file, not a symlink") + } + + // Verify existing server was preserved and taskyou was added + data, err := os.ReadFile(worktreeMCPFile) + if err != nil { + t.Fatal(err) + } + var config map[string]interface{} + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + mcpServers := config["mcpServers"].(map[string]interface{}) + if _, ok := mcpServers["existing"]; !ok { + t.Error("expected existing server to be preserved") + } + if _, ok := mcpServers["taskyou"]; !ok { + t.Error("expected taskyou server to be added") + } + + // Verify the project's original file was NOT modified + projectData2, _ := os.ReadFile(projectMCPFile) + var projectConfig map[string]interface{} + json.Unmarshal(projectData2, &projectConfig) + projectServers := projectConfig["mcpServers"].(map[string]interface{}) + if _, ok := projectServers["taskyou"]; ok { + t.Error("project .mcp.json should NOT have taskyou - symlink should have been broken") + } + }) + + t.Run("updates task ID on subsequent calls", func(t *testing.T) { + worktreePath := t.TempDir() + + err := writeWorktreeMCPConfig(worktreePath, 100) + if err != nil { + t.Fatalf("first call failed: %v", err) + } + + err = writeWorktreeMCPConfig(worktreePath, 200) + if err != nil { + t.Fatalf("second call failed: %v", err) + } + + taskyou := readWorktreeMCP(t, worktreePath) + args := taskyou["args"].([]interface{}) + if args[2] != "200" { + t.Errorf("task ID = %v, want 200", args[2]) + } + }) +}