From bd9839df3d1a105aef454fe9f66cc2816defe375 Mon Sep 17 00:00:00 2001 From: Pascal Date: Mon, 17 Nov 2025 22:09:01 +0100 Subject: [PATCH 01/34] webui: MCP client with low coupling to current codebase --- .../src/lib/agentic/openai-sse-client.ts | 190 ++++++++ .../webui/src/lib/agentic/orchestrator.ts | 255 +++++++++++ tools/server/webui/src/lib/agentic/types.ts | 71 +++ .../app/chat/ChatSettings/ChatSettings.svelte | 41 +- .../ChatSettings/McpSettingsSection.svelte | 290 ++++++++++++ .../webui/src/lib/components/app/index.ts | 1 + tools/server/webui/src/lib/config/agentic.ts | 51 +++ tools/server/webui/src/lib/config/mcp.ts | 155 +++++++ .../src/lib/constants/settings-config.ts | 12 + tools/server/webui/src/lib/mcp/client.ts | 413 ++++++++++++++++++ tools/server/webui/src/lib/mcp/index.ts | 3 + tools/server/webui/src/lib/mcp/protocol.ts | 46 ++ .../src/lib/mcp/transports/streamable-http.ts | 129 ++++++ .../webui/src/lib/mcp/transports/types.ts | 8 + .../webui/src/lib/mcp/transports/websocket.ts | 238 ++++++++++ tools/server/webui/src/lib/mcp/types.ts | 124 ++++++ tools/server/webui/src/lib/services/chat.ts | 72 ++- .../webui/src/lib/services/mcp-singleton.ts | 140 ++++++ tools/server/webui/src/lib/types/api.d.ts | 18 +- .../server/webui/src/lib/utils/chat-stream.ts | 85 ++++ 20 files changed, 2337 insertions(+), 5 deletions(-) create mode 100644 tools/server/webui/src/lib/agentic/openai-sse-client.ts create mode 100644 tools/server/webui/src/lib/agentic/orchestrator.ts create mode 100644 tools/server/webui/src/lib/agentic/types.ts create mode 100644 tools/server/webui/src/lib/components/app/chat/ChatSettings/McpSettingsSection.svelte create mode 100644 tools/server/webui/src/lib/config/agentic.ts create mode 100644 tools/server/webui/src/lib/config/mcp.ts create mode 100644 tools/server/webui/src/lib/mcp/client.ts create mode 100644 tools/server/webui/src/lib/mcp/index.ts create mode 100644 tools/server/webui/src/lib/mcp/protocol.ts create mode 100644 tools/server/webui/src/lib/mcp/transports/streamable-http.ts create mode 100644 tools/server/webui/src/lib/mcp/transports/types.ts create mode 100644 tools/server/webui/src/lib/mcp/transports/websocket.ts create mode 100644 tools/server/webui/src/lib/mcp/types.ts create mode 100644 tools/server/webui/src/lib/services/mcp-singleton.ts create mode 100644 tools/server/webui/src/lib/utils/chat-stream.ts diff --git a/tools/server/webui/src/lib/agentic/openai-sse-client.ts b/tools/server/webui/src/lib/agentic/openai-sse-client.ts new file mode 100644 index 00000000000..8f9c2b8ac59 --- /dev/null +++ b/tools/server/webui/src/lib/agentic/openai-sse-client.ts @@ -0,0 +1,190 @@ +import type { + ApiChatCompletionToolCall, + ApiChatCompletionToolCallDelta, + ApiChatCompletionStreamChunk +} from '$lib/types/api'; +import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat'; +import { mergeToolCallDeltas, extractModelName } from '$lib/utils/chat-stream'; +import type { AgenticChatCompletionRequest } from './types'; + +export type OpenAISseCallbacks = { + onChunk?: (chunk: string) => void; + onReasoningChunk?: (chunk: string) => void; + onToolCallChunk?: (serializedToolCalls: string) => void; + onModel?: (model: string) => void; + onFirstValidChunk?: () => void; + onProcessingUpdate?: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => void; +}; + +export type OpenAISseTurnResult = { + content: string; + reasoningContent?: string; + toolCalls: ApiChatCompletionToolCall[]; + finishReason?: string | null; + timings?: ChatMessageTimings; +}; + +export type OpenAISseClientOptions = { + url: string; + buildHeaders?: () => Record; +}; + +export class OpenAISseClient { + constructor(private readonly options: OpenAISseClientOptions) {} + + async stream( + request: AgenticChatCompletionRequest, + callbacks: OpenAISseCallbacks = {}, + abortSignal?: AbortSignal + ): Promise { + const response = await fetch(this.options.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(this.options.buildHeaders?.() ?? {}) + }, + body: JSON.stringify(request), + signal: abortSignal + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `LLM request failed (${response.status})`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('LLM response stream is not available'); + } + + return this.consumeStream(reader, callbacks, abortSignal); + } + + private async consumeStream( + reader: ReadableStreamDefaultReader, + callbacks: OpenAISseCallbacks, + abortSignal?: AbortSignal + ): Promise { + const decoder = new TextDecoder(); + let buffer = ''; + let aggregatedContent = ''; + let aggregatedReasoning = ''; + let aggregatedToolCalls: ApiChatCompletionToolCall[] = []; + let hasOpenToolCallBatch = false; + let toolCallIndexOffset = 0; + let finishReason: string | null | undefined; + let lastTimings: ChatMessageTimings | undefined; + let modelEmitted = false; + let firstValidChunkEmitted = false; + + const finalizeToolCallBatch = () => { + if (!hasOpenToolCallBatch) return; + toolCallIndexOffset = aggregatedToolCalls.length; + hasOpenToolCallBatch = false; + }; + + const processToolCalls = (toolCalls?: ApiChatCompletionToolCallDelta[]) => { + if (!toolCalls || toolCalls.length === 0) { + return; + } + aggregatedToolCalls = mergeToolCallDeltas( + aggregatedToolCalls, + toolCalls, + toolCallIndexOffset + ); + if (aggregatedToolCalls.length === 0) { + return; + } + hasOpenToolCallBatch = true; + }; + + try { + while (true) { + if (abortSignal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) { + continue; + } + + const payload = line.slice(6); + if (payload === '[DONE]' || payload.trim().length === 0) { + continue; + } + + let chunk: ApiChatCompletionStreamChunk; + try { + chunk = JSON.parse(payload) as ApiChatCompletionStreamChunk; + } catch (error) { + console.error('[Agentic][SSE] Failed to parse chunk:', error); + continue; + } + + if (!firstValidChunkEmitted && chunk.object === 'chat.completion.chunk') { + firstValidChunkEmitted = true; + callbacks.onFirstValidChunk?.(); + } + + const choice = chunk.choices?.[0]; + const delta = choice?.delta; + finishReason = choice?.finish_reason ?? finishReason; + + if (!modelEmitted) { + const chunkModel = extractModelName(chunk); + if (chunkModel) { + modelEmitted = true; + callbacks.onModel?.(chunkModel); + } + } + + if (chunk.timings || chunk.prompt_progress) { + callbacks.onProcessingUpdate?.(chunk.timings, chunk.prompt_progress); + if (chunk.timings) { + lastTimings = chunk.timings; + } + } + + if (delta?.content) { + finalizeToolCallBatch(); + aggregatedContent += delta.content; + callbacks.onChunk?.(delta.content); + } + + if (delta?.reasoning_content) { + finalizeToolCallBatch(); + aggregatedReasoning += delta.reasoning_content; + callbacks.onReasoningChunk?.(delta.reasoning_content); + } + + processToolCalls(delta?.tool_calls); + } + } + + finalizeToolCallBatch(); + } catch (error) { + if ((error as Error).name === 'AbortError') { + throw error; + } + throw error instanceof Error ? error : new Error('LLM stream error'); + } finally { + reader.releaseLock(); + } + + return { + content: aggregatedContent, + reasoningContent: aggregatedReasoning || undefined, + toolCalls: aggregatedToolCalls, + finishReason, + timings: lastTimings + }; + } +} diff --git a/tools/server/webui/src/lib/agentic/orchestrator.ts b/tools/server/webui/src/lib/agentic/orchestrator.ts new file mode 100644 index 00000000000..67f2b7cb452 --- /dev/null +++ b/tools/server/webui/src/lib/agentic/orchestrator.ts @@ -0,0 +1,255 @@ +import type { + ApiChatCompletionRequest, + ApiChatMessageData, + ApiChatCompletionToolCall +} from '$lib/types/api'; +import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat'; +import type { MCPToolCall } from '$lib/mcp'; +import { MCPClient } from '$lib/mcp'; +import { OpenAISseClient, type OpenAISseTurnResult } from './openai-sse-client'; +import type { AgenticChatCompletionRequest, AgenticMessage, AgenticToolCallList } from './types'; +import { toAgenticMessages } from './types'; + +export type AgenticOrchestratorCallbacks = { + onChunk?: (chunk: string) => void; + onReasoningChunk?: (chunk: string) => void; + onToolCallChunk?: (serializedToolCalls: string) => void; + onModel?: (model: string) => void; + onFirstValidChunk?: () => void; + onComplete?: () => void; + onError?: (error: Error) => void; +}; + +export type AgenticRunParams = { + initialMessages: ApiChatMessageData[]; + requestTemplate: ApiChatCompletionRequest; + callbacks: AgenticOrchestratorCallbacks; + abortSignal?: AbortSignal; + onProcessingUpdate?: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => void; + maxTurns?: number; + filterReasoningAfterFirstTurn?: boolean; +}; + +export type AgenticOrchestratorOptions = { + mcpClient: MCPClient; + llmClient: OpenAISseClient; + maxTurns: number; + maxToolPreviewLines: number; +}; + +export class AgenticOrchestrator { + private readonly mcpClient: MCPClient; + private readonly llmClient: OpenAISseClient; + private readonly maxTurns: number; + private readonly maxToolPreviewLines: number; + + constructor(options: AgenticOrchestratorOptions) { + this.mcpClient = options.mcpClient; + this.llmClient = options.llmClient; + this.maxTurns = options.maxTurns; + this.maxToolPreviewLines = options.maxToolPreviewLines; + } + + async run(params: AgenticRunParams): Promise { + const baseMessages = toAgenticMessages(params.initialMessages); + const sessionMessages: AgenticMessage[] = [...baseMessages]; + const tools = await this.mcpClient.getToolsDefinition(); + + const requestWithoutMessages = { ...params.requestTemplate }; + delete (requestWithoutMessages as Partial).messages; + const requestBase: AgenticChatCompletionRequest = { + ...(requestWithoutMessages as Omit), + stream: true, + messages: [] + }; + + const maxTurns = params.maxTurns ?? this.maxTurns; + + // Accumulate tool_calls across all turns (not per-turn) + const allToolCalls: ApiChatCompletionToolCall[] = []; + + for (let turn = 0; turn < maxTurns; turn++) { + if (params.abortSignal?.aborted) { + params.callbacks.onComplete?.(); + return; + } + + const llmRequest: AgenticChatCompletionRequest = { + ...requestBase, + messages: sessionMessages, + tools: tools.length > 0 ? tools : undefined + }; + + const shouldFilterReasoningChunks = params.filterReasoningAfterFirstTurn === true && turn > 0; + + let turnResult: OpenAISseTurnResult; + try { + turnResult = await this.llmClient.stream( + llmRequest, + { + onChunk: params.callbacks.onChunk, + onReasoningChunk: shouldFilterReasoningChunks + ? undefined + : params.callbacks.onReasoningChunk, + onModel: params.callbacks.onModel, + onFirstValidChunk: params.callbacks.onFirstValidChunk, + onProcessingUpdate: (timings, progress) => + params.onProcessingUpdate?.(timings, progress) + }, + params.abortSignal + ); + } catch (error) { + // Check if error is due to abort signal (stop button) + if (params.abortSignal?.aborted) { + params.callbacks.onComplete?.(); + return; + } + + const normalizedError = error instanceof Error ? error : new Error('LLM stream error'); + params.callbacks.onError?.(normalizedError); + const errorChunk = `\n\n\`\`\`\nUpstream LLM error:\n${normalizedError.message}\n\`\`\`\n`; + params.callbacks.onChunk?.(errorChunk); + params.callbacks.onComplete?.(); + return; + } + + if ( + turnResult.toolCalls.length === 0 || + (turnResult.finishReason && turnResult.finishReason !== 'tool_calls') + ) { + params.callbacks.onComplete?.(); + return; + } + + const normalizedCalls = this.normalizeToolCalls(turnResult.toolCalls); + if (normalizedCalls.length === 0) { + params.callbacks.onComplete?.(); + return; + } + + // Accumulate tool_calls from this turn + for (const call of normalizedCalls) { + allToolCalls.push({ + id: call.id, + type: call.type, + function: call.function ? { ...call.function } : undefined + }); + } + + // Forward the complete accumulated list + params.callbacks.onToolCallChunk?.(JSON.stringify(allToolCalls)); + + sessionMessages.push({ + role: 'assistant', + content: turnResult.content || undefined, + tool_calls: normalizedCalls + }); + + for (const toolCall of normalizedCalls) { + if (params.abortSignal?.aborted) { + params.callbacks.onComplete?.(); + return; + } + + const result = await this.executeTool(toolCall, params.abortSignal).catch( + (error: Error) => { + // Don't show error for AbortError + if (error.name !== 'AbortError') { + params.callbacks.onError?.(error); + } + return `Error: ${error.message}`; + } + ); + + // Stop silently if aborted during tool execution + if (params.abortSignal?.aborted) { + params.callbacks.onComplete?.(); + return; + } + + this.emitToolPreview(result, params.callbacks.onChunk); + + const contextValue = this.sanitizeToolContent(result); + sessionMessages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: contextValue + }); + } + } + + params.callbacks.onChunk?.('\n\n```\nTurn limit reached\n```\n'); + params.callbacks.onComplete?.(); + } + + 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 ?? '' + } + })); + } + + private async executeTool( + toolCall: AgenticToolCallList[number], + abortSignal?: AbortSignal + ): Promise { + const mcpCall: MCPToolCall = { + id: toolCall.id, + function: { + name: toolCall.function.name, + arguments: toolCall.function.arguments + } + }; + + const result = await this.mcpClient.execute(mcpCall, abortSignal); + return result; + } + + private emitToolPreview(result: string, emit?: (chunk: string) => void): void { + if (!emit) return; + const preview = this.createPreview(result); + emit(preview); + } + + private createPreview(result: string): string { + if (this.isBase64Image(result)) { + return `\n![tool-image](${result.trim()})\n`; + } + + const lines = result.split('\n'); + const trimmedLines = + lines.length > this.maxToolPreviewLines ? lines.slice(-this.maxToolPreviewLines) : lines; + const preview = trimmedLines.join('\n'); + return `\n\`\`\`\n${preview}\n\`\`\`\n`; + } + + private sanitizeToolContent(result: string): string { + if (this.isBase64Image(result)) { + return '[Image displayed to user]'; + } + return result; + } + + 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; + } +} diff --git a/tools/server/webui/src/lib/agentic/types.ts b/tools/server/webui/src/lib/agentic/types.ts new file mode 100644 index 00000000000..c3b61195f4d --- /dev/null +++ b/tools/server/webui/src/lib/agentic/types.ts @@ -0,0 +1,71 @@ +import type { + ApiChatCompletionRequest, + ApiChatMessageContentPart, + ApiChatMessageData +} from '$lib/types/api'; + +export type AgenticToolCallPayload = { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +}; + +export type AgenticMessage = + | { + role: 'system' | 'user'; + content: string | ApiChatMessageContentPart[]; + } + | { + role: 'assistant'; + content?: string | ApiChatMessageContentPart[]; + tool_calls?: AgenticToolCallPayload[]; + } + | { + role: 'tool'; + tool_call_id: string; + content: string; + }; + +export type AgenticAssistantMessage = Extract; +export type AgenticToolCallList = NonNullable; + +export type AgenticChatCompletionRequest = Omit & { + messages: AgenticMessage[]; + stream: true; + tools?: ApiChatCompletionRequest['tools']; +}; + +export function toAgenticMessages(messages: ApiChatMessageData[]): AgenticMessage[] { + return messages.map((message) => { + if (message.role === 'assistant' && message.tool_calls && message.tool_calls.length > 0) { + return { + role: 'assistant', + content: message.content, + tool_calls: message.tool_calls.map((call, index) => ({ + id: call.id ?? `call_${index}`, + type: (call.type as 'function') ?? 'function', + function: { + name: call.function?.name ?? '', + arguments: call.function?.arguments ?? '' + } + })) + } satisfies AgenticMessage; + } + + if (message.role === 'tool' && message.tool_call_id) { + return { + role: 'tool', + tool_call_id: message.tool_call_id, + content: typeof message.content === 'string' ? message.content : '' + } satisfies AgenticMessage; + } + + return { + role: message.role, + content: message.content + } satisfies AgenticMessage; + }); +} 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 4ec9b478fd2..3c087e94541 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 @@ -9,12 +9,14 @@ Moon, ChevronLeft, ChevronRight, - Database + Database, + Cable } from '@lucide/svelte'; import { ChatSettingsFooter, ChatSettingsImportExportTab, - ChatSettingsFields + ChatSettingsFields, + McpSettingsSection } from '$lib/components/app'; import { ScrollArea } from '$lib/components/ui/scroll-area'; import { config, settingsStore } from '$lib/stores/settings.svelte'; @@ -234,6 +236,27 @@ } ] }, + { + title: 'MCP Client', + icon: Cable, + fields: [ + { + key: 'agenticMaxTurns', + label: 'Agentic loop max turns', + type: 'input' + }, + { + key: 'agenticMaxToolPreviewLines', + label: 'Max lines per tool preview', + type: 'input' + }, + { + key: 'agenticFilterReasoningAfterFirstTurn', + label: 'Filter reasoning after first turn', + type: 'checkbox' + } + ] + }, { title: 'Import/Export', icon: Database, @@ -333,7 +356,9 @@ 'dry_multiplier', 'dry_base', 'dry_allowed_length', - 'dry_penalty_last_n' + 'dry_penalty_last_n', + 'agenticMaxTurns', + 'agenticMaxToolPreviewLines' ]; for (const field of numericFields) { @@ -481,6 +506,16 @@ {#if currentSection.title === 'Import/Export'} + {:else if currentSection.title === 'MCP Client'} +
+ + +
{:else}
+ import { Loader2, Plus, Trash2 } from '@lucide/svelte'; + import { Checkbox } from '$lib/components/ui/checkbox'; + import { Input } from '$lib/components/ui/input'; + import Label from '$lib/components/ui/label/label.svelte'; + import { Button } from '$lib/components/ui/button'; + import { + detectMcpTransportFromUrl, + parseMcpServerSettings, + getDefaultMcpConfig, + type MCPServerSettingsEntry + } from '$lib/config/mcp'; + import { MCPClient } from '$lib/mcp'; + import type { SettingsConfigType } from '$lib/types/settings'; + + interface Props { + localConfig: SettingsConfigType; + onConfigChange: (key: string, value: string | boolean) => void; + } + + let { localConfig, onConfigChange }: Props = $props(); + + const defaultMcpConfig = getDefaultMcpConfig(); + + type HealthCheckState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'success'; tools: { name: string; description?: string }[] }; + + let healthChecks: Record = $state({}); + + function serializeServers(servers: MCPServerSettingsEntry[]) { + onConfigChange('mcpServers', JSON.stringify(servers)); + } + + function getServers(): MCPServerSettingsEntry[] { + return parseMcpServerSettings(localConfig.mcpServers); + } + + function addServer() { + const servers = getServers(); + const newServer: MCPServerSettingsEntry = { + id: crypto.randomUUID ? crypto.randomUUID() : `server-${Date.now()}`, + enabled: true, + url: '', + requestTimeoutSeconds: defaultMcpConfig.requestTimeoutSeconds + }; + + serializeServers([...servers, newServer]); + } + + function updateServer(id: string, updates: Partial) { + const servers = getServers(); + const nextServers = servers.map((server) => + server.id === id + ? { + ...server, + ...updates + } + : server + ); + + serializeServers(nextServers); + } + + function removeServer(id: string) { + const servers = getServers().filter((server) => server.id !== id); + serializeServers(servers); + } + + function getHealthState(id: string): HealthCheckState { + return healthChecks[id] ?? { status: 'idle' }; + } + + function isErrorState(state: HealthCheckState): state is { status: 'error'; message: string } { + return state.status === 'error'; + } + + function isSuccessState( + state: HealthCheckState + ): state is { status: 'success'; tools: { name: string; description?: string }[] } { + return state.status === 'success'; + } + + function setHealthState(id: string, state: HealthCheckState) { + healthChecks = { ...healthChecks, [id]: state }; + } + + async function runHealthCheck(server: MCPServerSettingsEntry) { + const trimmedUrl = server.url.trim(); + + if (!trimmedUrl) { + setHealthState(server.id, { + status: 'error', + message: 'Please enter a server URL before running a health check.' + }); + return; + } + + setHealthState(server.id, { status: 'loading' }); + + const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000); + + const mcpClient = new MCPClient({ + protocolVersion: defaultMcpConfig.protocolVersion, + capabilities: defaultMcpConfig.capabilities, + clientInfo: defaultMcpConfig.clientInfo, + requestTimeoutMs: timeoutMs, + servers: { + [server.id]: { + url: trimmedUrl, + transport: detectMcpTransportFromUrl(trimmedUrl), + handshakeTimeoutMs: defaultMcpConfig.connectionTimeoutMs, + requestTimeoutMs: timeoutMs + } + } + }); + + try { + await mcpClient.initialize(); + const tools = (await mcpClient.getToolsDefinition()).map((tool) => ({ + name: tool.function.name, + description: tool.function.description + })); + + setHealthState(server.id, { status: 'success', tools }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error occurred'; + setHealthState(server.id, { status: 'error', message }); + } finally { + try { + await mcpClient.shutdown(); + } catch (shutdownError) { + console.warn('[MCP] Failed to cleanly shutdown client', shutdownError); + } + } + } + + +
+
+
+

MCP Servers

+

+ Configure one or more MCP Servers. Only enabled servers with a URL are used. +

+
+ + +
+ + {#if getServers().length === 0} +
+ No MCP Servers configured yet. Add one to enable agentic features. +
+ {/if} + +
+ {#each getServers() as server, index (server.id)} + {@const healthState = getHealthState(server.id)} + +
+
+
+ + updateServer(server.id, { + enabled: Boolean(checked) + })} + /> +
+ +

+ {detectMcpTransportFromUrl(server.url) === 'websocket' + ? 'WebSocket' + : 'Streamable HTTP'} +

+
+
+ +
+ +
+
+ +
+
+ + + updateServer(server.id, { + url: event.currentTarget.value + })} + /> +
+ +
+ +
+ { + const parsed = Number(event.currentTarget.value); + updateServer(server.id, { + requestTimeoutSeconds: + Number.isFinite(parsed) && parsed > 0 + ? parsed + : defaultMcpConfig.requestTimeoutSeconds + }); + }} + /> + + +
+
+
+ + {#if healthState.status !== 'idle'} +
+ {#if healthState.status === 'loading'} +
+ + Running health check... +
+ {:else if isErrorState(healthState)} +

+ Health check failed: {healthState.message} +

+ {:else if isSuccessState(healthState)} + {#if healthState.tools.length === 0} +

No tools returned by this server.

+ {:else} +
+

+ Available tools ({healthState.tools.length}) +

+
    + {#each healthState.tools as tool (tool.name)} +
  • + + {tool.name} + + {tool.description ?? 'No description provided.'} +
  • + {/each} +
+
+ {/if} + {/if} +
+ {/if} +
+ {/each} +
+
diff --git a/tools/server/webui/src/lib/components/app/index.ts b/tools/server/webui/src/lib/components/app/index.ts index 8631d4fb3bd..4773f0cc672 100644 --- a/tools/server/webui/src/lib/components/app/index.ts +++ b/tools/server/webui/src/lib/components/app/index.ts @@ -33,6 +33,7 @@ export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsF export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte'; export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte'; export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte'; +export { default as McpSettingsSection } from './chat/ChatSettings/McpSettingsSection.svelte'; export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte'; export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte'; diff --git a/tools/server/webui/src/lib/config/agentic.ts b/tools/server/webui/src/lib/config/agentic.ts new file mode 100644 index 00000000000..61f3aa96210 --- /dev/null +++ b/tools/server/webui/src/lib/config/agentic.ts @@ -0,0 +1,51 @@ +import { hasEnabledMcpServers } from './mcp'; +import type { SettingsConfigType } from '$lib/types/settings'; + +/** + * Agentic orchestration configuration. + */ +export interface AgenticConfig { + enabled: boolean; + maxTurns: number; + maxToolPreviewLines: number; + filterReasoningAfterFirstTurn: boolean; +} + +const defaultAgenticConfig: AgenticConfig = { + enabled: true, + maxTurns: 100, + maxToolPreviewLines: 25, + filterReasoningAfterFirstTurn: true +}; + +function normalizeNumber(value: unknown, fallback: number): number { + const parsed = typeof value === 'string' ? Number.parseFloat(value) : Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + return parsed; +} + +/** + * Gets the current agentic configuration. + * Automatically disables agentic mode if no MCP servers are configured. + */ +export function getAgenticConfig(settings: SettingsConfigType): AgenticConfig { + const maxTurns = normalizeNumber(settings.agenticMaxTurns, defaultAgenticConfig.maxTurns); + const maxToolPreviewLines = normalizeNumber( + settings.agenticMaxToolPreviewLines, + defaultAgenticConfig.maxToolPreviewLines + ); + const filterReasoningAfterFirstTurn = + typeof settings.agenticFilterReasoningAfterFirstTurn === 'boolean' + ? settings.agenticFilterReasoningAfterFirstTurn + : defaultAgenticConfig.filterReasoningAfterFirstTurn; + + return { + enabled: hasEnabledMcpServers(settings) && defaultAgenticConfig.enabled, + maxTurns, + maxToolPreviewLines, + filterReasoningAfterFirstTurn + }; +} diff --git a/tools/server/webui/src/lib/config/mcp.ts b/tools/server/webui/src/lib/config/mcp.ts new file mode 100644 index 00000000000..01e4ede3c50 --- /dev/null +++ b/tools/server/webui/src/lib/config/mcp.ts @@ -0,0 +1,155 @@ +import type { + MCPClientCapabilities, + MCPClientConfig, + MCPClientInfo, + MCPServerConfig +} from '../mcp/types'; +import type { SettingsConfigType } from '$lib/types/settings'; + +/** + * Raw MCP server configuration entry stored in settings. + */ +export type MCPServerSettingsEntry = { + id: string; + enabled: boolean; + url: string; + requestTimeoutSeconds: number; +}; + +const defaultMcpConfig = { + protocolVersion: '2025-06-18', + capabilities: { tools: { listChanged: true } } as MCPClientCapabilities, + clientInfo: { name: 'llama-webui-mcp', version: 'dev' } as MCPClientInfo, + requestTimeoutSeconds: 300, // 5 minutes for long-running tools + connectionTimeoutMs: 10_000 // 10 seconds for connection establishment +}; + +export function getDefaultMcpConfig() { + return defaultMcpConfig; +} + +export function detectMcpTransportFromUrl(url: string): 'websocket' | 'streamable_http' { + const normalized = url.trim().toLowerCase(); + return normalized.startsWith('ws://') || normalized.startsWith('wss://') + ? 'websocket' + : 'streamable_http'; +} + +function normalizeRequestTimeoutSeconds(value: unknown, fallback: number): number { + const parsed = typeof value === 'string' ? Number.parseFloat(value) : Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + return parsed; +} + +function sanitizeId(id: unknown, index: number): string { + if (typeof id === 'string' && id.trim()) { + return id.trim(); + } + + return `server-${index + 1}`; +} + +function sanitizeUrl(url: unknown): string { + if (typeof url === 'string') { + return url.trim(); + } + + return ''; +} + +export function parseMcpServerSettings( + rawServers: unknown, + fallbackRequestTimeoutSeconds = defaultMcpConfig.requestTimeoutSeconds +): MCPServerSettingsEntry[] { + if (!rawServers) return []; + + let parsed: unknown; + if (typeof rawServers === 'string') { + const trimmed = rawServers.trim(); + if (!trimmed) return []; + + try { + parsed = JSON.parse(trimmed); + } catch (error) { + console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error); + return []; + } + } else { + parsed = rawServers; + } + + if (!Array.isArray(parsed)) return []; + + return parsed.map((entry, index) => { + const requestTimeoutSeconds = normalizeRequestTimeoutSeconds( + (entry as { requestTimeoutSeconds?: unknown })?.requestTimeoutSeconds, + fallbackRequestTimeoutSeconds + ); + + const url = sanitizeUrl((entry as { url?: unknown })?.url); + + return { + id: sanitizeId((entry as { id?: unknown })?.id, index), + enabled: Boolean((entry as { enabled?: unknown })?.enabled), + url, + requestTimeoutSeconds + } satisfies MCPServerSettingsEntry; + }); +} + +function buildServerConfig( + entry: MCPServerSettingsEntry, + connectionTimeoutMs = defaultMcpConfig.connectionTimeoutMs +): MCPServerConfig | undefined { + if (!entry?.url) { + return undefined; + } + + return { + url: entry.url, + transport: detectMcpTransportFromUrl(entry.url), + handshakeTimeoutMs: connectionTimeoutMs, + requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000) + }; +} + +/** + * Builds MCP client configuration from settings. + * Returns undefined if no valid servers are configured. + */ +export function buildMcpClientConfig(config: SettingsConfigType): MCPClientConfig | undefined { + const rawServers = parseMcpServerSettings(config.mcpServers); + + if (!rawServers.length) { + return undefined; + } + + const servers: Record = {}; + for (const [index, entry] of rawServers.entries()) { + if (!entry.enabled) continue; + + const normalized = buildServerConfig(entry); + if (normalized) { + servers[sanitizeId(entry.id, index)] = normalized; + } + } + + if (Object.keys(servers).length === 0) { + return undefined; + } + + return { + protocolVersion: defaultMcpConfig.protocolVersion, + capabilities: defaultMcpConfig.capabilities, + clientInfo: defaultMcpConfig.clientInfo, + requestTimeoutMs: Math.round(defaultMcpConfig.requestTimeoutSeconds * 1000), + servers + }; +} + +export function hasEnabledMcpServers(config: SettingsConfigType): boolean { + return Boolean(buildMcpClientConfig(config)); +} diff --git a/tools/server/webui/src/lib/constants/settings-config.ts b/tools/server/webui/src/lib/constants/settings-config.ts index f9584d01d72..3c5010f6d9f 100644 --- a/tools/server/webui/src/lib/constants/settings-config.ts +++ b/tools/server/webui/src/lib/constants/settings-config.ts @@ -19,6 +19,10 @@ export const SETTING_CONFIG_DEFAULT: Record = alwaysShowSidebarOnDesktop: false, autoShowSidebarOnNewChat: true, autoMicOnEmpty: false, + mcpServers: '[]', + agenticMaxTurns: 10, + agenticMaxToolPreviewLines: 25, + agenticFilterReasoningAfterFirstTurn: true, // make sure these default values are in sync with `common.h` samplers: 'top_k;typ_p;top_p;min_p;temperature', temperature: 0.8, @@ -107,6 +111,14 @@ export const SETTING_CONFIG_INFO: Record = { 'Automatically show sidebar when starting a new chat. Disable to keep the sidebar hidden until you click on it.', autoMicOnEmpty: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.', + mcpServers: + 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.', + agenticMaxTurns: + 'Maximum number of tool execution cycles before stopping (prevents infinite loops).', + agenticMaxToolPreviewLines: + 'Number of lines shown in tool output previews (last N lines). Only these previews and the final LLM response persist after the agentic loop completes.', + agenticFilterReasoningAfterFirstTurn: + 'Only show reasoning from the first agentic turn. When disabled, reasoning from all turns is merged in one (WebUI limitation).', pyInterpreterEnabled: 'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.', enableContinueGeneration: diff --git a/tools/server/webui/src/lib/mcp/client.ts b/tools/server/webui/src/lib/mcp/client.ts new file mode 100644 index 00000000000..4f514b4107c --- /dev/null +++ b/tools/server/webui/src/lib/mcp/client.ts @@ -0,0 +1,413 @@ +import { getDefaultMcpConfig } from '$lib/config/mcp'; +import { JsonRpcProtocol } from './protocol'; +import type { + JsonRpcMessage, + MCPClientConfig, + MCPServerCapabilities, + MCPServerConfig, + MCPToolCall, + MCPToolDefinition, + MCPToolsCallResult +} from './types'; +import { MCPError } from './types'; +import type { MCPTransport } from './transports/types'; +import { WebSocketTransport } from './transports/websocket'; +import { StreamableHttpTransport } from './transports/streamable-http'; + +const MCP_DEFAULTS = getDefaultMcpConfig(); + +interface PendingRequest { + resolve: (value: Record) => void; + reject: (reason?: unknown) => void; + timeout: ReturnType; +} + +interface ServerState { + transport: MCPTransport; + pending: Map; + requestId: number; + tools: MCPToolDefinition[]; + requestTimeoutMs?: number; + capabilities?: MCPServerCapabilities; + protocolVersion?: string; +} + +export class MCPClient { + private readonly servers: Map = new Map(); + private readonly toolsToServer: Map = new Map(); + private readonly config: MCPClientConfig; + + constructor(config: MCPClientConfig) { + if (!config?.servers || Object.keys(config.servers).length === 0) { + throw new Error('MCPClient requires at least one server configuration'); + } + this.config = config; + } + + async initialize(): Promise { + const entries = Object.entries(this.config.servers); + await Promise.all( + entries.map(([name, serverConfig]) => this.initializeServer(name, serverConfig)) + ); + } + + listTools(): string[] { + return Array.from(this.toolsToServer.keys()); + } + + async getToolsDefinition(): Promise< + { + type: 'function'; + function: { name: string; description?: string; parameters: Record }; + }[] + > { + const tools: { + type: 'function'; + function: { name: string; description?: string; parameters: Record }; + }[] = []; + + for (const [, server] of this.servers) { + for (const tool of server.tools) { + tools.push({ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema ?? { + type: 'object', + properties: {}, + required: [] + } + } + }); + } + } + + return tools; + } + + async execute(toolCall: MCPToolCall, abortSignal?: AbortSignal): Promise { + const toolName = toolCall.function.name; + const serverName = this.toolsToServer.get(toolName); + if (!serverName) { + throw new MCPError(`Unknown tool: ${toolName}`, -32601); + } + + if (abortSignal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + + let args: Record; + const originalArgs = toolCall.function.arguments; + if (typeof originalArgs === 'string') { + const trimmed = originalArgs.trim(); + if (trimmed === '') { + args = {}; + } else { + 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 + ); + } + args = 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 + ); + } + } + } else if ( + typeof originalArgs === 'object' && + originalArgs !== null && + !Array.isArray(originalArgs) + ) { + args = originalArgs as Record; + } else { + throw new MCPError(`Invalid tool arguments type: ${typeof originalArgs}`, -32602); + } + + const response = await this.call( + serverName, + 'tools/call', + { + name: toolName, + arguments: args + }, + abortSignal + ); + + return MCPClient.formatToolResult(response as MCPToolsCallResult); + } + + async shutdown(): Promise { + for (const [, state] of this.servers) { + await state.transport.stop(); + } + this.servers.clear(); + this.toolsToServer.clear(); + } + + private async initializeServer(name: string, config: MCPServerConfig): Promise { + const protocolVersion = this.config.protocolVersion ?? MCP_DEFAULTS.protocolVersion; + const transport = this.createTransport(config, protocolVersion); + await transport.start(); + + const state: ServerState = { + transport, + pending: new Map(), + requestId: 0, + tools: [], + requestTimeoutMs: config.requestTimeoutMs + }; + + transport.onMessage((message) => this.handleMessage(name, message)); + this.servers.set(name, state); + + const clientInfo = this.config.clientInfo ?? MCP_DEFAULTS.clientInfo; + const capabilities = + config.capabilities ?? this.config.capabilities ?? MCP_DEFAULTS.capabilities; + + const initResult = await this.call(name, 'initialize', { + protocolVersion, + capabilities, + clientInfo + }); + + const negotiatedVersion = (initResult?.protocolVersion as string) ?? protocolVersion; + + state.capabilities = (initResult?.capabilities as MCPServerCapabilities) ?? {}; + state.protocolVersion = negotiatedVersion; + + const notification = JsonRpcProtocol.createNotification('notifications/initialized'); + await state.transport.send(notification as JsonRpcMessage); + + await this.refreshTools(name); + } + + private createTransport(config: MCPServerConfig, protocolVersion: string): MCPTransport { + if (!config.url) { + throw new Error('MCP server configuration is missing url'); + } + + const transportType = config.transport ?? 'websocket'; + + if (transportType === 'streamable_http') { + return new StreamableHttpTransport({ + url: config.url, + headers: config.headers, + credentials: config.credentials, + protocolVersion, + sessionId: config.sessionId + }); + } + + if (transportType !== 'websocket') { + throw new Error(`Unsupported transport "${transportType}" in webui environment`); + } + + return new WebSocketTransport({ + url: config.url, + protocols: config.protocols, + handshakeTimeoutMs: config.handshakeTimeoutMs + }); + } + + private async refreshTools(serverName: string): Promise { + const state = this.servers.get(serverName); + if (!state) return; + + const response = await this.call(serverName, 'tools/list'); + const tools = (response.tools as MCPToolDefinition[]) ?? []; + state.tools = tools; + + for (const [tool, owner] of Array.from(this.toolsToServer.entries())) { + if (owner === serverName && !tools.find((t) => t.name === tool)) { + this.toolsToServer.delete(tool); + } + } + + for (const tool of tools) { + this.toolsToServer.set(tool.name, serverName); + } + } + + private call( + serverName: string, + method: string, + params?: Record, + abortSignal?: AbortSignal + ): Promise> { + const state = this.servers.get(serverName); + if (!state) { + return Promise.reject(new MCPError(`Server ${serverName} is not connected`, -32000)); + } + + const id = ++state.requestId; + const message = JsonRpcProtocol.createRequest(id, method, params); + + const timeoutDuration = + state.requestTimeoutMs ?? + this.config.requestTimeoutMs ?? + MCP_DEFAULTS.requestTimeoutSeconds * 1000; + + if (abortSignal?.aborted) { + return Promise.reject(new DOMException('Aborted', 'AbortError')); + } + + return new Promise((resolve, reject) => { + const cleanupTasks: Array<() => void> = []; + const cleanup = () => { + for (const task of cleanupTasks.splice(0)) { + task(); + } + }; + + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Timeout while waiting for ${method} response from ${serverName}`)); + }, timeoutDuration); + cleanupTasks.push(() => clearTimeout(timeout)); + cleanupTasks.push(() => state.pending.delete(id)); + + if (abortSignal) { + const abortHandler = () => { + cleanup(); + reject(new DOMException('Aborted', 'AbortError')); + }; + abortSignal.addEventListener('abort', abortHandler, { once: true }); + cleanupTasks.push(() => abortSignal.removeEventListener('abort', abortHandler)); + } + + state.pending.set(id, { + resolve: (value) => { + cleanup(); + resolve(value); + }, + reject: (reason) => { + cleanup(); + reject(reason); + }, + timeout + }); + + const handleSendError = (error: unknown) => { + cleanup(); + reject(error); + }; + + try { + void state.transport + .send(message as JsonRpcMessage) + .catch((error) => handleSendError(error)); + } catch (error) { + handleSendError(error); + } + }); + } + + private handleMessage(serverName: string, message: JsonRpcMessage): void { + const state = this.servers.get(serverName); + if (!state) { + return; + } + + if ('method' in message && !('id' in message)) { + this.handleNotification(serverName, message.method, message.params); + return; + } + + const response = JsonRpcProtocol.parseResponse(message); + if (!response) { + return; + } + + const pending = state.pending.get(response.id as number); + if (!pending) { + return; + } + + state.pending.delete(response.id as number); + clearTimeout(pending.timeout); + + if (response.error) { + pending.reject( + new MCPError(response.error.message, response.error.code, response.error.data) + ); + return; + } + + pending.resolve(response.result ?? {}); + } + + private handleNotification( + serverName: string, + method: string, + params?: Record + ): void { + if (method === 'notifications/tools/list_changed') { + void this.refreshTools(serverName).catch((error) => { + console.error(`[MCP] Failed to refresh tools for ${serverName}:`, error); + }); + } else if (method === 'notifications/logging/message' && params) { + console.debug(`[MCP][${serverName}]`, params); + } + } + + private static formatToolResult(result: MCPToolsCallResult): string { + const content = result.content; + if (Array.isArray(content)) { + return content + .map((item) => MCPClient.formatSingleContent(item)) + .filter(Boolean) + .join('\n'); + } + if (content) { + return MCPClient.formatSingleContent(content); + } + if (result.result !== undefined) { + return typeof result.result === 'string' ? result.result : JSON.stringify(result.result); + } + return ''; + } + + private static formatSingleContent(content: unknown): string { + if (content === null || content === undefined) { + return ''; + } + + if (typeof content === 'string') { + return content; + } + + if (typeof content === 'object') { + const typed = content as { + type?: string; + text?: string; + data?: string; + mimeType?: string; + resource?: unknown; + }; + if (typed.type === 'text' && typeof typed.text === 'string') { + return typed.text; + } + if (typed.type === 'image' && typeof typed.data === 'string' && typed.mimeType) { + return `data:${typed.mimeType};base64,${typed.data}`; + } + if (typed.type === 'resource' && typed.resource) { + return JSON.stringify(typed.resource); + } + if (typeof typed.text === 'string') { + return typed.text; + } + } + + return JSON.stringify(content); + } +} diff --git a/tools/server/webui/src/lib/mcp/index.ts b/tools/server/webui/src/lib/mcp/index.ts new file mode 100644 index 00000000000..14d11859c4d --- /dev/null +++ b/tools/server/webui/src/lib/mcp/index.ts @@ -0,0 +1,3 @@ +export { MCPClient } from './client'; +export { MCPError } from './types'; +export type { MCPClientConfig, MCPServerConfig, MCPToolCall } from './types'; diff --git a/tools/server/webui/src/lib/mcp/protocol.ts b/tools/server/webui/src/lib/mcp/protocol.ts new file mode 100644 index 00000000000..eb161041e5a --- /dev/null +++ b/tools/server/webui/src/lib/mcp/protocol.ts @@ -0,0 +1,46 @@ +import type { + JsonRpcId, + JsonRpcMessage, + JsonRpcNotification, + JsonRpcRequest, + JsonRpcResponse +} from './types'; + +export class JsonRpcProtocol { + static createRequest( + id: JsonRpcId, + method: string, + params?: Record + ): JsonRpcRequest { + return { + jsonrpc: '2.0', + id, + method, + ...(params ? { params } : {}) + }; + } + + static createNotification(method: string, params?: Record): JsonRpcNotification { + return { + jsonrpc: '2.0', + method, + ...(params ? { params } : {}) + }; + } + + static parseResponse(message: JsonRpcMessage): JsonRpcResponse | null { + if (!message || typeof message !== 'object') { + return null; + } + + if ((message as JsonRpcResponse).jsonrpc !== '2.0') { + return null; + } + + if (!('id' in message)) { + return null; + } + + return message as JsonRpcResponse; + } +} diff --git a/tools/server/webui/src/lib/mcp/transports/streamable-http.ts b/tools/server/webui/src/lib/mcp/transports/streamable-http.ts new file mode 100644 index 00000000000..dc9321c1529 --- /dev/null +++ b/tools/server/webui/src/lib/mcp/transports/streamable-http.ts @@ -0,0 +1,129 @@ +import type { JsonRpcMessage } from '$lib/mcp/types'; +import type { MCPTransport } from './types'; + +export type StreamableHttpTransportOptions = { + url: string; + headers?: Record; + credentials?: RequestCredentials; + protocolVersion?: string; + sessionId?: string; +}; + +export class StreamableHttpTransport implements MCPTransport { + private handler: ((message: JsonRpcMessage) => void) | null = null; + private activeSessionId: string | undefined; + + constructor(private readonly options: StreamableHttpTransportOptions) {} + + async start(): Promise { + this.activeSessionId = this.options.sessionId ?? undefined; + } + + async stop(): Promise {} + + async send(message: JsonRpcMessage): Promise { + return this.dispatch(message); + } + + onMessage(handler: (message: JsonRpcMessage) => void): void { + this.handler = handler; + } + + private async dispatch(message: JsonRpcMessage): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...(this.options.headers ?? {}) + }; + + if (this.activeSessionId) { + headers['Mcp-Session-Id'] = this.activeSessionId; + } + + if (this.options.protocolVersion) { + headers['MCP-Protocol-Version'] = this.options.protocolVersion; + } + + const credentialsOption = + this.options.credentials ?? (this.activeSessionId ? 'include' : 'same-origin'); + const response = await fetch(this.options.url, { + method: 'POST', + headers, + body: JSON.stringify(message), + credentials: credentialsOption + }); + + const sessionHeader = response.headers.get('mcp-session-id'); + if (sessionHeader) { + this.activeSessionId = sessionHeader; + } + + if (!response.ok) { + const errorBody = await response.text().catch(() => ''); + throw new Error( + `Failed to send MCP request over Streamable HTTP (${response.status} ${response.statusText}): ${errorBody}` + ); + } + + const contentType = response.headers.get('content-type') ?? ''; + + if (contentType.includes('application/json')) { + const payload = (await response.json()) as JsonRpcMessage; + this.handler?.(payload); + return; + } + + if (contentType.includes('text/event-stream') && response.body) { + const reader = response.body.getReader(); + await this.consume(reader); + return; + } + + if (response.status >= 400) { + const bodyText = await response.text().catch(() => ''); + throw new Error( + `Unexpected MCP Streamable HTTP response (${response.status}): ${bodyText || 'no body'}` + ); + } + } + + private async consume(reader: ReadableStreamDefaultReader): Promise { + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const parts = buffer.split('\n\n'); + buffer = parts.pop() ?? ''; + + for (const part of parts) { + if (!part.startsWith('data: ')) { + continue; + } + const payload = part.slice(6); + if (!payload || payload === '[DONE]') { + continue; + } + + try { + const message = JSON.parse(payload) as JsonRpcMessage; + this.handler?.(message); + } catch (error) { + console.error('[MCP][Streamable HTTP] Failed to parse JSON payload:', error); + } + } + } + } catch (error) { + if ((error as Error)?.name === 'AbortError') { + return; + } + throw error; + } finally { + reader.releaseLock(); + } + } +} diff --git a/tools/server/webui/src/lib/mcp/transports/types.ts b/tools/server/webui/src/lib/mcp/transports/types.ts new file mode 100644 index 00000000000..c0182d5b492 --- /dev/null +++ b/tools/server/webui/src/lib/mcp/transports/types.ts @@ -0,0 +1,8 @@ +import type { JsonRpcMessage } from '../types'; + +export interface MCPTransport { + start(): Promise; + stop(): Promise; + send(message: JsonRpcMessage): Promise; + onMessage(handler: (message: JsonRpcMessage) => void): void; +} diff --git a/tools/server/webui/src/lib/mcp/transports/websocket.ts b/tools/server/webui/src/lib/mcp/transports/websocket.ts new file mode 100644 index 00000000000..f40aa941b6a --- /dev/null +++ b/tools/server/webui/src/lib/mcp/transports/websocket.ts @@ -0,0 +1,238 @@ +import type { JsonRpcMessage } from '$lib/mcp/types'; +import type { MCPTransport } from './types'; + +export type WebSocketTransportOptions = { + url: string; + protocols?: string | string[]; + handshakeTimeoutMs?: number; +}; + +export type TransportMessageHandler = (message: JsonRpcMessage) => void; + +function ensureWebSocket(): typeof WebSocket | null { + if (typeof WebSocket !== 'undefined') { + return WebSocket; + } + return null; +} + +function arrayBufferToString(buffer: ArrayBufferLike): string { + return new TextDecoder('utf-8').decode(new Uint8Array(buffer)); +} + +async function normalizePayload(data: unknown): Promise { + if (typeof data === 'string') { + return data; + } + + if (data instanceof ArrayBuffer) { + return arrayBufferToString(data); + } + + if (ArrayBuffer.isView(data)) { + return arrayBufferToString(data.buffer); + } + + if (typeof Blob !== 'undefined' && data instanceof Blob) { + return await data.text(); + } + + throw new Error('Unsupported WebSocket message payload type'); +} + +export class WebSocketTransport implements MCPTransport { + private socket: WebSocket | null = null; + private handler: TransportMessageHandler | null = null; + private openPromise: Promise | null = null; + private reconnectAttempts = 0; + private readonly maxReconnectAttempts = 5; + private readonly reconnectDelay = 1_000; + private isReconnecting = false; + private shouldAttemptReconnect = true; + + constructor(private readonly options: WebSocketTransportOptions) {} + + start(): Promise { + if (this.openPromise) { + return this.openPromise; + } + + this.shouldAttemptReconnect = true; + + this.openPromise = new Promise((resolve, reject) => { + const WebSocketImpl = ensureWebSocket(); + if (!WebSocketImpl) { + this.openPromise = null; + reject(new Error('WebSocket is not available in this environment')); + return; + } + + let handshakeTimeout: ReturnType | undefined; + const socket = this.options.protocols + ? new WebSocketImpl(this.options.url, this.options.protocols) + : new WebSocketImpl(this.options.url); + + const cleanup = () => { + if (!socket) return; + socket.onopen = null; + socket.onclose = null; + socket.onerror = null; + socket.onmessage = null; + if (handshakeTimeout) { + clearTimeout(handshakeTimeout); + handshakeTimeout = undefined; + } + }; + + const fail = (error: unknown) => { + cleanup(); + this.openPromise = null; + reject(error instanceof Error ? error : new Error('WebSocket connection error')); + }; + + socket.onopen = () => { + cleanup(); + this.socket = socket; + this.reconnectAttempts = 0; + this.attachMessageHandler(); + this.attachCloseHandler(socket); + resolve(); + this.openPromise = null; + }; + + socket.onerror = (event) => { + const error = event instanceof Event ? new Error('WebSocket connection error') : event; + fail(error); + }; + + socket.onclose = (event) => { + if (!this.socket) { + fail(new Error(`WebSocket closed before opening (code: ${event.code})`)); + } + }; + + if (this.options.handshakeTimeoutMs) { + handshakeTimeout = setTimeout(() => { + if (!this.socket) { + try { + socket.close(); + } catch (error) { + console.warn('[MCP][Transport] Failed to close socket after timeout:', error); + } + fail(new Error('WebSocket handshake timed out')); + } + }, this.options.handshakeTimeoutMs); + } + }); + + return this.openPromise; + } + + async send(message: JsonRpcMessage): Promise { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket transport is not connected'); + } + this.socket.send(JSON.stringify(message)); + } + + async stop(): Promise { + this.shouldAttemptReconnect = false; + this.reconnectAttempts = 0; + this.isReconnecting = false; + + const socket = this.socket; + if (!socket) { + this.openPromise = null; + return; + } + + await new Promise((resolve) => { + const onClose = () => { + socket.removeEventListener('close', onClose); + resolve(); + }; + socket.addEventListener('close', onClose); + try { + socket.close(); + } catch (error) { + socket.removeEventListener('close', onClose); + console.warn('[MCP][Transport] Failed to close WebSocket:', error); + resolve(); + } + }); + + this.socket = null; + this.openPromise = null; + } + + onMessage(handler: TransportMessageHandler): void { + this.handler = handler; + this.attachMessageHandler(); + } + + private attachMessageHandler(): void { + if (!this.socket) { + return; + } + + this.socket.onmessage = (event: MessageEvent) => { + const payload = event.data; + void (async () => { + try { + const text = await normalizePayload(payload); + const parsed = JSON.parse(text); + this.handler?.(parsed as JsonRpcMessage); + } catch (error) { + console.error('[MCP][Transport] Failed to handle message:', error); + } + })(); + }; + } + + private attachCloseHandler(socket: WebSocket): void { + socket.onclose = (event) => { + this.socket = null; + + if (event.code === 1000 || !this.shouldAttemptReconnect) { + return; + } + + console.warn('[MCP][WebSocket] Connection closed unexpectedly, attempting reconnect'); + void this.reconnect(); + }; + } + + private async reconnect(): Promise { + if ( + this.isReconnecting || + this.reconnectAttempts >= this.maxReconnectAttempts || + !this.shouldAttemptReconnect + ) { + return; + } + + this.isReconnecting = true; + this.reconnectAttempts++; + + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); + await new Promise((resolve) => setTimeout(resolve, delay)); + + try { + this.openPromise = null; + await this.start(); + this.reconnectAttempts = 0; + console.log('[MCP][WebSocket] Reconnected successfully'); + } catch (error) { + console.error('[MCP][WebSocket] Reconnection failed:', error); + } finally { + this.isReconnecting = false; + if ( + !this.socket && + this.shouldAttemptReconnect && + this.reconnectAttempts < this.maxReconnectAttempts + ) { + void this.reconnect(); + } + } + } +} diff --git a/tools/server/webui/src/lib/mcp/types.ts b/tools/server/webui/src/lib/mcp/types.ts new file mode 100644 index 00000000000..41b0c391b97 --- /dev/null +++ b/tools/server/webui/src/lib/mcp/types.ts @@ -0,0 +1,124 @@ +export type JsonRpcId = number | string; + +export type JsonRpcRequest = { + jsonrpc: '2.0'; + id: JsonRpcId; + method: string; + params?: Record; +}; + +export type JsonRpcNotification = { + jsonrpc: '2.0'; + method: string; + params?: Record; +}; + +export type JsonRpcError = { + code: number; + message: string; + data?: unknown; +}; + +export type JsonRpcResponse = { + jsonrpc: '2.0'; + id: JsonRpcId; + result?: Record; + error?: JsonRpcError; +}; + +export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification; + +export class MCPError extends Error { + code: number; + data?: unknown; + + constructor(message: string, code: number, data?: unknown) { + super(message); + this.name = 'MCPError'; + this.code = code; + this.data = data; + } +} + +export type MCPToolInputSchema = Record; + +export type MCPToolDefinition = { + name: string; + description?: string; + inputSchema?: MCPToolInputSchema; +}; + +export type MCPServerCapabilities = Record; + +export type MCPClientCapabilities = Record; + +export type MCPTransportType = 'websocket' | 'streamable_http'; + +export type MCPServerConfig = { + /** MCP transport type. Defaults to `streamable_http`. */ + transport?: MCPTransportType; + /** Remote MCP endpoint URL. */ + url: string; + /** Optional WebSocket subprotocol(s). */ + protocols?: string | string[]; + /** Optional HTTP headers for environments that support them. */ + headers?: Record; + /** Optional credentials policy for fetch-based transports. */ + credentials?: RequestCredentials; + /** Optional handshake timeout override (ms). */ + handshakeTimeoutMs?: number; + /** Optional per-server request timeout override (ms). */ + requestTimeoutMs?: number; + /** Optional per-server capability overrides. */ + capabilities?: MCPClientCapabilities; + /** Optional pre-negotiated session identifier for Streamable HTTP transport. */ + sessionId?: string; +}; + +export type MCPClientInfo = { + name: string; + version?: string; +}; + +export type MCPClientConfig = { + servers: Record; + /** Defaults to `2025-06-18`. */ + protocolVersion?: string; + /** Default capabilities advertised during initialize. */ + capabilities?: MCPClientCapabilities; + /** Custom client info to advertise. */ + clientInfo?: MCPClientInfo; + /** Request timeout when waiting for MCP responses (ms). Default: 30_000. */ + requestTimeoutMs?: number; +}; + +export type MCPToolCallArguments = Record; + +export type MCPToolCall = { + id: string; + function: { + name: string; + arguments: string | MCPToolCallArguments; + }; +}; + +export type MCPToolResultContent = + | string + | { + type: 'text'; + text: string; + } + | { + type: 'image'; + data: string; + mimeType?: string; + } + | { + type: 'resource'; + resource: Record; + }; + +export type MCPToolsCallResult = { + content?: MCPToolResultContent | MCPToolResultContent[]; + result?: unknown; +}; diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts index 86648f3cba0..4b1a9995870 100644 --- a/tools/server/webui/src/lib/services/chat.ts +++ b/tools/server/webui/src/lib/services/chat.ts @@ -1,5 +1,10 @@ -import { getJsonHeaders } from '$lib/utils'; +import { getAuthHeaders, getJsonHeaders } from '$lib/utils'; import { AttachmentType } from '$lib/enums'; +import { config } from '$lib/stores/settings.svelte'; +import { ensureMcpClient } from '$lib/services/mcp-singleton'; +import { getAgenticConfig } from '$lib/config/agentic'; +import { AgenticOrchestrator } from '$lib/agentic/orchestrator'; +import { OpenAISseClient } from '$lib/agentic/openai-sse-client'; /** * ChatService - Low-level API communication layer for Chat Completions @@ -170,6 +175,71 @@ export class ChatService { } } + // MCP agentic orchestration (low-coupling mode) + // Check if MCP client is available and agentic mode is enabled + if (stream) { + const mcpClient = await ensureMcpClient(); + const agenticConfig = mcpClient ? getAgenticConfig(config()) : undefined; + + // Debug: verify MCP tools are available + if (mcpClient) { + const availableTools = mcpClient.listTools(); + console.log( + `[MCP] Client initialized with ${availableTools.length} tools:`, + availableTools + ); + } else { + console.log('[MCP] No MCP client available'); + } + + if (mcpClient && agenticConfig?.enabled) { + try { + const llmClient = new OpenAISseClient({ + url: './v1/chat/completions', + buildHeaders: () => getAuthHeaders() + }); + + const orchestrator = new AgenticOrchestrator({ + mcpClient, + llmClient, + maxTurns: agenticConfig.maxTurns, + maxToolPreviewLines: agenticConfig.maxToolPreviewLines + }); + + let capturedTimings: ChatMessageTimings | undefined; + + await orchestrator.run({ + initialMessages: processedMessages, + requestTemplate: requestBody, + callbacks: { + onChunk, + onReasoningChunk, + onToolCallChunk, + onModel, + onComplete: onComplete + ? () => onComplete('', undefined, capturedTimings, undefined) + : undefined, + onError + }, + abortSignal: signal, + onProcessingUpdate: (timings, progress) => { + ChatService.notifyTimings(timings, progress, onTimings); + if (timings) { + capturedTimings = timings; + } + }, + maxTurns: agenticConfig.maxTurns, + filterReasoningAfterFirstTurn: agenticConfig.filterReasoningAfterFirstTurn + }); + + return; + } catch (error) { + // If MCP orchestration fails, log and fall through to standard flow + console.warn('MCP orchestration failed, falling back to standard flow:', error); + } + } + } + try { const response = await fetch(`./v1/chat/completions`, { method: 'POST', diff --git a/tools/server/webui/src/lib/services/mcp-singleton.ts b/tools/server/webui/src/lib/services/mcp-singleton.ts new file mode 100644 index 00000000000..4d91c08740a --- /dev/null +++ b/tools/server/webui/src/lib/services/mcp-singleton.ts @@ -0,0 +1,140 @@ +import { browser } from '$app/environment'; +import { MCPClient } from '$lib/mcp'; +import { buildMcpClientConfig } from '$lib/config/mcp'; +import { config } from '$lib/stores/settings.svelte'; + +const globalState = globalThis as typeof globalThis & { + __llamaMcpClient?: MCPClient; + __llamaMcpInitPromise?: Promise; + __llamaMcpConfigSignature?: string; + __llamaMcpInitConfigSignature?: string; +}; + +function serializeConfigSignature(): string | undefined { + const mcpConfig = buildMcpClientConfig(config()); + return mcpConfig ? JSON.stringify(mcpConfig) : undefined; +} + +async function shutdownClient(): Promise { + if (!globalState.__llamaMcpClient) return; + + const clientToShutdown = globalState.__llamaMcpClient; + globalState.__llamaMcpClient = undefined; + globalState.__llamaMcpConfigSignature = undefined; + + try { + await clientToShutdown.shutdown(); + } catch (error) { + console.error('[MCP] Failed to shutdown client:', error); + } +} + +async function bootstrapClient( + signature: string, + mcpConfig: ReturnType +): Promise { + if (!browser || !mcpConfig) { + return undefined; + } + + const client = new MCPClient(mcpConfig); + globalState.__llamaMcpInitConfigSignature = signature; + + const initPromise = client + .initialize() + .then(() => { + // Ignore initialization if config changed during bootstrap + if (globalState.__llamaMcpInitConfigSignature !== signature) { + void client.shutdown().catch((shutdownError) => { + console.error( + '[MCP] Failed to shutdown stale client after config change:', + shutdownError + ); + }); + return undefined; + } + + globalState.__llamaMcpClient = client; + globalState.__llamaMcpConfigSignature = signature; + return client; + }) + .catch((error) => { + console.error('[MCP] Failed to initialize client:', error); + + // Cleanup global references on error + if (globalState.__llamaMcpClient === client) { + globalState.__llamaMcpClient = undefined; + } + if (globalState.__llamaMcpConfigSignature === signature) { + globalState.__llamaMcpConfigSignature = undefined; + } + + void client.shutdown().catch((shutdownError) => { + console.error('[MCP] Failed to shutdown client after init error:', shutdownError); + }); + return undefined; + }) + .finally(() => { + // Clear init promise only if it's OUR promise + if (globalState.__llamaMcpInitPromise === initPromise) { + globalState.__llamaMcpInitPromise = undefined; + // Clear init signature only if it's still ours + if (globalState.__llamaMcpInitConfigSignature === signature) { + globalState.__llamaMcpInitConfigSignature = undefined; + } + } + }); + + globalState.__llamaMcpInitPromise = initPromise; + return initPromise; +} + +export function getMcpClient(): MCPClient | undefined { + return globalState.__llamaMcpClient; +} + +export async function ensureMcpClient(): Promise { + const signature = serializeConfigSignature(); + + // Configuration removed: shut down active client if present + if (!signature) { + // Wait for any in-flight init to complete before shutdown + if (globalState.__llamaMcpInitPromise) { + await globalState.__llamaMcpInitPromise; + } + await shutdownClient(); + globalState.__llamaMcpInitPromise = undefined; + globalState.__llamaMcpInitConfigSignature = undefined; + return undefined; + } + + // Client already initialized with correct config + if (globalState.__llamaMcpClient && globalState.__llamaMcpConfigSignature === signature) { + return globalState.__llamaMcpClient; + } + + // Init in progress with correct config + if ( + globalState.__llamaMcpInitPromise && + globalState.__llamaMcpInitConfigSignature === signature + ) { + return globalState.__llamaMcpInitPromise; + } + + // Config changed - wait for in-flight init before shutdown + if ( + globalState.__llamaMcpInitPromise && + globalState.__llamaMcpInitConfigSignature !== signature + ) { + await globalState.__llamaMcpInitPromise; + } + + // Shutdown if config changed + if (globalState.__llamaMcpConfigSignature !== signature) { + await shutdownClient(); + } + + // Bootstrap new client + const mcpConfig = buildMcpClientConfig(config()); + return bootstrapClient(signature, mcpConfig); +} diff --git a/tools/server/webui/src/lib/types/api.d.ts b/tools/server/webui/src/lib/types/api.d.ts index c2ecc02820a..bdbac19cf4c 100644 --- a/tools/server/webui/src/lib/types/api.d.ts +++ b/tools/server/webui/src/lib/types/api.d.ts @@ -1,6 +1,17 @@ import type { ServerModelStatus, ServerRole } from '$lib/enums'; import type { ChatMessagePromptProgress } from './chat'; +export interface ApiChatCompletionToolFunction { + name: string; + description?: string; + parameters: Record; +} + +export interface ApiChatCompletionTool { + type: 'function'; + function: ApiChatCompletionToolFunction; +} + export interface ApiChatMessageContentPart { type: 'text' | 'image_url' | 'input_audio'; text?: string; @@ -34,6 +45,8 @@ export interface ApiErrorResponse { export interface ApiChatMessageData { role: ChatRole; content: string | ApiChatMessageContentPart[]; + tool_calls?: ApiChatCompletionToolCall[]; + tool_call_id?: string; timestamp?: number; } @@ -187,6 +200,7 @@ export interface ApiChatCompletionRequest { stream?: boolean; model?: string; return_progress?: boolean; + tools?: ApiChatCompletionTool[]; // Reasoning parameters reasoning_format?: string; // Generation parameters @@ -245,6 +259,7 @@ export interface ApiChatCompletionStreamChunk { model?: string; tool_calls?: ApiChatCompletionToolCallDelta[]; }; + finish_reason?: string | null; }>; timings?: { prompt_n?: number; @@ -265,8 +280,9 @@ export interface ApiChatCompletionResponse { content: string; reasoning_content?: string; model?: string; - tool_calls?: ApiChatCompletionToolCallDelta[]; + tool_calls?: ApiChatCompletionToolCall[]; }; + finish_reason?: string | null; }>; } diff --git a/tools/server/webui/src/lib/utils/chat-stream.ts b/tools/server/webui/src/lib/utils/chat-stream.ts new file mode 100644 index 00000000000..44145348b2c --- /dev/null +++ b/tools/server/webui/src/lib/utils/chat-stream.ts @@ -0,0 +1,85 @@ +import type { + ApiChatCompletionResponse, + ApiChatCompletionStreamChunk, + ApiChatCompletionToolCall, + ApiChatCompletionToolCallDelta +} from '$lib/types/api'; + +export function mergeToolCallDeltas( + existing: ApiChatCompletionToolCall[], + deltas: ApiChatCompletionToolCallDelta[], + indexOffset = 0 +): ApiChatCompletionToolCall[] { + const result = existing.map((call) => ({ + ...call, + function: call.function ? { ...call.function } : undefined + })); + + for (const delta of deltas) { + const index = + typeof delta.index === 'number' && delta.index >= 0 + ? delta.index + indexOffset + : result.length; + + while (result.length <= index) { + result.push({ function: undefined }); + } + + const target = result[index]!; + + if (delta.id) { + target.id = delta.id; + } + + if (delta.type) { + target.type = delta.type; + } + + if (delta.function) { + const fn = target.function ? { ...target.function } : {}; + + if (delta.function.name) { + fn.name = delta.function.name; + } + + if (delta.function.arguments) { + fn.arguments = (fn.arguments ?? '') + delta.function.arguments; + } + + target.function = fn; + } + } + + return result; +} + +export function extractModelName( + data: ApiChatCompletionStreamChunk | ApiChatCompletionResponse | unknown +): string | undefined { + const asRecord = (value: unknown): Record | undefined => { + return typeof value === 'object' && value !== null + ? (value as Record) + : undefined; + }; + + const getTrimmedString = (value: unknown): string | undefined => { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + }; + + const root = asRecord(data); + if (!root) return undefined; + + const rootModel = getTrimmedString(root.model); + if (rootModel) return rootModel; + + const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined; + if (!firstChoice) return undefined; + + const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model); + if (deltaModel) return deltaModel; + + const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model); + if (messageModel) return messageModel; + + return undefined; +} From f715fe3dc064172811e9006ee0b0997cb9338b88 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 6 Dec 2025 14:38:40 +0100 Subject: [PATCH 02/34] webui: use normalizedMessages after upstream refactor --- tools/server/webui/src/lib/services/chat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts index 4b1a9995870..00b7c8b2ae3 100644 --- a/tools/server/webui/src/lib/services/chat.ts +++ b/tools/server/webui/src/lib/services/chat.ts @@ -209,7 +209,7 @@ export class ChatService { let capturedTimings: ChatMessageTimings | undefined; await orchestrator.run({ - initialMessages: processedMessages, + initialMessages: normalizedMessages, requestTemplate: requestBody, callbacks: { onChunk, From ad7d9ed1299af41a8b3c4403cbdef8056dfdf382 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 29 Dec 2025 10:35:46 +0100 Subject: [PATCH 03/34] feat: Introduce common utility functions --- tools/server/webui/src/lib/utils/mcp.ts | 23 ++++++++++++++++++++++ tools/server/webui/src/lib/utils/number.ts | 11 +++++++++++ 2 files changed, 34 insertions(+) create mode 100644 tools/server/webui/src/lib/utils/mcp.ts create mode 100644 tools/server/webui/src/lib/utils/number.ts diff --git a/tools/server/webui/src/lib/utils/mcp.ts b/tools/server/webui/src/lib/utils/mcp.ts new file mode 100644 index 00000000000..46efb36b4ab --- /dev/null +++ b/tools/server/webui/src/lib/utils/mcp.ts @@ -0,0 +1,23 @@ +import type { MCPTransportType } from '$lib/types/mcp'; + +/** + * Detects the MCP transport type from a URL. + * WebSocket URLs (ws:// or wss://) use 'websocket', others use 'streamable_http'. + */ +export function detectMcpTransportFromUrl(url: string): MCPTransportType { + const normalized = url.trim().toLowerCase(); + return normalized.startsWith('ws://') || normalized.startsWith('wss://') + ? 'websocket' + : 'streamable_http'; +} + +/** + * Generates a valid MCP server ID from user input. + * Returns the trimmed ID if valid, otherwise generates 'server-{index+1}'. + */ +export function generateMcpServerId(id: unknown, index: number): string { + if (typeof id === 'string' && id.trim()) { + return id.trim(); + } + return `server-${index + 1}`; +} diff --git a/tools/server/webui/src/lib/utils/number.ts b/tools/server/webui/src/lib/utils/number.ts new file mode 100644 index 00000000000..263869ec1d2 --- /dev/null +++ b/tools/server/webui/src/lib/utils/number.ts @@ -0,0 +1,11 @@ +/** + * Normalizes a value to a positive number, returning the fallback if invalid. + * Handles both string and number inputs. + */ +export function normalizePositiveNumber(value: unknown, fallback: number): number { + const parsed = typeof value === 'string' ? Number.parseFloat(value) : Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return parsed; +} From f64dc6e66c6c0827e0d8fc5c6dd9fa148171c4cd Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 29 Dec 2025 10:35:46 +0100 Subject: [PATCH 04/34] feat: Centralize MCP and Agentic type definitions and constants --- .../server/webui/src/lib/constants/agentic.ts | 8 ++ tools/server/webui/src/lib/constants/mcp.ts | 9 ++ tools/server/webui/src/lib/types/agentic.ts | 9 ++ .../src/lib/{mcp/types.ts => types/mcp.ts} | 107 +++++++----------- 4 files changed, 65 insertions(+), 68 deletions(-) create mode 100644 tools/server/webui/src/lib/constants/agentic.ts create mode 100644 tools/server/webui/src/lib/constants/mcp.ts create mode 100644 tools/server/webui/src/lib/types/agentic.ts rename tools/server/webui/src/lib/{mcp/types.ts => types/mcp.ts} (53%) diff --git a/tools/server/webui/src/lib/constants/agentic.ts b/tools/server/webui/src/lib/constants/agentic.ts new file mode 100644 index 00000000000..453549832a8 --- /dev/null +++ b/tools/server/webui/src/lib/constants/agentic.ts @@ -0,0 +1,8 @@ +import type { AgenticConfig } from '$lib/types/agentic'; + +export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = { + enabled: true, + maxTurns: 100, + maxToolPreviewLines: 25, + filterReasoningAfterFirstTurn: true +} as const; diff --git a/tools/server/webui/src/lib/constants/mcp.ts b/tools/server/webui/src/lib/constants/mcp.ts new file mode 100644 index 00000000000..b87d0b44af8 --- /dev/null +++ b/tools/server/webui/src/lib/constants/mcp.ts @@ -0,0 +1,9 @@ +import type { ClientCapabilities, Implementation } from '$lib/types/mcp'; + +export const DEFAULT_MCP_CONFIG = { + protocolVersion: '2025-06-18', + capabilities: { tools: { listChanged: true } } as ClientCapabilities, + clientInfo: { name: 'llama-webui-mcp', version: 'dev' } as Implementation, + requestTimeoutSeconds: 300, // 5 minutes for long-running tools + connectionTimeoutMs: 10_000 // 10 seconds for connection establishment +} as const; diff --git a/tools/server/webui/src/lib/types/agentic.ts b/tools/server/webui/src/lib/types/agentic.ts new file mode 100644 index 00000000000..5f40b295288 --- /dev/null +++ b/tools/server/webui/src/lib/types/agentic.ts @@ -0,0 +1,9 @@ +/** + * Agentic orchestration configuration. + */ +export interface AgenticConfig { + enabled: boolean; + maxTurns: number; + maxToolPreviewLines: number; + filterReasoningAfterFirstTurn: boolean; +} diff --git a/tools/server/webui/src/lib/mcp/types.ts b/tools/server/webui/src/lib/types/mcp.ts similarity index 53% rename from tools/server/webui/src/lib/mcp/types.ts rename to tools/server/webui/src/lib/types/mcp.ts index 41b0c391b97..e812080a09f 100644 --- a/tools/server/webui/src/lib/mcp/types.ts +++ b/tools/server/webui/src/lib/types/mcp.ts @@ -1,32 +1,14 @@ -export type JsonRpcId = number | string; - -export type JsonRpcRequest = { - jsonrpc: '2.0'; - id: JsonRpcId; - method: string; - params?: Record; -}; - -export type JsonRpcNotification = { - jsonrpc: '2.0'; - method: string; - params?: Record; -}; - -export type JsonRpcError = { - code: number; - message: string; - data?: unknown; -}; - -export type JsonRpcResponse = { - jsonrpc: '2.0'; - id: JsonRpcId; - result?: Record; - error?: JsonRpcError; -}; - -export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification; +// Re-export SDK types that we use +import type { + ClientCapabilities as SDKClientCapabilities, + Implementation as SDKImplementation, + Tool, + CallToolResult +} from '@modelcontextprotocol/sdk/types.js'; + +export type { Tool, CallToolResult }; +export type ClientCapabilities = SDKClientCapabilities; +export type Implementation = SDKImplementation; export class MCPError extends Error { code: number; @@ -40,18 +22,6 @@ export class MCPError extends Error { } } -export type MCPToolInputSchema = Record; - -export type MCPToolDefinition = { - name: string; - description?: string; - inputSchema?: MCPToolInputSchema; -}; - -export type MCPServerCapabilities = Record; - -export type MCPClientCapabilities = Record; - export type MCPTransportType = 'websocket' | 'streamable_http'; export type MCPServerConfig = { @@ -70,24 +40,19 @@ export type MCPServerConfig = { /** Optional per-server request timeout override (ms). */ requestTimeoutMs?: number; /** Optional per-server capability overrides. */ - capabilities?: MCPClientCapabilities; + capabilities?: ClientCapabilities; /** Optional pre-negotiated session identifier for Streamable HTTP transport. */ sessionId?: string; }; -export type MCPClientInfo = { - name: string; - version?: string; -}; - export type MCPClientConfig = { servers: Record; /** Defaults to `2025-06-18`. */ protocolVersion?: string; /** Default capabilities advertised during initialize. */ - capabilities?: MCPClientCapabilities; + capabilities?: ClientCapabilities; /** Custom client info to advertise. */ - clientInfo?: MCPClientInfo; + clientInfo?: Implementation; /** Request timeout when waiting for MCP responses (ms). Default: 30_000. */ requestTimeoutMs?: number; }; @@ -102,23 +67,29 @@ export type MCPToolCall = { }; }; -export type MCPToolResultContent = - | string - | { - type: 'text'; - text: string; - } - | { - type: 'image'; - data: string; - mimeType?: string; - } - | { - type: 'resource'; - resource: Record; - }; - -export type MCPToolsCallResult = { - content?: MCPToolResultContent | MCPToolResultContent[]; - result?: unknown; +/** + * Raw MCP server configuration entry stored in settings. + */ +export type MCPServerSettingsEntry = { + id: string; + enabled: boolean; + 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; +} From 2b1d783c780df45f97c2ab5a74ef14dd803dfc25 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 29 Dec 2025 10:35:46 +0100 Subject: [PATCH 05/34] refactor: Update Agentic and MCP config parsing to use new utils and constants --- .../ChatSettings/McpSettingsSection.svelte | 24 +++--- tools/server/webui/src/lib/config/agentic.ts | 42 +++------- tools/server/webui/src/lib/config/mcp.ts | 84 ++++--------------- 3 files changed, 35 insertions(+), 115 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpSettingsSection.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpSettingsSection.svelte index dbef67dd492..101dff27d45 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpSettingsSection.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpSettingsSection.svelte @@ -4,14 +4,12 @@ import { Input } from '$lib/components/ui/input'; import Label from '$lib/components/ui/label/label.svelte'; import { Button } from '$lib/components/ui/button'; - import { - detectMcpTransportFromUrl, - parseMcpServerSettings, - getDefaultMcpConfig, - type MCPServerSettingsEntry - } from '$lib/config/mcp'; + import { parseMcpServerSettings } from '$lib/config/mcp'; + import { detectMcpTransportFromUrl } from '$lib/utils/mcp'; + import type { MCPServerSettingsEntry } from '$lib/types/mcp'; import { MCPClient } from '$lib/mcp'; import type { SettingsConfigType } from '$lib/types/settings'; + import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp'; interface Props { localConfig: SettingsConfigType; @@ -20,8 +18,6 @@ let { localConfig, onConfigChange }: Props = $props(); - const defaultMcpConfig = getDefaultMcpConfig(); - type HealthCheckState = | { status: 'idle' } | { status: 'loading' } @@ -44,7 +40,7 @@ id: crypto.randomUUID ? crypto.randomUUID() : `server-${Date.now()}`, enabled: true, url: '', - requestTimeoutSeconds: defaultMcpConfig.requestTimeoutSeconds + requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds }; serializeServers([...servers, newServer]); @@ -103,15 +99,15 @@ const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000); const mcpClient = new MCPClient({ - protocolVersion: defaultMcpConfig.protocolVersion, - capabilities: defaultMcpConfig.capabilities, - clientInfo: defaultMcpConfig.clientInfo, + protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion, + capabilities: DEFAULT_MCP_CONFIG.capabilities, + clientInfo: DEFAULT_MCP_CONFIG.clientInfo, requestTimeoutMs: timeoutMs, servers: { [server.id]: { url: trimmedUrl, transport: detectMcpTransportFromUrl(trimmedUrl), - handshakeTimeoutMs: defaultMcpConfig.connectionTimeoutMs, + handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs, requestTimeoutMs: timeoutMs } } @@ -228,7 +224,7 @@ requestTimeoutSeconds: Number.isFinite(parsed) && parsed > 0 ? parsed - : defaultMcpConfig.requestTimeoutSeconds + : DEFAULT_MCP_CONFIG.requestTimeoutSeconds }); }} /> diff --git a/tools/server/webui/src/lib/config/agentic.ts b/tools/server/webui/src/lib/config/agentic.ts index 61f3aa96210..6aed7a37287 100644 --- a/tools/server/webui/src/lib/config/agentic.ts +++ b/tools/server/webui/src/lib/config/agentic.ts @@ -1,49 +1,29 @@ import { hasEnabledMcpServers } from './mcp'; import type { SettingsConfigType } from '$lib/types/settings'; - -/** - * Agentic orchestration configuration. - */ -export interface AgenticConfig { - enabled: boolean; - maxTurns: number; - maxToolPreviewLines: number; - filterReasoningAfterFirstTurn: boolean; -} - -const defaultAgenticConfig: AgenticConfig = { - enabled: true, - maxTurns: 100, - maxToolPreviewLines: 25, - filterReasoningAfterFirstTurn: true -}; - -function normalizeNumber(value: unknown, fallback: number): number { - const parsed = typeof value === 'string' ? Number.parseFloat(value) : Number(value); - if (!Number.isFinite(parsed) || parsed <= 0) { - return fallback; - } - - return parsed; -} +import type { AgenticConfig } from '$lib/types/agentic'; +import { DEFAULT_AGENTIC_CONFIG } from '$lib/constants/agentic'; +import { normalizePositiveNumber } from '$lib/utils/number'; /** * Gets the current agentic configuration. * Automatically disables agentic mode if no MCP servers are configured. */ export function getAgenticConfig(settings: SettingsConfigType): AgenticConfig { - const maxTurns = normalizeNumber(settings.agenticMaxTurns, defaultAgenticConfig.maxTurns); - const maxToolPreviewLines = normalizeNumber( + const maxTurns = normalizePositiveNumber( + settings.agenticMaxTurns, + DEFAULT_AGENTIC_CONFIG.maxTurns + ); + const maxToolPreviewLines = normalizePositiveNumber( settings.agenticMaxToolPreviewLines, - defaultAgenticConfig.maxToolPreviewLines + DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines ); const filterReasoningAfterFirstTurn = typeof settings.agenticFilterReasoningAfterFirstTurn === 'boolean' ? settings.agenticFilterReasoningAfterFirstTurn - : defaultAgenticConfig.filterReasoningAfterFirstTurn; + : DEFAULT_AGENTIC_CONFIG.filterReasoningAfterFirstTurn; return { - enabled: hasEnabledMcpServers(settings) && defaultAgenticConfig.enabled, + enabled: hasEnabledMcpServers(settings) && DEFAULT_AGENTIC_CONFIG.enabled, maxTurns, maxToolPreviewLines, filterReasoningAfterFirstTurn diff --git a/tools/server/webui/src/lib/config/mcp.ts b/tools/server/webui/src/lib/config/mcp.ts index 01e4ede3c50..83e08ae5577 100644 --- a/tools/server/webui/src/lib/config/mcp.ts +++ b/tools/server/webui/src/lib/config/mcp.ts @@ -1,68 +1,12 @@ -import type { - MCPClientCapabilities, - MCPClientConfig, - MCPClientInfo, - MCPServerConfig -} from '../mcp/types'; +import type { MCPClientConfig, MCPServerConfig, MCPServerSettingsEntry } from '$lib/types/mcp'; import type { SettingsConfigType } from '$lib/types/settings'; - -/** - * Raw MCP server configuration entry stored in settings. - */ -export type MCPServerSettingsEntry = { - id: string; - enabled: boolean; - url: string; - requestTimeoutSeconds: number; -}; - -const defaultMcpConfig = { - protocolVersion: '2025-06-18', - capabilities: { tools: { listChanged: true } } as MCPClientCapabilities, - clientInfo: { name: 'llama-webui-mcp', version: 'dev' } as MCPClientInfo, - requestTimeoutSeconds: 300, // 5 minutes for long-running tools - connectionTimeoutMs: 10_000 // 10 seconds for connection establishment -}; - -export function getDefaultMcpConfig() { - return defaultMcpConfig; -} - -export function detectMcpTransportFromUrl(url: string): 'websocket' | 'streamable_http' { - const normalized = url.trim().toLowerCase(); - return normalized.startsWith('ws://') || normalized.startsWith('wss://') - ? 'websocket' - : 'streamable_http'; -} - -function normalizeRequestTimeoutSeconds(value: unknown, fallback: number): number { - const parsed = typeof value === 'string' ? Number.parseFloat(value) : Number(value); - if (!Number.isFinite(parsed) || parsed <= 0) { - return fallback; - } - - return parsed; -} - -function sanitizeId(id: unknown, index: number): string { - if (typeof id === 'string' && id.trim()) { - return id.trim(); - } - - return `server-${index + 1}`; -} - -function sanitizeUrl(url: unknown): string { - if (typeof url === 'string') { - return url.trim(); - } - - return ''; -} +import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp'; +import { detectMcpTransportFromUrl, generateMcpServerId } from '$lib/utils/mcp'; +import { normalizePositiveNumber } from '$lib/utils/number'; export function parseMcpServerSettings( rawServers: unknown, - fallbackRequestTimeoutSeconds = defaultMcpConfig.requestTimeoutSeconds + fallbackRequestTimeoutSeconds = DEFAULT_MCP_CONFIG.requestTimeoutSeconds ): MCPServerSettingsEntry[] { if (!rawServers) return []; @@ -84,15 +28,15 @@ export function parseMcpServerSettings( if (!Array.isArray(parsed)) return []; return parsed.map((entry, index) => { - const requestTimeoutSeconds = normalizeRequestTimeoutSeconds( + const requestTimeoutSeconds = normalizePositiveNumber( (entry as { requestTimeoutSeconds?: unknown })?.requestTimeoutSeconds, fallbackRequestTimeoutSeconds ); - const url = sanitizeUrl((entry as { url?: unknown })?.url); + const url = typeof entry?.url === 'string' ? entry.url.trim() : ''; return { - id: sanitizeId((entry as { id?: unknown })?.id, index), + id: generateMcpServerId((entry as { id?: unknown })?.id, index), enabled: Boolean((entry as { enabled?: unknown })?.enabled), url, requestTimeoutSeconds @@ -102,7 +46,7 @@ export function parseMcpServerSettings( function buildServerConfig( entry: MCPServerSettingsEntry, - connectionTimeoutMs = defaultMcpConfig.connectionTimeoutMs + connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs ): MCPServerConfig | undefined { if (!entry?.url) { return undefined; @@ -133,7 +77,7 @@ export function buildMcpClientConfig(config: SettingsConfigType): MCPClientConfi const normalized = buildServerConfig(entry); if (normalized) { - servers[sanitizeId(entry.id, index)] = normalized; + servers[generateMcpServerId(entry.id, index)] = normalized; } } @@ -142,10 +86,10 @@ export function buildMcpClientConfig(config: SettingsConfigType): MCPClientConfi } return { - protocolVersion: defaultMcpConfig.protocolVersion, - capabilities: defaultMcpConfig.capabilities, - clientInfo: defaultMcpConfig.clientInfo, - requestTimeoutMs: Math.round(defaultMcpConfig.requestTimeoutSeconds * 1000), + protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion, + capabilities: DEFAULT_MCP_CONFIG.capabilities, + clientInfo: DEFAULT_MCP_CONFIG.clientInfo, + requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000), servers }; } From 609723f173b67da79e99c47dc80030eb48477704 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 29 Dec 2025 10:35:46 +0100 Subject: [PATCH 06/34] feat: Add @modelcontextprotocol/sdk and zod dependencies --- tools/server/webui/package-lock.json | 819 ++++++++++++++++++++++++++- tools/server/webui/package.json | 4 +- 2 files changed, 791 insertions(+), 32 deletions(-) diff --git a/tools/server/webui/package-lock.json b/tools/server/webui/package-lock.json index 6fa9d39c719..503176e1c8a 100644 --- a/tools/server/webui/package-lock.json +++ b/tools/server/webui/package-lock.json @@ -8,6 +8,7 @@ "name": "webui", "version": "1.0.0", "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.1", "highlight.js": "^11.11.1", "mode-watcher": "^1.1.0", "pdfjs-dist": "^5.4.54", @@ -19,7 +20,8 @@ "remark-html": "^16.0.1", "remark-rehype": "^11.1.2", "svelte-sonner": "^1.0.5", - "unist-util-visit": "^5.0.0" + "unist-util-visit": "^5.0.0", + "zod": "^4.2.1" }, "devDependencies": { "@chromatic-com/storybook": "^4.1.2", @@ -795,6 +797,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@hono/node-server": { + "version": "1.19.7", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", + "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -958,6 +972,67 @@ "react": ">=16" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", + "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.7", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@napi-rs/canvas": { "version": "0.1.76", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.76.tgz", @@ -3181,6 +3256,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3220,6 +3308,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3413,6 +3540,46 @@ "svelte": "^5.30.2" } }, + "node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3437,6 +3604,15 @@ "node": ">=8" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -3451,7 +3627,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3465,7 +3640,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3678,6 +3852,28 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -3688,6 +3884,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/corser": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", @@ -3702,7 +3920,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3742,9 +3959,9 @@ "peer": true }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3820,6 +4037,15 @@ "node": ">=0.10.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3877,7 +4103,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3888,6 +4113,21 @@ "node": ">= 0.4" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", @@ -3919,7 +4159,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3929,7 +4168,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3946,7 +4184,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4008,6 +4245,12 @@ "@esbuild/win32-x64": "0.25.8" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4270,6 +4513,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -4277,6 +4529,27 @@ "dev": true, "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -4287,6 +4560,73 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4297,7 +4637,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -4344,6 +4683,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -4415,6 +4770,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4474,6 +4850,24 @@ } } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -4493,7 +4887,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4503,7 +4896,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4528,7 +4920,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4568,7 +4959,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4605,7 +4995,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4618,7 +5007,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4886,6 +5274,16 @@ "node": ">=12.0.0" } }, + "node_modules/hono": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz", + "integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -4909,6 +5307,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", @@ -5019,12 +5437,27 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", "license": "MIT" }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5070,11 +5503,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jiti": { @@ -5087,6 +5525,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5121,6 +5568,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -5563,7 +6016,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5917,6 +6369,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6550,6 +7023,31 @@ "node": ">=4" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -6697,6 +7195,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -6705,11 +7212,19 @@ "license": "MIT", "optional": true }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6718,6 +7233,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -6804,6 +7340,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6818,12 +7363,21 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -6873,6 +7427,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/playwright": { "version": "1.56.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", @@ -7259,6 +7822,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7273,7 +7849,6 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -7306,6 +7881,46 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -7564,6 +8179,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -7632,6 +8256,22 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7695,7 +8335,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sass": { @@ -7753,6 +8392,51 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", @@ -7760,11 +8444,16 @@ "dev": true, "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -7777,7 +8466,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7787,7 +8475,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7807,7 +8494,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7824,7 +8510,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7843,7 +8528,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7925,6 +8609,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", @@ -8467,6 +9160,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -8563,6 +9265,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -8790,6 +9506,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unplugin": { "version": "2.3.10", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.10.tgz", @@ -8844,6 +9569,15 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -9214,7 +9948,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -9253,6 +9986,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -9304,6 +10043,24 @@ "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", "license": "MIT" }, + "node_modules/zod": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", + "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/tools/server/webui/package.json b/tools/server/webui/package.json index 1a8c2737496..19326229478 100644 --- a/tools/server/webui/package.json +++ b/tools/server/webui/package.json @@ -78,6 +78,7 @@ "vitest-browser-svelte": "^0.1.0" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.1", "highlight.js": "^11.11.1", "mode-watcher": "^1.1.0", "pdfjs-dist": "^5.4.54", @@ -89,6 +90,7 @@ "remark-html": "^16.0.1", "remark-rehype": "^11.1.2", "svelte-sonner": "^1.0.5", - "unist-util-visit": "^5.0.0" + "unist-util-visit": "^5.0.0", + "zod": "^4.2.1" } } From a0a2f09688317d7c7645ea19eda91857a2e2ede9 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 29 Dec 2025 10:35:46 +0100 Subject: [PATCH 07/34] feat: Refactor MCP client to use official SDK --- .../webui/src/lib/agentic/orchestrator.ts | 255 ----------- tools/server/webui/src/lib/mcp/client.ts | 421 +++++++----------- tools/server/webui/src/lib/mcp/index.ts | 4 +- tools/server/webui/src/lib/mcp/protocol.ts | 46 -- .../src/lib/mcp/transports/streamable-http.ts | 129 ------ .../webui/src/lib/mcp/transports/types.ts | 8 - .../webui/src/lib/mcp/transports/websocket.ts | 238 ---------- .../webui/src/lib/services/mcp-singleton.ts | 140 ------ 8 files changed, 166 insertions(+), 1075 deletions(-) delete mode 100644 tools/server/webui/src/lib/agentic/orchestrator.ts delete mode 100644 tools/server/webui/src/lib/mcp/protocol.ts delete mode 100644 tools/server/webui/src/lib/mcp/transports/streamable-http.ts delete mode 100644 tools/server/webui/src/lib/mcp/transports/types.ts delete mode 100644 tools/server/webui/src/lib/mcp/transports/websocket.ts delete mode 100644 tools/server/webui/src/lib/services/mcp-singleton.ts diff --git a/tools/server/webui/src/lib/agentic/orchestrator.ts b/tools/server/webui/src/lib/agentic/orchestrator.ts deleted file mode 100644 index 67f2b7cb452..00000000000 --- a/tools/server/webui/src/lib/agentic/orchestrator.ts +++ /dev/null @@ -1,255 +0,0 @@ -import type { - ApiChatCompletionRequest, - ApiChatMessageData, - ApiChatCompletionToolCall -} from '$lib/types/api'; -import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat'; -import type { MCPToolCall } from '$lib/mcp'; -import { MCPClient } from '$lib/mcp'; -import { OpenAISseClient, type OpenAISseTurnResult } from './openai-sse-client'; -import type { AgenticChatCompletionRequest, AgenticMessage, AgenticToolCallList } from './types'; -import { toAgenticMessages } from './types'; - -export type AgenticOrchestratorCallbacks = { - onChunk?: (chunk: string) => void; - onReasoningChunk?: (chunk: string) => void; - onToolCallChunk?: (serializedToolCalls: string) => void; - onModel?: (model: string) => void; - onFirstValidChunk?: () => void; - onComplete?: () => void; - onError?: (error: Error) => void; -}; - -export type AgenticRunParams = { - initialMessages: ApiChatMessageData[]; - requestTemplate: ApiChatCompletionRequest; - callbacks: AgenticOrchestratorCallbacks; - abortSignal?: AbortSignal; - onProcessingUpdate?: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => void; - maxTurns?: number; - filterReasoningAfterFirstTurn?: boolean; -}; - -export type AgenticOrchestratorOptions = { - mcpClient: MCPClient; - llmClient: OpenAISseClient; - maxTurns: number; - maxToolPreviewLines: number; -}; - -export class AgenticOrchestrator { - private readonly mcpClient: MCPClient; - private readonly llmClient: OpenAISseClient; - private readonly maxTurns: number; - private readonly maxToolPreviewLines: number; - - constructor(options: AgenticOrchestratorOptions) { - this.mcpClient = options.mcpClient; - this.llmClient = options.llmClient; - this.maxTurns = options.maxTurns; - this.maxToolPreviewLines = options.maxToolPreviewLines; - } - - async run(params: AgenticRunParams): Promise { - const baseMessages = toAgenticMessages(params.initialMessages); - const sessionMessages: AgenticMessage[] = [...baseMessages]; - const tools = await this.mcpClient.getToolsDefinition(); - - const requestWithoutMessages = { ...params.requestTemplate }; - delete (requestWithoutMessages as Partial).messages; - const requestBase: AgenticChatCompletionRequest = { - ...(requestWithoutMessages as Omit), - stream: true, - messages: [] - }; - - const maxTurns = params.maxTurns ?? this.maxTurns; - - // Accumulate tool_calls across all turns (not per-turn) - const allToolCalls: ApiChatCompletionToolCall[] = []; - - for (let turn = 0; turn < maxTurns; turn++) { - if (params.abortSignal?.aborted) { - params.callbacks.onComplete?.(); - return; - } - - const llmRequest: AgenticChatCompletionRequest = { - ...requestBase, - messages: sessionMessages, - tools: tools.length > 0 ? tools : undefined - }; - - const shouldFilterReasoningChunks = params.filterReasoningAfterFirstTurn === true && turn > 0; - - let turnResult: OpenAISseTurnResult; - try { - turnResult = await this.llmClient.stream( - llmRequest, - { - onChunk: params.callbacks.onChunk, - onReasoningChunk: shouldFilterReasoningChunks - ? undefined - : params.callbacks.onReasoningChunk, - onModel: params.callbacks.onModel, - onFirstValidChunk: params.callbacks.onFirstValidChunk, - onProcessingUpdate: (timings, progress) => - params.onProcessingUpdate?.(timings, progress) - }, - params.abortSignal - ); - } catch (error) { - // Check if error is due to abort signal (stop button) - if (params.abortSignal?.aborted) { - params.callbacks.onComplete?.(); - return; - } - - const normalizedError = error instanceof Error ? error : new Error('LLM stream error'); - params.callbacks.onError?.(normalizedError); - const errorChunk = `\n\n\`\`\`\nUpstream LLM error:\n${normalizedError.message}\n\`\`\`\n`; - params.callbacks.onChunk?.(errorChunk); - params.callbacks.onComplete?.(); - return; - } - - if ( - turnResult.toolCalls.length === 0 || - (turnResult.finishReason && turnResult.finishReason !== 'tool_calls') - ) { - params.callbacks.onComplete?.(); - return; - } - - const normalizedCalls = this.normalizeToolCalls(turnResult.toolCalls); - if (normalizedCalls.length === 0) { - params.callbacks.onComplete?.(); - return; - } - - // Accumulate tool_calls from this turn - for (const call of normalizedCalls) { - allToolCalls.push({ - id: call.id, - type: call.type, - function: call.function ? { ...call.function } : undefined - }); - } - - // Forward the complete accumulated list - params.callbacks.onToolCallChunk?.(JSON.stringify(allToolCalls)); - - sessionMessages.push({ - role: 'assistant', - content: turnResult.content || undefined, - tool_calls: normalizedCalls - }); - - for (const toolCall of normalizedCalls) { - if (params.abortSignal?.aborted) { - params.callbacks.onComplete?.(); - return; - } - - const result = await this.executeTool(toolCall, params.abortSignal).catch( - (error: Error) => { - // Don't show error for AbortError - if (error.name !== 'AbortError') { - params.callbacks.onError?.(error); - } - return `Error: ${error.message}`; - } - ); - - // Stop silently if aborted during tool execution - if (params.abortSignal?.aborted) { - params.callbacks.onComplete?.(); - return; - } - - this.emitToolPreview(result, params.callbacks.onChunk); - - const contextValue = this.sanitizeToolContent(result); - sessionMessages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: contextValue - }); - } - } - - params.callbacks.onChunk?.('\n\n```\nTurn limit reached\n```\n'); - params.callbacks.onComplete?.(); - } - - 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 ?? '' - } - })); - } - - private async executeTool( - toolCall: AgenticToolCallList[number], - abortSignal?: AbortSignal - ): Promise { - const mcpCall: MCPToolCall = { - id: toolCall.id, - function: { - name: toolCall.function.name, - arguments: toolCall.function.arguments - } - }; - - const result = await this.mcpClient.execute(mcpCall, abortSignal); - return result; - } - - private emitToolPreview(result: string, emit?: (chunk: string) => void): void { - if (!emit) return; - const preview = this.createPreview(result); - emit(preview); - } - - private createPreview(result: string): string { - if (this.isBase64Image(result)) { - return `\n![tool-image](${result.trim()})\n`; - } - - const lines = result.split('\n'); - const trimmedLines = - lines.length > this.maxToolPreviewLines ? lines.slice(-this.maxToolPreviewLines) : lines; - const preview = trimmedLines.join('\n'); - return `\n\`\`\`\n${preview}\n\`\`\`\n`; - } - - private sanitizeToolContent(result: string): string { - if (this.isBase64Image(result)) { - return '[Image displayed to user]'; - } - return result; - } - - 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; - } -} diff --git a/tools/server/webui/src/lib/mcp/client.ts b/tools/server/webui/src/lib/mcp/client.ts index 4f514b4107c..53864835242 100644 --- a/tools/server/webui/src/lib/mcp/client.ts +++ b/tools/server/webui/src/lib/mcp/client.ts @@ -1,39 +1,46 @@ -import { getDefaultMcpConfig } from '$lib/config/mcp'; -import { JsonRpcProtocol } from './protocol'; -import type { - JsonRpcMessage, - MCPClientConfig, - MCPServerCapabilities, - MCPServerConfig, - MCPToolCall, - MCPToolDefinition, - MCPToolsCallResult -} from './types'; -import { MCPError } from './types'; -import type { MCPTransport } from './transports/types'; -import { WebSocketTransport } from './transports/websocket'; -import { StreamableHttpTransport } from './transports/streamable-http'; - -const MCP_DEFAULTS = getDefaultMcpConfig(); - -interface PendingRequest { - resolve: (value: Record) => void; - reject: (reason?: unknown) => void; - timeout: ReturnType; +/** + * MCP Client implementation using the official @modelcontextprotocol/sdk + * + * This module provides a wrapper around the SDK's Client class that maintains + * backward compatibility with our existing MCPClient API. + */ + +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 { MCPClientConfig, MCPServerConfig, MCPToolCall } 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 }; } -interface ServerState { - transport: MCPTransport; - pending: Map; - requestId: number; - tools: MCPToolDefinition[]; - requestTimeoutMs?: number; - capabilities?: MCPServerCapabilities; - protocolVersion?: string; +// Type for tool call result (SDK uses complex union type) +interface ToolCallResult { + content?: ToolResultContentItem[]; + isError?: boolean; + _meta?: Record; } +interface ServerConnection { + client: Client; + transport: Transport; + tools: Tool[]; +} + +/** + * MCP Client using the official @modelcontextprotocol/sdk. + */ export class MCPClient { - private readonly servers: Map = new Map(); + private readonly servers: Map = new Map(); private readonly toolsToServer: Map = new Map(); private readonly config: MCPClientConfig; @@ -46,9 +53,25 @@ export class MCPClient { async initialize(): Promise { const entries = Object.entries(this.config.servers); - await Promise.all( + const results = await Promise.allSettled( entries.map(([name, serverConfig]) => this.initializeServer(name, serverConfig)) ); + + // Log any failures but don't throw if at least one server connected + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + for (const failure of failures) { + console.error( + '[MCP] Server initialization failed:', + (failure as PromiseRejectedResult).reason + ); + } + } + + const successes = results.filter((r) => r.status === 'fulfilled'); + if (successes.length === 0) { + throw new Error('All MCP server connections failed'); + } } listTools(): string[] { @@ -73,7 +96,7 @@ export class MCPClient { function: { name: tool.name, description: tool.description, - parameters: tool.inputSchema ?? { + parameters: (tool.inputSchema as Record) ?? { type: 'object', properties: {}, required: [] @@ -93,10 +116,16 @@ export class MCPClient { throw new MCPError(`Unknown tool: ${toolName}`, -32601); } + const connection = this.servers.get(serverName); + if (!connection) { + throw new MCPError(`Server ${serverName} is not connected`, -32000); + } + if (abortSignal?.aborted) { throw new DOMException('Aborted', 'AbortError'); } + // Parse arguments let args: Record; const originalArgs = toolCall.function.arguments; if (typeof originalArgs === 'string') { @@ -133,234 +162,128 @@ export class MCPClient { throw new MCPError(`Invalid tool arguments type: ${typeof originalArgs}`, -32602); } - const response = await this.call( - serverName, - 'tools/call', - { - name: toolName, - arguments: args - }, - abortSignal - ); + try { + const result = await connection.client.callTool( + { name: toolName, arguments: args }, + undefined, + { signal: abortSignal } + ); - return MCPClient.formatToolResult(response as MCPToolsCallResult); + return MCPClient.formatToolResult(result as ToolCallResult); + } 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); + } } async shutdown(): Promise { - for (const [, state] of this.servers) { - await state.transport.stop(); + const closePromises: Promise[] = []; + + for (const [name, connection] of this.servers) { + console.log(`[MCP][${name}] Closing connection...`); + closePromises.push( + connection.client.close().catch((error) => { + console.warn(`[MCP][${name}] Error closing client:`, error); + }) + ); } + + await Promise.allSettled(closePromises); this.servers.clear(); this.toolsToServer.clear(); } private async initializeServer(name: string, config: MCPServerConfig): Promise { - const protocolVersion = this.config.protocolVersion ?? MCP_DEFAULTS.protocolVersion; - const transport = this.createTransport(config, protocolVersion); - await transport.start(); - - const state: ServerState = { - transport, - pending: new Map(), - requestId: 0, - tools: [], - requestTimeoutMs: config.requestTimeoutMs - }; - - transport.onMessage((message) => this.handleMessage(name, message)); - this.servers.set(name, state); + console.log(`[MCP][${name}] Starting server initialization...`); - const clientInfo = this.config.clientInfo ?? MCP_DEFAULTS.clientInfo; + const clientInfo = this.config.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo; const capabilities = - config.capabilities ?? this.config.capabilities ?? MCP_DEFAULTS.capabilities; + config.capabilities ?? this.config.capabilities ?? DEFAULT_MCP_CONFIG.capabilities; - const initResult = await this.call(name, 'initialize', { - protocolVersion, - capabilities, - clientInfo - }); - - const negotiatedVersion = (initResult?.protocolVersion as string) ?? protocolVersion; + // Create SDK client + const client = new Client( + { name: clientInfo.name, version: clientInfo.version ?? '1.0.0' }, + { capabilities } + ); - state.capabilities = (initResult?.capabilities as MCPServerCapabilities) ?? {}; - state.protocolVersion = negotiatedVersion; + // Create transport with fallback + const transport = await this.createTransportWithFallback(name, config); - const notification = JsonRpcProtocol.createNotification('notifications/initialized'); - await state.transport.send(notification as JsonRpcMessage); + console.log(`[MCP][${name}] Connecting to server...`); + await client.connect(transport); + console.log(`[MCP][${name}] Connected, listing tools...`); - await this.refreshTools(name); - } - - private createTransport(config: MCPServerConfig, protocolVersion: string): MCPTransport { - if (!config.url) { - throw new Error('MCP server configuration is missing url'); - } + // List available tools + const toolsResult = await client.listTools(); + const tools = toolsResult.tools ?? []; + console.log(`[MCP][${name}] Found ${tools.length} tools`); - const transportType = config.transport ?? 'websocket'; + // Store connection + const connection: ServerConnection = { + client, + transport, + tools + }; + this.servers.set(name, connection); - if (transportType === 'streamable_http') { - return new StreamableHttpTransport({ - url: config.url, - headers: config.headers, - credentials: config.credentials, - protocolVersion, - sessionId: config.sessionId - }); + // Map tools to server + for (const tool of tools) { + this.toolsToServer.set(tool.name, name); } - if (transportType !== 'websocket') { - throw new Error(`Unsupported transport "${transportType}" in webui environment`); - } + // Note: Tool list changes will be handled by re-calling listTools when needed + // The SDK's listChanged handler requires server capability support - return new WebSocketTransport({ - url: config.url, - protocols: config.protocols, - handshakeTimeoutMs: config.handshakeTimeoutMs - }); + console.log(`[MCP][${name}] Server initialization complete`); } - private async refreshTools(serverName: string): Promise { - const state = this.servers.get(serverName); - if (!state) return; - - const response = await this.call(serverName, 'tools/list'); - const tools = (response.tools as MCPToolDefinition[]) ?? []; - state.tools = tools; - - for (const [tool, owner] of Array.from(this.toolsToServer.entries())) { - if (owner === serverName && !tools.find((t) => t.name === tool)) { - this.toolsToServer.delete(tool); - } + private async createTransportWithFallback( + name: string, + config: MCPServerConfig + ): Promise { + if (!config.url) { + throw new Error('MCP server configuration is missing url'); } - for (const tool of tools) { - this.toolsToServer.set(tool.name, serverName); - } - } + const url = new URL(config.url); + const requestInit: RequestInit = {}; - private call( - serverName: string, - method: string, - params?: Record, - abortSignal?: AbortSignal - ): Promise> { - const state = this.servers.get(serverName); - if (!state) { - return Promise.reject(new MCPError(`Server ${serverName} is not connected`, -32000)); + if (config.headers) { + requestInit.headers = config.headers; } - - const id = ++state.requestId; - const message = JsonRpcProtocol.createRequest(id, method, params); - - const timeoutDuration = - state.requestTimeoutMs ?? - this.config.requestTimeoutMs ?? - MCP_DEFAULTS.requestTimeoutSeconds * 1000; - - if (abortSignal?.aborted) { - return Promise.reject(new DOMException('Aborted', 'AbortError')); + if (config.credentials) { + requestInit.credentials = config.credentials; } - return new Promise((resolve, reject) => { - const cleanupTasks: Array<() => void> = []; - const cleanup = () => { - for (const task of cleanupTasks.splice(0)) { - task(); - } - }; - - const timeout = setTimeout(() => { - cleanup(); - reject(new Error(`Timeout while waiting for ${method} response from ${serverName}`)); - }, timeoutDuration); - cleanupTasks.push(() => clearTimeout(timeout)); - cleanupTasks.push(() => state.pending.delete(id)); - - if (abortSignal) { - const abortHandler = () => { - cleanup(); - reject(new DOMException('Aborted', 'AbortError')); - }; - abortSignal.addEventListener('abort', abortHandler, { once: true }); - cleanupTasks.push(() => abortSignal.removeEventListener('abort', abortHandler)); - } - - state.pending.set(id, { - resolve: (value) => { - cleanup(); - resolve(value); - }, - reject: (reason) => { - cleanup(); - reject(reason); - }, - timeout + // Try StreamableHTTP first (modern), fall back to SSE (legacy) + try { + console.log(`[MCP][${name}] Trying StreamableHTTP transport...`); + const transport = new StreamableHTTPClientTransport(url, { + requestInit, + sessionId: config.sessionId }); - - const handleSendError = (error: unknown) => { - cleanup(); - reject(error); - }; + return transport; + } catch (httpError) { + console.warn(`[MCP][${name}] StreamableHTTP failed, trying SSE transport...`, httpError); try { - void state.transport - .send(message as JsonRpcMessage) - .catch((error) => handleSendError(error)); - } catch (error) { - handleSendError(error); + const transport = new SSEClientTransport(url, { + requestInit + }); + return transport; + } catch (sseError) { + // Both failed, throw combined error + 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}`); } - }); - } - - private handleMessage(serverName: string, message: JsonRpcMessage): void { - const state = this.servers.get(serverName); - if (!state) { - return; - } - - if ('method' in message && !('id' in message)) { - this.handleNotification(serverName, message.method, message.params); - return; - } - - const response = JsonRpcProtocol.parseResponse(message); - if (!response) { - return; - } - - const pending = state.pending.get(response.id as number); - if (!pending) { - return; - } - - state.pending.delete(response.id as number); - clearTimeout(pending.timeout); - - if (response.error) { - pending.reject( - new MCPError(response.error.message, response.error.code, response.error.data) - ); - return; - } - - pending.resolve(response.result ?? {}); - } - - private handleNotification( - serverName: string, - method: string, - params?: Record - ): void { - if (method === 'notifications/tools/list_changed') { - void this.refreshTools(serverName).catch((error) => { - console.error(`[MCP] Failed to refresh tools for ${serverName}:`, error); - }); - } else if (method === 'notifications/logging/message' && params) { - console.debug(`[MCP][${serverName}]`, params); } } - private static formatToolResult(result: MCPToolsCallResult): string { + private static formatToolResult(result: ToolCallResult): string { const content = result.content; if (Array.isArray(content)) { return content @@ -368,46 +291,30 @@ export class MCPClient { .filter(Boolean) .join('\n'); } - if (content) { - return MCPClient.formatSingleContent(content); - } - if (result.result !== undefined) { - return typeof result.result === 'string' ? result.result : JSON.stringify(result.result); - } return ''; } - private static formatSingleContent(content: unknown): string { - if (content === null || content === undefined) { - return ''; + private static formatSingleContent(content: ToolResultContentItem): string { + if (content.type === 'text' && content.text) { + return content.text; } - - if (typeof content === 'string') { - return content; + if (content.type === 'image' && content.data) { + return `data:${content.mimeType ?? 'image/png'};base64,${content.data}`; } - - if (typeof content === 'object') { - const typed = content as { - type?: string; - text?: string; - data?: string; - mimeType?: string; - resource?: unknown; - }; - if (typed.type === 'text' && typeof typed.text === 'string') { - return typed.text; + if (content.type === 'resource' && content.resource) { + const resource = content.resource; + if (resource.text) { + return resource.text; } - if (typed.type === 'image' && typeof typed.data === 'string' && typed.mimeType) { - return `data:${typed.mimeType};base64,${typed.data}`; - } - if (typed.type === 'resource' && typed.resource) { - return JSON.stringify(typed.resource); - } - if (typeof typed.text === 'string') { - return typed.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/mcp/index.ts b/tools/server/webui/src/lib/mcp/index.ts index 14d11859c4d..ca21837e4db 100644 --- a/tools/server/webui/src/lib/mcp/index.ts +++ b/tools/server/webui/src/lib/mcp/index.ts @@ -1,3 +1,3 @@ export { MCPClient } from './client'; -export { MCPError } from './types'; -export type { MCPClientConfig, MCPServerConfig, MCPToolCall } from './types'; +export { MCPError } from '$lib/types/mcp'; +export type { MCPClientConfig, MCPServerConfig, MCPToolCall, IMCPClient } from '$lib/types/mcp'; diff --git a/tools/server/webui/src/lib/mcp/protocol.ts b/tools/server/webui/src/lib/mcp/protocol.ts deleted file mode 100644 index eb161041e5a..00000000000 --- a/tools/server/webui/src/lib/mcp/protocol.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { - JsonRpcId, - JsonRpcMessage, - JsonRpcNotification, - JsonRpcRequest, - JsonRpcResponse -} from './types'; - -export class JsonRpcProtocol { - static createRequest( - id: JsonRpcId, - method: string, - params?: Record - ): JsonRpcRequest { - return { - jsonrpc: '2.0', - id, - method, - ...(params ? { params } : {}) - }; - } - - static createNotification(method: string, params?: Record): JsonRpcNotification { - return { - jsonrpc: '2.0', - method, - ...(params ? { params } : {}) - }; - } - - static parseResponse(message: JsonRpcMessage): JsonRpcResponse | null { - if (!message || typeof message !== 'object') { - return null; - } - - if ((message as JsonRpcResponse).jsonrpc !== '2.0') { - return null; - } - - if (!('id' in message)) { - return null; - } - - return message as JsonRpcResponse; - } -} diff --git a/tools/server/webui/src/lib/mcp/transports/streamable-http.ts b/tools/server/webui/src/lib/mcp/transports/streamable-http.ts deleted file mode 100644 index dc9321c1529..00000000000 --- a/tools/server/webui/src/lib/mcp/transports/streamable-http.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { JsonRpcMessage } from '$lib/mcp/types'; -import type { MCPTransport } from './types'; - -export type StreamableHttpTransportOptions = { - url: string; - headers?: Record; - credentials?: RequestCredentials; - protocolVersion?: string; - sessionId?: string; -}; - -export class StreamableHttpTransport implements MCPTransport { - private handler: ((message: JsonRpcMessage) => void) | null = null; - private activeSessionId: string | undefined; - - constructor(private readonly options: StreamableHttpTransportOptions) {} - - async start(): Promise { - this.activeSessionId = this.options.sessionId ?? undefined; - } - - async stop(): Promise {} - - async send(message: JsonRpcMessage): Promise { - return this.dispatch(message); - } - - onMessage(handler: (message: JsonRpcMessage) => void): void { - this.handler = handler; - } - - private async dispatch(message: JsonRpcMessage): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - ...(this.options.headers ?? {}) - }; - - if (this.activeSessionId) { - headers['Mcp-Session-Id'] = this.activeSessionId; - } - - if (this.options.protocolVersion) { - headers['MCP-Protocol-Version'] = this.options.protocolVersion; - } - - const credentialsOption = - this.options.credentials ?? (this.activeSessionId ? 'include' : 'same-origin'); - const response = await fetch(this.options.url, { - method: 'POST', - headers, - body: JSON.stringify(message), - credentials: credentialsOption - }); - - const sessionHeader = response.headers.get('mcp-session-id'); - if (sessionHeader) { - this.activeSessionId = sessionHeader; - } - - if (!response.ok) { - const errorBody = await response.text().catch(() => ''); - throw new Error( - `Failed to send MCP request over Streamable HTTP (${response.status} ${response.statusText}): ${errorBody}` - ); - } - - const contentType = response.headers.get('content-type') ?? ''; - - if (contentType.includes('application/json')) { - const payload = (await response.json()) as JsonRpcMessage; - this.handler?.(payload); - return; - } - - if (contentType.includes('text/event-stream') && response.body) { - const reader = response.body.getReader(); - await this.consume(reader); - return; - } - - if (response.status >= 400) { - const bodyText = await response.text().catch(() => ''); - throw new Error( - `Unexpected MCP Streamable HTTP response (${response.status}): ${bodyText || 'no body'}` - ); - } - } - - private async consume(reader: ReadableStreamDefaultReader): Promise { - const decoder = new TextDecoder('utf-8'); - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - const parts = buffer.split('\n\n'); - buffer = parts.pop() ?? ''; - - for (const part of parts) { - if (!part.startsWith('data: ')) { - continue; - } - const payload = part.slice(6); - if (!payload || payload === '[DONE]') { - continue; - } - - try { - const message = JSON.parse(payload) as JsonRpcMessage; - this.handler?.(message); - } catch (error) { - console.error('[MCP][Streamable HTTP] Failed to parse JSON payload:', error); - } - } - } - } catch (error) { - if ((error as Error)?.name === 'AbortError') { - return; - } - throw error; - } finally { - reader.releaseLock(); - } - } -} diff --git a/tools/server/webui/src/lib/mcp/transports/types.ts b/tools/server/webui/src/lib/mcp/transports/types.ts deleted file mode 100644 index c0182d5b492..00000000000 --- a/tools/server/webui/src/lib/mcp/transports/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { JsonRpcMessage } from '../types'; - -export interface MCPTransport { - start(): Promise; - stop(): Promise; - send(message: JsonRpcMessage): Promise; - onMessage(handler: (message: JsonRpcMessage) => void): void; -} diff --git a/tools/server/webui/src/lib/mcp/transports/websocket.ts b/tools/server/webui/src/lib/mcp/transports/websocket.ts deleted file mode 100644 index f40aa941b6a..00000000000 --- a/tools/server/webui/src/lib/mcp/transports/websocket.ts +++ /dev/null @@ -1,238 +0,0 @@ -import type { JsonRpcMessage } from '$lib/mcp/types'; -import type { MCPTransport } from './types'; - -export type WebSocketTransportOptions = { - url: string; - protocols?: string | string[]; - handshakeTimeoutMs?: number; -}; - -export type TransportMessageHandler = (message: JsonRpcMessage) => void; - -function ensureWebSocket(): typeof WebSocket | null { - if (typeof WebSocket !== 'undefined') { - return WebSocket; - } - return null; -} - -function arrayBufferToString(buffer: ArrayBufferLike): string { - return new TextDecoder('utf-8').decode(new Uint8Array(buffer)); -} - -async function normalizePayload(data: unknown): Promise { - if (typeof data === 'string') { - return data; - } - - if (data instanceof ArrayBuffer) { - return arrayBufferToString(data); - } - - if (ArrayBuffer.isView(data)) { - return arrayBufferToString(data.buffer); - } - - if (typeof Blob !== 'undefined' && data instanceof Blob) { - return await data.text(); - } - - throw new Error('Unsupported WebSocket message payload type'); -} - -export class WebSocketTransport implements MCPTransport { - private socket: WebSocket | null = null; - private handler: TransportMessageHandler | null = null; - private openPromise: Promise | null = null; - private reconnectAttempts = 0; - private readonly maxReconnectAttempts = 5; - private readonly reconnectDelay = 1_000; - private isReconnecting = false; - private shouldAttemptReconnect = true; - - constructor(private readonly options: WebSocketTransportOptions) {} - - start(): Promise { - if (this.openPromise) { - return this.openPromise; - } - - this.shouldAttemptReconnect = true; - - this.openPromise = new Promise((resolve, reject) => { - const WebSocketImpl = ensureWebSocket(); - if (!WebSocketImpl) { - this.openPromise = null; - reject(new Error('WebSocket is not available in this environment')); - return; - } - - let handshakeTimeout: ReturnType | undefined; - const socket = this.options.protocols - ? new WebSocketImpl(this.options.url, this.options.protocols) - : new WebSocketImpl(this.options.url); - - const cleanup = () => { - if (!socket) return; - socket.onopen = null; - socket.onclose = null; - socket.onerror = null; - socket.onmessage = null; - if (handshakeTimeout) { - clearTimeout(handshakeTimeout); - handshakeTimeout = undefined; - } - }; - - const fail = (error: unknown) => { - cleanup(); - this.openPromise = null; - reject(error instanceof Error ? error : new Error('WebSocket connection error')); - }; - - socket.onopen = () => { - cleanup(); - this.socket = socket; - this.reconnectAttempts = 0; - this.attachMessageHandler(); - this.attachCloseHandler(socket); - resolve(); - this.openPromise = null; - }; - - socket.onerror = (event) => { - const error = event instanceof Event ? new Error('WebSocket connection error') : event; - fail(error); - }; - - socket.onclose = (event) => { - if (!this.socket) { - fail(new Error(`WebSocket closed before opening (code: ${event.code})`)); - } - }; - - if (this.options.handshakeTimeoutMs) { - handshakeTimeout = setTimeout(() => { - if (!this.socket) { - try { - socket.close(); - } catch (error) { - console.warn('[MCP][Transport] Failed to close socket after timeout:', error); - } - fail(new Error('WebSocket handshake timed out')); - } - }, this.options.handshakeTimeoutMs); - } - }); - - return this.openPromise; - } - - async send(message: JsonRpcMessage): Promise { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - throw new Error('WebSocket transport is not connected'); - } - this.socket.send(JSON.stringify(message)); - } - - async stop(): Promise { - this.shouldAttemptReconnect = false; - this.reconnectAttempts = 0; - this.isReconnecting = false; - - const socket = this.socket; - if (!socket) { - this.openPromise = null; - return; - } - - await new Promise((resolve) => { - const onClose = () => { - socket.removeEventListener('close', onClose); - resolve(); - }; - socket.addEventListener('close', onClose); - try { - socket.close(); - } catch (error) { - socket.removeEventListener('close', onClose); - console.warn('[MCP][Transport] Failed to close WebSocket:', error); - resolve(); - } - }); - - this.socket = null; - this.openPromise = null; - } - - onMessage(handler: TransportMessageHandler): void { - this.handler = handler; - this.attachMessageHandler(); - } - - private attachMessageHandler(): void { - if (!this.socket) { - return; - } - - this.socket.onmessage = (event: MessageEvent) => { - const payload = event.data; - void (async () => { - try { - const text = await normalizePayload(payload); - const parsed = JSON.parse(text); - this.handler?.(parsed as JsonRpcMessage); - } catch (error) { - console.error('[MCP][Transport] Failed to handle message:', error); - } - })(); - }; - } - - private attachCloseHandler(socket: WebSocket): void { - socket.onclose = (event) => { - this.socket = null; - - if (event.code === 1000 || !this.shouldAttemptReconnect) { - return; - } - - console.warn('[MCP][WebSocket] Connection closed unexpectedly, attempting reconnect'); - void this.reconnect(); - }; - } - - private async reconnect(): Promise { - if ( - this.isReconnecting || - this.reconnectAttempts >= this.maxReconnectAttempts || - !this.shouldAttemptReconnect - ) { - return; - } - - this.isReconnecting = true; - this.reconnectAttempts++; - - const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); - await new Promise((resolve) => setTimeout(resolve, delay)); - - try { - this.openPromise = null; - await this.start(); - this.reconnectAttempts = 0; - console.log('[MCP][WebSocket] Reconnected successfully'); - } catch (error) { - console.error('[MCP][WebSocket] Reconnection failed:', error); - } finally { - this.isReconnecting = false; - if ( - !this.socket && - this.shouldAttemptReconnect && - this.reconnectAttempts < this.maxReconnectAttempts - ) { - void this.reconnect(); - } - } - } -} diff --git a/tools/server/webui/src/lib/services/mcp-singleton.ts b/tools/server/webui/src/lib/services/mcp-singleton.ts deleted file mode 100644 index 4d91c08740a..00000000000 --- a/tools/server/webui/src/lib/services/mcp-singleton.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { browser } from '$app/environment'; -import { MCPClient } from '$lib/mcp'; -import { buildMcpClientConfig } from '$lib/config/mcp'; -import { config } from '$lib/stores/settings.svelte'; - -const globalState = globalThis as typeof globalThis & { - __llamaMcpClient?: MCPClient; - __llamaMcpInitPromise?: Promise; - __llamaMcpConfigSignature?: string; - __llamaMcpInitConfigSignature?: string; -}; - -function serializeConfigSignature(): string | undefined { - const mcpConfig = buildMcpClientConfig(config()); - return mcpConfig ? JSON.stringify(mcpConfig) : undefined; -} - -async function shutdownClient(): Promise { - if (!globalState.__llamaMcpClient) return; - - const clientToShutdown = globalState.__llamaMcpClient; - globalState.__llamaMcpClient = undefined; - globalState.__llamaMcpConfigSignature = undefined; - - try { - await clientToShutdown.shutdown(); - } catch (error) { - console.error('[MCP] Failed to shutdown client:', error); - } -} - -async function bootstrapClient( - signature: string, - mcpConfig: ReturnType -): Promise { - if (!browser || !mcpConfig) { - return undefined; - } - - const client = new MCPClient(mcpConfig); - globalState.__llamaMcpInitConfigSignature = signature; - - const initPromise = client - .initialize() - .then(() => { - // Ignore initialization if config changed during bootstrap - if (globalState.__llamaMcpInitConfigSignature !== signature) { - void client.shutdown().catch((shutdownError) => { - console.error( - '[MCP] Failed to shutdown stale client after config change:', - shutdownError - ); - }); - return undefined; - } - - globalState.__llamaMcpClient = client; - globalState.__llamaMcpConfigSignature = signature; - return client; - }) - .catch((error) => { - console.error('[MCP] Failed to initialize client:', error); - - // Cleanup global references on error - if (globalState.__llamaMcpClient === client) { - globalState.__llamaMcpClient = undefined; - } - if (globalState.__llamaMcpConfigSignature === signature) { - globalState.__llamaMcpConfigSignature = undefined; - } - - void client.shutdown().catch((shutdownError) => { - console.error('[MCP] Failed to shutdown client after init error:', shutdownError); - }); - return undefined; - }) - .finally(() => { - // Clear init promise only if it's OUR promise - if (globalState.__llamaMcpInitPromise === initPromise) { - globalState.__llamaMcpInitPromise = undefined; - // Clear init signature only if it's still ours - if (globalState.__llamaMcpInitConfigSignature === signature) { - globalState.__llamaMcpInitConfigSignature = undefined; - } - } - }); - - globalState.__llamaMcpInitPromise = initPromise; - return initPromise; -} - -export function getMcpClient(): MCPClient | undefined { - return globalState.__llamaMcpClient; -} - -export async function ensureMcpClient(): Promise { - const signature = serializeConfigSignature(); - - // Configuration removed: shut down active client if present - if (!signature) { - // Wait for any in-flight init to complete before shutdown - if (globalState.__llamaMcpInitPromise) { - await globalState.__llamaMcpInitPromise; - } - await shutdownClient(); - globalState.__llamaMcpInitPromise = undefined; - globalState.__llamaMcpInitConfigSignature = undefined; - return undefined; - } - - // Client already initialized with correct config - if (globalState.__llamaMcpClient && globalState.__llamaMcpConfigSignature === signature) { - return globalState.__llamaMcpClient; - } - - // Init in progress with correct config - if ( - globalState.__llamaMcpInitPromise && - globalState.__llamaMcpInitConfigSignature === signature - ) { - return globalState.__llamaMcpInitPromise; - } - - // Config changed - wait for in-flight init before shutdown - if ( - globalState.__llamaMcpInitPromise && - globalState.__llamaMcpInitConfigSignature !== signature - ) { - await globalState.__llamaMcpInitPromise; - } - - // Shutdown if config changed - if (globalState.__llamaMcpConfigSignature !== signature) { - await shutdownClient(); - } - - // Bootstrap new client - const mcpConfig = buildMcpClientConfig(config()); - return bootstrapClient(signature, mcpConfig); -} From 5b124efa293fca996c6ccedefbb4d1fcb300b394 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 29 Dec 2025 10:35:46 +0100 Subject: [PATCH 08/34] feat: Introduce reactive mcpStore for client lifecycle management --- .../server/webui/src/lib/stores/mcp.svelte.ts | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 tools/server/webui/src/lib/stores/mcp.svelte.ts diff --git a/tools/server/webui/src/lib/stores/mcp.svelte.ts b/tools/server/webui/src/lib/stores/mcp.svelte.ts new file mode 100644 index 00000000000..4e16a0d5f8a --- /dev/null +++ b/tools/server/webui/src/lib/stores/mcp.svelte.ts @@ -0,0 +1,264 @@ +import { browser } from '$app/environment'; +import { MCPClient, type IMCPClient } from '$lib/mcp'; +import { buildMcpClientConfig } from '$lib/config/mcp'; +import { config } from '$lib/stores/settings.svelte'; + +/** + * mcpStore - Reactive store for MCP (Model Context Protocol) client management + * + * This store manages: + * - MCP client lifecycle (initialization, shutdown) + * - Connection state tracking + * - Available tools from 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 + * - **settingsStore**: Provides MCP server configuration + * + * **Key Features:** + * - Reactive state with Svelte 5 runes ($state, $derived) + * - Automatic reinitialization on config changes + * - Graceful error handling with fallback to standard chat + */ +class MCPStore { + // ───────────────────────────────────────────────────────────────────────────── + // State + // ───────────────────────────────────────────────────────────────────────────── + + private _client = $state(null); + private _isInitializing = $state(false); + private _error = $state(null); + private _configSignature = $state(null); + private _initPromise: Promise | null = null; + + // ───────────────────────────────────────────────────────────────────────────── + // Computed Getters + // ───────────────────────────────────────────────────────────────────────────── + + get client(): IMCPClient | null { + return this._client; + } + + get isInitializing(): boolean { + return this._isInitializing; + } + + get isInitialized(): boolean { + return this._client !== null; + } + + get error(): string | null { + return this._error; + } + + /** + * Check if MCP is enabled (has configured servers) + */ + get isEnabled(): boolean { + const mcpConfig = buildMcpClientConfig(config()); + return ( + mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0 + ); + } + + /** + * Get list of available tool names + */ + get availableTools(): string[] { + return this._client?.listTools() ?? []; + } + + /** + * Get tool definitions for LLM + */ + async getToolDefinitions(): Promise< + { + type: 'function'; + function: { name: string; description?: string; parameters: Record }; + }[] + > { + if (!this._client) return []; + return this._client.getToolsDefinition(); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Lifecycle + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Ensure MCP client is initialized with current config. + * Returns the client if successful, undefined otherwise. + * Handles config changes by reinitializing as needed. + */ + async ensureClient(): Promise { + if (!browser) return undefined; + + const mcpConfig = buildMcpClientConfig(config()); + const signature = mcpConfig ? JSON.stringify(mcpConfig) : null; + + // No config - shutdown if needed + if (!signature) { + await this.shutdown(); + return undefined; + } + + // Already initialized with correct config + if (this._client && this._configSignature === signature) { + return this._client; + } + + // Init in progress with correct config - wait for it + if (this._initPromise && this._configSignature === signature) { + return this._initPromise; + } + + // Config changed or first init - shutdown old client first + if (this._client || this._initPromise) { + await this.shutdown(); + } + + // Initialize new client + return this.initialize(signature, mcpConfig!); + } + + /** + * Initialize MCP client with given config + */ + private async initialize( + signature: string, + mcpConfig: ReturnType + ): Promise { + if (!mcpConfig) return undefined; + + this._isInitializing = true; + this._error = null; + this._configSignature = signature; + + const client = new MCPClient(mcpConfig); + + this._initPromise = client + .initialize() + .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); + }); + return undefined; + } + + this._client = client; + this._isInitializing = false; + console.log( + `[MCP Store] Initialized with ${client.listTools().length} tools:`, + client.listTools() + ); + return client; + }) + .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) => { + console.error('[MCP Store] Failed to shutdown after error:', err); + }); + + return undefined; + }) + .finally(() => { + if (this._configSignature === signature) { + this._initPromise = null; + } + }); + + return this._initPromise; + } + + /** + * Shutdown MCP client and clear state + */ + async shutdown(): Promise { + // Wait for any pending initialization + if (this._initPromise) { + await this._initPromise.catch(() => {}); + this._initPromise = null; + } + + if (this._client) { + const clientToShutdown = this._client; + this._client = null; + this._configSignature = null; + this._error = null; + + try { + await clientToShutdown.shutdown(); + console.log('[MCP Store] Client shutdown complete'); + } catch (error) { + console.error('[MCP Store] Shutdown error:', error); + } + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Tool Execution + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Execute a tool call via MCP client + */ + async execute( + toolCall: { id: string; function: { name: string; arguments: string } }, + abortSignal?: AbortSignal + ): Promise { + if (!this._client) { + throw new Error('MCP client not initialized'); + } + return this._client.execute(toolCall, abortSignal); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Utilities + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Clear error state + */ + clearError(): void { + this._error = null; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Singleton Instance & Exports +// ───────────────────────────────────────────────────────────────────────────── + +export const mcpStore = new MCPStore(); + +// Reactive exports for components +export function mcpClient() { + return mcpStore.client; +} + +export function mcpIsInitializing() { + return mcpStore.isInitializing; +} + +export function mcpIsInitialized() { + return mcpStore.isInitialized; +} + +export function mcpError() { + return mcpStore.error; +} + +export function mcpIsEnabled() { + return mcpStore.isEnabled; +} + +export function mcpAvailableTools() { + return mcpStore.availableTools; +} From 5eeb3813c60913af23a0ac1a68d3298f2dfe3a29 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 29 Dec 2025 10:35:46 +0100 Subject: [PATCH 09/34] feat: Implement agentic orchestration within ChatService --- tools/server/webui/src/lib/services/chat.ts | 348 ++++++++++++++++---- 1 file changed, 284 insertions(+), 64 deletions(-) diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts index 00b7c8b2ae3..233d6c5cb37 100644 --- a/tools/server/webui/src/lib/services/chat.ts +++ b/tools/server/webui/src/lib/services/chat.ts @@ -1,10 +1,15 @@ import { getAuthHeaders, getJsonHeaders } from '$lib/utils'; import { AttachmentType } from '$lib/enums'; import { config } from '$lib/stores/settings.svelte'; -import { ensureMcpClient } from '$lib/services/mcp-singleton'; import { getAgenticConfig } from '$lib/config/agentic'; -import { AgenticOrchestrator } from '$lib/agentic/orchestrator'; -import { OpenAISseClient } from '$lib/agentic/openai-sse-client'; +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 @@ -57,7 +62,8 @@ export class ChatService { messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[], options: SettingsChatServiceOptions = {}, conversationId?: string, - signal?: AbortSignal + signal?: AbortSignal, + mcpClient?: IMCPClient ): Promise { const { stream, @@ -175,68 +181,35 @@ export class ChatService { } } - // MCP agentic orchestration (low-coupling mode) - // Check if MCP client is available and agentic mode is enabled - if (stream) { - const mcpClient = await ensureMcpClient(); - const agenticConfig = mcpClient ? getAgenticConfig(config()) : undefined; - - // Debug: verify MCP tools are available - if (mcpClient) { - const availableTools = mcpClient.listTools(); - console.log( - `[MCP] Client initialized with ${availableTools.length} tools:`, - availableTools - ); - } else { - console.log('[MCP] No MCP client available'); - } - - if (mcpClient && agenticConfig?.enabled) { - try { - const llmClient = new OpenAISseClient({ - url: './v1/chat/completions', - buildHeaders: () => getAuthHeaders() - }); - - const orchestrator = new AgenticOrchestrator({ - mcpClient, - llmClient, - maxTurns: agenticConfig.maxTurns, - maxToolPreviewLines: agenticConfig.maxToolPreviewLines - }); - - let capturedTimings: ChatMessageTimings | undefined; - - await orchestrator.run({ - initialMessages: normalizedMessages, - requestTemplate: requestBody, - callbacks: { - onChunk, - onReasoningChunk, - onToolCallChunk, - onModel, - onComplete: onComplete - ? () => onComplete('', undefined, capturedTimings, undefined) - : undefined, - onError - }, - abortSignal: signal, - onProcessingUpdate: (timings, progress) => { - ChatService.notifyTimings(timings, progress, onTimings); - if (timings) { - capturedTimings = timings; - } - }, - maxTurns: agenticConfig.maxTurns, - filterReasoningAfterFirstTurn: agenticConfig.filterReasoningAfterFirstTurn - }); + // 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 + }); - return; - } catch (error) { - // If MCP orchestration fails, log and fall through to standard flow - console.warn('MCP orchestration failed, falling back to standard flow:', error); + 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); } } @@ -848,4 +821,251 @@ 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; + } } From 783f1705defae12e5634c3149f3f91a52dceb4c7 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 29 Dec 2025 10:35:46 +0100 Subject: [PATCH 10/34] refactor: Update ChatStore to leverage mcpStore for agentic flow --- tools/server/webui/src/lib/stores/chat.svelte.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts index 67157e36ac0..d35cfbbe0a3 100644 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ b/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -1,6 +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 { contextSize, isRouterMode } from '$lib/stores/server.svelte'; import { selectedModelName, @@ -15,6 +16,7 @@ import { } from '$lib/utils'; import { SvelteMap } from 'svelte/reactivity'; import { DEFAULT_CONTEXT } from '$lib/constants/default-context'; +import { getAgenticConfig } from '$lib/config/agentic'; /** * chatStore - Active AI interaction and streaming state management @@ -517,6 +519,10 @@ 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; + await ChatService.sendMessage( allMessages, { @@ -635,7 +641,8 @@ class ChatStore { } }, assistantMessage.convId, - abortController.signal + abortController.signal, + mcpClient ?? undefined ); } @@ -1120,7 +1127,8 @@ class ChatStore { } }, msg.convId, - abortController.signal + abortController.signal, + undefined // No MCP for continue generation ); } catch (error) { if (!this.isAbortError(error)) console.error('Failed to continue message:', error); From 238f758cbb4e915df3ab20679e5292e06243f8d0 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 29 Dec 2025 10:35:46 +0100 Subject: [PATCH 11/34] feat: Add AgenticContent component for enhanced tool call rendering --- .../chat/ChatMessages/AgenticContent.svelte | 220 ++++++++++++++++++ .../ChatMessages/ChatMessageAssistant.svelte | 8 + .../webui/src/lib/components/app/index.ts | 1 + 3 files changed, 229 insertions(+) create mode 100644 tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte 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 new file mode 100644 index 00000000000..79547625b0b --- /dev/null +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte @@ -0,0 +1,220 @@ + + +
+ {#each sections as section, index (index)} + {#if section.type === 'text'} +
+ +
+ {:else if section.type === 'tool_call'} +
+
+
+ + {section.toolName} +
+ {#if section.toolArgs && section.toolArgs !== '{}'} + + {/if} +
+ + {#if section.toolArgs && section.toolArgs !== '{}' && !collapsedArgs[index]} +
+
{formatToolArgs(section.toolArgs)}
+
+ {/if} + + {#if section.toolResult} +
+
Result:
+ +
+ {/if} +
+ {/if} + {/each} +
+ + diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte index c1ef4dfd0f5..658f01fe2ea 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte @@ -1,5 +1,6 @@
{/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 3c087e94541..c3c48acc4c9 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 @@ -266,11 +266,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 3c5010f6d9f..9f6979056f1 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, @@ -91,8 +90,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 00000000000..73181a6de3d --- /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 ca21837e4db..a1a14f29485 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 00000000000..5c2a7fb503e --- /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 233d6c5cb37..86648f3cba0 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, @@ -181,38 +170,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', @@ -821,251 +778,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 d124cf5c8da..ec39f29761c 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 00000000000..7ee99f44b00 --- /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 d35cfbbe0a3..eedc40c0b8a 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); - 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; - 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); - 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 (onComplete) await onComplete(streamedContent); + 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); - if (isRouterMode()) { - modelsStore.fetchRouterModels().catch(console.error); - } - }, - onError: (error: Error) => { - this.stopStreaming(); + return; + } - if (this.isAbortError(error)) { - this.setChatLoading(assistantMessage.convId, false); - this.clearChatStreaming(assistantMessage.convId); - this.clearProcessingState(assistantMessage.convId); + console.error('Streaming error:', error); - return; - } + this.setChatLoading(assistantMessage.convId, false); + this.clearChatStreaming(assistantMessage.convId); + this.clearProcessingState(assistantMessage.convId); - console.error('Streaming error:', error); + const idx = conversationsStore.findMessageIndex(assistantMessage.id); - this.setChatLoading(assistantMessage.convId, false); - this.clearChatStreaming(assistantMessage.convId); - this.clearProcessingState(assistantMessage.convId); + if (idx !== -1) { + const failedMessage = conversationsStore.removeMessageAtIndex(idx); + if (failedMessage) DatabaseService.deleteMessage(failedMessage.id).catch(console.error); + } - const idx = conversationsStore.findMessageIndex(assistantMessage.id); + const contextInfo = ( + error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } + ).contextInfo; - if (idx !== -1) { - const failedMessage = conversationsStore.removeMessageAtIndex(idx); - if (failedMessage) DatabaseService.deleteMessage(failedMessage.id).catch(console.error); - } + this.showErrorDialog( + error.name === 'TimeoutError' ? 'timeout' : 'server', + error.message, + contextInfo + ); - const contextInfo = ( - error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } - ).contextInfo; + if (onError) onError(error); + } + }; - this.showErrorDialog( - error.name === 'TimeoutError' ? 'timeout' : 'server', - error.message, - contextInfo - ); + // 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 (onError) onError(error); - } + 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 } : {}), + ...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 4e16a0d5f8a..205401396b4 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 executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise { + if (!this._hostManager) { + throw new Error('MCP host manager not initialized'); + } + return this._hostManager.executeTool(toolCall, signal); + } + + /** + * Execute a tool by name with arguments. + * Simpler interface for direct tool calls. */ - async execute( - toolCall: { id: string; function: { name: string; arguments: string } }, - abortSignal?: AbortSignal - ): Promise { - if (!this._client) { - throw new Error('MCP client not initialized'); + async executeToolByName( + toolName: string, + args: Record, + signal?: AbortSignal + ): Promise { + if (!this._hostManager) { + throw new Error('MCP host manager not initialized'); } - return this._client.execute(toolCall, abortSignal); + 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 e812080a09f..dbbff703e5b 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; -} From faeefc6892855590d230cf13ac1660e3414be234 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Wed, 31 Dec 2025 13:08:26 +0100 Subject: [PATCH 14/34] feat: Raw LLM output switch per message --- .../ChatMessages/ChatMessageActions.svelte | 23 ++++++++++++++++--- .../ChatMessages/ChatMessageAssistant.svelte | 10 ++++++-- .../app/chat/ChatSettings/ChatSettings.svelte | 2 +- .../src/lib/constants/settings-config.ts | 2 +- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte index 3cb48157d8c..dbd9b982285 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte @@ -5,6 +5,7 @@ ChatMessageBranchingControls, DialogConfirmation } from '$lib/components/app'; + import { Switch } from '$lib/components/ui/switch'; interface Props { role: 'user' | 'assistant'; @@ -26,6 +27,9 @@ onConfirmDelete: () => void; onNavigateToSibling?: (siblingId: string) => void; onShowDeleteDialogChange: (show: boolean) => void; + showRawOutputSwitch?: boolean; + rawOutputEnabled?: boolean; + onRawOutputToggle?: (enabled: boolean) => void; } let { @@ -42,7 +46,10 @@ onRegenerate, role, siblingInfo = null, - showDeleteDialog + showDeleteDialog, + showRawOutputSwitch = false, + rawOutputEnabled = false, + onRawOutputToggle }: Props = $props(); function handleConfirmDelete() { @@ -51,9 +58,9 @@ } -
+
@@ -81,6 +88,16 @@
+ + {#if showRawOutputSwitch} +
+ Show raw output + onRawOutputToggle?.(checked)} + /> +
+ {/if}
') ?? false + messageContent?.includes('<<>>') ?? false ); const processingState = useProcessingState(); + // Local state for raw output toggle (per message) + let showRawOutput = $state(false); + let currentConfig = $derived(config()); let isRouter = $derived(isRouterMode()); let displayedModel = $derived((): string | null => { @@ -184,7 +187,7 @@
{:else if message.role === 'assistant'} - {#if config().disableReasoningFormat} + {#if showRawOutput}
{messageContent || ''}
{:else if isAgenticContent} @@ -258,6 +261,9 @@ {onConfirmDelete} {onNavigateToSibling} {onShowDeleteDialogChange} + showRawOutputSwitch={currentConfig.disableReasoningFormat} + rawOutputEnabled={showRawOutput} + onRawOutputToggle={(enabled) => (showRawOutput = enabled)} /> {/if} 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 c3c48acc4c9..d9d826e329f 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 @@ -268,7 +268,7 @@ fields: [ { key: 'disableReasoningFormat', - label: 'Show raw LLM output', + label: 'Enable raw LLM output switch', type: 'checkbox' }, { diff --git a/tools/server/webui/src/lib/constants/settings-config.ts b/tools/server/webui/src/lib/constants/settings-config.ts index 9f6979056f1..21b24e193e9 100644 --- a/tools/server/webui/src/lib/constants/settings-config.ts +++ b/tools/server/webui/src/lib/constants/settings-config.ts @@ -91,7 +91,7 @@ export const SETTING_CONFIG_INFO: Record = { custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.', showThoughtInProgress: 'Expand thought process by default when generating messages.', disableReasoningFormat: - 'Show raw LLM output without backend parsing and frontend Markdown rendering to inspect streaming across different models.', + 'Enable raw LLM output switch to show unprocessed model output without backend parsing and frontend Markdown rendering to inspect streaming across different models.', keepStatsVisible: 'Keep processing statistics visible after generation finishes.', showMessageStats: 'Display generation statistics (tokens/second, token count, duration) below each assistant message.', From bf679e0a098644bfb7a3724c208f904f30c0bdf5 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 2 Jan 2026 19:37:40 +0100 Subject: [PATCH 15/34] refactor: Consolidate UI CSS classes into shared module --- .../src/lib/components/app/chat/ChatForm/ChatForm.svelte | 2 +- .../app/chat/ChatMessages/ChatMessageAssistant.svelte | 2 +- .../app/chat/ChatMessages/ChatMessageEditForm.svelte | 2 +- .../app/chat/ChatMessages/ChatMessageSystem.svelte | 2 +- .../server/webui/src/lib/components/ui/card/card.svelte | 4 +++- tools/server/webui/src/lib/constants/css-classes.ts | 9 +++++++++ tools/server/webui/src/lib/constants/input-classes.ts | 6 ------ 7 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 tools/server/webui/src/lib/constants/css-classes.ts delete mode 100644 tools/server/webui/src/lib/constants/input-classes.ts diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index fd2f7f60e57..f3a8028265a 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -7,7 +7,7 @@ ChatFormHelperText, ChatFormTextarea } from '$lib/components/app'; - import { INPUT_CLASSES } from '$lib/constants/input-classes'; + import { INPUT_CLASSES } from '$lib/constants/css-classes'; import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; import { config } from '$lib/stores/settings.svelte'; import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte'; diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte index 02d0f2cf39c..61c62220ed3 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte @@ -16,7 +16,7 @@ import { Check, X } from '@lucide/svelte'; import { Button } from '$lib/components/ui/button'; import { Checkbox } from '$lib/components/ui/checkbox'; - import { INPUT_CLASSES } from '$lib/constants/input-classes'; + import { INPUT_CLASSES } from '$lib/constants/css-classes'; import Label from '$lib/components/ui/label/label.svelte'; import { config } from '$lib/stores/settings.svelte'; import { conversationsStore } from '$lib/stores/conversations.svelte'; diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte index f812ea2fd9d..5495b0cfbf3 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte @@ -3,7 +3,7 @@ import { Button } from '$lib/components/ui/button'; import { Switch } from '$lib/components/ui/switch'; import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app'; - import { INPUT_CLASSES } from '$lib/constants/input-classes'; + import { INPUT_CLASSES } from '$lib/constants/css-classes'; import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums'; import { config } from '$lib/stores/settings.svelte'; diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte index c203822f604..8246e375b9e 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte @@ -3,7 +3,7 @@ import { Card } from '$lib/components/ui/card'; import { Button } from '$lib/components/ui/button'; import { MarkdownContent } from '$lib/components/app'; - import { INPUT_CLASSES } from '$lib/constants/input-classes'; + import { INPUT_CLASSES } from '$lib/constants/css-classes'; import { config } from '$lib/stores/settings.svelte'; import ChatMessageActions from './ChatMessageActions.svelte'; diff --git a/tools/server/webui/src/lib/components/ui/card/card.svelte b/tools/server/webui/src/lib/components/ui/card/card.svelte index c40d14309f9..b9dcd2de6f4 100644 --- a/tools/server/webui/src/lib/components/ui/card/card.svelte +++ b/tools/server/webui/src/lib/components/ui/card/card.svelte @@ -1,6 +1,7 @@ + + From 3bd7b9016a4d17226d498cc69eaffc3062851b11 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 2 Jan 2026 19:37:40 +0100 Subject: [PATCH 17/34] feat: Enhance server config with headers and schema normalization --- tools/server/webui/src/lib/config/mcp.ts | 20 +++- tools/server/webui/src/lib/mcp/client.ts | 78 +++++++++++++++- .../server/webui/src/lib/mcp/host-manager.ts | 92 ++++++++++++++++--- tools/server/webui/src/lib/types/mcp.ts | 6 ++ 4 files changed, 178 insertions(+), 18 deletions(-) diff --git a/tools/server/webui/src/lib/config/mcp.ts b/tools/server/webui/src/lib/config/mcp.ts index 83e08ae5577..b85851d06fb 100644 --- a/tools/server/webui/src/lib/config/mcp.ts +++ b/tools/server/webui/src/lib/config/mcp.ts @@ -34,12 +34,14 @@ export function parseMcpServerSettings( ); const url = typeof entry?.url === 'string' ? entry.url.trim() : ''; + const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined; return { id: generateMcpServerId((entry as { id?: unknown })?.id, index), enabled: Boolean((entry as { enabled?: unknown })?.enabled), url, - requestTimeoutSeconds + requestTimeoutSeconds, + headers: headers || undefined } satisfies MCPServerSettingsEntry; }); } @@ -52,11 +54,25 @@ function buildServerConfig( return undefined; } + // Parse custom headers if provided + let headers: Record | undefined; + if (entry.headers) { + try { + const parsed = JSON.parse(entry.headers); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + headers = parsed as Record; + } + } catch { + console.warn('[MCP] Failed to parse custom headers JSON, ignoring:', entry.headers); + } + } + return { url: entry.url, transport: detectMcpTransportFromUrl(entry.url), handshakeTimeoutMs: connectionTimeoutMs, - requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000) + requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000), + headers }; } diff --git a/tools/server/webui/src/lib/mcp/client.ts b/tools/server/webui/src/lib/mcp/client.ts index 53864835242..3f2b4ce4112 100644 --- a/tools/server/webui/src/lib/mcp/client.ts +++ b/tools/server/webui/src/lib/mcp/client.ts @@ -78,6 +78,69 @@ export class MCPClient { return Array.from(this.toolsToServer.keys()); } + /** + * Normalize JSON Schema properties to ensure all have explicit types. + * Infers type from default value if missing - fixes compatibility with + * llama.cpp which requires explicit types in tool schemas. + */ + private normalizeSchemaProperties(schema: Record): Record { + if (!schema || typeof schema !== 'object') return schema; + + const normalized = { ...schema }; + + // Process properties object + if (normalized.properties && typeof normalized.properties === 'object') { + const props = normalized.properties as Record>; + const normalizedProps: Record> = {}; + + for (const [key, prop] of Object.entries(props)) { + if (!prop || typeof prop !== 'object') { + normalizedProps[key] = prop; + continue; + } + + const normalizedProp = { ...prop }; + + // Infer type from default if missing + if (!normalizedProp.type && normalizedProp.default !== undefined) { + const defaultVal = normalizedProp.default; + if (typeof defaultVal === 'string') { + normalizedProp.type = 'string'; + } else if (typeof defaultVal === 'number') { + normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number'; + } else if (typeof defaultVal === 'boolean') { + normalizedProp.type = 'boolean'; + } else if (Array.isArray(defaultVal)) { + normalizedProp.type = 'array'; + } else if (typeof defaultVal === 'object' && defaultVal !== null) { + normalizedProp.type = 'object'; + } + } + + // Recursively normalize nested schemas + if (normalizedProp.properties) { + Object.assign( + normalizedProp, + this.normalizeSchemaProperties(normalizedProp as Record) + ); + } + + // Normalize items in array schemas + if (normalizedProp.items && typeof normalizedProp.items === 'object') { + normalizedProp.items = this.normalizeSchemaProperties( + normalizedProp.items as Record + ); + } + + normalizedProps[key] = normalizedProp; + } + + normalized.properties = normalizedProps; + } + + return normalized; + } + async getToolsDefinition(): Promise< { type: 'function'; @@ -91,16 +154,21 @@ export class MCPClient { for (const [, server] of this.servers) { for (const tool of server.tools) { + const rawSchema = (tool.inputSchema as Record) ?? { + type: 'object', + properties: {}, + required: [] + }; + + // Normalize schema to fix missing types + const normalizedSchema = this.normalizeSchemaProperties(rawSchema); + tools.push({ type: 'function', function: { name: tool.name, description: tool.description, - parameters: (tool.inputSchema as Record) ?? { - type: 'object', - properties: {}, - required: [] - } + parameters: normalizedSchema } }); } diff --git a/tools/server/webui/src/lib/mcp/host-manager.ts b/tools/server/webui/src/lib/mcp/host-manager.ts index 73181a6de3d..84df0afd325 100644 --- a/tools/server/webui/src/lib/mcp/host-manager.ts +++ b/tools/server/webui/src/lib/mcp/host-manager.ts @@ -178,23 +178,93 @@ export class MCPHostManager { return allTools; } + /** + * Normalize JSON Schema properties to ensure all have explicit types. + * Infers type from default value if missing - fixes compatibility with + * llama.cpp which requires explicit types in tool schemas. + */ + private normalizeSchemaProperties(schema: Record): Record { + if (!schema || typeof schema !== 'object') return schema; + + const normalized = { ...schema }; + + // Process properties object + if (normalized.properties && typeof normalized.properties === 'object') { + const props = normalized.properties as Record>; + const normalizedProps: Record> = {}; + + for (const [key, prop] of Object.entries(props)) { + if (!prop || typeof prop !== 'object') { + normalizedProps[key] = prop; + continue; + } + + const normalizedProp = { ...prop }; + + // Infer type from default if missing + if (!normalizedProp.type && normalizedProp.default !== undefined) { + const defaultVal = normalizedProp.default; + if (typeof defaultVal === 'string') { + normalizedProp.type = 'string'; + } else if (typeof defaultVal === 'number') { + normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number'; + } else if (typeof defaultVal === 'boolean') { + normalizedProp.type = 'boolean'; + } else if (Array.isArray(defaultVal)) { + normalizedProp.type = 'array'; + } else if (typeof defaultVal === 'object' && defaultVal !== null) { + normalizedProp.type = 'object'; + } + } + + // Recursively normalize nested schemas + if (normalizedProp.properties) { + Object.assign( + normalizedProp, + this.normalizeSchemaProperties(normalizedProp as Record) + ); + } + + // Normalize items in array schemas + if (normalizedProp.items && typeof normalizedProp.items === 'object') { + normalizedProp.items = this.normalizeSchemaProperties( + normalizedProp.items as Record + ); + } + + normalizedProps[key] = normalizedProp; + } + + normalized.properties = normalizedProps; + } + + return normalized; + } + /** * Returns tools in OpenAI function calling format. * Ready to be sent to /v1/chat/completions API. */ getToolDefinitionsForLLM(): OpenAIToolDefinition[] { - return this.getAllTools().map((tool) => ({ - type: 'function' as const, - function: { - name: tool.name, - description: tool.description, - parameters: (tool.inputSchema as Record) ?? { - type: 'object', - properties: {}, - required: [] + return this.getAllTools().map((tool) => { + const rawSchema = (tool.inputSchema as Record) ?? { + type: 'object', + properties: {}, + required: [] + }; + + // Normalize schema to fix missing types + const normalizedSchema = this.normalizeSchemaProperties(rawSchema); + + return { + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: normalizedSchema } - } - })); + }; + }); } /** diff --git a/tools/server/webui/src/lib/types/mcp.ts b/tools/server/webui/src/lib/types/mcp.ts index dbbff703e5b..82e168f254b 100644 --- a/tools/server/webui/src/lib/types/mcp.ts +++ b/tools/server/webui/src/lib/types/mcp.ts @@ -75,4 +75,10 @@ export type MCPServerSettingsEntry = { enabled: boolean; url: string; requestTimeoutSeconds: number; + /** Optional custom HTTP headers (JSON string of key-value pairs). */ + headers?: string; + /** Server name from metadata (fetched during health check). */ + name?: string; + /** Server icon URL from metadata (fetched during health check). */ + iconUrl?: string; }; From 945a42931f70e4dfff31ba90359186d9ef970bc4 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 2 Jan 2026 19:37:41 +0100 Subject: [PATCH 18/34] refactor: Centralize health check logic in store --- .../server/webui/src/lib/stores/mcp.svelte.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/tools/server/webui/src/lib/stores/mcp.svelte.ts b/tools/server/webui/src/lib/stores/mcp.svelte.ts index 205401396b4..a704403d56b 100644 --- a/tools/server/webui/src/lib/stores/mcp.svelte.ts +++ b/tools/server/webui/src/lib/stores/mcp.svelte.ts @@ -9,6 +9,18 @@ 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'; +import { MCPClient } from '$lib/mcp'; +import { detectMcpTransportFromUrl } from '$lib/utils/mcp'; + +// ───────────────────────────────────────────────────────────────────────────── +// Health Check Types +// ───────────────────────────────────────────────────────────────────────────── + +export type HealthCheckState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'success'; tools: { name: string; description?: string }[] }; /** * mcpStore - Reactive store for MCP (Model Context Protocol) host management @@ -44,6 +56,9 @@ class MCPStore { private _configSignature = $state(null); private _initPromise: Promise | null = null; + // Health check state (in-memory only, not persisted) + private _healthChecks = $state>({}); + // ───────────────────────────────────────────────────────────────────────────── // Computed Getters // ───────────────────────────────────────────────────────────────────────────── @@ -297,6 +312,123 @@ class MCPStore { clearError(): void { this._error = null; } + + // ───────────────────────────────────────────────────────────────────────────── + // Health Check (Settings UI) + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Get health check state for a specific server + */ + getHealthCheckState(serverId: string): HealthCheckState { + return this._healthChecks[serverId] ?? { status: 'idle' }; + } + + /** + * Set health check state for a specific server + */ + private setHealthCheckState(serverId: string, state: HealthCheckState): void { + this._healthChecks = { ...this._healthChecks, [serverId]: state }; + } + + /** + * Check if health check has been performed for a server + */ + hasHealthCheck(serverId: string): boolean { + return serverId in this._healthChecks && this._healthChecks[serverId].status !== 'idle'; + } + + /** + * Parse custom headers from JSON string + */ + private parseHeaders(headersJson?: string): Record | 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; + } + } catch { + console.warn('[MCP Store] Failed to parse custom headers JSON:', headersJson); + } + return undefined; + } + + /** + * Run health check for a specific server + */ + async runHealthCheck(server: { + id: string; + url: string; + requestTimeoutSeconds: number; + headers?: string; + }): Promise { + const trimmedUrl = server.url.trim(); + + if (!trimmedUrl) { + this.setHealthCheckState(server.id, { + status: 'error', + message: 'Please enter a server URL first.' + }); + return; + } + + this.setHealthCheckState(server.id, { status: 'loading' }); + + const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000); + const headers = this.parseHeaders(server.headers); + + const mcpClient = new MCPClient({ + protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion, + capabilities: DEFAULT_MCP_CONFIG.capabilities, + clientInfo: DEFAULT_MCP_CONFIG.clientInfo, + requestTimeoutMs: timeoutMs, + servers: { + [server.id]: { + url: trimmedUrl, + transport: detectMcpTransportFromUrl(trimmedUrl), + handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs, + requestTimeoutMs: timeoutMs, + headers + } + } + }); + + try { + await mcpClient.initialize(); + const tools = (await mcpClient.getToolsDefinition()).map((tool) => ({ + name: tool.function.name, + description: tool.function.description + })); + + this.setHealthCheckState(server.id, { status: 'success', tools }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error occurred'; + this.setHealthCheckState(server.id, { status: 'error', message }); + } finally { + try { + await mcpClient.shutdown(); + } catch (shutdownError) { + console.warn('[MCP Store] Failed to cleanly shutdown health check client', shutdownError); + } + } + } + + /** + * Clear health check state for a specific server + */ + clearHealthCheck(serverId: string): void { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [serverId]: _removed, ...rest } = this._healthChecks; + this._healthChecks = rest; + } + + /** + * Clear all health check states + */ + clearAllHealthChecks(): void { + this._healthChecks = {}; + } } // ───────────────────────────────────────────────────────────────────────────── @@ -341,3 +473,25 @@ export function mcpConnectedServerNames() { export function mcpToolCount() { return mcpStore.toolCount; } + +// Health check exports +export function mcpGetHealthCheckState(serverId: string) { + return mcpStore.getHealthCheckState(serverId); +} + +export function mcpHasHealthCheck(serverId: string) { + return mcpStore.hasHealthCheck(serverId); +} + +export async function mcpRunHealthCheck(server: { + id: string; + url: string; + requestTimeoutSeconds: number; + headers?: string; +}) { + return mcpStore.runHealthCheck(server); +} + +export function mcpClearHealthCheck(serverId: string) { + return mcpStore.clearHealthCheck(serverId); +} From b19cd246a3cc0204af42853b9cd5f044baf4af68 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 2 Jan 2026 19:37:41 +0100 Subject: [PATCH 19/34] feat: Implement dedicated server management UI components --- .../chat/ChatSettings/McpServerCard.svelte | 307 +++++++++++++++ .../chat/ChatSettings/McpServerForm.svelte | 154 ++++++++ .../ChatSettings/McpSettingsSection.svelte | 352 ++++++------------ .../dialogs/DialogMcpServersSettings.svelte | 75 ++++ .../webui/src/lib/components/app/index.ts | 1 + 5 files changed, 660 insertions(+), 229 deletions(-) create mode 100644 tools/server/webui/src/lib/components/app/chat/ChatSettings/McpServerCard.svelte create mode 100644 tools/server/webui/src/lib/components/app/chat/ChatSettings/McpServerForm.svelte create mode 100644 tools/server/webui/src/lib/components/app/dialogs/DialogMcpServersSettings.svelte diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpServerCard.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpServerCard.svelte new file mode 100644 index 00000000000..a98b35eeb14 --- /dev/null +++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpServerCard.svelte @@ -0,0 +1,307 @@ + + + + {#if isEditing} + +
+
+

Configure Server

+
+ {#if server.url.trim()} + + {/if} + + +
+
+ + (editUrl = v)} + onHeadersChange={(v) => (editHeaders = v)} + urlError={editUrl ? urlError : null} + id={server.id} + /> +
+ {:else} + + +
+ +
+ {#if faviconUrl} + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + {:else} + + {/if} +

{displayName}

+ {#if server.url} + + + + {/if} + {#if isHealthChecking} + Checking... + {:else if isConnected} + Connected + {:else if isError} + Error + {/if} +
+ + +
+ +
+
+ + + {#if isError && errorMessage} +

{errorMessage}

+ {/if} + + + {#if tools.length === 0 && server.url.trim()} +
+ + + +
+ {/if} + {/if} + + + {#if tools.length > 0} + +
+ + {#if isExpanded} + + {:else} + + {/if} + {toolsCount} tools available · Show details + +
+ + + +
+
+ +
+ {#each tools as tool (tool.name)} +
+ {tool.name} + {#if tool.description} +

{tool.description}

+ {/if} +
+ {/each} +
+
+
+ {/if} +
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpServerForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpServerForm.svelte new file mode 100644 index 00000000000..d1b77843486 --- /dev/null +++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpServerForm.svelte @@ -0,0 +1,154 @@ + + +
+
+ + onUrlChange(e.currentTarget.value)} + class={urlError ? 'border-destructive' : ''} + /> + {#if urlError} +

{urlError}

+ {/if} +
+ +
+
+ + Custom Headers (optional) + + + +
+ {#if headerPairs.length > 0} +
+ {#each headerPairs as pair, index (index)} +
+ updatePairKey(index, e.currentTarget.value)} + class="flex-1" + /> + + +
+ {/each} +
+ {:else} +

No custom headers configured.

+ {/if} +
+
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpSettingsSection.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpSettingsSection.svelte index 101dff27d45..3d5a7200578 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpSettingsSection.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/McpSettingsSection.svelte @@ -1,15 +1,13 @@ @@ -137,150 +107,74 @@
-

MCP Servers

-

- Configure one or more MCP Servers. Only enabled servers with a URL are used. -

+

Manage Servers

- + {#if !isAddingServer} + + {/if}
- {#if getServers().length === 0} + + {#if isAddingServer} + +
+

Add New Server

+
+ + +
+
+ (newServerUrl = v)} + onHeadersChange={(v) => (newServerHeaders = v)} + urlError={newServerUrl ? newServerUrlError : null} + id="new-server" + /> +
+ {/if} + + {#if servers.length === 0 && !isAddingServer}
No MCP Servers configured yet. Add one to enable agentic features.
{/if} -
- {#each getServers() as server, index (server.id)} - {@const healthState = getHealthState(server.id)} - -
-
-
- - updateServer(server.id, { - enabled: Boolean(checked) - })} - /> -
- -

- {detectMcpTransportFromUrl(server.url) === 'websocket' - ? 'WebSocket' - : 'Streamable HTTP'} -

-
-
- -
- -
-
- -
-
- - - updateServer(server.id, { - url: event.currentTarget.value - })} - /> -
- -
- -
- { - const parsed = Number(event.currentTarget.value); - updateServer(server.id, { - requestTimeoutSeconds: - Number.isFinite(parsed) && parsed > 0 - ? parsed - : DEFAULT_MCP_CONFIG.requestTimeoutSeconds - }); - }} - /> - - -
-
-
- - {#if healthState.status !== 'idle'} -
- {#if healthState.status === 'loading'} -
- - Running health check... -
- {:else if isErrorState(healthState)} -

- Health check failed: {healthState.message} -

- {:else if isSuccessState(healthState)} - {#if healthState.tools.length === 0} -

No tools returned by this server.

- {:else} -
-

- Available tools ({healthState.tools.length}) -

-
    - {#each healthState.tools as tool (tool.name)} -
  • - - {tool.name} - - {tool.description ?? 'No description provided.'} -
  • - {/each} -
-
- {/if} - {/if} -
- {/if} -
- {/each} -
+ + {#if servers.length > 0} +
+ {#each servers as server (server.id)} + updateServer(server.id, { enabled })} + onUpdate={(updates) => updateServer(server.id, updates)} + onDelete={() => removeServer(server.id)} + /> + {/each} +
+ {/if}
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogMcpServersSettings.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogMcpServersSettings.svelte new file mode 100644 index 00000000000..ab6f7d91cba --- /dev/null +++ b/tools/server/webui/src/lib/components/app/dialogs/DialogMcpServersSettings.svelte @@ -0,0 +1,75 @@ + + + + +
+ + + + MCP Servers + + + Add and configure MCP servers to enable agentic tool execution capabilities. + +
+ +
+ +
+ +
+ + +
+
+
diff --git a/tools/server/webui/src/lib/components/app/index.ts b/tools/server/webui/src/lib/components/app/index.ts index fe1d9cb77ba..80a2cfa1927 100644 --- a/tools/server/webui/src/lib/components/app/index.ts +++ b/tools/server/webui/src/lib/components/app/index.ts @@ -50,6 +50,7 @@ export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svel export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte'; export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte'; export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte'; +export { default as DialogMcpServersSettings } from './dialogs/DialogMcpServersSettings.svelte'; export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte'; export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte'; From 761efb21cb54a689b9824b80c9f909b0b0efaeee Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 2 Jan 2026 19:37:41 +0100 Subject: [PATCH 20/34] feat: Integrate server management dialog into chat settings --- .../chat/ChatScreen/ChatScreenHeader.svelte | 1 + .../app/chat/ChatSettings/ChatSettings.svelte | 31 ++++++------------- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte index 874140feecc..53ae5411bea 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte @@ -3,6 +3,7 @@ import { DialogChatSettings } from '$lib/components/app'; import { Button } from '$lib/components/ui/button'; import { useSidebar } from '$lib/components/ui/sidebar'; + import McpLogo from '../../misc/McpLogo.svelte'; const sidebar = useSidebar(); 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 d9d826e329f..4c956dc409c 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 @@ -9,15 +9,14 @@ Moon, ChevronLeft, ChevronRight, - Database, - Cable + Database } from '@lucide/svelte'; import { ChatSettingsFooter, ChatSettingsImportExportTab, - ChatSettingsFields, - McpSettingsSection + ChatSettingsFields } from '$lib/components/app'; + import McpLogo from '$lib/components/app/misc/McpLogo.svelte'; import { ScrollArea } from '$lib/components/ui/scroll-area'; import { config, settingsStore } from '$lib/stores/settings.svelte'; import { setMode } from 'mode-watcher'; @@ -237,8 +236,13 @@ ] }, { - title: 'MCP Client', - icon: Cable, + title: 'Import/Export', + icon: Database, + fields: [] + }, + { + title: 'MCP', + icon: McpLogo, fields: [ { key: 'agenticMaxTurns', @@ -257,11 +261,6 @@ } ] }, - { - title: 'Import/Export', - icon: Database, - fields: [] - }, { title: 'Developer', icon: Code, @@ -501,16 +500,6 @@ {#if currentSection.title === 'Import/Export'} - {:else if currentSection.title === 'MCP Client'} -
- - -
{:else}
Date: Fri, 2 Jan 2026 19:37:41 +0100 Subject: [PATCH 21/34] feat: Display and manage servers in ChatForm actions --- .../ChatFormActionFileAttachments.svelte | 31 ++- .../ChatFormActions/ChatFormActions.svelte | 193 +++++++++++++++++- 2 files changed, 206 insertions(+), 18 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte index 127130fb847..45de9464281 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte @@ -1,17 +1,20 @@
- +
+ (showMcpDialog = true)} + {onFileUpload} + /> + + {#if hasMcpServers} + + + + + + +
+ {#each mcpServers as server (server.id)} + {@const healthState = mcpGetHealthCheckState(server.id)} + {@const hasError = healthState.status === 'error'} +
+
+ {#if getFaviconUrl(server)} + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + {/if} + {getServerDisplayName(server)} + {#if hasError} + Error + {/if} +
+ toggleServer(server.id, checked)} + disabled={hasError} + /> +
+ {/each} +
+ + (showMcpDialog = true)} + > + + Manage MCP Servers + +
+
+ {/if} +
{/if}
+ + (showMcpDialog = open)} +/> From 3745efa4bc31eb4c52c85763eaf7a16b82af90ef Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 2 Jan 2026 19:37:41 +0100 Subject: [PATCH 22/34] feat: Enhance tool call streaming UI and output format --- .../chat/ChatMessages/AgenticContent.svelte | 139 ++++++++++++++---- .../webui/src/lib/stores/agentic.svelte.ts | 40 +++-- 2 files changed, 142 insertions(+), 37 deletions(-) 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 b6c583994de..166aa838e1a 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 @@ -7,8 +7,8 @@ * similar to the reasoning/thinking block UI. */ - import { MarkdownContent } from '$lib/components/app'; - import { Wrench } from '@lucide/svelte'; + import { MarkdownContent, SyntaxHighlightedCode } from '$lib/components/app'; + import { Wrench, Loader2 } from '@lucide/svelte'; import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down'; import * as Collapsible from '$lib/components/ui/collapsible/index.js'; import { buttonVariants } from '$lib/components/ui/button/index.js'; @@ -19,7 +19,7 @@ } interface AgenticSection { - type: 'text' | 'tool_call'; + type: 'text' | 'tool_call' | 'tool_call_pending'; content: string; toolName?: string; toolArgs?: string; @@ -46,13 +46,16 @@ if (!rawContent) return []; const sections: AgenticSection[] = []; - const toolCallRegex = - /\n\n\n([\s\S]*?)/g; + + // Regex for completed tool calls (with END marker) + const completedToolCallRegex = + /<<>>\n<<>>\n<<>>([\s\S]*?)<<>>/g; let lastIndex = 0; let match; - while ((match = toolCallRegex.exec(rawContent)) !== null) { + // First pass: find all completed tool calls + while ((match = completedToolCallRegex.exec(rawContent)) !== null) { // Add text before this tool call if (match.index > lastIndex) { const textBefore = rawContent.slice(lastIndex, match.index).trim(); @@ -61,9 +64,15 @@ } } - // Add tool call section + // Add completed tool call section const toolName = match[1]; - const toolArgs = match[2].replace(/\\n/g, '\n'); + const toolArgsBase64 = match[2]; + let toolArgs = ''; + try { + toolArgs = decodeURIComponent(escape(atob(toolArgsBase64))); + } catch { + toolArgs = toolArgsBase64; + } const toolResult = match[3].trim(); sections.push({ @@ -77,8 +86,43 @@ lastIndex = match.index + match[0].length; } - // Add remaining text after last tool call - if (lastIndex < rawContent.length) { + // Check for pending tool call at the end (START without END) + const remainingContent = rawContent.slice(lastIndex); + const pendingMatch = remainingContent.match( + /<<>>\n<<>>\n<<>>([\s\S]*)$/ + ); + + if (pendingMatch) { + // Add text before pending tool call + const pendingIndex = remainingContent.indexOf('<<>>'); + if (pendingIndex > 0) { + const textBefore = remainingContent.slice(0, pendingIndex).trim(); + if (textBefore) { + sections.push({ type: 'text', content: textBefore }); + } + } + + // Add pending tool call + const toolName = pendingMatch[1]; + const toolArgsBase64 = pendingMatch[2]; + let toolArgs = ''; + try { + toolArgs = decodeURIComponent(escape(atob(toolArgsBase64))); + } catch { + toolArgs = toolArgsBase64; + } + // Capture streaming result content (everything after args marker) + const streamingResult = pendingMatch[3]?.trim() || ''; + + sections.push({ + type: 'tool_call_pending', + content: streamingResult, + toolName, + toolArgs, + toolResult: streamingResult || undefined + }); + } else if (lastIndex < rawContent.length) { + // Add remaining text after last completed tool call const remainingText = rawContent.slice(lastIndex).trim(); if (remainingText) { sections.push({ type: 'text', content: remainingText }); @@ -101,6 +145,23 @@ return args; } } + + function isJsonContent(content: string): boolean { + const trimmed = content.trim(); + return ( + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ); + } + + function formatJsonContent(content: string): string { + try { + const parsed = JSON.parse(content); + return JSON.stringify(parsed, null, 2); + } catch { + return content; + } + }
@@ -109,16 +170,24 @@
- {:else if section.type === 'tool_call'} - + {:else if section.type === 'tool_call' || section.type === 'tool_call_pending'} + {@const isPending = section.type === 'tool_call_pending'} + toggleExpanded(index)} >
- + {#if isPending} + + {:else} + + {/if} {section.toolName} + {#if isPending} + executing... + {/if}
{#if section.toolArgs && section.toolArgs !== '{}'}
-
Arguments:
-
{formatToolArgs(
-											section.toolArgs
-										)}
+
Arguments:
+
{/if} - {#if section.toolResult} -
-
Result:
-
- -
+
+
+ Result: + {#if isPending} + + {/if}
- {/if} + {#if section.toolResult} + {#if isJsonContent(section.toolResult)} + + {:else} +
+ +
+ {/if} + {:else if isPending} +
+ Waiting for result... +
+ {/if} +
diff --git a/tools/server/webui/src/lib/stores/agentic.svelte.ts b/tools/server/webui/src/lib/stores/agentic.svelte.ts index 7ee99f44b00..560062c6192 100644 --- a/tools/server/webui/src/lib/stores/agentic.svelte.ts +++ b/tools/server/webui/src/lib/stores/agentic.svelte.ts @@ -330,6 +330,9 @@ class AgenticStore { return; } + // Emit tool call start (shows "pending" state in UI) + this.emitToolCallStart(toolCall, onChunk); + const mcpCall: MCPToolCall = { id: toolCall.id, function: { @@ -355,8 +358,8 @@ class AgenticStore { return; } - // Emit tool preview (raw output for UI to format later) - this.emitToolPreview(toolCall, result, maxToolPreviewLines, onChunk); + // Emit tool result and end marker + this.emitToolCallResult(result, maxToolPreviewLines, onChunk); // Add tool result to session (sanitize base64 images for context) const contextValue = this.isBase64Image(result) ? '[Image displayed to user]' : result; @@ -393,33 +396,46 @@ class AgenticStore { } /** - * Emit tool call preview to the chunk callback. - * Output is raw/sterile - UI formatting is a separate concern. + * Emit tool call start marker (shows "pending" state in UI). */ - private emitToolPreview( + private emitToolCallStart( 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; + // Base64 encode args to avoid conflicts with markdown/HTML parsing + const toolArgsBase64 = btoa(unescape(encodeURIComponent(toolArgs))); - let output = `\n\n`; - output += `\n`; - output += `\n`; + let output = `\n\n<<>>`; + output += `\n<<>>`; + output += `\n<<>>`; + emit(output); + } + + /** + * Emit tool call result and end marker. + */ + private emitToolCallResult( + result: string, + maxLines: number, + emit?: (chunk: string) => void + ): void { + if (!emit) return; + let output = ''; if (this.isBase64Image(result)) { output += `\n![tool-result](${result.trim()})`; } else { + // Don't wrap in code fences - result may already be markdown with its own code blocks const lines = result.split('\n'); const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines; - output += `\n\`\`\`\n${trimmedLines.join('\n')}\n\`\`\``; + output += `\n${trimmedLines.join('\n')}`; } - output += `\n\n`; + output += `\n<<>>\n`; emit(output); } From b4a407eac01affa75eba82d6bfcf0d5717788f88 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 2 Jan 2026 19:37:41 +0100 Subject: [PATCH 23/34] feat: Implement lazy MCP client shutdown --- tools/server/webui/src/lib/stores/agentic.svelte.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/server/webui/src/lib/stores/agentic.svelte.ts b/tools/server/webui/src/lib/stores/agentic.svelte.ts index 560062c6192..26c0d9aafef 100644 --- a/tools/server/webui/src/lib/stores/agentic.svelte.ts +++ b/tools/server/webui/src/lib/stores/agentic.svelte.ts @@ -202,6 +202,13 @@ class AgenticStore { return { handled: true, error: normalizedError }; } finally { this._isRunning = false; + // Lazy Disconnect: Close MCP connections after agentic flow completes + // This prevents continuous keepalive/heartbeat polling when tools are not in use + await mcpStore.shutdown().catch((err) => { + console.warn('[AgenticStore] Failed to shutdown MCP after flow:', err); + }); + + console.log('[AgenticStore] MCP connections closed (lazy disconnect)'); } } From 2e2400f2d376e99299b41a1fe5743d67330445a3 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 2 Jan 2026 19:37:41 +0100 Subject: [PATCH 24/34] feat: Add image load error fallback in MarkdownContent --- .../app/misc/MarkdownContent.svelte | 128 +++++++++++++++++- 1 file changed, 125 insertions(+), 3 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte index cb3ae17a63f..a64cc2a1b79 100644 --- a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte +++ b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte @@ -25,6 +25,7 @@ interface Props { content: string; class?: string; + disableMath?: boolean; } interface MarkdownBlock { @@ -32,7 +33,7 @@ html: string; } - let { content, class: className = '' }: Props = $props(); + let { content, class: className = '', disableMath = false }: Props = $props(); let containerRef = $state(); let renderedBlocks = $state([]); @@ -47,6 +48,21 @@ const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`; let processor = $derived(() => { + if (disableMath) { + // Processor without math/LaTeX support + return remark() + .use(remarkGfm) // GitHub Flavored Markdown + .use(remarkBreaks) // Convert line breaks to
+ .use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation + .use(remarkRehype) // Convert Markdown AST to rehype + .use(rehypeHighlight) // Add syntax highlighting + .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g.,
,