feat: add MCP integration for external tool servers
Implement Model Context Protocol (MCP) support so Flynn can spawn MCP server processes, discover their tools, and make them available to the agent alongside builtin tools. - McpClient: wraps @modelcontextprotocol/sdk with StdioClientTransport for process lifecycle, tool discovery (listTools), and invocation (callTool) - McpManager: lifecycle management for multiple MCP servers with startAll/stopAll/restart, tool bridging into ToolRegistry - Bridge: converts MCP tools to Flynn Tool interface with mcp:<server>:<tool> namespacing to avoid collisions with builtin tools - Config: add env and cwd fields to mcp server schema - ToolRegistry: add unregister() method for MCP server cleanup - Daemon: wire McpManager into startup and shutdown lifecycle - Tests: 28 new tests (bridge, manager, registry unregister)
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { McpManager } from './manager.js';
|
||||
import { ToolRegistry } from '../tools/registry.js';
|
||||
|
||||
// Mock McpClient to avoid spawning real processes
|
||||
vi.mock('./client.js', () => ({
|
||||
McpClient: vi.fn().mockImplementation((config) => ({
|
||||
serverName: config.name,
|
||||
status: 'disconnected',
|
||||
tools: [],
|
||||
error: undefined,
|
||||
connect: vi.fn(async function (this: { status: string; tools: { name: string; description: string; inputSchema: { type: string; properties: Record<string, unknown> } }[] }) {
|
||||
this.status = 'connected';
|
||||
// Simulate tool discovery
|
||||
this.tools = [
|
||||
{
|
||||
name: 'do_thing',
|
||||
description: 'Does a thing',
|
||||
inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
|
||||
},
|
||||
];
|
||||
}),
|
||||
disconnect: vi.fn(async function (this: { status: string; tools: unknown[] }) {
|
||||
this.status = 'disconnected';
|
||||
this.tools = [];
|
||||
}),
|
||||
callTool: vi.fn().mockResolvedValue({ content: 'result', isError: false }),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('McpManager', () => {
|
||||
let registry: ToolRegistry;
|
||||
let manager: McpManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
registry = new ToolRegistry();
|
||||
manager = new McpManager(registry);
|
||||
});
|
||||
|
||||
it('starts a server and registers its tools', async () => {
|
||||
await manager.startServer({
|
||||
name: 'test-server',
|
||||
command: 'test-cmd',
|
||||
args: [],
|
||||
});
|
||||
|
||||
// Tool should be registered with mcp: prefix
|
||||
const tool = registry.get('mcp:test-server:do_thing');
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool!.name).toBe('mcp:test-server:do_thing');
|
||||
expect(tool!.description).toContain('[MCP:test-server]');
|
||||
});
|
||||
|
||||
it('startAll handles multiple servers', async () => {
|
||||
await manager.startAll([
|
||||
{ name: 'server-a', command: 'cmd-a', args: [] },
|
||||
{ name: 'server-b', command: 'cmd-b', args: [] },
|
||||
]);
|
||||
|
||||
expect(registry.get('mcp:server-a:do_thing')).toBeDefined();
|
||||
expect(registry.get('mcp:server-b:do_thing')).toBeDefined();
|
||||
});
|
||||
|
||||
it('stopServer unregisters tools and disconnects', async () => {
|
||||
await manager.startServer({
|
||||
name: 'test-server',
|
||||
command: 'test-cmd',
|
||||
args: [],
|
||||
});
|
||||
|
||||
expect(registry.get('mcp:test-server:do_thing')).toBeDefined();
|
||||
|
||||
await manager.stopServer('test-server');
|
||||
|
||||
expect(registry.get('mcp:test-server:do_thing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stopAll stops all servers', async () => {
|
||||
await manager.startAll([
|
||||
{ name: 'server-a', command: 'cmd-a', args: [] },
|
||||
{ name: 'server-b', command: 'cmd-b', args: [] },
|
||||
]);
|
||||
|
||||
await manager.stopAll();
|
||||
|
||||
expect(registry.get('mcp:server-a:do_thing')).toBeUndefined();
|
||||
expect(registry.get('mcp:server-b:do_thing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stopServer is safe when server does not exist', async () => {
|
||||
await expect(manager.stopServer('nonexistent')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('startServer replaces existing server with same name', async () => {
|
||||
await manager.startServer({
|
||||
name: 'test-server',
|
||||
command: 'cmd-v1',
|
||||
args: [],
|
||||
});
|
||||
|
||||
// Start again with same name — should replace
|
||||
await manager.startServer({
|
||||
name: 'test-server',
|
||||
command: 'cmd-v2',
|
||||
args: [],
|
||||
});
|
||||
|
||||
// Tool should still be registered (re-registered)
|
||||
expect(registry.get('mcp:test-server:do_thing')).toBeDefined();
|
||||
});
|
||||
|
||||
it('listServers returns state for all servers', async () => {
|
||||
await manager.startAll([
|
||||
{ name: 'server-a', command: 'cmd-a', args: [] },
|
||||
{ name: 'server-b', command: 'cmd-b', args: [] },
|
||||
]);
|
||||
|
||||
const servers = manager.listServers();
|
||||
expect(servers).toHaveLength(2);
|
||||
expect(servers[0].config.name).toBe('server-a');
|
||||
expect(servers[1].config.name).toBe('server-b');
|
||||
});
|
||||
|
||||
it('getServerState returns undefined for unknown server', () => {
|
||||
expect(manager.getServerState('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getServerState returns state for known server', async () => {
|
||||
await manager.startServer({
|
||||
name: 'test-server',
|
||||
command: 'test-cmd',
|
||||
args: ['--flag'],
|
||||
});
|
||||
|
||||
const state = manager.getServerState('test-server');
|
||||
expect(state).toBeDefined();
|
||||
expect(state!.config.name).toBe('test-server');
|
||||
expect(state!.config.args).toEqual(['--flag']);
|
||||
expect(state!.tools).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('getRegisteredTools returns all MCP tools', async () => {
|
||||
await manager.startAll([
|
||||
{ name: 'server-a', command: 'cmd-a', args: [] },
|
||||
{ name: 'server-b', command: 'cmd-b', args: [] },
|
||||
]);
|
||||
|
||||
const tools = manager.getRegisteredTools();
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools.map((t) => t.name)).toContain('mcp:server-a:do_thing');
|
||||
expect(tools.map((t) => t.name)).toContain('mcp:server-b:do_thing');
|
||||
});
|
||||
|
||||
it('restartServer stops and restarts with same config', async () => {
|
||||
await manager.startServer({
|
||||
name: 'test-server',
|
||||
command: 'test-cmd',
|
||||
args: ['--arg1'],
|
||||
});
|
||||
|
||||
await manager.restartServer('test-server');
|
||||
|
||||
const state = manager.getServerState('test-server');
|
||||
expect(state).toBeDefined();
|
||||
expect(state!.config.args).toEqual(['--arg1']);
|
||||
expect(registry.get('mcp:test-server:do_thing')).toBeDefined();
|
||||
});
|
||||
|
||||
it('restartServer throws for unknown server', async () => {
|
||||
await expect(manager.restartServer('nonexistent')).rejects.toThrow(
|
||||
"MCP server 'nonexistent' not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user