webui: MCP client with low coupling to current codebase

This commit is contained in:
Pascal 2025-11-17 22:09:01 +01:00 committed by Aleksander Grygier
parent 67e3f6f601
commit d4207ddd8a
20 changed files with 2337 additions and 5 deletions

View File

@ -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<string, string>;
};
export class OpenAISseClient {
constructor(private readonly options: OpenAISseClientOptions) {}
async stream(
request: AgenticChatCompletionRequest,
callbacks: OpenAISseCallbacks = {},
abortSignal?: AbortSignal
): Promise<OpenAISseTurnResult> {
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<Uint8Array>,
callbacks: OpenAISseCallbacks,
abortSignal?: AbortSignal
): Promise<OpenAISseTurnResult> {
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
};
}
}

View File

@ -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<void> {
const baseMessages = toAgenticMessages(params.initialMessages);
const sessionMessages: AgenticMessage[] = [...baseMessages];
const tools = await this.mcpClient.getToolsDefinition();
const requestWithoutMessages = { ...params.requestTemplate };
delete (requestWithoutMessages as Partial<ApiChatCompletionRequest>).messages;
const requestBase: AgenticChatCompletionRequest = {
...(requestWithoutMessages as Omit<ApiChatCompletionRequest, 'messages'>),
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<string> {
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;
}
}

View File

@ -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<AgenticMessage, { role: 'assistant' }>;
export type AgenticToolCallList = NonNullable<AgenticAssistantMessage['tool_calls']>;
export type AgenticChatCompletionRequest = Omit<ApiChatCompletionRequest, 'messages'> & {
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;
});
}

View File

