diff --git a/examples/full/agent.yaml b/examples/full/agent.yaml index 5257175..9862280 100644 --- a/examples/full/agent.yaml +++ b/examples/full/agent.yaml @@ -132,6 +132,18 @@ compliance: required_roles: [analyst, reviewer] approval_required: true enforcement: strict +mcp_servers: + regulatory-db: + command: npx + args: + - "-y" + - "@compliance/mcp-regulatory-search" + env: + REG_DB_URL: "${REG_DB_URL}" + compliance-api: + url: "https://compliance-api.internal/mcp" + headers: + Authorization: "Bearer ${COMPLIANCE_API_TOKEN}" tags: - compliance - financial-services diff --git a/spec/schemas/agent-yaml.schema.json b/spec/schemas/agent-yaml.schema.json index acb7d91..ca1ec39 100644 --- a/spec/schemas/agent-yaml.schema.json +++ b/spec/schemas/agent-yaml.schema.json @@ -228,6 +228,13 @@ "items": { "type": "string" }, "uniqueItems": true }, + "mcp_servers": { + "type": "object", + "description": "MCP server definitions. Keys are server names. Each server is either stdio-based (command) or HTTP-based (url).", + "additionalProperties": { + "$ref": "#/$defs/mcp_server_config" + } + }, "metadata": { "type": "object", "description": "Arbitrary key-value metadata. Values must be strings, numbers, or booleans.", @@ -243,6 +250,42 @@ "additionalProperties": false, "$defs": { + "mcp_server_config": { + "type": "object", + "description": "Configuration for a single MCP server (stdio or HTTP)", + "properties": { + "command": { + "type": "string", + "description": "Executable command for stdio-based servers" + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "description": "Arguments for the command" + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Environment variables. Use ${VAR} for interpolation." + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL for HTTP-based (SSE/streamable HTTP) MCP servers" + }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "HTTP headers for URL-based servers" + } + }, + "oneOf": [ + { "required": ["command"] }, + { "required": ["url"] } + ], + "additionalProperties": false + }, + "dependency": { "type": "object", "required": ["name", "source"], diff --git a/src/adapters/claude-code.ts b/src/adapters/claude-code.ts index 0550b48..9e9292e 100644 --- a/src/adapters/claude-code.ts +++ b/src/adapters/claude-code.ts @@ -3,6 +3,7 @@ import { join, resolve } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkillMetadata } from '../utils/skill-loader.js'; +import { buildMcpServersConfig } from './shared.js'; /** * Merge parent agent content into the current agent directory. @@ -51,10 +52,53 @@ function mergeParentContent(agentDir: string, parentDir: string): { return { mergedSoul, mergedRules }; } -export function exportToClaudeCode(dir: string): string { +/** + * Export a gitagent to Claude Code format. + * + * Claude Code uses: + * - CLAUDE.md (custom agent instructions, project root) + * - .mcp.json (MCP server configuration) + */ +export interface ClaudeCodeExport { + /** Content for CLAUDE.md */ + instructions: string; + /** Content for .mcp.json (null if no MCP servers defined) */ + mcpConfig: Record | null; +} + +export function exportToClaudeCode(dir: string): ClaudeCodeExport { const agentDir = resolve(dir); const manifest = loadAgentManifest(agentDir); + const instructions = buildInstructions(agentDir, manifest); + const mcpServers = buildMcpServersConfig(manifest.mcp_servers); + const mcpConfig = mcpServers ? { mcpServers } : null; + + return { instructions, mcpConfig }; +} + +/** + * Export as a single string (for `gitagent export -f claude-code`). + */ +export function exportToClaudeCodeString(dir: string): string { + const exp = exportToClaudeCode(dir); + const parts: string[] = []; + + parts.push('# === CLAUDE.md ==='); + parts.push(exp.instructions); + + if (exp.mcpConfig) { + parts.push('\n# === .mcp.json ==='); + parts.push(JSON.stringify(exp.mcpConfig, null, 2)); + } + + return parts.join('\n'); +} + +function buildInstructions( + agentDir: string, + manifest: ReturnType, +): string { // Check for installed parent agent (extends) const parentDir = join(agentDir, '.gitagent', 'parent'); const hasParent = existsSync(parentDir) && existsSync(join(parentDir, 'agent.yaml')); diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts index bddf05f..27dc0fe 100644 --- a/src/adapters/codex.ts +++ b/src/adapters/codex.ts @@ -3,7 +3,7 @@ import { join, resolve } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; -import { buildComplianceSection } from './shared.js'; +import { buildComplianceSection, buildMcpServersConfig } from './shared.js'; /** * Export a gitagent to OpenAI Codex CLI format. @@ -185,6 +185,12 @@ function buildConfig(manifest: ReturnType): Record = { agents: { diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 7a00f5e..51ad9b9 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -3,6 +3,7 @@ import { join, resolve, basename } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { buildMcpServersConfig } from './shared.js'; /** * Export a gitagent to Cursor rules format. @@ -28,6 +29,8 @@ export interface CursorRule { export interface CursorExport { rules: CursorRule[]; + /** Content for .cursor/mcp.json (null if no MCP servers defined) */ + mcpConfig: Record | null; } export function exportToCursor(dir: string): CursorExport { @@ -49,7 +52,11 @@ export function exportToCursor(dir: string): CursorExport { rules.push(buildSkillRule(skill)); } - return { rules }; + // MCP servers + const mcpServers = buildMcpServersConfig(manifest.mcp_servers); + const mcpConfig = mcpServers ? { mcpServers } : null; + + return { rules, mcpConfig }; } /** @@ -66,6 +73,12 @@ export function exportToCursorString(dir: string): string { parts.push(''); } + if (exp.mcpConfig) { + parts.push('# === .cursor/mcp.json ==='); + parts.push(JSON.stringify(exp.mcpConfig, null, 2)); + parts.push(''); + } + return parts.join('\n').trimEnd() + '\n'; } diff --git a/src/adapters/gemini.ts b/src/adapters/gemini.ts index 438805a..71c07ae 100644 --- a/src/adapters/gemini.ts +++ b/src/adapters/gemini.ts @@ -3,7 +3,7 @@ import { join, resolve } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; -import { buildComplianceSection } from './shared.js'; +import { buildComplianceSection, buildMcpServersConfig } from './shared.js'; /** * Export a gitagent to Google Gemini CLI format. @@ -244,8 +244,9 @@ function buildSettings( settings.hooks = hooksConfig; } - // MCP servers (placeholder - requires manual configuration) - settings.mcpServers = {}; + // MCP servers + const mcpServers = buildMcpServersConfig(manifest.mcp_servers); + settings.mcpServers = mcpServers ?? {}; return settings; } diff --git a/src/adapters/index.ts b/src/adapters/index.ts index ede6bd3..ba52490 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -1,5 +1,5 @@ export { exportToSystemPrompt } from './system-prompt.js'; -export { exportToClaudeCode } from './claude-code.js'; +export { exportToClaudeCode, exportToClaudeCodeString } from './claude-code.js'; export { exportToOpenAI } from './openai.js'; export { exportToCrewAI } from './crewai.js'; export { exportToOpenClawString, exportToOpenClaw } from './openclaw.js'; diff --git a/src/adapters/lyzr.ts b/src/adapters/lyzr.ts index 7d52cea..cf93f6c 100644 --- a/src/adapters/lyzr.ts +++ b/src/adapters/lyzr.ts @@ -1,6 +1,7 @@ import { resolve, join } from 'node:path'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { buildMcpServersMarkdown } from './shared.js'; export interface LyzrAgentPayload { name: string; @@ -66,6 +67,10 @@ function buildAgentInstructions(agentDir: string): string { parts.push(`## Skill: ${skill.frontmatter.name}\n${skill.frontmatter.description}${toolsNote}\n\n${skill.instructions}`); } + // MCP servers + const mcpSection = buildMcpServersMarkdown(manifest.mcp_servers); + if (mcpSection) parts.push(mcpSection); + // Compliance constraints if (manifest.compliance) { const c = manifest.compliance; diff --git a/src/adapters/mcp-servers.test.ts b/src/adapters/mcp-servers.test.ts new file mode 100644 index 0000000..229becd --- /dev/null +++ b/src/adapters/mcp-servers.test.ts @@ -0,0 +1,245 @@ +/** + * Tests for MCP server definitions in agent.yaml exports. + * + * Uses Node.js built-in test runner (node --test). + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { buildMcpServersConfig, buildMcpServersMarkdown } from './shared.js'; +import { exportToCodex } from './codex.js'; +import { exportToClaudeCode } from './claude-code.js'; +import { exportToCursor } from './cursor.js'; +import { exportToGemini } from './gemini.js'; +import { exportToOpenCode } from './opencode.js'; +import { exportToSystemPrompt } from './system-prompt.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const STDIO_SERVER = { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-postgres'], + env: { DATABASE_URL: '${DATABASE_URL}' }, +}; + +const HTTP_SERVER = { + url: 'https://mcp.example.com/sse', + headers: { Authorization: 'Bearer ${MCP_TOKEN}' }, +}; + +function makeAgentDir(opts: { + name?: string; + mcpServers?: Record; +}): string { + const dir = mkdtempSync(join(tmpdir(), 'gitagent-mcp-test-')); + + const mcpBlock = opts.mcpServers + ? `mcp_servers:\n${Object.entries(opts.mcpServers) + .map(([name, config]) => { + const c = config as Record; + let block = ` ${name}:\n`; + if (c.command) block += ` command: ${c.command}\n`; + if (c.args) block += ` args:\n${(c.args as string[]).map(a => ` - '${a}'`).join('\n')}\n`; + if (c.env) { + block += ` env:\n`; + for (const [k, v] of Object.entries(c.env as Record)) { + block += ` ${k}: '${v}'\n`; + } + } + if (c.url) block += ` url: '${c.url}'\n`; + if (c.headers) { + block += ` headers:\n`; + for (const [k, v] of Object.entries(c.headers as Record)) { + block += ` ${k}: '${v}'\n`; + } + } + return block; + }) + .join('')}` + : ''; + + writeFileSync( + join(dir, 'agent.yaml'), + `spec_version: '0.1.0'\nname: ${opts.name ?? 'test-agent'}\nversion: '0.1.0'\ndescription: 'A test agent'\n${mcpBlock}`, + 'utf-8', + ); + + writeFileSync(join(dir, 'SOUL.md'), '# Test Agent\nA test agent soul.', 'utf-8'); + + return dir; +} + +// --------------------------------------------------------------------------- +// buildMcpServersConfig +// --------------------------------------------------------------------------- + +describe('buildMcpServersConfig', () => { + test('returns null for undefined input', () => { + assert.equal(buildMcpServersConfig(undefined), null); + }); + + test('returns null for empty object', () => { + assert.equal(buildMcpServersConfig({}), null); + }); + + test('maps stdio server correctly', () => { + const result = buildMcpServersConfig({ 'my-db': STDIO_SERVER }); + assert.ok(result); + const entry = result['my-db'] as Record; + assert.equal(entry.command, 'npx'); + assert.deepEqual(entry.args, ['-y', '@modelcontextprotocol/server-postgres']); + assert.deepEqual(entry.env, { DATABASE_URL: '${DATABASE_URL}' }); + assert.equal(entry.url, undefined); + }); + + test('maps HTTP server correctly', () => { + const result = buildMcpServersConfig({ remote: HTTP_SERVER }); + assert.ok(result); + const entry = result['remote'] as Record; + assert.equal(entry.url, 'https://mcp.example.com/sse'); + assert.deepEqual(entry.headers, { Authorization: 'Bearer ${MCP_TOKEN}' }); + assert.equal(entry.command, undefined); + }); + + test('handles multiple servers', () => { + const result = buildMcpServersConfig({ + db: STDIO_SERVER, + remote: HTTP_SERVER, + }); + assert.ok(result); + assert.ok(result['db']); + assert.ok(result['remote']); + }); +}); + +// --------------------------------------------------------------------------- +// buildMcpServersMarkdown +// --------------------------------------------------------------------------- + +describe('buildMcpServersMarkdown', () => { + test('returns empty string for undefined input', () => { + assert.equal(buildMcpServersMarkdown(undefined), ''); + }); + + test('returns empty string for empty object', () => { + assert.equal(buildMcpServersMarkdown({}), ''); + }); + + test('includes server name and type for stdio server', () => { + const result = buildMcpServersMarkdown({ 'my-db': STDIO_SERVER }); + assert.match(result, /### my-db/); + assert.match(result, /Type: stdio/); + assert.match(result, /npx/); + }); + + test('includes server name and type for HTTP server', () => { + const result = buildMcpServersMarkdown({ remote: HTTP_SERVER }); + assert.match(result, /### remote/); + assert.match(result, /Type: HTTP/); + assert.match(result, /mcp\.example\.com/); + }); + + test('includes environment variable names', () => { + const result = buildMcpServersMarkdown({ db: STDIO_SERVER }); + assert.match(result, /DATABASE_URL/); + }); +}); + +// --------------------------------------------------------------------------- +// Tier 1 adapter integration tests +// --------------------------------------------------------------------------- + +describe('Tier 1 adapters: MCP config in structured output', () => { + test('Codex: mcpServers in config when mcp_servers defined', () => { + const dir = makeAgentDir({ mcpServers: { db: STDIO_SERVER } }); + const { config } = exportToCodex(dir); + assert.ok(config.mcpServers); + const servers = config.mcpServers as Record>; + assert.equal(servers.db.command, 'npx'); + }); + + test('Codex: no mcpServers in config when mcp_servers absent', () => { + const dir = makeAgentDir({}); + const { config } = exportToCodex(dir); + assert.equal(config.mcpServers, undefined); + }); + + test('Claude Code: mcpConfig populated when mcp_servers defined', () => { + const dir = makeAgentDir({ mcpServers: { remote: HTTP_SERVER } }); + const result = exportToClaudeCode(dir); + assert.ok(result.mcpConfig); + const servers = (result.mcpConfig as Record).mcpServers as Record>; + assert.equal(servers.remote.url, 'https://mcp.example.com/sse'); + }); + + test('Claude Code: mcpConfig is null when mcp_servers absent', () => { + const dir = makeAgentDir({}); + const result = exportToClaudeCode(dir); + assert.equal(result.mcpConfig, null); + }); + + test('Cursor: mcpConfig populated when mcp_servers defined', () => { + const dir = makeAgentDir({ mcpServers: { db: STDIO_SERVER } }); + const result = exportToCursor(dir); + assert.ok(result.mcpConfig); + const servers = (result.mcpConfig as Record).mcpServers as Record>; + assert.equal(servers.db.command, 'npx'); + }); + + test('Cursor: mcpConfig is null when mcp_servers absent', () => { + const dir = makeAgentDir({}); + const result = exportToCursor(dir); + assert.equal(result.mcpConfig, null); + }); + + test('Gemini: settings.mcpServers populated when mcp_servers defined', () => { + const dir = makeAgentDir({ mcpServers: { db: STDIO_SERVER } }); + const { settings } = exportToGemini(dir); + const servers = settings.mcpServers as Record>; + assert.equal(servers.db.command, 'npx'); + }); + + test('Gemini: settings.mcpServers is empty object when mcp_servers absent', () => { + const dir = makeAgentDir({}); + const { settings } = exportToGemini(dir); + assert.deepEqual(settings.mcpServers, {}); + }); + + test('OpenCode: mcpServers in config when mcp_servers defined', () => { + const dir = makeAgentDir({ mcpServers: { remote: HTTP_SERVER } }); + const { config } = exportToOpenCode(dir); + assert.ok(config.mcpServers); + const servers = config.mcpServers as Record>; + assert.equal(servers.remote.url, 'https://mcp.example.com/sse'); + }); + + test('OpenCode: no mcpServers in config when mcp_servers absent', () => { + const dir = makeAgentDir({}); + const { config } = exportToOpenCode(dir); + assert.equal(config.mcpServers, undefined); + }); +}); + +// --------------------------------------------------------------------------- +// Tier 2 adapter integration tests +// --------------------------------------------------------------------------- + +describe('Tier 2 adapters: MCP servers in markdown output', () => { + test('system-prompt includes MCP section when mcp_servers defined', () => { + const dir = makeAgentDir({ mcpServers: { db: STDIO_SERVER } }); + const result = exportToSystemPrompt(dir); + assert.match(result, /MCP Servers/); + assert.match(result, /my-db|db/); + }); + + test('system-prompt omits MCP section when mcp_servers absent', () => { + const dir = makeAgentDir({}); + const result = exportToSystemPrompt(dir); + assert.ok(!result.includes('MCP Servers')); + }); +}); diff --git a/src/adapters/nanobot.ts b/src/adapters/nanobot.ts index da95de6..81b7a91 100644 --- a/src/adapters/nanobot.ts +++ b/src/adapters/nanobot.ts @@ -3,6 +3,7 @@ import { join, resolve } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { buildMcpServersMarkdown } from './shared.js'; /** * Export a gitagent to Nanobot format. @@ -152,6 +153,12 @@ function buildSystemPrompt(agentDir: string, manifest: ReturnType): Record | null { + if (!mcpServers || Object.keys(mcpServers).length === 0) return null; + + const result: Record = {}; + for (const [name, config] of Object.entries(mcpServers)) { + const entry: Record = {}; + if (config.command) { + entry.command = config.command; + if (config.args) entry.args = config.args; + } + if (config.url) { + entry.url = config.url; + if (config.headers) entry.headers = config.headers; + } + if (config.env) entry.env = config.env; + result[name] = entry; + } + return result; +} + +/** + * Build a markdown documentation section for MCP servers. + * Used by adapters without native MCP config support. + */ +export function buildMcpServersMarkdown( + mcpServers?: AgentManifest['mcp_servers'], +): string { + if (!mcpServers || Object.keys(mcpServers).length === 0) return ''; + + const parts: string[] = ['## MCP Servers\n']; + for (const [name, config] of Object.entries(mcpServers)) { + parts.push(`### ${name}`); + if (config.command) { + const cmd = config.args + ? `${config.command} ${config.args.join(' ')}` + : config.command; + parts.push(`- Type: stdio`); + parts.push(`- Command: \`${cmd}\``); + } + if (config.url) { + parts.push(`- Type: HTTP`); + parts.push(`- URL: ${config.url}`); + } + if (config.env && Object.keys(config.env).length > 0) { + parts.push(`- Environment: ${Object.keys(config.env).join(', ')}`); + } + parts.push(''); + } + return parts.join('\n'); +} diff --git a/src/adapters/system-prompt.ts b/src/adapters/system-prompt.ts index 95f5ede..e7e612f 100644 --- a/src/adapters/system-prompt.ts +++ b/src/adapters/system-prompt.ts @@ -3,6 +3,7 @@ import { join, resolve } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { buildMcpServersMarkdown } from './shared.js'; export function exportToSystemPrompt(dir: string): string { const agentDir = resolve(dir); @@ -59,6 +60,12 @@ export function exportToSystemPrompt(dir: string): string { } } + // MCP servers + const mcpSection = buildMcpServersMarkdown(manifest.mcp_servers); + if (mcpSection) { + parts.push(mcpSection); + } + // Compliance constraints as system instructions if (manifest.compliance) { const c = manifest.compliance; diff --git a/src/commands/export.ts b/src/commands/export.ts index 0fc8639..fdfe4af 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path'; import { error, heading, info, success } from '../utils/format.js'; import { exportToSystemPrompt, - exportToClaudeCode, + exportToClaudeCodeString, exportToOpenAI, exportToCrewAI, exportToOpenClawString, @@ -44,7 +44,7 @@ export const exportCommand = new Command('export') result = exportToSystemPrompt(dir); break; case 'claude-code': - result = exportToClaudeCode(dir); + result = exportToClaudeCodeString(dir); break; case 'openai': result = exportToOpenAI(dir); diff --git a/src/utils/loader.ts b/src/utils/loader.ts index 38f5084..fda3c09 100644 --- a/src/utils/loader.ts +++ b/src/utils/loader.ts @@ -62,6 +62,13 @@ export interface AgentManifest { protocols?: string[]; }; compliance?: ComplianceConfig; + mcp_servers?: Record; + url?: string; + headers?: Record; + }>; tags?: string[]; metadata?: Record; }