Skip to content

Commit f7bb241

Browse files
committed
Support .agents/mcp.json
1 parent a26f73c commit f7bb241

File tree

4 files changed

+594
-1
lines changed

4 files changed

+594
-1
lines changed

cli/src/utils/local-agent-registry.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import os from 'os'
33
import path from 'path'
44

55
import { pluralize } from '@codebuff/common/util/string'
6-
import { loadLocalAgents as sdkLoadLocalAgents } from '@codebuff/sdk'
6+
import { loadLocalAgents as sdkLoadLocalAgents, loadMCPConfigSync } from '@codebuff/sdk'
7+
8+
import type { MCPConfig } from '@codebuff/common/types/mcp'
79

810
import { getProjectRoot } from '../project-files'
911
import { AGENT_MODE_TO_ID, type AgentMode } from './constants'
@@ -32,6 +34,8 @@ export interface LocalAgentInfo {
3234
let userAgentsCache: Record<string, AgentDefinition> = {}
3335
// Map from agent ID to source file path (for UI "Open file" links)
3436
let userAgentFilePaths: Map<string, string> = new Map()
37+
// Cache for MCP servers loaded from mcp.json in .agents directories
38+
let mcpServersCache: Record<string, MCPConfig> = {}
3539

3640
/**
3741
* Initialize the agent registry by loading user agents via the SDK.
@@ -56,6 +60,21 @@ export async function initializeAgentRegistry(): Promise<void> {
5660
userAgentsCache = {}
5761
userAgentFilePaths = new Map()
5862
}
63+
64+
// Load MCP config from mcp.json files in .agents directories
65+
try {
66+
const mcpConfig = loadMCPConfigSync({ verbose: false })
67+
mcpServersCache = mcpConfig.mcpServers
68+
if (Object.keys(mcpServersCache).length > 0) {
69+
logger.debug(
70+
{ mcpServers: Object.keys(mcpServersCache), source: mcpConfig._sourceFilePath },
71+
'[agents] Loaded MCP servers from mcp.json',
72+
)
73+
}
74+
} catch (error) {
75+
logger.warn({ error }, 'Failed to load MCP config from .agents directories')
76+
mcpServersCache = {}
77+
}
5978
}
6079

6180
/**
@@ -329,6 +348,25 @@ export const loadAgentDefinitions = (): AgentDefinition[] => {
329348
}
330349
}
331350

351+
// Merge MCP servers from mcp.json into base agents
352+
// This allows users to configure MCP tools that are available to the main agent
353+
if (Object.keys(mcpServersCache).length > 0) {
354+
for (const def of definitions) {
355+
// Consider any agent with an ID starting with 'base' as a base agent
356+
if (def.id.startsWith('base')) {
357+
// Initialize mcpServers if not present
358+
if (!def.mcpServers) {
359+
def.mcpServers = {}
360+
}
361+
// Merge MCP servers (user config can override existing servers)
362+
def.mcpServers = {
363+
...def.mcpServers,
364+
...mcpServersCache,
365+
}
366+
}
367+
}
368+
}
369+
332370
return definitions
333371
}
334372

@@ -412,4 +450,13 @@ export const __resetLocalAgentRegistryForTests = (): void => {
412450
cachedAgentsDir = null
413451
userAgentsCache = {}
414452
userAgentFilePaths = new Map()
453+
mcpServersCache = {}
454+
}
455+
456+
/**
457+
* Get the currently loaded MCP servers from mcp.json.
458+
* Useful for debugging and displaying loaded MCP configuration.
459+
*/
460+
export const getLoadedMCPServers = (): Record<string, MCPConfig> => {
461+
return { ...mcpServersCache }
415462
}
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import fs from 'fs'
2+
import os from 'os'
3+
import path from 'path'
4+
5+
import { loadMCPConfig, loadMCPConfigSync, mcpFileSchema } from '../agents/load-mcp-config'
6+
7+
import type { MCPConfig } from '@codebuff/common/types/mcp'
8+
9+
// Helper to safely access stdio config properties
10+
function isStdioConfig(config: MCPConfig): config is MCPConfig & { command: string; env?: Record<string, string> } {
11+
return 'command' in config
12+
}
13+
14+
describe('mcpFileSchema', () => {
15+
it('should parse a valid mcp.json with stdio config', () => {
16+
const config = {
17+
mcpServers: {
18+
myServer: {
19+
command: 'npx',
20+
args: ['-y', 'my-package'],
21+
env: {
22+
API_KEY: 'test-key',
23+
},
24+
},
25+
},
26+
}
27+
28+
const result = mcpFileSchema.safeParse(config)
29+
expect(result.success).toBe(true)
30+
if (result.success) {
31+
expect(result.data.mcpServers.myServer).toBeDefined()
32+
expect(result.data.mcpServers.myServer.command).toBe('npx')
33+
}
34+
})
35+
36+
it('should parse a valid mcp.json with http config', () => {
37+
const config = {
38+
mcpServers: {
39+
remoteServer: {
40+
type: 'http',
41+
url: 'https://example.com/mcp',
42+
headers: {
43+
Authorization: 'Bearer token',
44+
},
45+
},
46+
},
47+
}
48+
49+
const result = mcpFileSchema.safeParse(config)
50+
expect(result.success).toBe(true)
51+
if (result.success) {
52+
expect(result.data.mcpServers.remoteServer).toBeDefined()
53+
expect(result.data.mcpServers.remoteServer.url).toBe('https://example.com/mcp')
54+
}
55+
})
56+
57+
it('should default mcpServers to empty object if not provided', () => {
58+
const config = {}
59+
60+
const result = mcpFileSchema.safeParse(config)
61+
expect(result.success).toBe(true)
62+
if (result.success) {
63+
expect(result.data.mcpServers).toEqual({})
64+
}
65+
})
66+
67+
it('should reject invalid config', () => {
68+
const config = {
69+
mcpServers: {
70+
invalidServer: {
71+
// Missing required fields
72+
type: 'invalid-type',
73+
},
74+
},
75+
}
76+
77+
const result = mcpFileSchema.safeParse(config)
78+
expect(result.success).toBe(false)
79+
})
80+
})
81+
82+
describe('loadMCPConfigSync', () => {
83+
let tempDir: string
84+
let originalCwd: string
85+
86+
beforeEach(() => {
87+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-config-test-'))
88+
originalCwd = process.cwd()
89+
process.chdir(tempDir)
90+
})
91+
92+
afterEach(() => {
93+
process.chdir(originalCwd)
94+
fs.rmSync(tempDir, { recursive: true, force: true })
95+
})
96+
97+
it('should return empty config when no mcp.json exists in project dir', () => {
98+
// No mcp.json in tempDir/.agents - should not find any project-specific servers
99+
const result = loadMCPConfigSync({ verbose: false })
100+
// Check that no server named 'testProjectServer' exists (which we'd create if one existed)
101+
expect(result.mcpServers.testProjectServer).toBeUndefined()
102+
})
103+
104+
it('should load mcp.json from .agents directory', () => {
105+
const agentsDir = path.join(tempDir, '.agents')
106+
fs.mkdirSync(agentsDir, { recursive: true })
107+
108+
const mcpConfig = {
109+
mcpServers: {
110+
testServer: {
111+
command: 'node',
112+
args: ['server.js'],
113+
},
114+
},
115+
}
116+
fs.writeFileSync(
117+
path.join(agentsDir, 'mcp.json'),
118+
JSON.stringify(mcpConfig, null, 2),
119+
)
120+
121+
const result = loadMCPConfigSync({ verbose: false })
122+
expect(result.mcpServers.testServer).toBeDefined()
123+
const testServer = result.mcpServers.testServer
124+
if (isStdioConfig(testServer)) {
125+
expect(testServer.command).toBe('node')
126+
}
127+
// Verify a source path was recorded (don't check exact path due to temp dir variations)
128+
expect(result._sourceFilePath).toContain('mcp.json')
129+
})
130+
131+
it('should resolve environment variable references', () => {
132+
const agentsDir = path.join(tempDir, '.agents')
133+
fs.mkdirSync(agentsDir, { recursive: true })
134+
135+
// Set env var for test
136+
process.env.TEST_MCP_API_KEY = 'resolved-api-key'
137+
138+
const mcpConfig = {
139+
mcpServers: {
140+
envServer: {
141+
command: 'npx',
142+
args: ['-y', 'my-mcp-server'],
143+
env: {
144+
API_KEY: '$TEST_MCP_API_KEY',
145+
},
146+
},
147+
},
148+
}
149+
fs.writeFileSync(
150+
path.join(agentsDir, 'mcp.json'),
151+
JSON.stringify(mcpConfig, null, 2),
152+
)
153+
154+
const result = loadMCPConfigSync({ verbose: false })
155+
expect(result.mcpServers.envServer).toBeDefined()
156+
const envServer = result.mcpServers.envServer
157+
if (isStdioConfig(envServer)) {
158+
expect(envServer.env?.API_KEY).toBe('resolved-api-key')
159+
}
160+
161+
// Cleanup
162+
delete process.env.TEST_MCP_API_KEY
163+
})
164+
165+
it('should skip config if env var is missing', () => {
166+
const agentsDir = path.join(tempDir, '.agents')
167+
fs.mkdirSync(agentsDir, { recursive: true })
168+
169+
const mcpConfig = {
170+
mcpServers: {
171+
missingEnvServer: {
172+
command: 'npx',
173+
args: ['-y', 'my-mcp-server'],
174+
env: {
175+
API_KEY: '$NONEXISTENT_VAR_12345',
176+
},
177+
},
178+
},
179+
}
180+
fs.writeFileSync(
181+
path.join(agentsDir, 'mcp.json'),
182+
JSON.stringify(mcpConfig, null, 2),
183+
)
184+
185+
// Should not throw, just skip the server with missing env var
186+
const result = loadMCPConfigSync({ verbose: false })
187+
// The server with missing env var should not be loaded
188+
expect(result.mcpServers.missingEnvServer).toBeUndefined()
189+
})
190+
191+
it('should load config from project .agents directory', () => {
192+
// Create project .agents directory
193+
const projectAgentsDir = path.join(tempDir, '.agents')
194+
fs.mkdirSync(projectAgentsDir, { recursive: true })
195+
196+
// Project config
197+
const projectConfig = {
198+
mcpServers: {
199+
projectServer: {
200+
command: 'project-command',
201+
args: ['--flag'],
202+
},
203+
},
204+
}
205+
fs.writeFileSync(
206+
path.join(projectAgentsDir, 'mcp.json'),
207+
JSON.stringify(projectConfig, null, 2),
208+
)
209+
210+
const result = loadMCPConfigSync({ verbose: false })
211+
212+
// Project config should be loaded
213+
const projectServer = result.mcpServers.projectServer
214+
expect(projectServer).toBeDefined()
215+
if (projectServer && isStdioConfig(projectServer)) {
216+
expect(projectServer.command).toBe('project-command')
217+
}
218+
})
219+
220+
it('should handle invalid JSON gracefully', () => {
221+
const agentsDir = path.join(tempDir, '.agents')
222+
fs.mkdirSync(agentsDir, { recursive: true })
223+
224+
fs.writeFileSync(path.join(agentsDir, 'mcp.json'), 'not valid json {')
225+
226+
// Should not throw - just skip the invalid file
227+
const result = loadMCPConfigSync({ verbose: false })
228+
// The result should not contain any servers from this invalid config
229+
// (though it might contain servers from other directories like home)
230+
expect(result.mcpServers.invalidServer).toBeUndefined()
231+
})
232+
})
233+
234+
describe('loadMCPConfig', () => {
235+
let tempDir: string
236+
let originalCwd: string
237+
238+
beforeEach(() => {
239+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-config-async-test-'))
240+
originalCwd = process.cwd()
241+
process.chdir(tempDir)
242+
})
243+
244+
afterEach(() => {
245+
process.chdir(originalCwd)
246+
fs.rmSync(tempDir, { recursive: true, force: true })
247+
})
248+
249+
it('should load mcp.json asynchronously', async () => {
250+
const agentsDir = path.join(tempDir, '.agents')
251+
fs.mkdirSync(agentsDir, { recursive: true })
252+
253+
const mcpConfig = {
254+
mcpServers: {
255+
asyncServer: {
256+
command: 'async-command',
257+
args: ['--async'],
258+
},
259+
},
260+
}
261+
fs.writeFileSync(
262+
path.join(agentsDir, 'mcp.json'),
263+
JSON.stringify(mcpConfig, null, 2),
264+
)
265+
266+
const result = await loadMCPConfig({ verbose: false })
267+
expect(result.mcpServers.asyncServer).toBeDefined()
268+
const asyncServer = result.mcpServers.asyncServer
269+
if (isStdioConfig(asyncServer)) {
270+
expect(asyncServer.command).toBe('async-command')
271+
}
272+
})
273+
})

0 commit comments

Comments
 (0)