@ -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';
@ -239,6 +241,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,
@ -338,7 +361,9 @@
'dry_multiplier',
'dry_base',
'dry_allowed_length',
'dry_penalty_last_n'
'dry_penalty_last_n',
'agenticMaxTurns',
'agenticMaxToolPreviewLines'
];
for (const field of numericFields) {
@ -486,6 +511,16 @@
{#if currentSection.title === 'Import/Export'}
<ChatSettingsImportExportTab />
{:else if currentSection.title === 'MCP Client'}
<div class="space-y-6">
<McpSettingsSection {localConfig} onConfigChange={handleConfigChange} />
<ChatSettingsFields
fields={currentSection.fields}
{localConfig}
onConfigChange={handleConfigChange}
onThemeChange={handleThemeChange}
/>
</div>
{:else}
<div class="space-y-6">
<ChatSettingsFields

View File

@ -0,0 +1,290 @@
<script lang="ts">
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<string, HealthCheckState> = $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<MCPServerSettingsEntry>) {
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);
}
}
}
</script>
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<h4 class="text-base font-semibold">MCP Servers</h4>
<p class="text-sm text-muted-foreground">
Configure one or more MCP Servers. Only enabled servers with a URL are used.
</p>
</div>
<Button variant="outline" class="shrink-0" onclick={addServer}>
<Plus class="mr-2 h-4 w-4" />
Add MCP Server
</Button>
</div>
{#if getServers().length === 0}
<div class="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
No MCP Servers configured yet. Add one to enable agentic features.
</div>
{/if}
<div class="space-y-3">
{#each getServers() as server, index (server.id)}
{@const healthState = getHealthState(server.id)}
<div class="space-y-3 rounded-lg border p-4 shadow-sm">
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<Checkbox
id={`mcp-enabled-${server.id}`}
checked={server.enabled}
onCheckedChange={(checked) =>
updateServer(server.id, {
enabled: Boolean(checked)
})}
/>
<div class="space-y-1">
<Label for={`mcp-enabled-${server.id}`} class="cursor-pointer text-sm font-medium">
MCP Server {index + 1}
</Label>
<p class="text-xs text-muted-foreground">
{detectMcpTransportFromUrl(server.url) === 'websocket'
? 'WebSocket'
: 'Streamable HTTP'}
</p>
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="text-muted-foreground hover:text-foreground"
onclick={() => removeServer(server.id)}
aria-label={`Remove MCP Server ${index + 1}`}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-3">
<div class="space-y-2">
<Label class="text-sm font-medium">Endpoint URL</Label>
<Input
value={server.url}
placeholder="http://127.0.0.1:8080 or ws://..."
class="w-full"
oninput={(event) =>
updateServer(server.id, {
url: event.currentTarget.value
})}
/>
</div>
<div class="space-y-2 md:min-w-[14rem]">
<Label class="text-sm font-medium">Request timeout (seconds)</Label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Input
type="number"
min="1"
inputmode="numeric"
value={String(server.requestTimeoutSeconds ?? '')}
class="w-20 sm:w-28"
oninput={(event) => {
const parsed = Number(event.currentTarget.value);
updateServer(server.id, {
requestTimeoutSeconds:
Number.isFinite(parsed) && parsed > 0
? parsed
: defaultMcpConfig.requestTimeoutSeconds
});
}}
/>
<Button
variant="secondary"
size="sm"
class="w-full sm:ml-auto sm:w-auto"
onclick={() => runHealthCheck(server)}
disabled={healthState.status === 'loading'}
>
Health Check
</Button>
</div>
</div>
</div>
{#if healthState.status !== 'idle'}
<div class="space-y-2 text-sm">
{#if healthState.status === 'loading'}
<div class="flex items-center gap-2 text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span>Running health check...</span>
</div>
{:else if isErrorState(healthState)}
<p class="text-destructive">
Health check failed: {healthState.message}
</p>
{:else if isSuccessState(healthState)}
{#if healthState.tools.length === 0}
<p class="text-muted-foreground">No tools returned by this server.</p>
{:else}
<div class="space-y-2">
<p class="font-medium">
Available tools ({healthState.tools.length})
</p>
<ul class="space-y-2">
{#each healthState.tools as tool (tool.name)}
<li class="leading-relaxed">
<span
class="mr-2 inline-flex items-center rounded bg-accent/70 px-2 py-0.5 font-semibold text-accent-foreground"
>
{tool.name}
</span>
<span class="text-muted-foreground"
>{tool.description ?? 'No description provided.'}</span
>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>

View File

@ -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';

View File

@ -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
};
}

View File

@ -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<string, MCPServerConfig> = {};
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));
}

View File

@ -19,6 +19,10 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
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',
backend_sampling: false,
@ -110,6 +114,14 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
'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:

View File

@ -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<string, unknown>) => void;
reject: (reason?: unknown) => void;
timeout: ReturnType<typeof setTimeout>;
}
interface ServerState {
transport: MCPTransport;
pending: Map<number, PendingRequest>;
requestId: number;
tools: MCPToolDefinition[];
requestTimeoutMs?: number;
capabilities?: MCPServerCapabilities;
protocolVersion?: string;
}
export class MCPClient {
private readonly servers: Map<string, ServerState> = new Map();
private readonly toolsToServer: Map<string, string> = 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<void> {
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<string, unknown> };
}[]
> {
const tools: {
type: 'function';
function: { name: string; description?: string; parameters: Record<string, unknown> };
}[] = [];
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<string> {
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<string, unknown>;
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<string, unknown>;
} catch (error) {
if (error instanceof MCPError) {
throw error;
}
throw new MCPError(
`Failed to parse tool arguments as JSON: ${(error as Error).message}`,
-32700
);
}
}
} else if (
typeof originalArgs === 'object' &&
originalArgs !== null &&
!Array.isArray(originalArgs)
) {
args = originalArgs as Record<string, unknown>;
} 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<void> {
for (const [, state] of this.servers) {
await state.transport.stop();
}
this.servers.clear();
this.toolsToServer.clear();
}
private async initializeServer(name: string, config: MCPServerConfig): Promise<void> {
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<void> {
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<string, unknown>,
abortSignal?: AbortSignal
): Promise<Record<string, unknown>> {
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<string, unknown>
): 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);
}
}

View File

@ -0,0 +1,3 @@
export { MCPClient } from './client';
export { MCPError } from './types';
export type { MCPClientConfig, MCPServerConfig, MCPToolCall } from './types';

View File

@ -0,0 +1,46 @@
import type {
JsonRpcId,
JsonRpcMessage,
JsonRpcNotification,
JsonRpcRequest,
JsonRpcResponse
} from './types';
export class JsonRpcProtocol {
static createRequest(
id: JsonRpcId,
method: string,
params?: Record<string, unknown>
): JsonRpcRequest {
return {
jsonrpc: '2.0',
id,
method,
...(params ? { params } : {})
};
}
static createNotification(method: string, params?: Record<string, unknown>): 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;
}
}

