llama.cpp/tools/server/webui/src/lib/stores/mcp.svelte.ts

1354 lines
42 KiB
TypeScript

/**
* mcpStore - Reactive State Store for MCP Operations
*
* Implements the "Host" role in MCP architecture, coordinating multiple server
* connections and providing a unified interface for tool operations.
*
* **Architecture & Relationships:**
* - **MCPService**: Stateless protocol layer (transport, connect, callTool)
* - **mcpStore** (this): Reactive state + business logic
*
* **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
*
* @see MCPService in services/mcp.service.ts for protocol operations
*/
import { browser } from '$app/environment';
import { MCPService } from '$lib/services/mcp.service';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
import { parseMcpServerSettings, detectMcpTransportFromUrl } from '$lib/utils';
import { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus, MCPRefType } from '$lib/enums';
import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
import type {
MCPToolCall,
OpenAIToolDefinition,
ServerStatus,
ToolExecutionResult,
MCPClientConfig,
MCPConnection,
HealthCheckParams,
ServerCapabilities,
ClientCapabilities,
MCPCapabilitiesInfo,
MCPConnectionLog,
MCPPromptInfo,
GetPromptResult,
Tool,
HealthCheckState,
MCPServerSettingsEntry,
MCPServerConfig
} from '$lib/types';
import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js';
import type { McpServerOverride } from '$lib/types/database';
import type { SettingsConfigType } from '$lib/types/settings';
function generateMcpServerId(id: unknown, index: number): string {
if (typeof id === 'string' && id.trim()) return id.trim();
return `${MCP_SERVER_ID_PREFIX}${index + 1}`;
}
function parseServerSettings(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:', 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,
name: (entry as { name?: string })?.name,
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
headers: headers || undefined,
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
} satisfies MCPServerSettingsEntry;
});
}
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:', entry.headers);
}
}
return {
url: entry.url,
transport: detectMcpTransportFromUrl(entry.url),
handshakeTimeoutMs: connectionTimeoutMs,
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
headers,
useProxy: entry.useProxy
};
}
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;
}
function buildMcpClientConfigInternal(
cfg: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
const rawServers = parseServerSettings(cfg.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
};
}
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 function buildMcpClientConfig(
cfg: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
return buildMcpClientConfigInternal(cfg, perChatOverrides);
}
class MCPStore {
private _isInitializing = $state(false);
private _error = $state<string | null>(null);
private _toolCount = $state(0);
private _connectedServers = $state<string[]>([]);
private _healthChecks = $state<Record<string, HealthCheckState>>({});
private connections = new Map<string, MCPConnection>();
private toolsIndex = new Map<string, string>();
private configSignature: string | null = null;
private initPromise: Promise<boolean> | null = null;
private activeFlowCount = 0;
get isInitializing(): boolean {
return this._isInitializing;
}
get isInitialized(): boolean {
return this.connections.size > 0;
}
get error(): string | null {
return this._error;
}
get toolCount(): number {
return this._toolCount;
}
get connectedServerCount(): number {
return this._connectedServers.length;
}
get connectedServerNames(): string[] {
return this._connectedServers;
}
get isEnabled(): boolean {
const mcpConfig = buildMcpClientConfigInternal(config());
return (
mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0
);
}
get availableTools(): string[] {
return Array.from(this.toolsIndex.keys());
}
private updateState(state: {
isInitializing?: boolean;
error?: string | null;
toolCount?: number;
connectedServers?: string[];
}): void {
if (state.isInitializing !== undefined) this._isInitializing = state.isInitializing;
if (state.error !== undefined) this._error = state.error;
if (state.toolCount !== undefined) this._toolCount = state.toolCount;
if (state.connectedServers !== undefined) this._connectedServers = state.connectedServers;
}
updateHealthCheck(serverId: string, state: HealthCheckState): void {
this._healthChecks = { ...this._healthChecks, [serverId]: state };
}
getHealthCheckState(serverId: string): HealthCheckState {
return this._healthChecks[serverId] ?? { status: 'idle' };
}
hasHealthCheck(serverId: string): boolean {
return serverId in this._healthChecks && this._healthChecks[serverId].status !== 'idle';
}
clearHealthCheck(serverId: string): void {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [serverId]: _removed, ...rest } = this._healthChecks;
this._healthChecks = rest;
}
clearAllHealthChecks(): void {
this._healthChecks = {};
}
clearError(): void {
this._error = null;
}
getServers(): MCPServerSettingsEntry[] {
return parseMcpServerSettings(config().mcpServers);
}
/**
* Get all active MCP connections.
* @returns Map of server names to connections
*/
getConnections(): Map<string, MCPConnection> {
return this.connections;
}
getServerLabel(server: MCPServerSettingsEntry): string {
const healthState = this.getHealthCheckState(server.id);
if (healthState?.status === HealthCheckStatus.SUCCESS)
return (
healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url
);
return server.url;
}
getServerById(serverId: string): MCPServerSettingsEntry | undefined {
return this.getServers().find((s) => s.id === serverId);
}
/**
* Get display name for an MCP server by its ID.
* Falls back to the server ID if server is not found.
*/
getServerDisplayName(serverId: string): string {
const server = this.getServerById(serverId);
return server ? this.getServerLabel(server) : serverId;
}
/**
* Get favicon URL for an MCP server by its ID.
* Returns null if server is not found.
*/
getServerFavicon(serverId: string): string | null {
const server = this.getServerById(serverId);
if (!server) return null;
try {
const url = new URL(server.url);
return `${url.origin}/favicon.ico`;
} catch {
return null;
}
}
isAnyServerLoading(): boolean {
return this.getServers().some((s) => {
const state = this.getHealthCheckState(s.id);
return (
state.status === HealthCheckStatus.IDLE || state.status === HealthCheckStatus.CONNECTING
);
});
}
getServersSorted(): MCPServerSettingsEntry[] {
const servers = this.getServers();
if (this.isAnyServerLoading()) return servers;
return [...servers].sort((a, b) =>
this.getServerLabel(a).localeCompare(this.getServerLabel(b))
);
}
addServer(
serverData: Omit<MCPServerSettingsEntry, 'id' | 'requestTimeoutSeconds'> & { id?: string }
): void {
const servers = this.getServers();
const newServer: MCPServerSettingsEntry = {
id: serverData.id || (crypto.randomUUID ? crypto.randomUUID() : `server-${Date.now()}`),
enabled: serverData.enabled,
url: serverData.url.trim(),
name: serverData.name,
headers: serverData.headers?.trim() || undefined,
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
useProxy: serverData.useProxy
};
settingsStore.updateConfig('mcpServers', JSON.stringify([...servers, newServer]));
}
updateServer(id: string, updates: Partial<MCPServerSettingsEntry>): void {
const servers = this.getServers();
settingsStore.updateConfig(
'mcpServers',
JSON.stringify(
servers.map((server) => (server.id === id ? { ...server, ...updates } : server))
)
);
}
removeServer(id: string): void {
const servers = this.getServers();
settingsStore.updateConfig('mcpServers', JSON.stringify(servers.filter((s) => s.id !== id)));
this.clearHealthCheck(id);
}
hasAvailableServers(): boolean {
return parseMcpServerSettings(config().mcpServers).some((s) => s.enabled && s.url.trim());
}
hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean {
return Boolean(buildMcpClientConfigInternal(config(), perChatOverrides));
}
getEnabledServersForConversation(
perChatOverrides?: McpServerOverride[]
): MCPServerSettingsEntry[] {
if (!perChatOverrides?.length) return [];
return this.getServers().filter((server) => {
if (!server.enabled) return false;
const override = perChatOverrides.find((o) => o.serverId === server.id);
return override?.enabled ?? false;
});
}
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
if (!browser) return false;
const mcpConfig = buildMcpClientConfigInternal(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!);
}
private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise<boolean> {
this.updateState({ isInitializing: true, error: null });
this.configSignature = signature;
const serverEntries = Object.entries(mcpConfig.servers);
if (serverEntries.length === 0) {
this.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) {
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(
`[MCPStore] 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(`[MCPStore] Failed to connect:`, result.reason);
}
}
const successCount = this.connections.size;
if (successCount === 0 && serverEntries.length > 0) {
this.updateState({
isInitializing: false,
error: 'All MCP server connections failed',
toolCount: 0,
connectedServers: []
});
this.initPromise = null;
return false;
}
this.updateState({
isInitializing: false,
error: null,
toolCount: this.toolsIndex.size,
connectedServers: Array.from(this.connections.keys())
});
this.initPromise = null;
return true;
}
private createListChangedHandlers(serverName: string): ListChangedHandlers {
return {
tools: {
onChanged: (error: Error | null, tools: Tool[] | null) => {
if (error) {
console.warn(`[MCPStore][${serverName}] Tools list changed error:`, error);
return;
}
this.handleToolsListChanged(serverName, tools ?? []);
}
},
prompts: {
onChanged: (error: Error | null) => {
if (error) {
console.warn(`[MCPStore][${serverName}] Prompts list changed error:`, error);
return;
}
}
}
};
}
private handleToolsListChanged(serverName: string, tools: Tool[]): void {
const connection = this.connections.get(serverName);
if (!connection) return;
for (const [toolName, ownerServer] of this.toolsIndex.entries()) {
if (ownerServer === serverName) this.toolsIndex.delete(toolName);
}
connection.tools = tools;
for (const tool of tools) {
if (this.toolsIndex.has(tool.name))
console.warn(
`[MCPStore] 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);
}
this.updateState({ toolCount: this.toolsIndex.size });
}
acquireConnection(): void {
this.activeFlowCount++;
}
/**
* Release a connection reference.
* By default, keeps connections alive for reuse (shutdownIfUnused=false).
* MCP spec encourages long-lived sessions to avoid reconnection overhead.
*/
async releaseConnection(shutdownIfUnused = false): Promise<void> {
this.activeFlowCount = Math.max(0, this.activeFlowCount - 1);
if (shutdownIfUnused && this.activeFlowCount === 0) {
await this.shutdown();
}
}
getActiveFlowCount(): number {
return this.activeFlowCount;
}
async shutdown(): Promise<void> {
if (this.initPromise) {
await this.initPromise.catch(() => {});
this.initPromise = null;
}
if (this.connections.size === 0) return;
await Promise.all(
Array.from(this.connections.values()).map((conn) =>
MCPService.disconnect(conn).catch((error) =>
console.warn(`[MCPStore] Error disconnecting ${conn.serverName}:`, error)
)
)
);
this.connections.clear();
this.toolsIndex.clear();
this.configSignature = null;
this.updateState({ isInitializing: false, error: null, toolCount: 0, connectedServers: [] });
}
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: []
};
tools.push({
type: 'function' as const,
function: {
name: tool.name,
description: tool.description,
parameters: this.normalizeSchemaProperties(rawSchema)
}
});
}
}
return tools;
}
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 };
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;
}
getToolNames(): string[] {
return Array.from(this.toolsIndex.keys());
}
hasTool(toolName: string): boolean {
return this.toolsIndex.has(toolName);
}
getToolServer(toolName: string): string | undefined {
return this.toolsIndex.get(toolName);
}
hasPromptsSupport(): boolean {
for (const connection of this.connections.values()) {
if (connection.serverCapabilities?.prompts) return true;
}
return false;
}
/**
* Check if any enabled server with successful health check supports prompts.
* Uses health check state since servers may not have active connections until
* the user actually sends a message or uses prompts.
* @param perChatOverrides - Per-chat server overrides to filter by enabled servers.
* If provided (even empty array), only checks enabled servers.
* If undefined, checks all servers with successful health checks.
*/
hasPromptsCapability(perChatOverrides?: McpServerOverride[]): boolean {
// If perChatOverrides is provided (even empty array), filter by enabled servers
if (perChatOverrides !== undefined) {
const enabledServerIds = new Set(
perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId)
);
// No enabled servers = no capability
if (enabledServerIds.size === 0) return false;
// Check health check states for enabled servers with prompts capability
for (const [serverId, state] of Object.entries(this._healthChecks)) {
if (!enabledServerIds.has(serverId)) continue;
if (
state.status === HealthCheckStatus.SUCCESS &&
state.capabilities?.server?.prompts !== undefined
) {
return true;
}
}
// Also check active connections as fallback
for (const [serverName, connection] of this.connections) {
if (!enabledServerIds.has(serverName)) continue;
if (connection.serverCapabilities?.prompts) {
return true;
}
}
return false;
}
// No overrides provided - check all servers (global mode)
for (const state of Object.values(this._healthChecks)) {
if (
state.status === HealthCheckStatus.SUCCESS &&
state.capabilities?.server?.prompts !== undefined
) {
return true;
}
}
for (const connection of this.connections.values()) {
if (connection.serverCapabilities?.prompts) {
return true;
}
}
return false;
}
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;
}
async getPrompt(
serverName: string,
promptName: string,
args?: Record<string, string>
): Promise<GetPromptResult> {
const connection = this.connections.get(serverName);
if (!connection) throw new Error(`Server "${serverName}" not found for prompt "${promptName}"`);
return MCPService.getPrompt(connection, promptName, args);
}
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);
}
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}`);
}
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(`[MCPStore] Server "${serverName}" is not connected`);
return null;
}
if (!connection.serverCapabilities?.completions) return null;
return MCPService.complete(
connection,
{ type: MCPRefType.PROMPT, name: promptName },
{ name: argumentName, value: argumentValue }
);
}
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('[MCPStore] Failed to parse custom headers JSON:', headersJson);
}
return undefined;
}
async runHealthChecksForServers(
servers: {
id: string;
enabled: boolean;
url: string;
requestTimeoutSeconds: number;
headers?: string;
}[],
skipIfChecked = true,
promoteToActive = false
): Promise<void> {
const serversToCheck = skipIfChecked
? servers.filter((s) => !this.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.allSettled(batch.map((server) => this.runHealthCheck(server, promoteToActive)));
}
}
/**
* Check if a server already has an active connection that can be reused.
* Returns the existing connection if available.
*/
getExistingConnection(serverId: string): MCPConnection | undefined {
return this.connections.get(serverId);
}
/**
* Run a health check for a server.
* If the server already has an active connection, reuses it instead of creating a new one.
* If promoteToActive is true and server is enabled, the connection will be kept
* and promoted to an active connection instead of being disconnected.
*/
async runHealthCheck(server: HealthCheckParams, promoteToActive = false): Promise<void> {
// Check if we already have an active connection for this server
const existingConnection = this.connections.get(server.id);
if (existingConnection) {
// Reuse existing connection - just refresh tools list
try {
const tools = await MCPService.listTools(existingConnection);
const capabilities = buildCapabilitiesInfo(
existingConnection.serverCapabilities,
existingConnection.clientCapabilities
);
this.updateHealthCheck(server.id, {
status: HealthCheckStatus.SUCCESS,
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
title: tool.title
})),
serverInfo: existingConnection.serverInfo,
capabilities,
transportType: existingConnection.transportType,
protocolVersion: existingConnection.protocolVersion,
instructions: existingConnection.instructions,
connectionTimeMs: existingConnection.connectionTimeMs,
logs: []
});
return;
} catch (error) {
console.warn(
`[MCPStore] Failed to reuse connection for ${server.id}, creating new one:`,
error
);
// Connection may be stale, remove it and create new one
this.connections.delete(server.id);
}
}
const trimmedUrl = server.url.trim();
const logs: MCPConnectionLog[] = [];
let currentPhase: MCPConnectionPhase = MCPConnectionPhase.IDLE;
if (!trimmedUrl) {
this.updateHealthCheck(server.id, {
status: HealthCheckStatus.ERROR,
message: 'Please enter a server URL first.',
logs: []
});
return;
}
this.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,
useProxy: server.useProxy
},
DEFAULT_MCP_CONFIG.clientInfo,
DEFAULT_MCP_CONFIG.capabilities,
(phase, log) => {
currentPhase = phase;
logs.push(log);
this.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
);
this.updateHealthCheck(server.id, {
status: HealthCheckStatus.SUCCESS,
tools,
serverInfo: connection.serverInfo,
capabilities,
transportType: connection.transportType,
protocolVersion: connection.protocolVersion,
instructions: connection.instructions,
connectionTimeMs: connection.connectionTimeMs,
logs
});
// Promote to active connection or disconnect
if (promoteToActive && server.enabled) {
this.promoteHealthCheckToConnection(server.id, connection);
} else {
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
});
this.updateHealthCheck(server.id, {
status: HealthCheckStatus.ERROR,
message,
phase: currentPhase,
logs
});
}
}
/**
* Promote a health check connection to an active connection.
* This avoids the need to reconnect when the server is needed for agentic flows.
*/
private promoteHealthCheckToConnection(serverId: string, connection: MCPConnection): void {
// Register tools from the connection
for (const tool of connection.tools) {
if (this.toolsIndex.has(tool.name)) {
console.warn(
`[MCPStore] Tool name conflict during promotion: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${serverId}". Using tool from "${serverId}".`
);
}
this.toolsIndex.set(tool.name, serverId);
}
// Add to active connections
this.connections.set(serverId, connection);
// Update state
this.updateState({
toolCount: this.toolsIndex.size,
connectedServers: Array.from(this.connections.keys())
});
}
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;
}
/**
* Get aggregated server instructions from all connected servers.
* Returns an array of { serverName, serverTitle, instructions } objects.
*/
getServerInstructions(): Array<{
serverName: string;
serverTitle?: string;
instructions: string;
}> {
const results: Array<{ serverName: string; serverTitle?: string; instructions: string }> = [];
for (const [serverName, connection] of this.connections) {
if (connection.instructions) {
results.push({
serverName,
serverTitle: connection.serverInfo?.title || connection.serverInfo?.name,
instructions: connection.instructions
});
}
}
return results;
}
/**
* Get server instructions from health check results (for display before active connection).
* Useful for showing instructions in settings UI.
*/
getHealthCheckInstructions(): Array<{
serverId: string;
serverTitle?: string;
instructions: string;
}> {
const results: Array<{ serverId: string; serverTitle?: string; instructions: string }> = [];
for (const [serverId, state] of Object.entries(this._healthChecks)) {
if (state.status === HealthCheckStatus.SUCCESS && state.instructions) {
results.push({
serverId,
serverTitle: state.serverInfo?.title || state.serverInfo?.name,
instructions: state.instructions
});
}
}
return results;
}
/**
* Check if any connected server has instructions.
*/
hasServerInstructions(): boolean {
for (const connection of this.connections.values()) {
if (connection.instructions) return true;
}
return false;
}
/**
*
*
* Resources Operations
*
*
*/
/**
* Check if any enabled server with successful health check supports resources.
* Uses health check state since servers may not have active connections until
* the user actually sends a message or uses prompts.
* @param perChatOverrides - Per-chat server overrides to filter by enabled servers.
* If provided (even empty array), only checks enabled servers.
* If undefined, checks all servers with successful health checks.
*/
hasResourcesCapability(perChatOverrides?: McpServerOverride[]): boolean {
// If perChatOverrides is provided (even empty array), filter by enabled servers
if (perChatOverrides !== undefined) {
const enabledServerIds = new Set(
perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId)
);
// No enabled servers = no capability
if (enabledServerIds.size === 0) return false;
// Check health check states for enabled servers with resources capability
for (const [serverId, state] of Object.entries(this._healthChecks)) {
if (!enabledServerIds.has(serverId)) continue;
if (
state.status === HealthCheckStatus.SUCCESS &&
state.capabilities?.server?.resources !== undefined
) {
return true;
}
}
// Also check active connections as fallback
for (const [serverName, connection] of this.connections) {
if (!enabledServerIds.has(serverName)) continue;
if (MCPService.supportsResources(connection)) {
return true;
}
}
return false;
}
// No overrides provided - check all servers (global mode)
for (const state of Object.values(this._healthChecks)) {
if (
state.status === HealthCheckStatus.SUCCESS &&
state.capabilities?.server?.resources !== undefined
) {
return true;
}
}
for (const connection of this.connections.values()) {
if (MCPService.supportsResources(connection)) {
return true;
}
}
return false;
}
/**
* Get list of servers that support resources.
* Checks active connections first, then health check state as fallback.
*/
getServersWithResources(): string[] {
const servers: string[] = [];
// Check active connections
for (const [name, connection] of this.connections) {
if (MCPService.supportsResources(connection) && !servers.includes(name)) {
servers.push(name);
}
}
// Also check health check states for servers not yet connected
for (const [serverId, state] of Object.entries(this._healthChecks)) {
if (
!servers.includes(serverId) &&
state.status === HealthCheckStatus.SUCCESS &&
state.capabilities?.server?.resources !== undefined
) {
servers.push(serverId);
}
}
return servers;
}
/**
* Fetch resources from all connected servers that support them.
* Updates mcpResourceStore with the results.
* @param forceRefresh - If true, bypass cache and fetch fresh data
*/
async fetchAllResources(forceRefresh: boolean = false): Promise<void> {
const serversWithResources = this.getServersWithResources();
if (serversWithResources.length === 0) {
return;
}
// Check if we have cached resources and they're recent (unless force refresh)
if (!forceRefresh) {
const allServersCached = serversWithResources.every((serverName) => {
const serverRes = mcpResourceStore.getServerResources(serverName);
if (!serverRes || !serverRes.lastFetched) return false;
// Cache is valid for 5 minutes
const age = Date.now() - serverRes.lastFetched.getTime();
return age < 5 * 60 * 1000;
});
if (allServersCached) {
console.log('[MCPStore] Using cached resources');
return;
}
}
mcpResourceStore.setLoading(true);
try {
await Promise.all(
serversWithResources.map((serverName) => this.fetchServerResources(serverName))
);
} finally {
mcpResourceStore.setLoading(false);
}
}
/**
* Fetch resources from a specific server.
* Updates mcpResourceStore with the results.
*/
async fetchServerResources(serverName: string): Promise<void> {
const connection = this.connections.get(serverName);
if (!connection) {
console.warn(`[MCPStore] No connection found for server: ${serverName}`);
return;
}
if (!MCPService.supportsResources(connection)) {
return;
}
mcpResourceStore.setServerLoading(serverName, true);
try {
const [resources, templates] = await Promise.all([
MCPService.listAllResources(connection),
MCPService.listAllResourceTemplates(connection)
]);
mcpResourceStore.setServerResources(serverName, resources, templates);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
mcpResourceStore.setServerError(serverName, message);
console.error(`[MCPStore][${serverName}] Failed to fetch resources:`, error);
}
}
/**
* Read resource content from a server.
* Caches the result in mcpResourceStore.
*/
async readResource(uri: string): Promise<import('$lib/types').MCPResourceContent[] | null> {
// Check cache first
const cached = mcpResourceStore.getCachedContent(uri);
if (cached) {
return cached.content;
}
// Find which server has this resource
const serverName = mcpResourceStore.findServerForUri(uri);
if (!serverName) {
console.error(`[MCPStore] No server found for resource URI: ${uri}`);
return null;
}
const connection = this.connections.get(serverName);
if (!connection) {
console.error(`[MCPStore] No connection found for server: ${serverName}`);
return null;
}
try {
const result = await MCPService.readResource(connection, uri);
const resourceInfo = mcpResourceStore.findResourceByUri(uri);
if (resourceInfo) {
mcpResourceStore.cacheResourceContent(resourceInfo, result.contents);
}
return result.contents;
} catch (error) {
console.error(`[MCPStore] Failed to read resource ${uri}:`, error);
return null;
}
}
/**
* Subscribe to resource updates.
*/
async subscribeToResource(uri: string): Promise<boolean> {
const serverName = mcpResourceStore.findServerForUri(uri);
if (!serverName) {
console.error(`[MCPStore] No server found for resource URI: ${uri}`);
return false;
}
const connection = this.connections.get(serverName);
if (!connection) {
console.error(`[MCPStore] No connection found for server: ${serverName}`);
return false;
}
if (!MCPService.supportsResourceSubscriptions(connection)) {
return false;
}
try {
await MCPService.subscribeResource(connection, uri);
mcpResourceStore.addSubscription(uri, serverName);
return true;
} catch (error) {
console.error(`[MCPStore] Failed to subscribe to resource ${uri}:`, error);
return false;
}
}
/**
* Unsubscribe from resource updates.
*/
async unsubscribeFromResource(uri: string): Promise<boolean> {
const serverName = mcpResourceStore.findServerForUri(uri);
if (!serverName) {
console.error(`[MCPStore] No server found for resource URI: ${uri}`);
return false;
}
const connection = this.connections.get(serverName);
if (!connection) {
console.error(`[MCPStore] No connection found for server: ${serverName}`);
return false;
}
try {
await MCPService.unsubscribeResource(connection, uri);
mcpResourceStore.removeSubscription(uri);
return true;
} catch (error) {
console.error(`[MCPStore] Failed to unsubscribe from resource ${uri}:`, error);
return false;
}
}
/**
* Add a resource as attachment to chat context.
* Automatically fetches content if not cached.
*/
async attachResource(uri: string): Promise<import('$lib/types').MCPResourceAttachment | null> {
const resourceInfo = mcpResourceStore.findResourceByUri(uri);
if (!resourceInfo) {
console.error(`[MCPStore] Resource not found: ${uri}`);
return null;
}
// Check if already attached
if (mcpResourceStore.isAttached(uri)) {
return null;
}
// Add attachment (initially loading)
const attachment = mcpResourceStore.addAttachment(resourceInfo);
// Fetch content
try {
const content = await this.readResource(uri);
if (content) {
mcpResourceStore.updateAttachmentContent(attachment.id, content);
} else {
mcpResourceStore.updateAttachmentError(attachment.id, 'Failed to read resource');
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
mcpResourceStore.updateAttachmentError(attachment.id, message);
}
return mcpResourceStore.getAttachment(attachment.id) ?? null;
}
/**
* Remove a resource attachment from chat context.
*/
removeResourceAttachment(attachmentId: string): void {
mcpResourceStore.removeAttachment(attachmentId);
}
/**
* Clear all resource attachments.
*/
clearResourceAttachments(): void {
mcpResourceStore.clearAttachments();
}
/**
* Get formatted resource context for chat.
*/
getResourceContextForChat(): string {
return mcpResourceStore.formatAttachmentsForContext();
}
}
export const mcpStore = new MCPStore();
export const mcpIsInitializing = () => mcpStore.isInitializing;
export const mcpIsInitialized = () => mcpStore.isInitialized;
export const mcpError = () => mcpStore.error;
export const mcpIsEnabled = () => mcpStore.isEnabled;
export const mcpAvailableTools = () => mcpStore.availableTools;
export const mcpConnectedServerCount = () => mcpStore.connectedServerCount;
export const mcpConnectedServerNames = () => mcpStore.connectedServerNames;
export const mcpToolCount = () => mcpStore.toolCount;
export const mcpServerInstructions = () => mcpStore.getServerInstructions();
export const mcpHasServerInstructions = () => mcpStore.hasServerInstructions();
// Resources exports
export const mcpHasResourcesCapability = () => mcpStore.hasResourcesCapability();
export const mcpServersWithResources = () => mcpStore.getServersWithResources();
export const mcpResourceContext = () => mcpStore.getResourceContextForChat();