webui: MCP client with low coupling to current codebase
This commit is contained in:
parent
67e3f6f601
commit
d4207ddd8a
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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})\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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { MCPClient } from './client';
|
||||
export { MCPError } from './types';
|
||||
export type { MCPClientConfig, MCPServerConfig, MCPToolCall } from './types';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue