From ba13c3d3099e20920c18a8f496afcaafdf8f23a2 Mon Sep 17 00:00:00 2001 From: Jannik Date: Thu, 26 Mar 2026 11:52:00 +0200 Subject: [PATCH] fix: propagate parent workspace MCP server config to spawned agents Read MCP server configuration from the parent workspace's .claude/settings.json and pass it via --mcp-config flag when spawning agent Claude CLI sessions, so agents in submodule worktrees can access MCP tools configured at the workspace level. Closes #643 Co-Authored-By: Claude Opus 4.6 --- src/agents/tech-lead.ts | 3 + src/cli-runtimes/claude.ts | 6 + src/cli-runtimes/index.test.ts | 71 +++++++++ src/cli-runtimes/types.ts | 1 + .../commands/manager/tech-lead-lifecycle.ts | 3 + src/cli/commands/message.ts | 5 +- src/cli/commands/req.ts | 4 +- src/cli/commands/resume.ts | 3 + src/orchestrator/scheduler.ts | 3 + src/utils/mcp-config.test.ts | 136 ++++++++++++++++++ src/utils/mcp-config.ts | 30 ++++ 11 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 src/utils/mcp-config.test.ts create mode 100644 src/utils/mcp-config.ts diff --git a/src/agents/tech-lead.ts b/src/agents/tech-lead.ts index f0af3e9c..32ad6ba6 100644 --- a/src/agents/tech-lead.ts +++ b/src/agents/tech-lead.ts @@ -21,6 +21,7 @@ import { getAllTeams, type TeamRow } from '../db/queries/teams.js'; import { NotFoundError } from '../errors/index.js'; import { generateTechLeadJiraInstructions } from '../orchestrator/prompt-templates.js'; import { generateSessionName, spawnTmuxSession } from '../tmux/manager.js'; +import { getParentMcpConfig } from '../utils/mcp-config.js'; import { findHiveRoot, getHivePaths } from '../utils/paths.js'; import { BaseAgent, type AgentContext } from './base-agent.js'; @@ -303,9 +304,11 @@ Respond in JSON format: // Build spawn command using CLI runtime builder (spawn fresh session, will be resumed later) const chromeEnabled = config.agents?.chrome_enabled === true && cliTool === 'claude'; + const mcpConfig = getParentMcpConfig(hiveRoot); const runtimeBuilder = getCliRuntimeBuilder(cliTool); const commandArgs = runtimeBuilder.buildSpawnCommand(model, safetyMode, { chrome: chromeEnabled, + ...(mcpConfig ? { mcpConfig } : {}), }); await spawnTmuxSession({ diff --git a/src/cli-runtimes/claude.ts b/src/cli-runtimes/claude.ts index 68b3cf8f..4e5cba67 100644 --- a/src/cli-runtimes/claude.ts +++ b/src/cli-runtimes/claude.ts @@ -15,6 +15,9 @@ export class ClaudeRuntimeBuilder implements CliRuntimeBuilder { if (options?.chrome) { args.push('--chrome'); } + if (options?.mcpConfig) { + args.push('--mcp-config', options.mcpConfig); + } return args; } @@ -31,6 +34,9 @@ export class ClaudeRuntimeBuilder implements CliRuntimeBuilder { if (options?.chrome) { args.push('--chrome'); } + if (options?.mcpConfig) { + args.push('--mcp-config', options.mcpConfig); + } return args; } diff --git a/src/cli-runtimes/index.test.ts b/src/cli-runtimes/index.test.ts index ed2ba952..1ecf4561 100644 --- a/src/cli-runtimes/index.test.ts +++ b/src/cli-runtimes/index.test.ts @@ -83,6 +83,77 @@ describe('CLI Runtime Builders', () => { const builder = new ClaudeRuntimeBuilder(); expect(builder.getModelFlag()).toBe('--model'); }); + + it('should include --mcp-config flag in spawn command when mcpConfig is provided', () => { + const builder = new ClaudeRuntimeBuilder(); + const mcpConfig = JSON.stringify({ myServer: { command: 'node', args: ['server.js'] } }); + const command = builder.buildSpawnCommand('claude-sonnet-4-20250514', 'unsafe', { + mcpConfig, + }); + + expect(command).toEqual([ + 'claude', + '--dangerously-skip-permissions', + '--model', + 'claude-sonnet-4-20250514', + '--mcp-config', + mcpConfig, + ]); + }); + + it('should include --mcp-config flag in resume command when mcpConfig is provided', () => { + const builder = new ClaudeRuntimeBuilder(); + const mcpConfig = JSON.stringify({ myServer: { command: 'node', args: ['server.js'] } }); + const command = builder.buildResumeCommand( + 'claude-sonnet-4-20250514', + 'session-123', + 'unsafe', + { mcpConfig } + ); + + expect(command).toEqual([ + 'claude', + '--dangerously-skip-permissions', + '--model', + 'claude-sonnet-4-20250514', + '--resume', + 'session-123', + '--mcp-config', + mcpConfig, + ]); + }); + + it('should not include --mcp-config flag when mcpConfig is not provided', () => { + const builder = new ClaudeRuntimeBuilder(); + const command = builder.buildSpawnCommand('claude-sonnet-4-20250514', 'unsafe', {}); + + expect(command).toEqual([ + 'claude', + '--dangerously-skip-permissions', + '--model', + 'claude-sonnet-4-20250514', + ]); + expect(command).not.toContain('--mcp-config'); + }); + + it('should include both chrome and mcp-config flags when both are provided', () => { + const builder = new ClaudeRuntimeBuilder(); + const mcpConfig = JSON.stringify({ server: { command: 'cmd' } }); + const command = builder.buildSpawnCommand('claude-sonnet-4-20250514', 'unsafe', { + chrome: true, + mcpConfig, + }); + + expect(command).toEqual([ + 'claude', + '--dangerously-skip-permissions', + '--model', + 'claude-sonnet-4-20250514', + '--chrome', + '--mcp-config', + mcpConfig, + ]); + }); }); describe('CodexRuntimeBuilder', () => { diff --git a/src/cli-runtimes/types.ts b/src/cli-runtimes/types.ts index b6127d57..04e4f3be 100644 --- a/src/cli-runtimes/types.ts +++ b/src/cli-runtimes/types.ts @@ -5,6 +5,7 @@ export type RuntimeSafetyMode = 'safe' | 'unsafe'; export interface RuntimeOptions { chrome?: boolean; + mcpConfig?: string; } export interface CliRuntimeBuilder { diff --git a/src/cli/commands/manager/tech-lead-lifecycle.ts b/src/cli/commands/manager/tech-lead-lifecycle.ts index f4dfb7af..0f8bcf9f 100644 --- a/src/cli/commands/manager/tech-lead-lifecycle.ts +++ b/src/cli/commands/manager/tech-lead-lifecycle.ts @@ -16,6 +16,7 @@ import { } from '../../../tmux/manager.js'; import type { CLITool } from '../../../utils/cli-commands.js'; import { getTechLeadSessionName } from '../../../utils/instance.js'; +import { getParentMcpConfig } from '../../../utils/mcp-config.js'; import { findHiveRoot as findHiveRootFromDir, getHivePaths } from '../../../utils/paths.js'; import { generateTechLeadPrompt } from '../req.js'; import { detectAgentState } from './agent-monitoring.js'; @@ -132,9 +133,11 @@ export async function restartStaleTechLead(ctx: ManagerCheckContext): Promise { const prompt = buildChatAgentPrompt(sessionName, requirementContext); // Build CLI command + const mcpConfig = getParentMcpConfig(root); const runtimeBuilder = getCliRuntimeBuilder(cliTool); const commandArgs = runtimeBuilder.buildSpawnCommand( modelConfig.model, - modelConfig.safety_mode || 'unsafe' + modelConfig.safety_mode || 'unsafe', + { ...(mcpConfig ? { mcpConfig } : {}) } ); // Spawn tmux session diff --git a/src/cli/commands/req.ts b/src/cli/commands/req.ts index 60768594..c51cf4cb 100644 --- a/src/cli/commands/req.ts +++ b/src/cli/commands/req.ts @@ -29,6 +29,7 @@ import { } from '../../tmux/manager.js'; import { getTechLeadSessionName } from '../../utils/instance.js'; import { statusColor } from '../../utils/logger.js'; +import { getParentMcpConfig } from '../../utils/mcp-config.js'; import { withHiveContext, withReadOnlyHiveContext } from '../../utils/with-hive-context.js'; import { startDashboard } from '../dashboard/index.js'; @@ -256,10 +257,11 @@ export const reqCommand = new Command('req') // Build CLI command using the configured runtime for Tech Lead const chromeEnabled = config.agents?.chrome_enabled === true && techLeadCliTool === 'claude'; + const mcpConfig = getParentMcpConfig(root); const commandArgs = getCliRuntimeBuilder(techLeadCliTool).buildSpawnCommand( techLeadModel, techLeadSafetyMode, - { chrome: chromeEnabled } + { chrome: chromeEnabled, ...(mcpConfig ? { mcpConfig } : {}) } ); // Pass the prompt as initialPrompt so it's included as a CLI positional diff --git a/src/cli/commands/resume.ts b/src/cli/commands/resume.ts index 686d4147..e77889f5 100644 --- a/src/cli/commands/resume.ts +++ b/src/cli/commands/resume.ts @@ -14,6 +14,7 @@ import { createLog } from '../../db/queries/logs.js'; import { getTeamById } from '../../db/queries/teams.js'; import { isTmuxAvailable, isTmuxSessionRunning, spawnTmuxSession } from '../../tmux/manager.js'; import { requireAgent } from '../../utils/cli-helpers.js'; +import { getParentMcpConfig } from '../../utils/mcp-config.js'; import { withHiveContext } from '../../utils/with-hive-context.js'; export const resumeCommand = new Command('resume') @@ -91,9 +92,11 @@ export const resumeCommand = new Command('resume') // Build resume command using CLI runtime builder const chromeEnabled = config.agents?.chrome_enabled === true && cliTool === 'claude'; + const mcpConfig = getParentMcpConfig(root); const runtimeBuilder = getCliRuntimeBuilder(cliTool); const commandArgs = runtimeBuilder.buildResumeCommand(model, sessionName, safetyMode, { chrome: chromeEnabled, + ...(mcpConfig ? { mcpConfig } : {}), }); // Spawn new session diff --git a/src/orchestrator/scheduler.ts b/src/orchestrator/scheduler.ts index f35136b7..0da87305 100644 --- a/src/orchestrator/scheduler.ts +++ b/src/orchestrator/scheduler.ts @@ -49,6 +49,7 @@ import { } from '../tmux/manager.js'; import { getTechLeadSessionName } from '../utils/instance.js'; import * as logger from '../utils/logger.js'; +import { getParentMcpConfig } from '../utils/mcp-config.js'; import { getHivePaths } from '../utils/paths.js'; import { selectAgentWithLeastWorkload } from './agent-selector.js'; import { getCapacityPoints, selectStoriesForCapacity } from './capacity-planner.js'; @@ -1167,11 +1168,13 @@ export class Scheduler { } // Build CLI command using the configured runtime + const mcpConfig = getParentMcpConfig(this.config.rootDir); const commandArgs = getCliRuntimeBuilder(cliTool).buildSpawnCommand( runtimeModel, safetyMode, { chrome: chromeEnabled, + ...(mcpConfig ? { mcpConfig } : {}), } ); diff --git a/src/utils/mcp-config.test.ts b/src/utils/mcp-config.test.ts new file mode 100644 index 00000000..ceaeff9a --- /dev/null +++ b/src/utils/mcp-config.test.ts @@ -0,0 +1,136 @@ +// Licensed under the Hungry Ghost Hive License. See LICENSE. + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getParentMcpConfig } from './mcp-config.js'; + +describe('getParentMcpConfig', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'mcp-config-test-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should return null when .claude directory does not exist', () => { + const result = getParentMcpConfig(tempDir); + expect(result).toBeNull(); + }); + + it('should return null when settings files do not exist', () => { + mkdirSync(join(tempDir, '.claude'), { recursive: true }); + const result = getParentMcpConfig(tempDir); + expect(result).toBeNull(); + }); + + it('should return null when settings.json has no mcpServers', () => { + const claudeDir = join(tempDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, 'settings.json'), JSON.stringify({ someOtherKey: true })); + + const result = getParentMcpConfig(tempDir); + expect(result).toBeNull(); + }); + + it('should return null when mcpServers is empty', () => { + const claudeDir = join(tempDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, 'settings.json'), JSON.stringify({ mcpServers: {} })); + + const result = getParentMcpConfig(tempDir); + expect(result).toBeNull(); + }); + + it('should return MCP config from settings.json', () => { + const claudeDir = join(tempDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + const mcpServers = { + myServer: { command: 'node', args: ['server.js'] }, + }; + writeFileSync(join(claudeDir, 'settings.json'), JSON.stringify({ mcpServers })); + + const result = getParentMcpConfig(tempDir); + expect(result).not.toBeNull(); + expect(JSON.parse(result!)).toEqual(mcpServers); + }); + + it('should return MCP config from settings.local.json', () => { + const claudeDir = join(tempDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + const mcpServers = { + localServer: { command: 'python', args: ['serve.py'] }, + }; + writeFileSync(join(claudeDir, 'settings.local.json'), JSON.stringify({ mcpServers })); + + const result = getParentMcpConfig(tempDir); + expect(result).not.toBeNull(); + expect(JSON.parse(result!)).toEqual(mcpServers); + }); + + it('should merge MCP config from both settings files', () => { + const claudeDir = join(tempDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, 'settings.json'), + JSON.stringify({ mcpServers: { server1: { command: 'cmd1' } } }) + ); + writeFileSync( + join(claudeDir, 'settings.local.json'), + JSON.stringify({ mcpServers: { server2: { command: 'cmd2' } } }) + ); + + const result = getParentMcpConfig(tempDir); + expect(result).not.toBeNull(); + expect(JSON.parse(result!)).toEqual({ + server1: { command: 'cmd1' }, + server2: { command: 'cmd2' }, + }); + }); + + it('should let settings.local.json override settings.json for same key', () => { + const claudeDir = join(tempDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, 'settings.json'), + JSON.stringify({ mcpServers: { server1: { command: 'original' } } }) + ); + writeFileSync( + join(claudeDir, 'settings.local.json'), + JSON.stringify({ mcpServers: { server1: { command: 'override' } } }) + ); + + const result = getParentMcpConfig(tempDir); + expect(result).not.toBeNull(); + expect(JSON.parse(result!)).toEqual({ + server1: { command: 'override' }, + }); + }); + + it('should ignore malformed JSON gracefully', () => { + const claudeDir = join(tempDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, 'settings.json'), 'not valid json{{{'); + writeFileSync( + join(claudeDir, 'settings.local.json'), + JSON.stringify({ mcpServers: { server1: { command: 'cmd1' } } }) + ); + + const result = getParentMcpConfig(tempDir); + expect(result).not.toBeNull(); + expect(JSON.parse(result!)).toEqual({ server1: { command: 'cmd1' } }); + }); + + it('should return null when mcpServers is not an object', () => { + const claudeDir = join(tempDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, 'settings.json'), JSON.stringify({ mcpServers: 'not-object' })); + + const result = getParentMcpConfig(tempDir); + expect(result).toBeNull(); + }); +}); diff --git a/src/utils/mcp-config.ts b/src/utils/mcp-config.ts new file mode 100644 index 00000000..a0dd11d4 --- /dev/null +++ b/src/utils/mcp-config.ts @@ -0,0 +1,30 @@ +// Licensed under the Hungry Ghost Hive License. See LICENSE. + +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; + +/** + * Read MCP server configuration from the parent workspace's .claude settings files. + * Merges settings from both settings.json and settings.local.json. + * + * @param rootDir - The hive root directory (parent workspace) + * @returns JSON string of merged mcpServers config, or null if none found + */ +export function getParentMcpConfig(rootDir: string): string | null { + const mcpServers: Record = {}; + + for (const filename of ['settings.json', 'settings.local.json']) { + const settingsPath = join(rootDir, '.claude', filename); + if (!existsSync(settingsPath)) continue; + try { + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + if (settings.mcpServers && typeof settings.mcpServers === 'object') { + Object.assign(mcpServers, settings.mcpServers); + } + } catch { + // Ignore parse errors + } + } + + return Object.keys(mcpServers).length > 0 ? JSON.stringify(mcpServers) : null; +}