llama.cpp/tools/server/webui/src/lib/clients/mcp.client.ts

1052 lines
28 KiB
TypeScript

/**
* MCPClient - Business Logic Facade for MCP Operations
*
* Implements the "Host" role in MCP architecture, coordinating multiple server
* connections and providing a unified interface for tool operations.
*
* **Architecture & Relationships:**
* - **MCPClient** (this class): Business logic facade
* - Uses MCPService for low-level protocol operations
* - Updates mcpStore with reactive state
* - Coordinates multiple server connections
* - Aggregates tools from all connected servers
* - Routes tool calls to the appropriate server
*
* - **MCPService**: Stateless protocol layer (transport, connect, callTool)
* - **mcpStore**: Reactive state only ($state, getters, setters)
*
* **Key Responsibilities:**
* - Lifecycle management (initialize, shutdown)
* - Multi-server coordination
* - Tool name conflict detection and resolution
* - OpenAI-compatible tool definition generation
* - Automatic tool-to-server routing
* - Health checks
* - Usage statistics tracking
*/
import { mcpStore } from '$lib/stores/mcp.svelte';
import { browser } from '$app/environment';
import { MCPService } from '$lib/services/mcp.service';
import type {
MCPToolCall,
OpenAIToolDefinition,
ServerStatus,
ToolExecutionResult,
MCPClientConfig,
MCPConnection,
HealthCheckParams,
ServerCapabilities,
ClientCapabilities,
MCPCapabilitiesInfo,
MCPConnectionLog,
MCPPromptInfo,
GetPromptResult,
Tool,
Prompt
} from '$lib/types';
import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js';
import { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums';
import type { McpServerOverride } from '$lib/types/database';
import { detectMcpTransportFromUrl } from '$lib/utils';
import { config } from '$lib/stores/settings.svelte';
import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
import type { MCPServerConfig, MCPServerSettingsEntry } from '$lib/types';
import type { SettingsConfigType } from '$lib/types/settings';
/**
* Generates a valid MCP server ID from user input.
* Returns the trimmed ID if valid, otherwise generates 'server-{index+1}'.
*/
function generateMcpServerId(id: unknown, index: number): string {
if (typeof id === 'string' && id.trim()) {
return id.trim();
}
return `${MCP_SERVER_ID_PREFIX}${index + 1}`;
}
/**
* Parses MCP server settings from a JSON string or array.
* requestTimeoutSeconds is not user-configurable in the UI, so we always use the default value.
* @param rawServers - The raw servers to parse
* @returns An empty array if the input is invalid.
*/
function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
if (!rawServers) return [];
let parsed: unknown;
if (typeof rawServers === 'string') {
const trimmed = rawServers.trim();
if (!trimmed) return [];
try {
parsed = JSON.parse(trimmed);
} catch (error) {
console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error);
return [];
}
} else {
parsed = rawServers;
}
if (!Array.isArray(parsed)) return [];
return parsed.map((entry, index) => {
const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
return {
id: generateMcpServerId((entry as { id?: unknown })?.id, index),
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
url,
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
headers: headers || undefined
} satisfies MCPServerSettingsEntry;
});
}
/**
* Builds an MCP server configuration from a server settings entry.
* @param entry - The server settings entry to build the configuration from
* @param connectionTimeoutMs - The connection timeout in milliseconds
* @returns The built server configuration, or undefined if the entry is invalid
*/
function buildServerConfig(
entry: MCPServerSettingsEntry,
connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs
): MCPServerConfig | undefined {
if (!entry?.url) {
return undefined;
}
let headers: Record<string, string> | undefined;
if (entry.headers) {
try {
const parsed = JSON.parse(entry.headers);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
headers = parsed as Record<string, string>;
}
} catch {
console.warn('[MCP] Failed to parse custom headers JSON, ignoring:', entry.headers);
}
}
return {
url: entry.url,
transport: detectMcpTransportFromUrl(entry.url),
handshakeTimeoutMs: connectionTimeoutMs,
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
headers
};
}
/**
* Checks if a server is enabled for the current chat.
* Server must be available (server.enabled) AND have a per-chat override enabling it.
* Pure helper function - no side effects.
*/
function checkServerEnabled(
server: MCPServerSettingsEntry,
perChatOverrides?: McpServerOverride[]
): boolean {
if (!server.enabled) {
return false;
}
if (perChatOverrides) {
const override = perChatOverrides.find((o) => o.serverId === server.id);
return override?.enabled ?? false;
}
return false;
}
/**
* Builds MCP client configuration from settings.
* Returns undefined if no valid servers are configured.
* @param config - Global settings configuration
* @param perChatOverrides - Optional per-chat server overrides
*/
export function buildMcpClientConfig(
config: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
const rawServers = parseMcpServerSettings(config.mcpServers);
if (!rawServers.length) {
return undefined;
}
const servers: Record<string, MCPServerConfig> = {};
for (const [index, entry] of rawServers.entries()) {
if (!checkServerEnabled(entry, perChatOverrides)) continue;
const normalized = buildServerConfig(entry);
if (normalized) {
servers[generateMcpServerId(entry.id, index)] = normalized;
}
}
if (Object.keys(servers).length === 0) {
return undefined;
}
return {
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
capabilities: DEFAULT_MCP_CONFIG.capabilities,
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000),
servers
};
}
/**
* Build capabilities info from server and client capabilities
*/
function buildCapabilitiesInfo(
serverCaps?: ServerCapabilities,
clientCaps?: ClientCapabilities
): MCPCapabilitiesInfo {
return {
server: {
tools: serverCaps?.tools ? { listChanged: serverCaps.tools.listChanged } : undefined,
prompts: serverCaps?.prompts ? { listChanged: serverCaps.prompts.listChanged } : undefined,
resources: serverCaps?.resources
? {
subscribe: serverCaps.resources.subscribe,
listChanged: serverCaps.resources.listChanged
}
: undefined,
logging: !!serverCaps?.logging,
completions: !!serverCaps?.completions,
tasks: !!serverCaps?.tasks
},
client: {
roots: clientCaps?.roots ? { listChanged: clientCaps.roots.listChanged } : undefined,
sampling: !!clientCaps?.sampling,
elicitation: clientCaps?.elicitation
? {
form: !!clientCaps.elicitation.form,
url: !!clientCaps.elicitation.url
}
: undefined,
tasks: !!clientCaps?.tasks
}
};
}
export class MCPClient {
private connections = new Map<string, MCPConnection>();
private toolsIndex = new Map<string, string>();
private configSignature: string | null = null;
private initPromise: Promise<boolean> | null = null;
/**
* Reference counter for active agentic flows using MCP connections.
* Prevents shutdown while any conversation is still using connections.
*/
private activeFlowCount = 0;
/**
* Ensures MCP is initialized with current config.
* Handles config changes by reinitializing as needed.
* @param perChatOverrides - Optional per-chat MCP server overrides
*/
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
if (!browser) return false;
const mcpConfig = buildMcpClientConfig(config(), perChatOverrides);
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
if (!signature) {
await this.shutdown();
return false;
}
if (this.isInitialized && this.configSignature === signature) {
return true;
}
if (this.initPromise && this.configSignature === signature) {
return this.initPromise;
}
if (this.connections.size > 0 || this.initPromise) {
await this.shutdown();
}
return this.initialize(signature, mcpConfig!);
}
/**
* Initialize connections to all configured MCP servers.
*/
private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise<boolean> {
console.log('[MCPClient] Starting initialization...');
mcpStore.updateState({ isInitializing: true, error: null });
this.configSignature = signature;
const serverEntries = Object.entries(mcpConfig.servers);
if (serverEntries.length === 0) {
console.log('[MCPClient] No servers configured');
mcpStore.updateState({ isInitializing: false, toolCount: 0, connectedServers: [] });
return false;
}
this.initPromise = this.doInitialize(signature, mcpConfig, serverEntries);
return this.initPromise;
}
private async doInitialize(
signature: string,
mcpConfig: MCPClientConfig,
serverEntries: [string, MCPClientConfig['servers'][string]][]
): Promise<boolean> {
const clientInfo = mcpConfig.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo;
const capabilities = mcpConfig.capabilities ?? DEFAULT_MCP_CONFIG.capabilities;
const results = await Promise.allSettled(
serverEntries.map(async ([name, serverConfig]) => {
const listChangedHandlers = this.createListChangedHandlers(name);
const connection = await MCPService.connect(
name,
serverConfig,
clientInfo,
capabilities,
undefined,
listChangedHandlers
);
return { name, connection };
})
);
if (this.configSignature !== signature) {
console.log('[MCPClient] Config changed during init, aborting');
for (const result of results) {
if (result.status === 'fulfilled') {
await MCPService.disconnect(result.value.connection).catch(console.warn);
}
}
return false;
}
for (const result of results) {
if (result.status === 'fulfilled') {
const { name, connection } = result.value;
this.connections.set(name, connection);
for (const tool of connection.tools) {
if (this.toolsIndex.has(tool.name)) {
console.warn(
`[MCPClient] Tool name conflict: "${tool.name}" exists in ` +
`"${this.toolsIndex.get(tool.name)}" and "${name}". ` +
`Using tool from "${name}".`
);
}
this.toolsIndex.set(tool.name, name);
}
} else {
console.error(`[MCPClient] Failed to connect:`, result.reason);
}
}
const successCount = this.connections.size;
const totalCount = serverEntries.length;
if (successCount === 0 && totalCount > 0) {
const error = 'All MCP server connections failed';
mcpStore.updateState({
isInitializing: false,
error,
toolCount: 0,
connectedServers: []
});
this.initPromise = null;
return false;
}
mcpStore.updateState({
isInitializing: false,
error: null,
toolCount: this.toolsIndex.size,
connectedServers: Array.from(this.connections.keys())
});
console.log(
`[MCPClient] Initialization complete: ${successCount}/${totalCount} servers connected, ` +
`${this.toolsIndex.size} tools available`
);
this.initPromise = null;
return true;
}
/**
* Create list changed handlers for a server connection.
* These handlers are called when the server notifies about changes to tools, prompts, or resources.
*/
private createListChangedHandlers(serverName: string): ListChangedHandlers {
return {
tools: {
onChanged: (error: Error | null, tools: Tool[] | null) => {
if (error) {
console.warn(`[MCPClient][${serverName}] Tools list changed error:`, error);
return;
}
console.log(`[MCPClient][${serverName}] Tools list changed, ${tools?.length ?? 0} tools`);
this.handleToolsListChanged(serverName, tools ?? []);
}
},
prompts: {
onChanged: (error: Error | null, prompts: Prompt[] | null) => {
if (error) {
console.warn(`[MCPClient][${serverName}] Prompts list changed error:`, error);
return;
}
console.log(
`[MCPClient][${serverName}] Prompts list changed, ${prompts?.length ?? 0} prompts`
);
this.handlePromptsListChanged(serverName);
}
}
};
}
/**
* Handle tools list changed notification from a server.
* Updates the tools index and store.
*/
private handleToolsListChanged(serverName: string, tools: Tool[]): void {
const connection = this.connections.get(serverName);
if (!connection) return;
// Remove old tools from this server from the index
for (const [toolName, ownerServer] of this.toolsIndex.entries()) {
if (ownerServer === serverName) {
this.toolsIndex.delete(toolName);
}
}
// Update connection tools
connection.tools = tools;
// Add new tools to the index
for (const tool of tools) {
if (this.toolsIndex.has(tool.name)) {
console.warn(
`[MCPClient] Tool name conflict after list change: "${tool.name}" exists in ` +
`"${this.toolsIndex.get(tool.name)}" and "${serverName}". ` +
`Using tool from "${serverName}".`
);
}
this.toolsIndex.set(tool.name, serverName);
}
// Update store
mcpStore.updateState({
toolCount: this.toolsIndex.size
});
}
/**
* Handle prompts list changed notification from a server.
* Triggers a refresh of the prompts cache if needed.
*/
private handlePromptsListChanged(serverName: string): void {
// Prompts are fetched on-demand, so we just log the change
// The UI will get fresh prompts on next getAllPrompts() call
console.log(
`[MCPClient][${serverName}] Prompts list updated - will be refreshed on next fetch`
);
}
/**
* Acquire a reference to MCP connections for an agentic flow.
* Call this when starting an agentic flow to prevent premature shutdown.
*/
acquireConnection(): void {
this.activeFlowCount++;
console.log(`[MCPClient] Connection acquired (active flows: ${this.activeFlowCount})`);
}
/**
* Release a reference to MCP connections.
* Call this when an agentic flow completes.
* @param shutdownIfUnused - If true, shutdown connections when no flows are active
*/
async releaseConnection(shutdownIfUnused = true): Promise<void> {
this.activeFlowCount = Math.max(0, this.activeFlowCount - 1);
console.log(`[MCPClient] Connection released (active flows: ${this.activeFlowCount})`);
if (shutdownIfUnused && this.activeFlowCount === 0) {
console.log('[MCPClient] No active flows, initiating lazy disconnect...');
await this.shutdown();
}
}
/**
* Get the number of active agentic flows using MCP connections.
*/
getActiveFlowCount(): number {
return this.activeFlowCount;
}
/**
* Shutdown all MCP connections and clear state.
* Note: This will force shutdown regardless of active flow count.
*/
async shutdown(): Promise<void> {
if (this.initPromise) {
await this.initPromise.catch(() => {});
this.initPromise = null;
}
if (this.connections.size === 0) {
return;
}
console.log(`[MCPClient] Shutting down ${this.connections.size} connections...`);
await Promise.all(
Array.from(this.connections.values()).map((conn) =>
MCPService.disconnect(conn).catch((error) => {
console.warn(`[MCPClient] Error disconnecting ${conn.serverName}:`, error);
})
)
);
this.connections.clear();
this.toolsIndex.clear();
this.configSignature = null;
mcpStore.updateState({
isInitializing: false,
error: null,
toolCount: 0,
connectedServers: []
});
console.log('[MCPClient] Shutdown complete');
}
/**
*
*
* Tool Definitions
*
*
*/
/**
* Returns tools in OpenAI function calling format.
* Ready to be sent to /v1/chat/completions API.
*/
getToolDefinitionsForLLM(): OpenAIToolDefinition[] {
const tools: OpenAIToolDefinition[] = [];
for (const connection of this.connections.values()) {
for (const tool of connection.tools) {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
type: 'object',
properties: {},
required: []
};
const normalizedSchema = this.normalizeSchemaProperties(rawSchema);
tools.push({
type: 'function' as const,
function: {
name: tool.name,
description: tool.description,
parameters: normalizedSchema
}
});
}
}
return tools;
}
/**
* 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 };
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';
}
}
if (normalizedProp.properties) {
Object.assign(
normalizedProp,
this.normalizeSchemaProperties(normalizedProp as Record<string, unknown>)
);
}
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;
}
/**
*
*
* Tool Queries
*
*
*/
/**
* 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);
}
/**
*
*
* Prompts
*
*
*/
/**
* Get all prompts from all connected servers that support prompts.
*/
async getAllPrompts(): Promise<MCPPromptInfo[]> {
const results: MCPPromptInfo[] = [];
for (const [serverName, connection] of this.connections) {
if (!connection.serverCapabilities?.prompts) continue;
const prompts = await MCPService.listPrompts(connection);
for (const prompt of prompts) {
results.push({
name: prompt.name,
description: prompt.description,
title: prompt.title,
serverName,
arguments: prompt.arguments?.map((arg) => ({
name: arg.name,
description: arg.description,
required: arg.required
}))
});
}
}
return results;
}
/**
* Get a prompt by name from a specific server.
* Returns the prompt messages ready to be used in chat.
* Throws an error if the server is not found or prompt execution fails.
*/
async getPrompt(
serverName: string,
promptName: string,
args?: Record<string, string>
): Promise<GetPromptResult> {
const connection = this.connections.get(serverName);
if (!connection) {
const errorMsg = `Server "${serverName}" not found for prompt "${promptName}"`;
console.error(`[MCPClient] ${errorMsg}`);
throw new Error(errorMsg);
}
return MCPService.getPrompt(connection, promptName, args);
}
/**
* Check if any connected server supports prompts.
*/
hasPromptsSupport(): boolean {
for (const connection of this.connections.values()) {
if (connection.serverCapabilities?.prompts) {
return true;
}
}
return false;
}
/**
*
*
* Tool Execution
*
*
*/
/**
* Executes a tool call, automatically routing to the appropriate server.
* Accepts the OpenAI-style tool call format.
* @param toolCall - Tool call with function name and arguments
* @param signal - Optional abort signal
* @returns Tool execution result
*/
async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
const toolName = toolCall.function.name;
const serverName = this.toolsIndex.get(toolName);
if (!serverName) {
throw new Error(`Unknown tool: ${toolName}`);
}
const connection = this.connections.get(serverName);
if (!connection) {
throw new Error(`Server "${serverName}" is not connected`);
}
const args = this.parseToolArguments(toolCall.function.arguments);
return MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
}
/**
* Executes a tool by name with arguments object.
* Simpler interface for direct tool calls.
* @param toolName - Name of the tool to execute
* @param args - Tool arguments as key-value pairs
* @param signal - Optional abort signal
*/
async executeToolByName(
toolName: string,
args: Record<string, unknown>,
signal?: AbortSignal
): Promise<ToolExecutionResult> {
const serverName = this.toolsIndex.get(toolName);
if (!serverName) {
throw new Error(`Unknown tool: ${toolName}`);
}
const connection = this.connections.get(serverName);
if (!connection) {
throw new Error(`Server "${serverName}" is not connected`);
}
return MCPService.callTool(connection, { 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 Error(
`Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`
);
}
return parsed as Record<string, unknown>;
} catch (error) {
throw new Error(`Failed to parse tool arguments as JSON: ${(error as Error).message}`);
}
}
if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
return args;
}
throw new Error(`Invalid tool arguments type: ${typeof args}`);
}
/**
*
*
* Completions
*
*
*/
/**
* Get completion suggestions for a prompt argument.
* Used for autocompleting prompt argument values.
*
* @param serverName - Name of the server hosting the prompt
* @param promptName - Name of the prompt
* @param argumentName - Name of the argument being completed
* @param argumentValue - Current partial value of the argument
* @returns Completion suggestions or null if not supported/error
*/
async getPromptCompletions(
serverName: string,
promptName: string,
argumentName: string,
argumentValue: string
): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
const connection = this.connections.get(serverName);
if (!connection) {
console.warn(`[MCPClient] Server "${serverName}" is not connected`);
return null;
}
if (!connection.serverCapabilities?.completions) {
return null;
}
return MCPService.complete(
connection,
{ type: 'ref/prompt', name: promptName },
{ name: argumentName, value: argumentValue }
);
}
/**
*
*
* Health Checks
*
*
*/
private parseHeaders(headersJson?: string): Record<string, string> | undefined {
if (!headersJson?.trim()) return undefined;
try {
const parsed = JSON.parse(headersJson);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed as Record<string, string>;
}
} catch {
console.warn('[MCPClient] Failed to parse custom headers JSON:', headersJson);
}
return undefined;
}
/**
* Run health checks for multiple servers that don't have a recent check.
* Useful for lazy-loading health checks when UI is opened.
* @param servers - Array of servers to check
* @param skipIfChecked - If true, skip servers that already have a health check result
*/
async runHealthChecksForServers(
servers: {
id: string;
enabled: boolean;
url: string;
requestTimeoutSeconds: number;
headers?: string;
}[],
skipIfChecked = true
): Promise<void> {
const serversToCheck = skipIfChecked
? servers.filter((s) => !mcpStore.hasHealthCheck(s.id) && s.url.trim())
: servers.filter((s) => s.url.trim());
if (serversToCheck.length === 0) return;
const BATCH_SIZE = 5;
for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) {
const batch = serversToCheck.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map((server) => this.runHealthCheck(server)));
}
}
/**
* Run health check for a specific server configuration.
* Creates a temporary connection to test connectivity and list tools.
* Tracks connection phases and collects detailed connection info.
*/
async runHealthCheck(server: HealthCheckParams): Promise<void> {
const trimmedUrl = server.url.trim();
const logs: MCPConnectionLog[] = [];
let currentPhase: MCPConnectionPhase = MCPConnectionPhase.IDLE;
if (!trimmedUrl) {
mcpStore.updateHealthCheck(server.id, {
status: HealthCheckStatus.ERROR,
message: 'Please enter a server URL first.',
logs: []
});
return;
}
// Initial connecting state
mcpStore.updateHealthCheck(server.id, {
status: HealthCheckStatus.CONNECTING,
phase: MCPConnectionPhase.TRANSPORT_CREATING,
logs: []
});
const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000);
const headers = this.parseHeaders(server.headers);
try {
const connection = await MCPService.connect(
server.id,
{
url: trimmedUrl,
transport: detectMcpTransportFromUrl(trimmedUrl),
handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs,
requestTimeoutMs: timeoutMs,
headers
},
DEFAULT_MCP_CONFIG.clientInfo,
DEFAULT_MCP_CONFIG.capabilities,
// Phase callback for tracking progress
(phase, log) => {
currentPhase = phase;
logs.push(log);
mcpStore.updateHealthCheck(server.id, {
status: HealthCheckStatus.CONNECTING,
phase,
logs: [...logs]
});
}
);
const tools = connection.tools.map((tool) => ({
name: tool.name,
description: tool.description,
title: tool.title
}));
const capabilities = buildCapabilitiesInfo(
connection.serverCapabilities,
connection.clientCapabilities
);
mcpStore.updateHealthCheck(server.id, {
status: HealthCheckStatus.SUCCESS,
tools,
serverInfo: connection.serverInfo,
capabilities,
transportType: connection.transportType,
protocolVersion: connection.protocolVersion,
instructions: connection.instructions,
connectionTimeMs: connection.connectionTimeMs,
logs
});
await MCPService.disconnect(connection);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error occurred';
logs.push({
timestamp: new Date(),
phase: MCPConnectionPhase.ERROR,
message: `Connection failed: ${message}`,
level: MCPLogLevel.ERROR
});
mcpStore.updateHealthCheck(server.id, {
status: HealthCheckStatus.ERROR,
message,
phase: currentPhase,
logs
});
}
}
/**
*
*
* Status Getters
*
*
*/
get isInitialized(): boolean {
return this.connections.size > 0;
}
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 connected servers.
*/
getServersStatus(): ServerStatus[] {
const statuses: ServerStatus[] = [];
for (const [name, connection] of this.connections) {
statuses.push({
name,
isConnected: true,
toolCount: connection.tools.length,
error: undefined
});
}
return statuses;
}
}
export const mcpClient = new MCPClient();