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:
@@ -54,6 +54,8 @@ const mcpServerSchema = z.object({
|
||||
name: z.string(),
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).default([]),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
cwd: z.string().optional(),
|
||||
});
|
||||
|
||||
const mcpSchema = z.object({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ToolRegistry, ToolExecutor, allBuiltinTools } from '../tools/index.js';
|
||||
import { GatewayServer } from '../gateway/index.js';
|
||||
import { ChannelRegistry, TelegramAdapter, WebChatAdapter } from '../channels/index.js';
|
||||
import type { InboundMessage, OutboundMessage } from '../channels/index.js';
|
||||
import { McpManager } from '../mcp/index.js';
|
||||
import { resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { mkdirSync, readFileSync, existsSync } from 'fs';
|
||||
@@ -23,6 +24,7 @@ export interface DaemonContext {
|
||||
toolExecutor: ToolExecutor;
|
||||
gateway: GatewayServer;
|
||||
channelRegistry: ChannelRegistry;
|
||||
mcpManager: McpManager;
|
||||
}
|
||||
|
||||
function loadSystemPrompt(): string {
|
||||
@@ -184,6 +186,19 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||
}
|
||||
const toolExecutor = new ToolExecutor(toolRegistry, hookEngine);
|
||||
|
||||
// Initialize MCP manager and start configured servers
|
||||
const mcpManager = new McpManager(toolRegistry);
|
||||
|
||||
if (config.mcp.servers.length > 0) {
|
||||
console.log(`Starting ${config.mcp.servers.length} MCP server(s)...`);
|
||||
await mcpManager.startAll(config.mcp.servers);
|
||||
}
|
||||
|
||||
lifecycle.onShutdown(async () => {
|
||||
await mcpManager.stopAll();
|
||||
console.log('MCP servers stopped');
|
||||
});
|
||||
|
||||
// Initialize model router
|
||||
const modelRouter = createModelRouter(config);
|
||||
|
||||
@@ -280,6 +295,7 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||
toolExecutor,
|
||||
gateway,
|
||||
channelRegistry,
|
||||
mcpManager,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mcpToolName, parseMcpToolName, bridgeMcpTool, bridgeAllTools } from './bridge.js';
|
||||
import type { McpClient } from './client.js';
|
||||
import type { McpToolInfo } from './types.js';
|
||||
|
||||
describe('mcpToolName', () => {
|
||||
it('creates prefixed tool name', () => {
|
||||
expect(mcpToolName('filesystem', 'read_file')).toBe('mcp:filesystem:read_file');
|
||||
});
|
||||
|
||||
it('handles server names with hyphens', () => {
|
||||
expect(mcpToolName('my-server', 'do_thing')).toBe('mcp:my-server:do_thing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMcpToolName', () => {
|
||||
it('parses a valid mcp tool name', () => {
|
||||
expect(parseMcpToolName('mcp:filesystem:read_file')).toEqual({
|
||||
serverName: 'filesystem',
|
||||
toolName: 'read_file',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles tool names containing colons', () => {
|
||||
expect(parseMcpToolName('mcp:server:ns:tool')).toEqual({
|
||||
serverName: 'server',
|
||||
toolName: 'ns:tool',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for non-mcp tool names', () => {
|
||||
expect(parseMcpToolName('shell.exec')).toBeNull();
|
||||
expect(parseMcpToolName('file.read')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed mcp names', () => {
|
||||
expect(parseMcpToolName('mcp:')).toBeNull();
|
||||
expect(parseMcpToolName('mcp:server')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bridgeMcpTool', () => {
|
||||
const toolInfo: McpToolInfo = {
|
||||
name: 'read_file',
|
||||
description: 'Read a file from disk',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
required: ['path'],
|
||||
},
|
||||
};
|
||||
|
||||
function createMockClient(
|
||||
callResult = { content: 'file contents', isError: false },
|
||||
): McpClient {
|
||||
return {
|
||||
serverName: 'filesystem',
|
||||
tools: [toolInfo],
|
||||
status: 'connected',
|
||||
callTool: vi.fn().mockResolvedValue(callResult),
|
||||
} as unknown as McpClient;
|
||||
}
|
||||
|
||||
it('creates a tool with prefixed name', () => {
|
||||
const client = createMockClient();
|
||||
const tool = bridgeMcpTool(client, toolInfo);
|
||||
|
||||
expect(tool.name).toBe('mcp:filesystem:read_file');
|
||||
});
|
||||
|
||||
it('includes server name in description', () => {
|
||||
const client = createMockClient();
|
||||
const tool = bridgeMcpTool(client, toolInfo);
|
||||
|
||||
expect(tool.description).toContain('[MCP:filesystem]');
|
||||
expect(tool.description).toContain('Read a file from disk');
|
||||
});
|
||||
|
||||
it('preserves input schema', () => {
|
||||
const client = createMockClient();
|
||||
const tool = bridgeMcpTool(client, toolInfo);
|
||||
|
||||
expect(tool.inputSchema.type).toBe('object');
|
||||
expect(tool.inputSchema.properties).toEqual({ path: { type: 'string' } });
|
||||
expect(tool.inputSchema.required).toEqual(['path']);
|
||||
});
|
||||
|
||||
it('execute calls the MCP client with correct tool name and args', async () => {
|
||||
const client = createMockClient();
|
||||
const tool = bridgeMcpTool(client, toolInfo);
|
||||
|
||||
const result = await tool.execute({ path: '/tmp/test.txt' });
|
||||
|
||||
expect(client.callTool).toHaveBeenCalledWith('read_file', { path: '/tmp/test.txt' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe('file contents');
|
||||
});
|
||||
|
||||
it('execute returns error result when MCP server reports error', async () => {
|
||||
const client = createMockClient({ content: 'file not found', isError: true });
|
||||
const tool = bridgeMcpTool(client, toolInfo);
|
||||
|
||||
const result = await tool.execute({ path: '/nonexistent' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('file not found');
|
||||
});
|
||||
|
||||
it('execute catches exceptions from MCP client', async () => {
|
||||
const client = {
|
||||
serverName: 'filesystem',
|
||||
tools: [toolInfo],
|
||||
callTool: vi.fn().mockRejectedValue(new Error('connection lost')),
|
||||
} as unknown as McpClient;
|
||||
|
||||
const tool = bridgeMcpTool(client, toolInfo);
|
||||
const result = await tool.execute({ path: '/tmp/test.txt' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('connection lost');
|
||||
});
|
||||
|
||||
it('execute handles null args gracefully', async () => {
|
||||
const client = createMockClient();
|
||||
const tool = bridgeMcpTool(client, toolInfo);
|
||||
|
||||
await tool.execute(null);
|
||||
|
||||
expect(client.callTool).toHaveBeenCalledWith('read_file', {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('bridgeAllTools', () => {
|
||||
it('bridges all tools from a client', () => {
|
||||
const client = {
|
||||
serverName: 'myserver',
|
||||
tools: [
|
||||
{ name: 'tool_a', description: 'A', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
{ name: 'tool_b', description: 'B', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
],
|
||||
callTool: vi.fn(),
|
||||
} as unknown as McpClient;
|
||||
|
||||
const tools = bridgeAllTools(client);
|
||||
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools[0].name).toBe('mcp:myserver:tool_a');
|
||||
expect(tools[1].name).toBe('mcp:myserver:tool_b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* MCP Bridge — converts MCP tools into Flynn's Tool interface.
|
||||
*
|
||||
* Each MCP tool is prefixed with "mcp:<serverName>:" to avoid
|
||||
* namespace collisions with builtin tools. When the tool is executed,
|
||||
* the bridge routes the call back to the originating McpClient.
|
||||
*/
|
||||
|
||||
import type { Tool, ToolResult } from '../tools/types.js';
|
||||
import type { McpClient } from './client.js';
|
||||
import type { McpToolInfo } from './types.js';
|
||||
|
||||
/**
|
||||
* Create the prefixed tool name used in Flynn's tool registry.
|
||||
*
|
||||
* Example: MCP server "filesystem" with tool "read_file" -> "mcp:filesystem:read_file"
|
||||
*/
|
||||
export function mcpToolName(serverName: string, toolName: string): string {
|
||||
return `mcp:${serverName}:${toolName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a prefixed tool name back into server + tool components.
|
||||
* Returns null if the name doesn't match the mcp: prefix pattern.
|
||||
*/
|
||||
export function parseMcpToolName(prefixedName: string): { serverName: string; toolName: string } | null {
|
||||
const match = prefixedName.match(/^mcp:([^:]+):(.+)$/);
|
||||
if (!match) return null;
|
||||
return { serverName: match[1], toolName: match[2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single MCP tool into a Flynn Tool.
|
||||
*
|
||||
* The returned Tool's execute() calls back into the McpClient
|
||||
* to invoke the tool on the remote MCP server.
|
||||
*/
|
||||
export function bridgeMcpTool(client: McpClient, toolInfo: McpToolInfo): Tool {
|
||||
const prefixedName = mcpToolName(client.serverName, toolInfo.name);
|
||||
|
||||
return {
|
||||
name: prefixedName,
|
||||
description: `[MCP:${client.serverName}] ${toolInfo.description}`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: toolInfo.inputSchema.properties ?? {},
|
||||
required: toolInfo.inputSchema.required,
|
||||
},
|
||||
|
||||
async execute(args: unknown): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await client.callTool(
|
||||
toolInfo.name,
|
||||
(args ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
|
||||
return {
|
||||
success: !result.isError,
|
||||
output: result.content,
|
||||
error: result.isError ? result.content : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge all tools from an MCP client into Flynn Tool objects.
|
||||
*/
|
||||
export function bridgeAllTools(client: McpClient): Tool[] {
|
||||
return client.tools.map((t) => bridgeMcpTool(client, t));
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* MCP Client — wraps the MCP SDK to manage a single server connection.
|
||||
*
|
||||
* Handles stdio transport lifecycle, tool discovery, and tool invocation.
|
||||
* Each McpClient instance maps to one MCP server process.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
|
||||
import type { McpServerConfig, McpToolInfo, McpServerStatus } from './types.js';
|
||||
|
||||
export class McpClient {
|
||||
readonly serverName: string;
|
||||
|
||||
private client: Client | null = null;
|
||||
private transport: StdioClientTransport | null = null;
|
||||
private _status: McpServerStatus = 'disconnected';
|
||||
private _tools: McpToolInfo[] = [];
|
||||
private _error?: string;
|
||||
private config: McpServerConfig;
|
||||
|
||||
get status(): McpServerStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
get tools(): McpToolInfo[] {
|
||||
return this._tools;
|
||||
}
|
||||
|
||||
get error(): string | undefined {
|
||||
return this._error;
|
||||
}
|
||||
|
||||
constructor(config: McpServerConfig) {
|
||||
this.config = config;
|
||||
this.serverName = config.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the MCP server: spawn process, initialize protocol, discover tools.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this._status === 'connected' || this._status === 'connecting') {
|
||||
return;
|
||||
}
|
||||
|
||||
this._status = 'connecting';
|
||||
this._error = undefined;
|
||||
|
||||
try {
|
||||
// Create stdio transport — spawns the server process
|
||||
this.transport = new StdioClientTransport({
|
||||
command: this.config.command,
|
||||
args: this.config.args,
|
||||
env: this.config.env,
|
||||
cwd: this.config.cwd,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
// Create MCP client
|
||||
this.client = new Client(
|
||||
{ name: 'flynn', version: '0.1.0' },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
|
||||
// Connect — performs MCP initialize handshake
|
||||
await this.client.connect(this.transport);
|
||||
|
||||
// Discover tools
|
||||
await this.refreshTools();
|
||||
|
||||
this._status = 'connected';
|
||||
console.log(`MCP server '${this.serverName}' connected (${this._tools.length} tools)`);
|
||||
} catch (error) {
|
||||
this._status = 'error';
|
||||
this._error = error instanceof Error ? error.message : String(error);
|
||||
console.error(`MCP server '${this.serverName}' failed to connect:`, this._error);
|
||||
|
||||
// Clean up partial state
|
||||
await this.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the MCP server and kill the process.
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
await this.cleanup();
|
||||
this._status = 'disconnected';
|
||||
this._tools = [];
|
||||
this._error = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-discover tools from the server (e.g. after a tools/list_changed notification).
|
||||
*/
|
||||
async refreshTools(): Promise<McpToolInfo[]> {
|
||||
if (!this.client) {
|
||||
throw new Error(`MCP server '${this.serverName}' is not connected`);
|
||||
}
|
||||
|
||||
const result = await this.client.listTools();
|
||||
|
||||
this._tools = result.tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: (t.inputSchema.properties ?? {}) as Record<string, unknown>,
|
||||
required: t.inputSchema.required,
|
||||
},
|
||||
}));
|
||||
|
||||
return this._tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a tool on this MCP server by its original (unprefixed) name.
|
||||
*
|
||||
* Returns the text content of the result, or throws on error.
|
||||
*/
|
||||
async callTool(toolName: string, args: Record<string, unknown>): Promise<{ content: string; isError: boolean }> {
|
||||
if (!this.client) {
|
||||
throw new Error(`MCP server '${this.serverName}' is not connected`);
|
||||
}
|
||||
|
||||
const result = await this.client.callTool({ name: toolName, arguments: args });
|
||||
|
||||
// Extract text content from the result
|
||||
const textParts: string[] = [];
|
||||
let isError = false;
|
||||
|
||||
if ('isError' in result && result.isError) {
|
||||
isError = true;
|
||||
}
|
||||
|
||||
if ('content' in result && Array.isArray(result.content)) {
|
||||
for (const block of result.content) {
|
||||
if ('text' in block && typeof block.text === 'string') {
|
||||
textParts.push(block.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if toolResult format is returned instead
|
||||
if ('toolResult' in result && textParts.length === 0) {
|
||||
textParts.push(String(result.toolResult));
|
||||
}
|
||||
|
||||
return {
|
||||
content: textParts.join('\n') || '(no output)',
|
||||
isError,
|
||||
};
|
||||
}
|
||||
|
||||
/** Clean up transport and client without changing status. */
|
||||
private async cleanup(): Promise<void> {
|
||||
try {
|
||||
if (this.transport) {
|
||||
await this.transport.close();
|
||||
}
|
||||
} catch {
|
||||
// Ignore close errors
|
||||
}
|
||||
this.client = null;
|
||||
this.transport = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type { McpServerConfig, McpToolInfo, McpServerState, McpServerStatus } from './types.js';
|
||||
export { McpClient } from './client.js';
|
||||
export { McpManager } from './manager.js';
|
||||
export { bridgeMcpTool, bridgeAllTools, mcpToolName, parseMcpToolName } from './bridge.js';
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* MCP Manager — lifecycle management for MCP server connections.
|
||||
*
|
||||
* Reads server configs, creates McpClients, connects them, bridges
|
||||
* their tools into the Flynn ToolRegistry, and handles shutdown.
|
||||
*/
|
||||
|
||||
import type { ToolRegistry } from '../tools/registry.js';
|
||||
import type { Tool } from '../tools/types.js';
|
||||
import type { McpServerConfig, McpServerState } from './types.js';
|
||||
import { McpClient } from './client.js';
|
||||
import { bridgeAllTools } from './bridge.js';
|
||||
|
||||
export class McpManager {
|
||||
private clients: Map<string, McpClient> = new Map();
|
||||
private registeredToolNames: Map<string, string[]> = new Map(); // serverName -> prefixed tool names
|
||||
private toolRegistry: ToolRegistry;
|
||||
|
||||
constructor(toolRegistry: ToolRegistry) {
|
||||
this.toolRegistry = toolRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start all MCP servers from config and register their tools.
|
||||
*
|
||||
* Errors on individual servers are caught and logged — one bad server
|
||||
* doesn't prevent the others from starting.
|
||||
*/
|
||||
async startAll(configs: McpServerConfig[]): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
configs.map((config) => this.startServer(config)),
|
||||
);
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
if (result.status === 'rejected') {
|
||||
console.error(
|
||||
`MCP server '${configs[i].name}' failed to start:`,
|
||||
result.reason instanceof Error ? result.reason.message : result.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a single MCP server, connect, discover tools, and register them.
|
||||
*/
|
||||
async startServer(config: McpServerConfig): Promise<void> {
|
||||
// Stop existing server with same name if any
|
||||
if (this.clients.has(config.name)) {
|
||||
await this.stopServer(config.name);
|
||||
}
|
||||
|
||||
const client = new McpClient(config);
|
||||
this.clients.set(config.name, client);
|
||||
this.storedConfigs.set(config.name, config);
|
||||
|
||||
// Connect and discover tools
|
||||
await client.connect();
|
||||
|
||||
// Bridge discovered tools into Flynn's registry
|
||||
const tools = bridgeAllTools(client);
|
||||
const toolNames: string[] = [];
|
||||
|
||||
for (const tool of tools) {
|
||||
try {
|
||||
this.toolRegistry.register(tool);
|
||||
toolNames.push(tool.name);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Could not register MCP tool '${tool.name}':`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.registeredToolNames.set(config.name, toolNames);
|
||||
|
||||
console.log(
|
||||
`MCP server '${config.name}': ${toolNames.length} tools registered`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a single MCP server and unregister its tools.
|
||||
*/
|
||||
async stopServer(name: string): Promise<void> {
|
||||
const client = this.clients.get(name);
|
||||
if (!client) return;
|
||||
|
||||
// Unregister tools from the registry
|
||||
const toolNames = this.registeredToolNames.get(name) ?? [];
|
||||
for (const toolName of toolNames) {
|
||||
this.toolRegistry.unregister(toolName);
|
||||
}
|
||||
this.registeredToolNames.delete(name);
|
||||
|
||||
// Disconnect client
|
||||
await client.disconnect();
|
||||
this.clients.delete(name);
|
||||
this.storedConfigs.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all MCP servers and clean up.
|
||||
*/
|
||||
async stopAll(): Promise<void> {
|
||||
const names = Array.from(this.clients.keys());
|
||||
await Promise.allSettled(names.map((name) => this.stopServer(name)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart a single MCP server (stop + start with same config).
|
||||
*/
|
||||
async restartServer(name: string): Promise<void> {
|
||||
const client = this.clients.get(name);
|
||||
if (!client) {
|
||||
throw new Error(`MCP server '${name}' not found`);
|
||||
}
|
||||
|
||||
// We need the original config to restart
|
||||
const state = this.getServerState(name);
|
||||
if (!state) {
|
||||
throw new Error(`MCP server '${name}' state not found`);
|
||||
}
|
||||
|
||||
await this.stopServer(name);
|
||||
await this.startServer(state.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the state of a specific server.
|
||||
*/
|
||||
getServerState(name: string): McpServerState | undefined {
|
||||
const client = this.clients.get(name);
|
||||
if (!client) return undefined;
|
||||
|
||||
const config = this.storedConfigs.get(name) ?? { name, command: '', args: [] };
|
||||
|
||||
return {
|
||||
config,
|
||||
status: client.status,
|
||||
tools: client.tools,
|
||||
error: client.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all MCP server states.
|
||||
*/
|
||||
listServers(): McpServerState[] {
|
||||
return Array.from(this.clients.entries()).map(([name, client]) => ({
|
||||
config: this.storedConfigs.get(name) ?? { name, command: '', args: [] },
|
||||
status: client.status,
|
||||
tools: client.tools,
|
||||
error: client.error,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered MCP tools (as Flynn Tool objects).
|
||||
*/
|
||||
getRegisteredTools(): Tool[] {
|
||||
const tools: Tool[] = [];
|
||||
for (const toolNames of this.registeredToolNames.values()) {
|
||||
for (const name of toolNames) {
|
||||
const tool = this.toolRegistry.get(name);
|
||||
if (tool) tools.push(tool);
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
/** Stored configs for restart support and state reporting. */
|
||||
private storedConfigs: Map<string, McpServerConfig> = new Map();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* MCP (Model Context Protocol) type definitions.
|
||||
*
|
||||
* Internal types for Flynn's MCP client integration.
|
||||
* These wrap the SDK types into simpler forms used by the bridge and manager.
|
||||
*/
|
||||
|
||||
/** Configuration for a single MCP server from flynn.yaml. */
|
||||
export interface McpServerConfig {
|
||||
/** Unique name for this server (used as tool name prefix). */
|
||||
name: string;
|
||||
/** Executable command to spawn the server process. */
|
||||
command: string;
|
||||
/** Arguments passed to the command. */
|
||||
args: string[];
|
||||
/** Optional environment variables for the server process. */
|
||||
env?: Record<string, string>;
|
||||
/** Optional working directory for the server process. */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/** Connection status of an MCP server. */
|
||||
export type McpServerStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
/** Information about a tool discovered from an MCP server. */
|
||||
export interface McpToolInfo {
|
||||
/** Tool name as reported by the MCP server. */
|
||||
name: string;
|
||||
/** Human-readable description. */
|
||||
description: string;
|
||||
/** JSON Schema for the tool's input parameters. */
|
||||
inputSchema: {
|
||||
type: 'object';
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Runtime state of a connected MCP server. */
|
||||
export interface McpServerState {
|
||||
/** Config this server was started from. */
|
||||
config: McpServerConfig;
|
||||
/** Current connection status. */
|
||||
status: McpServerStatus;
|
||||
/** Tools discovered from this server. */
|
||||
tools: McpToolInfo[];
|
||||
/** Error message if status is 'error'. */
|
||||
error?: string;
|
||||
}
|
||||
@@ -62,6 +62,22 @@ describe('ToolRegistry', () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
it('unregisters a tool by name', () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(echoTool);
|
||||
registry.register(greetTool);
|
||||
|
||||
expect(registry.unregister('test.echo')).toBe(true);
|
||||
expect(registry.get('test.echo')).toBeUndefined();
|
||||
expect(registry.list()).toHaveLength(1);
|
||||
expect(registry.list()[0].name).toBe('test.greet');
|
||||
});
|
||||
|
||||
it('returns false when unregistering a nonexistent tool', () => {
|
||||
const registry = new ToolRegistry();
|
||||
expect(registry.unregister('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('serializes to OpenAI format', () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(echoTool);
|
||||
|
||||
@@ -25,6 +25,10 @@ export class ToolRegistry {
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
|
||||
unregister(name: string): boolean {
|
||||
return this.tools.delete(name);
|
||||
}
|
||||
|
||||
get(name: string): Tool | undefined {
|
||||
return this.tools.get(name);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user