View File

@ -0,0 +1,129 @@
import type { JsonRpcMessage } from '$lib/mcp/types';
import type { MCPTransport } from './types';
export type StreamableHttpTransportOptions = {
url: string;
headers?: Record<string, string>;
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<void> {
this.activeSessionId = this.options.sessionId ?? undefined;
}
async stop(): Promise<void> {}
async send(message: JsonRpcMessage): Promise<void> {
return this.dispatch(message);
}
onMessage(handler: (message: JsonRpcMessage) => void): void {
this.handler = handler;
}
private async dispatch(message: JsonRpcMessage): Promise<void> {
const headers: Record<string, string> = {
'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<Uint8Array>): Promise<void> {
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();
}
}
}

View File

@ -0,0 +1,8 @@
import type { JsonRpcMessage } from '../types';
export interface MCPTransport {
start(): Promise<void>;
stop(): Promise<void>;
send(message: JsonRpcMessage): Promise<void>;
onMessage(handler: (message: JsonRpcMessage) => void): void;
}

View File

@ -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<string> {
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<void> | 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<void> {
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<typeof setTimeout> | 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<void> {
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<void> {
this.shouldAttemptReconnect = false;
this.reconnectAttempts = 0;
this.isReconnecting = false;
const socket = this.socket;
if (!socket) {
this.openPromise = null;
return;
}
await new Promise<void>((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<void> {
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();
}
}
}
}

View File

@ -0,0 +1,124 @@
export type JsonRpcId = number | string;
export type JsonRpcRequest = {
jsonrpc: '2.0';
id: JsonRpcId;
method: string;
params?: Record<string, unknown>;
};
export type JsonRpcNotification = {
jsonrpc: '2.0';
method: string;
params?: Record<string, unknown>;
};
export type JsonRpcError = {
code: number;
message: string;
data?: unknown;
};
export type JsonRpcResponse = {
jsonrpc: '2.0';
id: JsonRpcId;
result?: Record<string, unknown>;
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<string, unknown>;
export type MCPToolDefinition = {
name: string;
description?: string;
inputSchema?: MCPToolInputSchema;
};
export type MCPServerCapabilities = Record<string, unknown>;
export type MCPClientCapabilities = Record<string, unknown>;
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<string, string>;
/** 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<string, MCPServerConfig>;
/** 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<string, unknown>;
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<string, unknown>;
};
export type MCPToolsCallResult = {
content?: MCPToolResultContent | MCPToolResultContent[];
result?: unknown;
};

View File

@ -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
@ -173,6 +178,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',

View File

@ -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<MCPClient | undefined>;
__llamaMcpConfigSignature?: string;
__llamaMcpInitConfigSignature?: string;
};
function serializeConfigSignature(): string | undefined {
const mcpConfig = buildMcpClientConfig(config());
return mcpConfig ? JSON.stringify(mcpConfig) : undefined;
}
async function shutdownClient(): Promise<void> {
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<typeof buildMcpClientConfig>
): Promise<MCPClient | undefined> {
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<MCPClient | undefined> {
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);
}

View File

@ -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<string, unknown>;
}
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;
}
@ -188,6 +201,7 @@ export interface ApiChatCompletionRequest {
stream?: boolean;
model?: string;
return_progress?: boolean;
tools?: ApiChatCompletionTool[];
// Reasoning parameters
reasoning_format?: string;
// Generation parameters
@ -247,6 +261,7 @@ export interface ApiChatCompletionStreamChunk {
model?: string;
tool_calls?: ApiChatCompletionToolCallDelta[];
};
finish_reason?: string | null;
}>;
timings?: {
prompt_n?: number;
@ -267,8 +282,9 @@ export interface ApiChatCompletionResponse {
content: string;
reasoning_content?: string;
model?: string;
tool_calls?: ApiChatCompletionToolCallDelta[];
tool_calls?: ApiChatCompletionToolCall[];
};
finish_reason?: string | null;
}>;
}

View File

@ -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<string, unknown> | undefined => {
return typeof value === 'object' && value !== null
? (value as Record<string, unknown>)
: 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;
}