diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz index e572817dca..aff92f3ab3 100644 Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte index 79547625b0..b6c583994d 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte @@ -1,7 +1,18 @@
{/if} - - {#if config().showToolCalls} - {#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls} - - - - - Tool calls: - - - {#if toolCalls && toolCalls.length > 0} - {#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)} - {@const badge = formatToolCallBadge(toolCall, index)} - - {/each} - {:else if fallbackToolCalls} - - {/if} - - {/if} - {/if}
{#if message.timestamp && !isEditing} @@ -410,17 +308,4 @@ white-space: pre-wrap; word-break: break-word; } - - .tool-call-badge { - max-width: 12rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .tool-call-badge--fallback { - max-width: 20rem; - white-space: normal; - word-break: break-word; - } diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte index b4ee2ac047..9964c95fc3 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte @@ -271,11 +271,6 @@ title: 'Developer', icon: Code, fields: [ - { - key: 'showToolCalls', - label: 'Show tool call labels', - type: 'checkbox' - }, { key: 'disableReasoningFormat', label: 'Show raw LLM output', diff --git a/tools/server/webui/src/lib/constants/settings-config.ts b/tools/server/webui/src/lib/constants/settings-config.ts index 19be02a440..a100a6e69e 100644 --- a/tools/server/webui/src/lib/constants/settings-config.ts +++ b/tools/server/webui/src/lib/constants/settings-config.ts @@ -6,7 +6,6 @@ export const SETTING_CONFIG_DEFAULT: Record = showSystemMessage: true, theme: 'system', showThoughtInProgress: false, - showToolCalls: false, disableReasoningFormat: false, keepStatsVisible: false, showMessageStats: true, @@ -94,8 +93,6 @@ export const SETTING_CONFIG_INFO: Record = { max_tokens: 'The maximum number of token per output. Use -1 for infinite (no limit).', custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.', showThoughtInProgress: 'Expand thought process by default when generating messages.', - showToolCalls: - 'Display tool call labels and payloads from Harmony-compatible delta.tool_calls data below assistant messages.', disableReasoningFormat: 'Show raw LLM output without backend parsing and frontend Markdown rendering to inspect streaming across different models.', keepStatsVisible: 'Keep processing statistics visible after generation finishes.', diff --git a/tools/server/webui/src/lib/mcp/host-manager.ts b/tools/server/webui/src/lib/mcp/host-manager.ts new file mode 100644 index 0000000000..73181a6de3 --- /dev/null +++ b/tools/server/webui/src/lib/mcp/host-manager.ts @@ -0,0 +1,354 @@ +/** + * MCPHostManager - Agregator wielu połączeń MCP. + * + * Zgodnie z architekturą MCP, Host: + * - Koordynuje wiele instancji Client (MCPServerConnection) + * - Agreguje tools/resources/prompts ze wszystkich serwerów + * - Routuje tool calls do odpowiedniego serwera + * - Zarządza lifecycle wszystkich połączeń + */ + +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; + }; +} + +export interface ServerStatus { + name: string; + isConnected: boolean; + toolCount: number; + error?: string; +} + +/** + * MCPHostManager manages multiple MCP server connections. + * + * This corresponds to the "Host" role in MCP architecture: + * - Coordinates multiple Client instances (MCPServerConnection) + * - Aggregates tools from all connected servers + * - Routes tool calls to the appropriate server + */ +export class MCPHostManager { + private connections = new Map(); + private toolsIndex = new Map(); // toolName → serverName + private _isInitialized = false; + private _initializationError: Error | null = null; + + // ───────────────────────────────────────────────────────────────────────── + // Lifecycle + // ───────────────────────────────────────────────────────────────────────── + + async initialize(config: MCPHostManagerConfig): Promise { + 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 { + 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; + } + + /** + * Returns tools in OpenAI function calling format. + * Ready to be sent to /v1/chat/completions API. + */ + getToolDefinitionsForLLM(): OpenAIToolDefinition[] { + return this.getAllTools().map((tool) => ({ + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: (tool.inputSchema as Record) ?? { + type: 'object', + properties: {}, + required: [] + } + } + })); + } + + /** + * 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 { + 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, + signal?: AbortSignal + ): Promise { + 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): Record { + 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; + } 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); + } +} diff --git a/tools/server/webui/src/lib/mcp/index.ts b/tools/server/webui/src/lib/mcp/index.ts index ca21837e4d..a1a14f2948 100644 --- a/tools/server/webui/src/lib/mcp/index.ts +++ b/tools/server/webui/src/lib/mcp/index.ts @@ -1,3 +1,21 @@ +// New architecture exports +export { MCPHostManager } from './host-manager'; +export type { MCPHostManagerConfig, OpenAIToolDefinition, ServerStatus } from './host-manager'; +export { MCPServerConnection } from './server-connection'; +export type { + MCPServerConnectionConfig, + ToolCallParams, + ToolExecutionResult +} from './server-connection'; + +// Legacy client export (deprecated - use MCPHostManager instead) export { MCPClient } from './client'; + +// Types export { MCPError } from '$lib/types/mcp'; -export type { MCPClientConfig, MCPServerConfig, MCPToolCall, IMCPClient } from '$lib/types/mcp'; +export type { + MCPClientConfig, + MCPServerConfig, + MCPToolCall, + MCPServerSettingsEntry +} from '$lib/types/mcp'; diff --git a/tools/server/webui/src/lib/mcp/server-connection.ts b/tools/server/webui/src/lib/mcp/server-connection.ts new file mode 100644 index 0000000000..5c2a7fb503 --- /dev/null +++ b/tools/server/webui/src/lib/mcp/server-connection.ts @@ -0,0 +1,289 @@ +/** + * MCPServerConnection - Wrapper na SDK Client dla pojedynczego serwera MCP. + * + * Zgodnie z architekturą MCP: + * - Jeden MCPServerConnection = jedno połączenie = jeden SDK Client + * - Izolacja między serwerami - każdy ma własny transport i capabilities + * - Własny lifecycle (connect, disconnect) + */ + +import { Client } from '@modelcontextprotocol/sdk/client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { MCPServerConfig, ClientCapabilities, Implementation } from '$lib/types/mcp'; +import { MCPError } from '$lib/types/mcp'; +import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp'; + +// Type for tool call result content item +interface ToolResultContentItem { + type: string; + text?: string; + data?: string; + mimeType?: string; + resource?: { text?: string; blob?: string; uri?: string }; +} + +// Type for tool call result +interface ToolCallResult { + content?: ToolResultContentItem[]; + isError?: boolean; + _meta?: Record; +} + +export interface MCPServerConnectionConfig { + /** Unique server name/identifier */ + name: string; + /** Server configuration */ + server: MCPServerConfig; + /** Client info to advertise */ + clientInfo?: Implementation; + /** Capabilities to advertise */ + capabilities?: ClientCapabilities; +} + +export interface ToolCallParams { + name: string; + arguments: Record; +} + +export interface ToolExecutionResult { + content: string; + isError: boolean; +} + +/** + * Represents a single connection to an MCP server. + * Wraps the SDK Client and provides a clean interface for tool operations. + */ +export class MCPServerConnection { + private client: Client; + private transport: Transport | null = null; + private _tools: Tool[] = []; + private _isConnected = false; + private _lastError: Error | null = null; + + readonly serverName: string; + readonly config: MCPServerConnectionConfig; + + constructor(config: MCPServerConnectionConfig) { + this.serverName = config.name; + this.config = config; + + const clientInfo = config.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo; + const capabilities = config.capabilities ?? DEFAULT_MCP_CONFIG.capabilities; + + // Create SDK Client with our host info + this.client = new Client( + { + name: clientInfo.name, + version: clientInfo.version ?? '1.0.0' + }, + { capabilities } + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Lifecycle + // ───────────────────────────────────────────────────────────────────────── + + async connect(): Promise { + if (this._isConnected) { + console.log(`[MCP][${this.serverName}] Already connected`); + return; + } + + try { + console.log(`[MCP][${this.serverName}] Creating transport...`); + this.transport = await this.createTransport(); + + console.log(`[MCP][${this.serverName}] Connecting to server...`); + // SDK Client.connect() performs: + // 1. initialize request → server + // 2. Receives server capabilities + // 3. Sends initialized notification + await this.client.connect(this.transport); + + console.log(`[MCP][${this.serverName}] Connected, listing tools...`); + await this.refreshTools(); + + this._isConnected = true; + this._lastError = null; + console.log( + `[MCP][${this.serverName}] Initialization complete with ${this._tools.length} tools` + ); + } catch (error) { + this._lastError = error instanceof Error ? error : new Error(String(error)); + console.error(`[MCP][${this.serverName}] Connection failed:`, error); + throw error; + } + } + + async disconnect(): Promise { + if (!this._isConnected) { + return; + } + + console.log(`[MCP][${this.serverName}] Disconnecting...`); + try { + await this.client.close(); + } catch (error) { + console.warn(`[MCP][${this.serverName}] Error during disconnect:`, error); + } + + this._isConnected = false; + this._tools = []; + this.transport = null; + } + + private async createTransport(): Promise { + const serverConfig = this.config.server; + + if (!serverConfig.url) { + throw new Error('MCP server configuration is missing url'); + } + + const url = new URL(serverConfig.url); + const requestInit: RequestInit = {}; + + if (serverConfig.headers) { + requestInit.headers = serverConfig.headers; + } + if (serverConfig.credentials) { + requestInit.credentials = serverConfig.credentials; + } + + // Try StreamableHTTP first (modern), fall back to SSE (legacy) + try { + console.log(`[MCP][${this.serverName}] Trying StreamableHTTP transport...`); + const transport = new StreamableHTTPClientTransport(url, { + requestInit, + sessionId: serverConfig.sessionId + }); + return transport; + } catch (httpError) { + console.warn( + `[MCP][${this.serverName}] StreamableHTTP failed, trying SSE transport...`, + httpError + ); + + try { + const transport = new SSEClientTransport(url, { + requestInit + }); + return transport; + } catch (sseError) { + const httpMsg = httpError instanceof Error ? httpError.message : String(httpError); + const sseMsg = sseError instanceof Error ? sseError.message : String(sseError); + throw new Error(`Failed to create transport. StreamableHTTP: ${httpMsg}; SSE: ${sseMsg}`); + } + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Tool Discovery + // ───────────────────────────────────────────────────────────────────────── + + private async refreshTools(): Promise { + try { + const toolsResult = await this.client.listTools(); + this._tools = toolsResult.tools ?? []; + } catch (error) { + console.warn(`[MCP][${this.serverName}] Failed to list tools:`, error); + this._tools = []; + } + } + + get tools(): Tool[] { + return this._tools; + } + + get toolNames(): string[] { + return this._tools.map((t) => t.name); + } + + // ───────────────────────────────────────────────────────────────────────── + // Tool Execution + // ───────────────────────────────────────────────────────────────────────── + + async callTool(params: ToolCallParams, signal?: AbortSignal): Promise { + if (!this._isConnected) { + throw new MCPError(`Server ${this.serverName} is not connected`, -32000); + } + + if (signal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + + try { + const result = await this.client.callTool( + { name: params.name, arguments: params.arguments }, + undefined, + { signal } + ); + + return { + content: this.formatToolResult(result as ToolCallResult), + isError: (result as ToolCallResult).isError ?? false + }; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + throw new MCPError(`Tool execution failed: ${message}`, -32603); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // State + // ───────────────────────────────────────────────────────────────────────── + + get isConnected(): boolean { + return this._isConnected; + } + + get lastError(): Error | null { + return this._lastError; + } + + // ───────────────────────────────────────────────────────────────────────── + // Formatting + // ───────────────────────────────────────────────────────────────────────── + + private formatToolResult(result: ToolCallResult): string { + const content = result.content; + if (Array.isArray(content)) { + return content + .map((item) => this.formatSingleContent(item)) + .filter(Boolean) + .join('\n'); + } + return ''; + } + + private formatSingleContent(content: ToolResultContentItem): string { + if (content.type === 'text' && content.text) { + return content.text; + } + if (content.type === 'image' && content.data) { + return `data:${content.mimeType ?? 'image/png'};base64,${content.data}`; + } + if (content.type === 'resource' && content.resource) { + const resource = content.resource; + if (resource.text) { + return resource.text; + } + if (resource.blob) { + return resource.blob; + } + return JSON.stringify(resource); + } + // audio type + if (content.data && content.mimeType) { + return `data:${content.mimeType};base64,${content.data}`; + } + return JSON.stringify(content); + } +} diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts index 636d3c5463..02fc6381c0 100644 --- a/tools/server/webui/src/lib/services/chat.ts +++ b/tools/server/webui/src/lib/services/chat.ts @@ -1,15 +1,5 @@ -import { getAuthHeaders, getJsonHeaders } from '$lib/utils'; +import { getJsonHeaders } from '$lib/utils'; import { AttachmentType } from '$lib/enums'; -import { config } from '$lib/stores/settings.svelte'; -import { getAgenticConfig } from '$lib/config/agentic'; -import { OpenAISseClient, type OpenAISseTurnResult } from '$lib/agentic/openai-sse-client'; -import type { - AgenticChatCompletionRequest, - AgenticMessage, - AgenticToolCallList -} from '$lib/agentic/types'; -import { toAgenticMessages } from '$lib/agentic/types'; -import type { MCPToolCall, IMCPClient } from '$lib/mcp'; /** * ChatService - Low-level API communication layer for Chat Completions @@ -62,8 +52,7 @@ export class ChatService { messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[], options: SettingsChatServiceOptions = {}, conversationId?: string, - signal?: AbortSignal, - mcpClient?: IMCPClient + signal?: AbortSignal ): Promise { const { stream, @@ -184,38 +173,6 @@ export class ChatService { } } - // MCP agentic orchestration - // Run agentic loop if MCP client is provided and agentic mode is enabled - const agenticConfig = getAgenticConfig(config()); - if (stream && agenticConfig.enabled && mcpClient) { - console.log('[ChatService] Running agentic loop with MCP client'); - try { - const agenticResult = await ChatService.runAgenticLoop({ - mcpClient, - messages: normalizedMessages, - requestBody, - agenticConfig, - callbacks: { - onChunk, - onReasoningChunk, - onToolCallChunk, - onModel, - onComplete, - onError, - onTimings - }, - signal - }); - - if (agenticResult) { - return; // Agentic loop handled the request - } - // Fall through to standard flow if agentic loop returned false - } catch (error) { - console.warn('[ChatService] Agentic loop failed, falling back to standard flow:', error); - } - } - try { const response = await fetch(`./v1/chat/completions`, { method: 'POST', @@ -824,251 +781,4 @@ export class ChatService { onTimingsCallback(timings, promptProgress); } - - // ───────────────────────────────────────────────────────────────────────────── - // Agentic Orchestration - // ───────────────────────────────────────────────────────────────────────────── - - /** - * Run the agentic orchestration loop with MCP tools. - * The MCP client is passed from the store layer - ChatService remains stateless. - * - * @param params - Parameters for the agentic loop including the MCP client - * @returns true if agentic loop handled the request - */ - private static async runAgenticLoop(params: { - mcpClient: IMCPClient; - messages: ApiChatMessageData[]; - requestBody: ApiChatCompletionRequest; - agenticConfig: ReturnType; - callbacks: { - onChunk?: (chunk: string) => void; - onReasoningChunk?: (chunk: string) => void; - onToolCallChunk?: (serializedToolCalls: string) => void; - onModel?: (model: string) => void; - onComplete?: ( - content: string, - reasoningContent?: string, - timings?: ChatMessageTimings, - toolCalls?: string - ) => void; - onError?: (error: Error) => void; - onTimings?: (timings: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void; - }; - signal?: AbortSignal; - }): Promise { - const { mcpClient, messages, requestBody, agenticConfig, callbacks, signal } = params; - const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onError, onTimings } = - callbacks; - - console.log(`[ChatService] Running agentic loop with ${mcpClient.listTools().length} tools`); - - // Set up LLM client - const llmClient = new OpenAISseClient({ - url: './v1/chat/completions', - buildHeaders: () => getAuthHeaders() - }); - - // Prepare session state - const sessionMessages: AgenticMessage[] = toAgenticMessages(messages); - const tools = await mcpClient.getToolsDefinition(); - const allToolCalls: ApiChatCompletionToolCall[] = []; - let capturedTimings: ChatMessageTimings | undefined; - - const requestWithoutMessages = { ...requestBody }; - delete (requestWithoutMessages as Partial).messages; - const requestBase: AgenticChatCompletionRequest = { - ...(requestWithoutMessages as Omit), - stream: true, - messages: [] - }; - - const maxTurns = agenticConfig.maxTurns; - const maxToolPreviewLines = agenticConfig.maxToolPreviewLines; - - // Run agentic loop - for (let turn = 0; turn < maxTurns; turn++) { - if (signal?.aborted) { - onComplete?.('', undefined, capturedTimings, undefined); - return true; - } - - const llmRequest: AgenticChatCompletionRequest = { - ...requestBase, - messages: sessionMessages, - tools: tools.length > 0 ? tools : undefined - }; - - const shouldFilterReasoning = agenticConfig.filterReasoningAfterFirstTurn && turn > 0; - - let turnResult: OpenAISseTurnResult; - try { - turnResult = await llmClient.stream( - llmRequest, - { - onChunk, - onReasoningChunk: shouldFilterReasoning ? undefined : onReasoningChunk, - onModel, - onFirstValidChunk: undefined, - onProcessingUpdate: (timings, progress) => { - ChatService.notifyTimings(timings, progress, onTimings); - if (timings) capturedTimings = timings; - } - }, - signal - ); - } catch (error) { - if (signal?.aborted) { - onComplete?.('', undefined, capturedTimings, undefined); - return true; - } - const normalizedError = error instanceof Error ? error : new Error('LLM stream error'); - onError?.(normalizedError); - onChunk?.(`\n\n\`\`\`\nUpstream LLM error:\n${normalizedError.message}\n\`\`\`\n`); - onComplete?.('', undefined, capturedTimings, undefined); - return true; - } - - // Check if we should stop (no tool calls or finish reason isn't tool_calls) - if ( - turnResult.toolCalls.length === 0 || - (turnResult.finishReason && turnResult.finishReason !== 'tool_calls') - ) { - onComplete?.('', undefined, capturedTimings, undefined); - return true; - } - - // Normalize and validate tool calls - const normalizedCalls = ChatService.normalizeToolCalls(turnResult.toolCalls); - if (normalizedCalls.length === 0) { - onComplete?.('', undefined, capturedTimings, undefined); - return true; - } - - // Accumulate tool calls - for (const call of normalizedCalls) { - allToolCalls.push({ - id: call.id, - type: call.type, - function: call.function ? { ...call.function } : undefined - }); - } - onToolCallChunk?.(JSON.stringify(allToolCalls)); - - // Add assistant message with tool calls to session - sessionMessages.push({ - role: 'assistant', - content: turnResult.content || undefined, - tool_calls: normalizedCalls - }); - - // Execute each tool call - for (const toolCall of normalizedCalls) { - if (signal?.aborted) { - onComplete?.('', undefined, capturedTimings, undefined); - return true; - } - - const mcpCall: MCPToolCall = { - id: toolCall.id, - function: { - name: toolCall.function.name, - arguments: toolCall.function.arguments - } - }; - - let result: string; - try { - result = await mcpClient.execute(mcpCall, signal); - } catch (error) { - if (error instanceof Error && error.name !== 'AbortError') { - onError?.(error); - } - result = `Error: ${error instanceof Error ? error.message : String(error)}`; - } - - if (signal?.aborted) { - onComplete?.('', undefined, capturedTimings, undefined); - return true; - } - - // Emit tool preview - ChatService.emitToolPreview(toolCall, result, maxToolPreviewLines, onChunk); - - // Add tool result to session (sanitize base64 images for context) - const contextValue = ChatService.isBase64Image(result) - ? '[Image displayed to user]' - : result; - sessionMessages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: contextValue - }); - } - } - - // Turn limit reached - onChunk?.('\n\n```\nTurn limit reached\n```\n'); - onComplete?.('', undefined, capturedTimings, undefined); - return true; - } - - /** - * Normalize tool calls from LLM response - */ - private static normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList { - if (!toolCalls) return []; - return toolCalls.map((call, index) => ({ - id: call?.id ?? `tool_${index}`, - type: (call?.type as 'function') ?? 'function', - function: { - name: call?.function?.name ?? '', - arguments: call?.function?.arguments ?? '' - } - })); - } - - /** - * Emit tool call preview to the chunk callback - */ - private static emitToolPreview( - toolCall: AgenticToolCallList[number], - result: string, - maxLines: number, - emit?: (chunk: string) => void - ): void { - if (!emit) return; - - const toolName = toolCall.function.name; - const toolArgs = toolCall.function.arguments; - - let output = `\n\n`; - output += `\n`; - output += `\n`; - - if (ChatService.isBase64Image(result)) { - output += `\n![tool-result](${result.trim()})`; - } else { - const lines = result.split('\n'); - const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines; - output += `\n\`\`\`\n${trimmedLines.join('\n')}\n\`\`\``; - } - - output += `\n\n`; - emit(output); - } - - /** - * Check if content is a base64 image - */ - private static isBase64Image(content: string): boolean { - const trimmed = content.trim(); - if (!trimmed.startsWith('data:image/')) return false; - - const match = trimmed.match(/^data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/]+=*)$/); - if (!match) return false; - - const base64Payload = match[2]; - return base64Payload.length > 0 && base64Payload.length % 4 === 0; - } } diff --git a/tools/server/webui/src/lib/services/parameter-sync.ts b/tools/server/webui/src/lib/services/parameter-sync.ts index d124cf5c8d..ec39f29761 100644 --- a/tools/server/webui/src/lib/services/parameter-sync.ts +++ b/tools/server/webui/src/lib/services/parameter-sync.ts @@ -69,7 +69,6 @@ export const SYNCABLE_PARAMETERS: SyncableParameter[] = [ type: 'boolean', canSync: true }, - { key: 'showToolCalls', serverKey: 'showToolCalls', type: 'boolean', canSync: true }, { key: 'disableReasoningFormat', serverKey: 'disableReasoningFormat', diff --git a/tools/server/webui/src/lib/stores/agentic.svelte.ts b/tools/server/webui/src/lib/stores/agentic.svelte.ts new file mode 100644 index 0000000000..7ee99f44b0 --- /dev/null +++ b/tools/server/webui/src/lib/stores/agentic.svelte.ts @@ -0,0 +1,473 @@ +/** + * agenticStore - Orchestration of the agentic loop with MCP tools + * + * This store is responsible for: + * - Managing the agentic loop lifecycle + * - Coordinating between LLM and MCP tool execution + * - Tracking session state (messages, turns, tool calls) + * - Emitting streaming content and tool results + * + * **Architecture & Relationships:** + * - **mcpStore**: Provides MCP host manager for tool execution + * - **chatStore**: Triggers agentic flow and receives streaming updates + * - **OpenAISseClient**: LLM communication for streaming responses + * - **settingsStore**: Provides agentic configuration (maxTurns, etc.) + * + * **Key Features:** + * - Stateful session management (unlike stateless ChatService) + * - Multi-turn tool call orchestration + * - Automatic routing of tool calls to appropriate MCP servers + * - Raw LLM output streaming (UI formatting is separate concern) + */ + +import { mcpStore } from '$lib/stores/mcp.svelte'; +import { OpenAISseClient, type OpenAISseTurnResult } from '$lib/agentic/openai-sse-client'; +import { + toAgenticMessages, + type AgenticMessage, + type AgenticChatCompletionRequest, + type AgenticToolCallList +} from '$lib/agentic/types'; +import type { ApiChatCompletionToolCall, ApiChatMessageData } from '$lib/types/api'; +import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat'; +import type { MCPToolCall } from '$lib/types/mcp'; +import type { DatabaseMessage, DatabaseMessageExtra } from '$lib/types/database'; +import { getAgenticConfig } from '$lib/config/agentic'; +import { config } from '$lib/stores/settings.svelte'; +import { getAuthHeaders } from '$lib/utils'; +import { ChatService } from '$lib/services'; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface AgenticFlowCallbacks { + onChunk?: (chunk: string) => void; + onReasoningChunk?: (chunk: string) => void; + onToolCallChunk?: (serializedToolCalls: string) => void; + onModel?: (model: string) => void; + onComplete?: ( + content: string, + reasoningContent?: string, + timings?: ChatMessageTimings, + toolCalls?: string + ) => void; + onError?: (error: Error) => void; + onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void; +} + +export interface AgenticFlowOptions { + stream?: boolean; + model?: string; + temperature?: number; + max_tokens?: number; + [key: string]: unknown; +} + +export interface AgenticFlowParams { + messages: (ApiChatMessageData | (DatabaseMessage & { extra?: DatabaseMessageExtra[] }))[]; + options?: AgenticFlowOptions; + callbacks: AgenticFlowCallbacks; + signal?: AbortSignal; +} + +export interface AgenticFlowResult { + handled: boolean; + error?: Error; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Agentic Store +// ───────────────────────────────────────────────────────────────────────────── + +class AgenticStore { + // ───────────────────────────────────────────────────────────────────────────── + // State + // ───────────────────────────────────────────────────────────────────────────── + + private _isRunning = $state(false); + private _currentTurn = $state(0); + private _totalToolCalls = $state(0); + private _lastError = $state(null); + + // ───────────────────────────────────────────────────────────────────────────── + // Getters + // ───────────────────────────────────────────────────────────────────────────── + + get isRunning(): boolean { + return this._isRunning; + } + + get currentTurn(): number { + return this._currentTurn; + } + + get totalToolCalls(): number { + return this._totalToolCalls; + } + + get lastError(): Error | null { + return this._lastError; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Main Agentic Flow + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Run the agentic orchestration loop with MCP tools. + * + * This is the main entry point called by chatStore when agentic mode is enabled. + * It coordinates: + * 1. Initial LLM request with available tools + * 2. Tool call detection and execution via MCP + * 3. Multi-turn loop until completion or turn limit + * + * @returns AgenticFlowResult indicating if the flow handled the request + */ + async runAgenticFlow(params: AgenticFlowParams): Promise { + const { messages, options = {}, callbacks, signal } = params; + const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onError, onTimings } = + callbacks; + + // Get agentic configuration + const agenticConfig = getAgenticConfig(config()); + if (!agenticConfig.enabled) { + return { handled: false }; + } + + // Ensure MCP is initialized + const hostManager = await mcpStore.ensureInitialized(); + if (!hostManager) { + console.log('[AgenticStore] MCP not initialized, falling back to standard chat'); + return { handled: false }; + } + + const tools = mcpStore.getToolDefinitions(); + if (tools.length === 0) { + console.log('[AgenticStore] No tools available, falling back to standard chat'); + return { handled: false }; + } + + console.log(`[AgenticStore] Starting agentic flow with ${tools.length} tools`); + + // Normalize messages to API format + const normalizedMessages: ApiChatMessageData[] = messages + .map((msg) => { + if ('id' in msg && 'convId' in msg && 'timestamp' in msg) { + // DatabaseMessage - use ChatService to convert + return ChatService.convertDbMessageToApiChatMessageData( + msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] } + ); + } + return msg as ApiChatMessageData; + }) + .filter((msg) => { + // Filter out empty system messages + if (msg.role === 'system') { + const content = typeof msg.content === 'string' ? msg.content : ''; + return content.trim().length > 0; + } + return true; + }); + + // Reset state + this._isRunning = true; + this._currentTurn = 0; + this._totalToolCalls = 0; + this._lastError = null; + + try { + await this.executeAgenticLoop({ + messages: normalizedMessages, + options, + tools, + agenticConfig, + callbacks: { + onChunk, + onReasoningChunk, + onToolCallChunk, + onModel, + onComplete, + onError, + onTimings + }, + signal + }); + return { handled: true }; + } catch (error) { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + this._lastError = normalizedError; + onError?.(normalizedError); + return { handled: true, error: normalizedError }; + } finally { + this._isRunning = false; + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Private: Agentic Loop Implementation + // ───────────────────────────────────────────────────────────────────────────── + + private async executeAgenticLoop(params: { + messages: ApiChatMessageData[]; + options: AgenticFlowOptions; + tools: ReturnType; + agenticConfig: ReturnType; + callbacks: AgenticFlowCallbacks; + signal?: AbortSignal; + }): Promise { + const { messages, options, tools, agenticConfig, callbacks, signal } = params; + const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onTimings } = + callbacks; + + // Set up LLM client + const llmClient = new OpenAISseClient({ + url: './v1/chat/completions', + buildHeaders: () => getAuthHeaders() + }); + + // Prepare session state + const sessionMessages: AgenticMessage[] = toAgenticMessages(messages); + const allToolCalls: ApiChatCompletionToolCall[] = []; + let capturedTimings: ChatMessageTimings | undefined; + + // Build base request from options (messages change per turn) + const requestBase: AgenticChatCompletionRequest = { + ...options, + stream: true, + messages: [] + }; + + const maxTurns = agenticConfig.maxTurns; + const maxToolPreviewLines = agenticConfig.maxToolPreviewLines; + + // Run agentic loop + for (let turn = 0; turn < maxTurns; turn++) { + this._currentTurn = turn + 1; + + if (signal?.aborted) { + onComplete?.('', undefined, capturedTimings, undefined); + return; + } + + // Build LLM request for this turn + const llmRequest: AgenticChatCompletionRequest = { + ...requestBase, + messages: sessionMessages, + tools: tools.length > 0 ? tools : undefined + }; + + // Filter reasoning content after first turn if configured + const shouldFilterReasoning = agenticConfig.filterReasoningAfterFirstTurn && turn > 0; + + // Stream from LLM + let turnResult: OpenAISseTurnResult; + try { + turnResult = await llmClient.stream( + llmRequest, + { + onChunk, + onReasoningChunk: shouldFilterReasoning ? undefined : onReasoningChunk, + onModel, + onFirstValidChunk: undefined, + onProcessingUpdate: (timings, progress) => { + onTimings?.(timings, progress); + if (timings) capturedTimings = timings; + } + }, + signal + ); + } catch (error) { + if (signal?.aborted) { + onComplete?.('', undefined, capturedTimings, undefined); + return; + } + const normalizedError = error instanceof Error ? error : new Error('LLM stream error'); + onChunk?.(`\n\n\`\`\`\nUpstream LLM error:\n${normalizedError.message}\n\`\`\`\n`); + onComplete?.('', undefined, capturedTimings, undefined); + throw normalizedError; + } + + // Check if we should stop (no tool calls or finish reason isn't tool_calls) + if ( + turnResult.toolCalls.length === 0 || + (turnResult.finishReason && turnResult.finishReason !== 'tool_calls') + ) { + onComplete?.('', undefined, capturedTimings, undefined); + return; + } + + // Normalize and validate tool calls + const normalizedCalls = this.normalizeToolCalls(turnResult.toolCalls); + if (normalizedCalls.length === 0) { + onComplete?.('', undefined, capturedTimings, undefined); + return; + } + + // Accumulate tool calls + for (const call of normalizedCalls) { + allToolCalls.push({ + id: call.id, + type: call.type, + function: call.function ? { ...call.function } : undefined + }); + } + this._totalToolCalls = allToolCalls.length; + onToolCallChunk?.(JSON.stringify(allToolCalls)); + + // Add assistant message with tool calls to session + sessionMessages.push({ + role: 'assistant', + content: turnResult.content || undefined, + tool_calls: normalizedCalls + }); + + // Execute each tool call via MCP + for (const toolCall of normalizedCalls) { + if (signal?.aborted) { + onComplete?.('', undefined, capturedTimings, undefined); + return; + } + + const mcpCall: MCPToolCall = { + id: toolCall.id, + function: { + name: toolCall.function.name, + arguments: toolCall.function.arguments + } + }; + + let result: string; + try { + const executionResult = await mcpStore.executeTool(mcpCall, signal); + result = executionResult.content; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + onComplete?.('', undefined, capturedTimings, undefined); + return; + } + result = `Error: ${error instanceof Error ? error.message : String(error)}`; + } + + if (signal?.aborted) { + onComplete?.('', undefined, capturedTimings, undefined); + return; + } + + // Emit tool preview (raw output for UI to format later) + this.emitToolPreview(toolCall, result, maxToolPreviewLines, onChunk); + + // Add tool result to session (sanitize base64 images for context) + const contextValue = this.isBase64Image(result) ? '[Image displayed to user]' : result; + sessionMessages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: contextValue + }); + } + } + + // Turn limit reached + onChunk?.('\n\n```\nTurn limit reached\n```\n'); + onComplete?.('', undefined, capturedTimings, undefined); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Private: Helper Methods + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Normalize tool calls from LLM response + */ + private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList { + if (!toolCalls) return []; + return toolCalls.map((call, index) => ({ + id: call?.id ?? `tool_${index}`, + type: (call?.type as 'function') ?? 'function', + function: { + name: call?.function?.name ?? '', + arguments: call?.function?.arguments ?? '' + } + })); + } + + /** + * Emit tool call preview to the chunk callback. + * Output is raw/sterile - UI formatting is a separate concern. + */ + private emitToolPreview( + toolCall: AgenticToolCallList[number], + result: string, + maxLines: number, + emit?: (chunk: string) => void + ): void { + if (!emit) return; + + const toolName = toolCall.function.name; + const toolArgs = toolCall.function.arguments; + + let output = `\n\n`; + output += `\n`; + output += `\n`; + + if (this.isBase64Image(result)) { + output += `\n![tool-result](${result.trim()})`; + } else { + const lines = result.split('\n'); + const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines; + output += `\n\`\`\`\n${trimmedLines.join('\n')}\n\`\`\``; + } + + output += `\n\n`; + emit(output); + } + + /** + * Check if content is a base64 image + */ + private isBase64Image(content: string): boolean { + const trimmed = content.trim(); + if (!trimmed.startsWith('data:image/')) return false; + + const match = trimmed.match(/^data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/]+=*)$/); + if (!match) return false; + + const base64Payload = match[2]; + return base64Payload.length > 0 && base64Payload.length % 4 === 0; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Utilities + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Clear error state + */ + clearError(): void { + this._lastError = null; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Singleton Instance & Exports +// ───────────────────────────────────────────────────────────────────────────── + +export const agenticStore = new AgenticStore(); + +// Reactive exports for components +export function agenticIsRunning() { + return agenticStore.isRunning; +} + +export function agenticCurrentTurn() { + return agenticStore.currentTurn; +} + +export function agenticTotalToolCalls() { + return agenticStore.totalToolCalls; +} + +export function agenticLastError() { + return agenticStore.lastError; +} diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts index 63d4237e5b..ffcc0a13e6 100644 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ b/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -1,7 +1,7 @@ import { DatabaseService, ChatService } from '$lib/services'; import { conversationsStore } from '$lib/stores/conversations.svelte'; import { config } from '$lib/stores/settings.svelte'; -import { mcpStore } from '$lib/stores/mcp.svelte'; +import { agenticStore } from '$lib/stores/agentic.svelte'; import { contextSize, isRouterMode } from '$lib/stores/server.svelte'; import { selectedModelName, @@ -519,130 +519,150 @@ class ChatStore { const abortController = this.getOrCreateAbortController(assistantMessage.convId); - // Get MCP client if agentic mode is enabled (store layer responsibility) - const agenticConfig = getAgenticConfig(config()); - const mcpClient = agenticConfig.enabled ? await mcpStore.ensureClient() : undefined; + // Build common callbacks for both agentic and standard flows + const streamCallbacks = { + onChunk: (chunk: string) => { + streamedContent += chunk; + this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id); + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + conversationsStore.updateMessageAtIndex(idx, { content: streamedContent }); + }, + onReasoningChunk: (reasoningChunk: string) => { + streamedReasoningContent += reasoningChunk; + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + conversationsStore.updateMessageAtIndex(idx, { thinking: streamedReasoningContent }); + }, + onToolCallChunk: (toolCallChunk: string) => { + const chunk = toolCallChunk.trim(); + if (!chunk) return; + streamedToolCallContent = chunk; + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent }); + }, + onModel: (modelName: string) => recordModel(modelName), + onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => { + const tokensPerSecond = + timings?.predicted_ms && timings?.predicted_n + ? (timings.predicted_n / timings.predicted_ms) * 1000 + : 0; + this.updateProcessingStateFromTimings( + { + prompt_n: timings?.prompt_n || 0, + prompt_ms: timings?.prompt_ms, + predicted_n: timings?.predicted_n || 0, + predicted_per_second: tokensPerSecond, + cache_n: timings?.cache_n || 0, + prompt_progress: promptProgress + }, + assistantMessage.convId + ); + }, + onComplete: async ( + finalContent?: string, + reasoningContent?: string, + timings?: ChatMessageTimings, + toolCallContent?: string + ) => { + this.stopStreaming(); + const updateData: Record = { + content: finalContent || streamedContent, + thinking: reasoningContent || streamedReasoningContent, + toolCalls: toolCallContent || streamedToolCallContent, + timings + }; + if (resolvedModel && !modelPersisted) { + updateData.model = resolvedModel; + } + await DatabaseService.updateMessage(assistantMessage.id, updateData); + + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + const uiUpdate: Partial = { + content: updateData.content as string, + toolCalls: updateData.toolCalls as string + }; + if (timings) uiUpdate.timings = timings; + if (resolvedModel) uiUpdate.model = resolvedModel; + + conversationsStore.updateMessageAtIndex(idx, uiUpdate); + await conversationsStore.updateCurrentNode(assistantMessage.id); + + if (onComplete) await onComplete(streamedContent); + this.setChatLoading(assistantMessage.convId, false); + this.clearChatStreaming(assistantMessage.convId); + this.clearProcessingState(assistantMessage.convId); + + if (isRouterMode()) { + modelsStore.fetchRouterModels().catch(console.error); + } + }, + onError: (error: Error) => { + this.stopStreaming(); + + if (this.isAbortError(error)) { + this.setChatLoading(assistantMessage.convId, false); + this.clearChatStreaming(assistantMessage.convId); + this.clearProcessingState(assistantMessage.convId); + + return; + } + + console.error('Streaming error:', error); + + this.setChatLoading(assistantMessage.convId, false); + this.clearChatStreaming(assistantMessage.convId); + this.clearProcessingState(assistantMessage.convId); + + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + + if (idx !== -1) { + const failedMessage = conversationsStore.removeMessageAtIndex(idx); + if (failedMessage) DatabaseService.deleteMessage(failedMessage.id).catch(console.error); + } + + const contextInfo = ( + error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } + ).contextInfo; + + this.showErrorDialog( + error.name === 'TimeoutError' ? 'timeout' : 'server', + error.message, + contextInfo + ); + + if (onError) onError(error); + } + }; + + // Try agentic flow first if enabled + const agenticConfig = getAgenticConfig(config()); + if (agenticConfig.enabled) { + const agenticResult = await agenticStore.runAgenticFlow({ + messages: allMessages, + options: { + ...this.getApiOptions(), + ...(modelOverride ? { model: modelOverride } : {}) + }, + callbacks: streamCallbacks, + signal: abortController.signal + }); + + if (agenticResult.handled) { + return; // Agentic flow handled the request + } + // Fall through to standard ChatService if not handled + } + + // Standard ChatService flow await ChatService.sendMessage( allMessages, { ...this.getApiOptions(), ...(modelOverride ? { model: modelOverride } : {}), - onChunk: (chunk: string) => { - streamedContent += chunk; - this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id); - const idx = conversationsStore.findMessageIndex(assistantMessage.id); - conversationsStore.updateMessageAtIndex(idx, { content: streamedContent }); - }, - onReasoningChunk: (reasoningChunk: string) => { - streamedReasoningContent += reasoningChunk; - const idx = conversationsStore.findMessageIndex(assistantMessage.id); - conversationsStore.updateMessageAtIndex(idx, { thinking: streamedReasoningContent }); - }, - onToolCallChunk: (toolCallChunk: string) => { - const chunk = toolCallChunk.trim(); - if (!chunk) return; - streamedToolCallContent = chunk; - const idx = conversationsStore.findMessageIndex(assistantMessage.id); - conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent }); - }, - onModel: (modelName: string) => recordModel(modelName), - onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => { - const tokensPerSecond = - timings?.predicted_ms && timings?.predicted_n - ? (timings.predicted_n / timings.predicted_ms) * 1000 - : 0; - this.updateProcessingStateFromTimings( - { - prompt_n: timings?.prompt_n || 0, - prompt_ms: timings?.prompt_ms, - predicted_n: timings?.predicted_n || 0, - predicted_per_second: tokensPerSecond, - cache_n: timings?.cache_n || 0, - prompt_progress: promptProgress - }, - assistantMessage.convId - ); - }, - onComplete: async ( - finalContent?: string, - reasoningContent?: string, - timings?: ChatMessageTimings, - toolCallContent?: string - ) => { - this.stopStreaming(); - - const updateData: Record = { - content: finalContent || streamedContent, - thinking: reasoningContent || streamedReasoningContent, - toolCalls: toolCallContent || streamedToolCallContent, - timings - }; - if (resolvedModel && !modelPersisted) { - updateData.model = resolvedModel; - } - await DatabaseService.updateMessage(assistantMessage.id, updateData); - - const idx = conversationsStore.findMessageIndex(assistantMessage.id); - const uiUpdate: Partial = { - content: updateData.content as string, - toolCalls: updateData.toolCalls as string - }; - if (timings) uiUpdate.timings = timings; - if (resolvedModel) uiUpdate.model = resolvedModel; - - conversationsStore.updateMessageAtIndex(idx, uiUpdate); - await conversationsStore.updateCurrentNode(assistantMessage.id); - - if (onComplete) await onComplete(streamedContent); - this.setChatLoading(assistantMessage.convId, false); - this.clearChatStreaming(assistantMessage.convId); - this.clearProcessingState(assistantMessage.convId); - - if (isRouterMode()) { - modelsStore.fetchRouterModels().catch(console.error); - } - }, - onError: (error: Error) => { - this.stopStreaming(); - - if (this.isAbortError(error)) { - this.setChatLoading(assistantMessage.convId, false); - this.clearChatStreaming(assistantMessage.convId); - this.clearProcessingState(assistantMessage.convId); - - return; - } - - console.error('Streaming error:', error); - - this.setChatLoading(assistantMessage.convId, false); - this.clearChatStreaming(assistantMessage.convId); - this.clearProcessingState(assistantMessage.convId); - - const idx = conversationsStore.findMessageIndex(assistantMessage.id); - - if (idx !== -1) { - const failedMessage = conversationsStore.removeMessageAtIndex(idx); - if (failedMessage) DatabaseService.deleteMessage(failedMessage.id).catch(console.error); - } - - const contextInfo = ( - error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } - ).contextInfo; - - this.showErrorDialog( - error.name === 'TimeoutError' ? 'timeout' : 'server', - error.message, - contextInfo - ); - - if (onError) onError(error); - } + ...streamCallbacks }, assistantMessage.convId, - abortController.signal, - mcpClient ?? undefined + abortController.signal ); } @@ -1127,8 +1147,7 @@ class ChatStore { } }, msg.convId, - abortController.signal, - undefined // No MCP for continue generation + abortController.signal ); } catch (error) { if (!this.isAbortError(error)) console.error('Failed to continue message:', error); diff --git a/tools/server/webui/src/lib/stores/mcp.svelte.ts b/tools/server/webui/src/lib/stores/mcp.svelte.ts index 4e16a0d5f8..205401396b 100644 --- a/tools/server/webui/src/lib/stores/mcp.svelte.ts +++ b/tools/server/webui/src/lib/stores/mcp.svelte.ts @@ -1,26 +1,36 @@ import { browser } from '$app/environment'; -import { MCPClient, type IMCPClient } from '$lib/mcp'; +import { + MCPHostManager, + type OpenAIToolDefinition, + type ServerStatus +} from '$lib/mcp/host-manager'; +import type { ToolExecutionResult } from '$lib/mcp/server-connection'; import { buildMcpClientConfig } from '$lib/config/mcp'; import { config } from '$lib/stores/settings.svelte'; +import type { MCPToolCall } from '$lib/types/mcp'; +import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp'; /** - * mcpStore - Reactive store for MCP (Model Context Protocol) client management + * mcpStore - Reactive store for MCP (Model Context Protocol) host management * * This store manages: - * - MCP client lifecycle (initialization, shutdown) - * - Connection state tracking - * - Available tools from connected MCP servers + * - MCPHostManager lifecycle (initialization, shutdown) + * - Connection state tracking for multiple MCP servers + * - Aggregated tools from all connected MCP servers * - Error handling for MCP operations * * **Architecture & Relationships:** - * - **MCPClient**: SDK-based client wrapper for MCP server communication - * - **mcpStore** (this class): Reactive store for MCP state - * - **ChatService**: Uses mcpStore for agentic orchestration + * - **MCPHostManager**: Coordinates multiple MCPServerConnection instances + * - **MCPServerConnection**: Single SDK Client wrapper per server + * - **mcpStore** (this class): Reactive Svelte store for MCP state + * - **agenticStore**: Uses mcpStore for tool execution in agentic loop * - **settingsStore**: Provides MCP server configuration * * **Key Features:** * - Reactive state with Svelte 5 runes ($state, $derived) * - Automatic reinitialization on config changes + * - Aggregates tools from multiple servers + * - Routes tool calls to appropriate server automatically * - Graceful error handling with fallback to standard chat */ class MCPStore { @@ -28,18 +38,18 @@ class MCPStore { // State // ───────────────────────────────────────────────────────────────────────────── - private _client = $state(null); + private _hostManager = $state(null); private _isInitializing = $state(false); private _error = $state(null); private _configSignature = $state(null); - private _initPromise: Promise | null = null; + private _initPromise: Promise | null = null; // ───────────────────────────────────────────────────────────────────────────── // Computed Getters // ───────────────────────────────────────────────────────────────────────────── - get client(): IMCPClient | null { - return this._client; + get hostManager(): MCPHostManager | null { + return this._hostManager; } get isInitializing(): boolean { @@ -47,7 +57,7 @@ class MCPStore { } get isInitialized(): boolean { - return this._client !== null; + return this._hostManager?.isInitialized ?? false; } get error(): string | null { @@ -65,23 +75,45 @@ class MCPStore { } /** - * Get list of available tool names + * Get list of available tool names (aggregated from all servers) */ get availableTools(): string[] { - return this._client?.listTools() ?? []; + return this._hostManager?.getToolNames() ?? []; } /** - * Get tool definitions for LLM + * Get number of connected servers */ - async getToolDefinitions(): Promise< - { - type: 'function'; - function: { name: string; description?: string; parameters: Record }; - }[] - > { - if (!this._client) return []; - return this._client.getToolsDefinition(); + get connectedServerCount(): number { + return this._hostManager?.connectedServerCount ?? 0; + } + + /** + * Get names of connected servers + */ + get connectedServerNames(): string[] { + return this._hostManager?.connectedServerNames ?? []; + } + + /** + * Get total tool count + */ + get toolCount(): number { + return this._hostManager?.toolCount ?? 0; + } + + /** + * Get tool definitions for LLM (OpenAI function calling format) + */ + getToolDefinitions(): OpenAIToolDefinition[] { + return this._hostManager?.getToolDefinitionsForLLM() ?? []; + } + + /** + * Get status of all servers + */ + getServersStatus(): ServerStatus[] { + return this._hostManager?.getServersStatus() ?? []; } // ───────────────────────────────────────────────────────────────────────────── @@ -89,11 +121,11 @@ class MCPStore { // ───────────────────────────────────────────────────────────────────────────── /** - * Ensure MCP client is initialized with current config. - * Returns the client if successful, undefined otherwise. + * Ensure MCP host manager is initialized with current config. + * Returns the host manager if successful, undefined otherwise. * Handles config changes by reinitializing as needed. */ - async ensureClient(): Promise { + async ensureInitialized(): Promise { if (!browser) return undefined; const mcpConfig = buildMcpClientConfig(config()); @@ -106,8 +138,8 @@ class MCPStore { } // Already initialized with correct config - if (this._client && this._configSignature === signature) { - return this._client; + if (this._hostManager?.isInitialized && this._configSignature === signature) { + return this._hostManager; } // Init in progress with correct config - wait for it @@ -115,55 +147,63 @@ class MCPStore { return this._initPromise; } - // Config changed or first init - shutdown old client first - if (this._client || this._initPromise) { + // Config changed or first init - shutdown old manager first + if (this._hostManager || this._initPromise) { await this.shutdown(); } - // Initialize new client + // Initialize new host manager return this.initialize(signature, mcpConfig!); } /** - * Initialize MCP client with given config + * Initialize MCP host manager with given config */ private async initialize( signature: string, - mcpConfig: ReturnType - ): Promise { - if (!mcpConfig) return undefined; - + mcpConfig: NonNullable> + ): Promise { this._isInitializing = true; this._error = null; this._configSignature = signature; - const client = new MCPClient(mcpConfig); + const hostManager = new MCPHostManager(); - this._initPromise = client - .initialize() + this._initPromise = hostManager + .initialize({ + servers: mcpConfig.servers, + clientInfo: mcpConfig.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo, + capabilities: mcpConfig.capabilities ?? DEFAULT_MCP_CONFIG.capabilities + }) .then(() => { // Check if config changed during initialization if (this._configSignature !== signature) { - void client.shutdown().catch((err) => { - console.error('[MCP Store] Failed to shutdown stale client:', err); + void hostManager.shutdown().catch((err) => { + console.error('[MCP Store] Failed to shutdown stale host manager:', err); }); return undefined; } - this._client = client; + this._hostManager = hostManager; this._isInitializing = false; + + const toolNames = hostManager.getToolNames(); + const serverNames = hostManager.connectedServerNames; + console.log( - `[MCP Store] Initialized with ${client.listTools().length} tools:`, - client.listTools() + `[MCP Store] Initialized: ${serverNames.length} servers, ${toolNames.length} tools` ); - return client; + console.log(`[MCP Store] Servers: ${serverNames.join(', ')}`); + console.log(`[MCP Store] Tools: ${toolNames.join(', ')}`); + + return hostManager; }) .catch((error) => { console.error('[MCP Store] Initialization failed:', error); this._error = error instanceof Error ? error.message : String(error); this._isInitializing = false; - void client.shutdown().catch((err) => { + void hostManager.shutdown().catch((err) => { console.error('[MCP Store] Failed to shutdown after error:', err); }); @@ -179,7 +219,7 @@ class MCPStore { } /** - * Shutdown MCP client and clear state + * Shutdown MCP host manager and clear state */ async shutdown(): Promise { // Wait for any pending initialization @@ -188,15 +228,15 @@ class MCPStore { this._initPromise = null; } - if (this._client) { - const clientToShutdown = this._client; - this._client = null; + if (this._hostManager) { + const managerToShutdown = this._hostManager; + this._hostManager = null; this._configSignature = null; this._error = null; try { - await clientToShutdown.shutdown(); - console.log('[MCP Store] Client shutdown complete'); + await managerToShutdown.shutdown(); + console.log('[MCP Store] Host manager shutdown complete'); } catch (error) { console.error('[MCP Store] Shutdown error:', error); } @@ -208,16 +248,43 @@ class MCPStore { // ───────────────────────────────────────────────────────────────────────────── /** - * Execute a tool call via MCP client + * Execute a tool call via MCP host manager. + * Automatically routes to the appropriate server. */ - async execute( - toolCall: { id: string; function: { name: string; arguments: string } }, - abortSignal?: AbortSignal - ): Promise { - if (!this._client) { - throw new Error('MCP client not initialized'); + async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise { + if (!this._hostManager) { + throw new Error('MCP host manager not initialized'); } - return this._client.execute(toolCall, abortSignal); + return this._hostManager.executeTool(toolCall, signal); + } + + /** + * Execute a tool by name with arguments. + * Simpler interface for direct tool calls. + */ + async executeToolByName( + toolName: string, + args: Record, + signal?: AbortSignal + ): Promise { + if (!this._hostManager) { + throw new Error('MCP host manager not initialized'); + } + return this._hostManager.executeToolByName(toolName, args, signal); + } + + /** + * Check if a tool exists + */ + hasTool(toolName: string): boolean { + return this._hostManager?.hasTool(toolName) ?? false; + } + + /** + * Get which server provides a specific tool + */ + getToolServer(toolName: string): string | undefined { + return this._hostManager?.getToolServer(toolName); } // ───────────────────────────────────────────────────────────────────────────── @@ -239,8 +306,8 @@ class MCPStore { export const mcpStore = new MCPStore(); // Reactive exports for components -export function mcpClient() { - return mcpStore.client; +export function mcpHostManager() { + return mcpStore.hostManager; } export function mcpIsInitializing() { @@ -262,3 +329,15 @@ export function mcpIsEnabled() { export function mcpAvailableTools() { return mcpStore.availableTools; } + +export function mcpConnectedServerCount() { + return mcpStore.connectedServerCount; +} + +export function mcpConnectedServerNames() { + return mcpStore.connectedServerNames; +} + +export function mcpToolCount() { + return mcpStore.toolCount; +} diff --git a/tools/server/webui/src/lib/types/mcp.ts b/tools/server/webui/src/lib/types/mcp.ts index e812080a09..dbbff703e5 100644 --- a/tools/server/webui/src/lib/types/mcp.ts +++ b/tools/server/webui/src/lib/types/mcp.ts @@ -76,20 +76,3 @@ export type MCPServerSettingsEntry = { url: string; requestTimeoutSeconds: number; }; - -/** - * Interface defining the public API for MCP clients. - * Both MCPClient (custom) and MCPClientSDK (official SDK) implement this interface. - */ -export interface IMCPClient { - initialize(): Promise; - shutdown(): Promise; - listTools(): string[]; - getToolsDefinition(): Promise< - { - type: 'function'; - function: { name: string; description?: string; parameters: Record }; - }[] - >; - execute(toolCall: MCPToolCall, abortSignal?: AbortSignal): Promise; -}