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,
|
Moon,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Database
|
Database,
|
||||||
|
Cable
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import {
|
import {
|
||||||
ChatSettingsFooter,
|
ChatSettingsFooter,
|
||||||
ChatSettingsImportExportTab,
|
ChatSettingsImportExportTab,
|
||||||
ChatSettingsFields
|
ChatSettingsFields,
|
||||||
|
McpSettingsSection
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
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',
|
title: 'Import/Export',
|
||||||
icon: Database,
|
icon: Database,
|
||||||
|
|
@ -338,7 +361,9 @@
|
||||||
'dry_multiplier',
|
'dry_multiplier',
|
||||||
'dry_base',
|
'dry_base',
|
||||||
'dry_allowed_length',
|
'dry_allowed_length',
|
||||||
'dry_penalty_last_n'
|
'dry_penalty_last_n',
|
||||||
|
'agenticMaxTurns',
|
||||||
|
'agenticMaxToolPreviewLines'
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const field of numericFields) {
|
for (const field of numericFields) {
|
||||||
|
|
@ -486,6 +511,16 @@
|
||||||
|
|
||||||
{#if currentSection.title === 'Import/Export'}
|
{#if currentSection.title === 'Import/Export'}
|
||||||
<ChatSettingsImportExportTab />
|
<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}
|
{:else}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<ChatSettingsFields
|
<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 ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
|
||||||
export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
|
export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
|
||||||
export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.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 ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
|
||||||
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.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,
|
alwaysShowSidebarOnDesktop: false,
|
||||||
autoShowSidebarOnNewChat: true,
|
autoShowSidebarOnNewChat: true,
|
||||||
autoMicOnEmpty: false,
|
autoMicOnEmpty: false,
|
||||||
|
mcpServers: '[]',
|
||||||
|
agenticMaxTurns: 10,
|
||||||
|
agenticMaxToolPreviewLines: 25,
|
||||||
|
agenticFilterReasoningAfterFirstTurn: true,
|
||||||
// make sure these default values are in sync with `common.h`
|
// make sure these default values are in sync with `common.h`
|
||||||
samplers: 'top_k;typ_p;top_p;min_p;temperature',
|
samplers: 'top_k;typ_p;top_p;min_p;temperature',
|
||||||
backend_sampling: false,
|
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.',
|
'Automatically show sidebar when starting a new chat. Disable to keep the sidebar hidden until you click on it.',
|
||||||
autoMicOnEmpty:
|
autoMicOnEmpty:
|
||||||
'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
|
'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:
|
pyInterpreterEnabled:
|
||||||
'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
|
'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
|
||||||
enableContinueGeneration:
|
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 { 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
|
* 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 {
|
try {
|
||||||
const response = await fetch(`./v1/chat/completions`, {
|
const response = await fetch(`./v1/chat/completions`, {
|
||||||
method: 'POST',
|
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 { ServerModelStatus, ServerRole } from '$lib/enums';
|
||||||
import type { ChatMessagePromptProgress } from './chat';
|
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 {
|
export interface ApiChatMessageContentPart {
|
||||||
type: 'text' | 'image_url' | 'input_audio';
|
type: 'text' | 'image_url' | 'input_audio';
|
||||||
text?: string;
|
text?: string;
|
||||||
|
|
@ -34,6 +45,8 @@ export interface ApiErrorResponse {
|
||||||
export interface ApiChatMessageData {
|
export interface ApiChatMessageData {
|
||||||
role: ChatRole;
|
role: ChatRole;
|
||||||
content: string | ApiChatMessageContentPart[];
|
content: string | ApiChatMessageContentPart[];
|
||||||
|
tool_calls?: ApiChatCompletionToolCall[];
|
||||||
|
tool_call_id?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,6 +201,7 @@ export interface ApiChatCompletionRequest {
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
model?: string;
|
model?: string;
|
||||||
return_progress?: boolean;
|
return_progress?: boolean;
|
||||||
|
tools?: ApiChatCompletionTool[];
|
||||||
// Reasoning parameters
|
// Reasoning parameters
|
||||||
reasoning_format?: string;
|
reasoning_format?: string;
|
||||||
// Generation parameters
|
// Generation parameters
|
||||||
|
|
@ -247,6 +261,7 @@ export interface ApiChatCompletionStreamChunk {
|
||||||
model?: string;
|
model?: string;
|
||||||
tool_calls?: ApiChatCompletionToolCallDelta[];
|
tool_calls?: ApiChatCompletionToolCallDelta[];
|
||||||
};
|
};
|
||||||
|
finish_reason?: string | null;
|
||||||
}>;
|
}>;
|
||||||
timings?: {
|
timings?: {
|
||||||
prompt_n?: number;
|
prompt_n?: number;
|
||||||
|
|
@ -267,8 +282,9 @@ export interface ApiChatCompletionResponse {
|
||||||
content: string;
|
content: string;
|
||||||
reasoning_content?: string;
|
reasoning_content?: string;
|
||||||
model?: 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