diff --git a/packages/cli/src/ui/components/NewAgentsNotification.test.tsx b/packages/cli/src/ui/components/NewAgentsNotification.test.tsx index b184eebffb8..d234b70c4d5 100644 --- a/packages/cli/src/ui/components/NewAgentsNotification.test.tsx +++ b/packages/cli/src/ui/components/NewAgentsNotification.test.tsx @@ -22,6 +22,25 @@ describe('NewAgentsNotification', () => { { name: 'Agent B', description: 'Description B', + kind: 'local' as const, + inputConfig: { inputSchema: {} }, + promptConfig: {}, + modelConfig: {}, + runConfig: {}, + mcpServers: { + github: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + }, + postgres: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-postgres'], + }, + }, + }, + { + name: 'Agent C', + description: 'Description C', kind: 'remote' as const, agentCardUrl: '', inputConfig: { inputSchema: {} }, diff --git a/packages/cli/src/ui/components/NewAgentsNotification.tsx b/packages/cli/src/ui/components/NewAgentsNotification.tsx index e7aa8be5100..53287ec4336 100644 --- a/packages/cli/src/ui/components/NewAgentsNotification.tsx +++ b/packages/cli/src/ui/components/NewAgentsNotification.tsx @@ -80,16 +80,35 @@ export const NewAgentsNotification = ({ borderStyle="single" padding={1} > - {displayAgents.map((agent) => ( - - - - - {agent.name}:{' '} - + {displayAgents.map((agent) => { + const mcpServers = + agent.kind === 'local' ? agent.mcpServers : undefined; + const hasMcpServers = + mcpServers && Object.keys(mcpServers).length > 0; + return ( + + + + + - {agent.name}:{' '} + + + + {' '} + {agent.description} + + + {hasMcpServers && ( + + + (Includes MCP servers:{' '} + {Object.keys(mcpServers).join(', ')}) + + + )} - {agent.description} - - ))} + ); + })} {remaining > 0 && ( ... and {remaining} more. diff --git a/packages/cli/src/ui/components/__snapshots__/NewAgentsNotification.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/NewAgentsNotification.test.tsx.snap index bac1f7af366..74dcb8a9148 100644 --- a/packages/cli/src/ui/components/__snapshots__/NewAgentsNotification.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/NewAgentsNotification.test.tsx.snap @@ -10,6 +10,8 @@ exports[`NewAgentsNotification > renders agent list 1`] = ` │ │ │ │ │ │ - Agent A: Description A │ │ │ │ - Agent B: Description B │ │ + │ │ (Includes MCP servers: github, postgres) │ │ + │ │ - Agent C: Description C │ │ │ │ │ │ │ └────────────────────────────────────────────────────────────────────────────────────────────┘ │ │ │ diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts index a526382553f..ea7ef0b2c38 100644 --- a/packages/core/src/agents/agentLoader.test.ts +++ b/packages/core/src/agents/agentLoader.test.ts @@ -81,6 +81,33 @@ System prompt content.`); }); }); + it('should parse frontmatter with mcp_servers', async () => { + const filePath = await writeAgentMarkdown(`--- +name: mcp-agent +description: An agent with MCP servers +mcp_servers: + test-server: + command: node + args: [server.js] + include_tools: [tool1, tool2] +--- +System prompt content.`); + + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: 'mcp-agent', + description: 'An agent with MCP servers', + mcp_servers: { + 'test-server': { + command: 'node', + args: ['server.js'], + include_tools: ['tool1', 'tool2'], + }, + }, + }); + }); + it('should throw AgentLoadError if frontmatter is missing', async () => { const filePath = await writeAgentMarkdown(`Just some markdown content.`); await expect(parseAgentMarkdown(filePath)).rejects.toThrow( @@ -274,6 +301,33 @@ Body`); expect(result.modelConfig.model).toBe(GEMINI_MODEL_ALIAS_PRO); }); + it('should convert mcp_servers in local agent', () => { + const markdown = { + kind: 'local' as const, + name: 'mcp-agent', + description: 'An agent with MCP servers', + mcp_servers: { + 'test-server': { + command: 'node', + args: ['server.js'], + include_tools: ['tool1'], + }, + }, + system_prompt: 'prompt', + }; + + const result = markdownToAgentDefinition( + markdown, + ) as LocalAgentDefinition; + expect(result.kind).toBe('local'); + expect(result.mcpServers).toBeDefined(); + expect(result.mcpServers!['test-server']).toMatchObject({ + command: 'node', + args: ['server.js'], + includeTools: ['tool1'], + }); + }); + it('should pass through unknown model names (e.g. auto)', () => { const markdown = { kind: 'local' as const, diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index c867a1c9a3a..2cb7b3c439f 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -16,6 +16,7 @@ import { DEFAULT_MAX_TIME_MINUTES, } from './types.js'; import type { A2AAuthConfig } from './auth-provider/types.js'; +import { MCPServerConfig } from '../config/config.js'; import { isValidToolName } from '../tools/tool-names.js'; import { FRONTMATTER_REGEX } from '../skills/skillLoader.js'; import { getErrorMessage } from '../utils/errors.js'; @@ -28,11 +29,29 @@ interface FrontmatterBaseAgentDefinition { display_name?: string; } +interface FrontmatterMCPServerConfig { + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + url?: string; + http_url?: string; + headers?: Record; + tcp?: string; + type?: 'sse' | 'http'; + timeout?: number; + trust?: boolean; + description?: string; + include_tools?: string[]; + exclude_tools?: string[]; +} + interface FrontmatterLocalAgentDefinition extends FrontmatterBaseAgentDefinition { kind: 'local'; description: string; tools?: string[]; + mcp_servers?: Record; system_prompt: string; model?: string; temperature?: number; @@ -100,6 +119,23 @@ const nameSchema = z .string() .regex(/^[a-z0-9-_]+$/, 'Name must be a valid slug'); +const mcpServerSchema = z.object({ + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string()).optional(), + cwd: z.string().optional(), + url: z.string().optional(), + http_url: z.string().optional(), + headers: z.record(z.string()).optional(), + tcp: z.string().optional(), + type: z.enum(['sse', 'http']).optional(), + timeout: z.number().optional(), + trust: z.boolean().optional(), + description: z.string().optional(), + include_tools: z.array(z.string()).optional(), + exclude_tools: z.array(z.string()).optional(), +}); + const localAgentSchema = z .object({ kind: z.literal('local').optional().default('local'), @@ -115,6 +151,7 @@ const localAgentSchema = z }), ) .optional(), + mcp_servers: z.record(mcpServerSchema).optional(), model: z.string().optional(), temperature: z.number().optional(), max_turns: z.number().int().positive().optional(), @@ -495,6 +532,28 @@ export function markdownToAgentDefinition( // If a model is specified, use it. Otherwise, inherit const modelName = markdown.model || 'inherit'; + const mcpServers: Record = {}; + if (markdown.kind === 'local' && markdown.mcp_servers) { + for (const [name, config] of Object.entries(markdown.mcp_servers)) { + mcpServers[name] = new MCPServerConfig( + config.command, + config.args, + config.env, + config.cwd, + config.url, + config.http_url, + config.headers, + config.tcp, + config.type, + config.timeout, + config.trust, + config.description, + config.include_tools, + config.exclude_tools, + ); + } + } + return { kind: 'local', name: markdown.name, @@ -520,6 +579,7 @@ export function markdownToAgentDefinition( tools: markdown.tools, } : undefined, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, inputConfig, metadata, }; diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 23cf912055a..3a815aa0128 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -570,6 +570,19 @@ export class AgentRegistry { }, }; + if (overrides.tools) { + merged.toolConfig = { + tools: overrides.tools, + }; + } + + if (overrides.mcpServers) { + merged.mcpServers = { + ...definition.mcpServers, + ...overrides.mcpServers, + }; + } + return merged; } diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index b6d0d6212bc..41db981a7b3 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -14,6 +14,7 @@ import { type z } from 'zod'; import type { ModelConfig } from '../services/modelConfigService.js'; import type { AnySchema } from 'ajv'; import type { A2AAuthConfig } from './auth-provider/types.js'; +import type { MCPServerConfig } from '../config/config.js'; /** * Describes the possible termination modes for an agent. @@ -130,6 +131,11 @@ export interface LocalAgentDefinition< // Optional configs toolConfig?: ToolConfig; + /** + * Optional inline MCP servers for this agent. + */ + mcpServers?: Record; + /** * An optional function to process the raw output from the agent's final tool * call into a string format. diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index fe3f31edfc3..2e9102250c1 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -240,6 +240,8 @@ export interface AgentOverride { modelConfig?: ModelConfig; runConfig?: AgentRunConfig; enabled?: boolean; + tools?: string[]; + mcpServers?: Record; } export interface AgentSettings { diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 03dddf4b8f1..c94cef4a921 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -435,6 +435,25 @@ export abstract class DeclarativeTool< readonly extensionId?: string, ) {} + clone(messageBus?: MessageBus): this { + // Note: we cannot use structuredClone() here because it does not preserve + // prototype chains or handle non-serializable properties (like functions). + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const cloned = Object.assign( + // eslint-disable-next-line no-restricted-syntax + Object.create(Object.getPrototypeOf(this)), + this, + ) as this; + if (messageBus) { + Object.defineProperty(cloned, 'messageBus', { + value: messageBus, + writable: false, + configurable: true, + }); + } + return cloned; + } + get isReadOnly(): boolean { return READ_ONLY_KINDS.includes(this.kind); }