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
3 changes: 3 additions & 0 deletions src/agents/tech-lead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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({
Expand Down
6 changes: 6 additions & 0 deletions src/cli-runtimes/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down
71 changes: 71 additions & 0 deletions src/cli-runtimes/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions src/cli-runtimes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type RuntimeSafetyMode = 'safe' | 'unsafe';

export interface RuntimeOptions {
chrome?: boolean;
mcpConfig?: string;
}

export interface CliRuntimeBuilder {
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/manager/tech-lead-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -132,9 +133,11 @@ export async function restartStaleTechLead(ctx: ManagerCheckContext): Promise<vo
const model = resolveRuntimeModelForCli(agentConfig.model, cliTool);

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 } : {}),
});

// Look up active requirement and teams to provide context to the restarted tech lead
Expand Down
5 changes: 4 additions & 1 deletion src/cli/commands/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
spawnTmuxSession,
} from '../../tmux/manager.js';
import { buildInstanceSessionName, getTechLeadSessionName } from '../../utils/instance.js';
import { getParentMcpConfig } from '../../utils/mcp-config.js';
import { withHiveContext } from '../../utils/with-hive-context.js';

export const messageCommand = new Command('message').description(
Expand Down Expand Up @@ -170,10 +171,12 @@ async function handleNewAgent(_from?: string): Promise<void> {
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
Expand Down
4 changes: 3 additions & 1 deletion src/cli/commands/req.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/orchestrator/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 } : {}),
}
);

Expand Down
136 changes: 136 additions & 0 deletions src/utils/mcp-config.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
30 changes: 30 additions & 0 deletions src/utils/mcp-config.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

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;
}
Loading