433 lines
13 KiB
TypeScript
433 lines
13 KiB
TypeScript
/**
|
|
* MCPHostManager - Multi-server MCP connection aggregator
|
|
*
|
|
* Implements the "Host" role in MCP architecture, coordinating multiple server
|
|
* connections and providing a unified interface for tool operations.
|
|
*
|
|
* **Architecture & Relationships:**
|
|
* - **MCPHostManager** (this class): Host-level coordination layer
|
|
* - Coordinates multiple Client instances (MCPServerConnection)
|
|
* - Aggregates tools from all connected servers
|
|
* - Routes tool calls to the appropriate server
|
|
* - Manages lifecycle of all connections
|
|
*
|
|
* - **MCPServerConnection**: Individual server connection wrapper
|
|
* - **agenticStore**: Uses MCPHostManager for tool execution in agentic loops
|
|
*
|
|
* **Key Responsibilities:**
|
|
* - Parallel server initialization and shutdown
|
|
* - Tool name conflict detection and resolution
|
|
* - OpenAI-compatible tool definition generation
|
|
* - Automatic tool-to-server routing
|
|
*/
|
|
|
|
import { MCPServerConnection, type ToolExecutionResult } from './server-connection';
|
|
import type {
|
|
MCPClientConfig,
|
|
MCPToolCall,
|
|
ClientCapabilities,
|
|
Implementation
|
|
} from '$lib/types/mcp';
|
|
import { MCPError } from '$lib/types/mcp';
|
|
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
|
|
export interface MCPHostManagerConfig {
|
|
/** Server configurations keyed by server name */
|
|
servers: MCPClientConfig['servers'];
|
|
/** Client info to advertise to all servers */
|
|
clientInfo?: Implementation;
|
|
/** Default capabilities to advertise */
|
|
capabilities?: ClientCapabilities;
|
|
}
|
|
|
|
export interface OpenAIToolDefinition {
|
|
type: 'function';
|
|
function: {
|
|
name: string;
|
|
description?: string;
|
|
parameters: Record<string, unknown>;
|
|
};
|
|
}
|
|
|
|
export interface ServerStatus {
|
|
name: string;
|
|
isConnected: boolean;
|
|
toolCount: number;
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Manages multiple MCP server connections and provides unified tool access.
|
|
*/
|
|
export class MCPHostManager {
|
|
private connections = new Map<string, MCPServerConnection>();
|
|
private toolsIndex = new Map<string, string>(); // toolName → serverName
|
|
private _isInitialized = false;
|
|
private _initializationError: Error | null = null;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Lifecycle
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
async initialize(config: MCPHostManagerConfig): Promise<void> {
|
|
console.log('[MCPHost] Starting initialization...');
|
|
|
|
// Clean up previous connections
|
|
await this.shutdown();
|
|
|
|
const serverEntries = Object.entries(config.servers);
|
|
if (serverEntries.length === 0) {
|
|
console.log('[MCPHost] No servers configured');
|
|
this._isInitialized = true;
|
|
return;
|
|
}
|
|
|
|
// Connect to each server in parallel
|
|
const connectionPromises = serverEntries.map(async ([name, serverConfig]) => {
|
|
try {
|
|
const connection = new MCPServerConnection({
|
|
name,
|
|
server: serverConfig,
|
|
clientInfo: config.clientInfo,
|
|
capabilities: config.capabilities
|
|
});
|
|
|
|
await connection.connect();
|
|
return { name, connection, success: true, error: null };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
console.error(`[MCPHost] Failed to connect to ${name}:`, errorMessage);
|
|
return { name, connection: null, success: false, error: errorMessage };
|
|
}
|
|
});
|
|
|
|
const results = await Promise.all(connectionPromises);
|
|
|
|
// Store successful connections
|
|
for (const result of results) {
|
|
if (result.success && result.connection) {
|
|
this.connections.set(result.name, result.connection);
|
|
}
|
|
}
|
|
|
|
// Build tools index
|
|
this.rebuildToolsIndex();
|
|
|
|
const successCount = this.connections.size;
|
|
const totalCount = serverEntries.length;
|
|
|
|
if (successCount === 0 && totalCount > 0) {
|
|
this._initializationError = new Error('All MCP server connections failed');
|
|
throw this._initializationError;
|
|
}
|
|
|
|
this._isInitialized = true;
|
|
this._initializationError = null;
|
|
|
|
console.log(
|
|
`[MCPHost] Initialization complete: ${successCount}/${totalCount} servers connected, ` +
|
|
`${this.toolsIndex.size} tools available`
|
|
);
|
|
}
|
|
|
|
async shutdown(): Promise<void> {
|
|
if (this.connections.size === 0) {
|
|
return;
|
|
}
|
|
|
|
console.log(`[MCPHost] Shutting down ${this.connections.size} connections...`);
|
|
|
|
const shutdownPromises = Array.from(this.connections.values()).map((conn) =>
|
|
conn.disconnect().catch((error) => {
|
|
console.warn(`[MCPHost] Error disconnecting ${conn.serverName}:`, error);
|
|
})
|
|
);
|
|
|
|
await Promise.all(shutdownPromises);
|
|
|
|
this.connections.clear();
|
|
this.toolsIndex.clear();
|
|
this._isInitialized = false;
|
|
|
|
console.log('[MCPHost] Shutdown complete');
|
|
}
|
|
|
|
private rebuildToolsIndex(): void {
|
|
this.toolsIndex.clear();
|
|
|
|
for (const [serverName, connection] of this.connections) {
|
|
for (const tool of connection.tools) {
|
|
// Check for name conflicts
|
|
if (this.toolsIndex.has(tool.name)) {
|
|
console.warn(
|
|
`[MCPHost] Tool name conflict: "${tool.name}" exists in ` +
|
|
`"${this.toolsIndex.get(tool.name)}" and "${serverName}". ` +
|
|
`Using tool from "${serverName}".`
|
|
);
|
|
}
|
|
this.toolsIndex.set(tool.name, serverName);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Tool Aggregation
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Returns ALL tools from ALL connected servers.
|
|
* This is what we send to LLM as available tools.
|
|
*/
|
|
getAllTools(): Tool[] {
|
|
const allTools: Tool[] = [];
|
|
for (const connection of this.connections.values()) {
|
|
allTools.push(...connection.tools);
|
|
}
|
|
return allTools;
|
|
}
|
|
|
|
/**
|
|
* Normalize JSON Schema properties to ensure all have explicit types.
|
|
* Infers type from default value if missing - fixes compatibility with
|
|
* llama.cpp which requires explicit types in tool schemas.
|
|
*/
|
|
private normalizeSchemaProperties(schema: Record<string, unknown>): Record<string, unknown> {
|
|
if (!schema || typeof schema !== 'object') return schema;
|
|
|
|
const normalized = { ...schema };
|
|
|
|
// Process properties object
|
|
if (normalized.properties && typeof normalized.properties === 'object') {
|
|
const props = normalized.properties as Record<string, Record<string, unknown>>;
|
|
const normalizedProps: Record<string, Record<string, unknown>> = {};
|
|
|
|
for (const [key, prop] of Object.entries(props)) {
|
|
if (!prop || typeof prop !== 'object') {
|
|
normalizedProps[key] = prop;
|
|
continue;
|
|
}
|
|
|
|
const normalizedProp = { ...prop };
|
|
|
|
// Infer type from default if missing
|
|
if (!normalizedProp.type && normalizedProp.default !== undefined) {
|
|
const defaultVal = normalizedProp.default;
|
|
if (typeof defaultVal === 'string') {
|
|
normalizedProp.type = 'string';
|
|
} else if (typeof defaultVal === 'number') {
|
|
normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number';
|
|
} else if (typeof defaultVal === 'boolean') {
|
|
normalizedProp.type = 'boolean';
|
|
} else if (Array.isArray(defaultVal)) {
|
|
normalizedProp.type = 'array';
|
|
} else if (typeof defaultVal === 'object' && defaultVal !== null) {
|
|
normalizedProp.type = 'object';
|
|
}
|
|
}
|
|
|
|
// Recursively normalize nested schemas
|
|
if (normalizedProp.properties) {
|
|
Object.assign(
|
|
normalizedProp,
|
|
this.normalizeSchemaProperties(normalizedProp as Record<string, unknown>)
|
|
);
|
|
}
|
|
|
|
// Normalize items in array schemas
|
|
if (normalizedProp.items && typeof normalizedProp.items === 'object') {
|
|
normalizedProp.items = this.normalizeSchemaProperties(
|
|
normalizedProp.items as Record<string, unknown>
|
|
);
|
|
}
|
|
|
|
normalizedProps[key] = normalizedProp;
|
|
}
|
|
|
|
normalized.properties = normalizedProps;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
/**
|
|
* Returns tools in OpenAI function calling format.
|
|
* Ready to be sent to /v1/chat/completions API.
|
|
*/
|
|
getToolDefinitionsForLLM(): OpenAIToolDefinition[] {
|
|
return this.getAllTools().map((tool) => {
|
|
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
|
|
type: 'object',
|
|
properties: {},
|
|
required: []
|
|
};
|
|
|
|
// Normalize schema to fix missing types
|
|
const normalizedSchema = this.normalizeSchemaProperties(rawSchema);
|
|
|
|
return {
|
|
type: 'function' as const,
|
|
function: {
|
|
name: tool.name,
|
|
description: tool.description,
|
|
parameters: normalizedSchema
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns names of all available tools.
|
|
*/
|
|
getToolNames(): string[] {
|
|
return Array.from(this.toolsIndex.keys());
|
|
}
|
|
|
|
/**
|
|
* Check if a tool exists.
|
|
*/
|
|
hasTool(toolName: string): boolean {
|
|
return this.toolsIndex.has(toolName);
|
|
}
|
|
|
|
/**
|
|
* Get which server provides a specific tool.
|
|
*/
|
|
getToolServer(toolName: string): string | undefined {
|
|
return this.toolsIndex.get(toolName);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Tool Execution
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Execute a tool call, automatically routing to the appropriate server.
|
|
* Accepts the OpenAI-style tool call format.
|
|
*/
|
|
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
|
|
const toolName = toolCall.function.name;
|
|
|
|
// Find which server handles this tool
|
|
const serverName = this.toolsIndex.get(toolName);
|
|
if (!serverName) {
|
|
throw new MCPError(`Unknown tool: ${toolName}`, -32601);
|
|
}
|
|
|
|
const connection = this.connections.get(serverName);
|
|
if (!connection) {
|
|
throw new MCPError(`Server "${serverName}" is not connected`, -32000);
|
|
}
|
|
|
|
// Parse arguments
|
|
const args = this.parseToolArguments(toolCall.function.arguments);
|
|
|
|
// Delegate to the appropriate server
|
|
return connection.callTool({ name: toolName, arguments: args }, signal);
|
|
}
|
|
|
|
/**
|
|
* Execute a tool by name with arguments object.
|
|
* Simpler interface for direct tool calls.
|
|
*/
|
|
async executeToolByName(
|
|
toolName: string,
|
|
args: Record<string, unknown>,
|
|
signal?: AbortSignal
|
|
): Promise<ToolExecutionResult> {
|
|
const serverName = this.toolsIndex.get(toolName);
|
|
if (!serverName) {
|
|
throw new MCPError(`Unknown tool: ${toolName}`, -32601);
|
|
}
|
|
|
|
const connection = this.connections.get(serverName);
|
|
if (!connection) {
|
|
throw new MCPError(`Server "${serverName}" is not connected`, -32000);
|
|
}
|
|
|
|
return connection.callTool({ name: toolName, arguments: args }, signal);
|
|
}
|
|
|
|
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
|
|
if (typeof args === 'string') {
|
|
const trimmed = args.trim();
|
|
if (trimmed === '') {
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
throw new MCPError(
|
|
`Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`,
|
|
-32602
|
|
);
|
|
}
|
|
return parsed as Record<string, unknown>;
|
|
} catch (error) {
|
|
if (error instanceof MCPError) {
|
|
throw error;
|
|
}
|
|
throw new MCPError(
|
|
`Failed to parse tool arguments as JSON: ${(error as Error).message}`,
|
|
-32700
|
|
);
|
|
}
|
|
}
|
|
|
|
if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
|
|
return args;
|
|
}
|
|
|
|
throw new MCPError(`Invalid tool arguments type: ${typeof args}`, -32602);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// State
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
get isInitialized(): boolean {
|
|
return this._isInitialized;
|
|
}
|
|
|
|
get initializationError(): Error | null {
|
|
return this._initializationError;
|
|
}
|
|
|
|
get connectedServerCount(): number {
|
|
return this.connections.size;
|
|
}
|
|
|
|
get connectedServerNames(): string[] {
|
|
return Array.from(this.connections.keys());
|
|
}
|
|
|
|
get toolCount(): number {
|
|
return this.toolsIndex.size;
|
|
}
|
|
|
|
/**
|
|
* Get status of all configured servers.
|
|
*/
|
|
getServersStatus(): ServerStatus[] {
|
|
const statuses: ServerStatus[] = [];
|
|
|
|
for (const [name, connection] of this.connections) {
|
|
statuses.push({
|
|
name,
|
|
isConnected: connection.isConnected,
|
|
toolCount: connection.tools.length,
|
|
error: connection.lastError?.message
|
|
});
|
|
}
|
|
|
|
return statuses;
|
|
}
|
|
|
|
/**
|
|
* Get a specific server connection (for advanced use cases).
|
|
*/
|
|
getServerConnection(name: string): MCPServerConnection | undefined {
|
|
return this.connections.get(name);
|
|
}
|
|
}
|