webui: Agentic Loop + MCP Client with support for Tools, Resources and Prompts (#18655)

This commit is contained in:
Aleksander Grygier
2026-03-06 10:00:39 +01:00
committed by GitHub
parent 2850bc6a13
commit f6235a41ef
147 changed files with 15285 additions and 366 deletions
+284
View File
@@ -0,0 +1,284 @@
import { AgenticSectionType } from '$lib/enums';
import { AGENTIC_TAGS, AGENTIC_REGEX, REASONING_TAGS, TRIM_NEWLINES_REGEX } from '$lib/constants';
/**
* Represents a parsed section of agentic content
*/
export interface AgenticSection {
type: AgenticSectionType;
content: string;
toolName?: string;
toolArgs?: string;
toolResult?: string;
}
/**
* Represents a segment of content that may contain reasoning blocks
*/
type ReasoningSegment = {
type:
| AgenticSectionType.TEXT
| AgenticSectionType.REASONING
| AgenticSectionType.REASONING_PENDING;
content: string;
};
/**
* Parses agentic content into structured sections
*
* Main parsing function that processes content containing:
* - Tool calls (completed, pending, or streaming)
* - Reasoning blocks (completed or streaming)
* - Regular text content
*
* The parser handles chronological display of agentic flow output, maintaining
* the order of operations and properly identifying different states of tool calls
* and reasoning blocks during streaming.
*
* @param rawContent - The raw content string to parse
* @returns Array of structured agentic sections ready for display
*
* @example
* ```typescript
* const content = "Some text <<<AGENTIC_TOOL_CALL>>>tool_name...";
* const sections = parseAgenticContent(content);
* // Returns: [{ type: 'text', content: 'Some text' }, { type: 'tool_call_streaming', ... }]
* ```
*/
export function parseAgenticContent(rawContent: string): AgenticSection[] {
if (!rawContent) return [];
const segments = splitReasoningSegments(rawContent);
const sections: AgenticSection[] = [];
for (const segment of segments) {
if (segment.type === AgenticSectionType.TEXT) {
sections.push(...parseToolCallContent(segment.content));
continue;
}
if (segment.type === AgenticSectionType.REASONING) {
if (segment.content.trim()) {
sections.push({ type: AgenticSectionType.REASONING, content: segment.content });
}
continue;
}
sections.push({
type: AgenticSectionType.REASONING_PENDING,
content: segment.content
});
}
return sections;
}
/**
* Parses content containing tool call markers
*
* Identifies and extracts tool calls from content, handling:
* - Completed tool calls with name, arguments, and results
* - Pending tool calls (execution in progress)
* - Streaming tool calls (arguments being received)
* - Early-stage tool calls (just started)
*
* @param rawContent - The raw content string to parse
* @returns Array of agentic sections representing tool calls and text
*/
function parseToolCallContent(rawContent: string): AgenticSection[] {
if (!rawContent) return [];
const sections: AgenticSection[] = [];
const completedToolCallRegex = new RegExp(AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
let lastIndex = 0;
let match;
while ((match = completedToolCallRegex.exec(rawContent)) !== null) {
if (match.index > lastIndex) {
const textBefore = rawContent.slice(lastIndex, match.index).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const toolName = match[1];
const toolArgs = match[2];
const toolResult = match[3].replace(TRIM_NEWLINES_REGEX, '');
sections.push({
type: AgenticSectionType.TOOL_CALL,
content: toolResult,
toolName,
toolArgs,
toolResult
});
lastIndex = match.index + match[0].length;
}
const remainingContent = rawContent.slice(lastIndex);
const pendingMatch = remainingContent.match(AGENTIC_REGEX.PENDING_TOOL_CALL);
const partialWithNameMatch = remainingContent.match(AGENTIC_REGEX.PARTIAL_WITH_NAME);
const earlyMatch = remainingContent.match(AGENTIC_REGEX.EARLY_MATCH);
if (pendingMatch) {
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
if (pendingIndex > 0) {
const textBefore = remainingContent.slice(0, pendingIndex).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const toolName = pendingMatch[1];
const toolArgs = pendingMatch[2];
const streamingResult = (pendingMatch[3] || '').replace(TRIM_NEWLINES_REGEX, '');
sections.push({
type: AgenticSectionType.TOOL_CALL_PENDING,
content: streamingResult,
toolName,
toolArgs,
toolResult: streamingResult || undefined
});
} else if (partialWithNameMatch) {
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
if (pendingIndex > 0) {
const textBefore = remainingContent.slice(0, pendingIndex).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const partialArgs = partialWithNameMatch[2] || '';
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: partialWithNameMatch[1],
toolArgs: partialArgs || undefined,
toolResult: undefined
});
} else if (earlyMatch) {
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
if (pendingIndex > 0) {
const textBefore = remainingContent.slice(0, pendingIndex).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const nameMatch = earlyMatch[1]?.match(AGENTIC_REGEX.TOOL_NAME_EXTRACT);
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: nameMatch?.[1],
toolArgs: undefined,
toolResult: undefined
});
} else if (lastIndex < rawContent.length) {
let remainingText = rawContent.slice(lastIndex).trim();
const partialMarkerMatch = remainingText.match(AGENTIC_REGEX.PARTIAL_MARKER);
if (partialMarkerMatch) {
remainingText = remainingText.slice(0, partialMarkerMatch.index).trim();
}
if (remainingText) {
sections.push({ type: AgenticSectionType.TEXT, content: remainingText });
}
}
if (sections.length === 0 && rawContent.trim()) {
sections.push({ type: AgenticSectionType.TEXT, content: rawContent });
}
return sections;
}
/**
* Strips partial marker from text content
*
* Removes incomplete agentic markers (e.g., "<<<", "<<<AGENTIC") that may appear
* at the end of streaming content.
*
* @param text - The text content to process
* @returns Text with partial markers removed
*/
function stripPartialMarker(text: string): string {
const partialMarkerMatch = text.match(AGENTIC_REGEX.PARTIAL_MARKER);
if (partialMarkerMatch) {
return text.slice(0, partialMarkerMatch.index).trim();
}
return text;
}
/**
* Splits raw content into segments based on reasoning blocks
*
* Identifies and extracts reasoning content wrapped in REASONING_TAGS.START/END markers,
* separating it from regular text content. Handles both complete and incomplete
* (streaming) reasoning blocks.
*
* @param rawContent - The raw content string to parse
* @returns Array of reasoning segments with their types and content
*/
function splitReasoningSegments(rawContent: string): ReasoningSegment[] {
if (!rawContent) return [];
const segments: ReasoningSegment[] = [];
let cursor = 0;
while (cursor < rawContent.length) {
const startIndex = rawContent.indexOf(REASONING_TAGS.START, cursor);
if (startIndex === -1) {
const remainingText = rawContent.slice(cursor);
if (remainingText) {
segments.push({ type: AgenticSectionType.TEXT, content: remainingText });
}
break;
}
if (startIndex > cursor) {
const textBefore = rawContent.slice(cursor, startIndex);
if (textBefore) {
segments.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const contentStart = startIndex + REASONING_TAGS.START.length;
const endIndex = rawContent.indexOf(REASONING_TAGS.END, contentStart);
if (endIndex === -1) {
const pendingContent = rawContent.slice(contentStart);
segments.push({
type: AgenticSectionType.REASONING_PENDING,
content: stripPartialMarker(pendingContent)
});
break;
}
const reasoningContent = rawContent.slice(contentStart, endIndex);
segments.push({ type: AgenticSectionType.REASONING, content: reasoningContent });
cursor = endIndex + REASONING_TAGS.END.length;
}
return segments;
}
@@ -1,6 +1,6 @@
import { base } from '$app/paths';
import { getJsonHeaders, getAuthHeaders } from './api-headers';
import { UrlPrefix } from '$lib/enums';
import { UrlProtocol } from '$lib/enums';
/**
* API Fetch Utilities
@@ -50,7 +50,9 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
const headers = { ...baseHeaders, ...customHeaders };
const url =
path.startsWith(UrlPrefix.HTTP) || path.startsWith(UrlPrefix.HTTPS) ? path : `${base}${path}`;
path.startsWith(UrlProtocol.HTTP) || path.startsWith(UrlProtocol.HTTPS)
? path
: `${base}${path}`;
const response = await fetch(url, {
...fetchOptions,
@@ -1,9 +1,30 @@
import { FileTypeCategory } from '$lib/enums';
import { AttachmentType, FileTypeCategory, SpecialFileType } from '$lib/enums';
import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
import type {
AttachmentDisplayItemsOptions,
ChatUploadedFile,
DatabaseMessageExtra
} from '$lib/types';
export interface AttachmentDisplayItemsOptions {
uploadedFiles?: ChatUploadedFile[];
attachments?: DatabaseMessageExtra[];
/**
* Check if an uploaded file is an MCP prompt
*/
function isMcpPromptUpload(file: ChatUploadedFile): boolean {
return file.type === SpecialFileType.MCP_PROMPT && !!file.mcpPrompt;
}
/**
* Check if an attachment is an MCP prompt
*/
function isMcpPromptAttachment(attachment: DatabaseMessageExtra): boolean {
return attachment.type === AttachmentType.MCP_PROMPT;
}
/**
* Check if an attachment is an MCP resource
*/
function isMcpResourceAttachment(attachment: DatabaseMessageExtra): boolean {
return attachment.type === AttachmentType.MCP_RESOURCE;
}
/**
@@ -37,6 +58,9 @@ export function getAttachmentDisplayItems(
size: file.size,
preview: file.preview,
isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
isMcpPrompt: isMcpPromptUpload(file),
isLoading: file.isLoading,
loadError: file.loadError,
uploadedFile: file,
textContent: file.textContent
});
@@ -45,12 +69,16 @@ export function getAttachmentDisplayItems(
// Add stored attachments (ChatMessage)
for (const [index, attachment] of attachments.entries()) {
const isImage = isImageFile(attachment);
const isMcpPrompt = isMcpPromptAttachment(attachment);
const isMcpResource = isMcpResourceAttachment(attachment);
items.push({
id: `attachment-${index}`,
name: attachment.name,
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
isImage,
isMcpPrompt,
isMcpResource,
attachment,
attachmentIndex: index,
textContent: 'content' in attachment ? attachment.content : undefined
+82 -15
View File
@@ -4,7 +4,11 @@ import type {
DatabaseMessageExtra,
DatabaseMessageExtraTextFile,
DatabaseMessageExtraLegacyContext,
DatabaseMessageExtraMcpPrompt,
DatabaseMessageExtraMcpResource,
ClipboardTextAttachment,
ClipboardMcpPromptAttachment,
ClipboardAttachment,
ParsedClipboardContent
} from '$lib/types';
@@ -101,11 +105,20 @@ export function formatMessageForClipboard(
extras?: DatabaseMessageExtra[],
asPlainText: boolean = false
): string {
// Filter only text attachments (TEXT type and legacy CONTEXT type)
// Filter text-like attachments (TEXT, LEGACY_CONTEXT, MCP_PROMPT, and MCP_RESOURCE types)
const textAttachments =
extras?.filter(
(extra): extra is DatabaseMessageExtraTextFile | DatabaseMessageExtraLegacyContext =>
extra.type === AttachmentType.TEXT || extra.type === AttachmentType.LEGACY_CONTEXT
(
extra
): extra is
| DatabaseMessageExtraTextFile
| DatabaseMessageExtraLegacyContext
| DatabaseMessageExtraMcpPrompt
| DatabaseMessageExtraMcpResource =>
extra.type === AttachmentType.TEXT ||
extra.type === AttachmentType.LEGACY_CONTEXT ||
extra.type === AttachmentType.MCP_PROMPT ||
extra.type === AttachmentType.MCP_RESOURCE
) ?? [];
if (textAttachments.length === 0) {
@@ -120,11 +133,24 @@ export function formatMessageForClipboard(
return parts.join('\n\n');
}
const clipboardAttachments: ClipboardTextAttachment[] = textAttachments.map((att) => ({
type: AttachmentType.TEXT,
name: att.name,
content: att.content
}));
const clipboardAttachments: ClipboardAttachment[] = textAttachments.map((att) => {
if (att.type === AttachmentType.MCP_PROMPT) {
const mcpAtt = att as DatabaseMessageExtraMcpPrompt;
return {
type: AttachmentType.MCP_PROMPT,
name: mcpAtt.name,
serverName: mcpAtt.serverName,
promptName: mcpAtt.promptName,
content: mcpAtt.content,
arguments: mcpAtt.arguments
} as ClipboardMcpPromptAttachment;
}
return {
type: AttachmentType.TEXT,
name: att.name,
content: att.content
} as ClipboardTextAttachment;
});
return `${JSON.stringify(content)}\n${JSON.stringify(clipboardAttachments, null, 2)}`;
}
@@ -139,7 +165,8 @@ export function formatMessageForClipboard(
export function parseClipboardContent(clipboardText: string): ParsedClipboardContent {
const defaultResult: ParsedClipboardContent = {
message: clipboardText,
textAttachments: []
textAttachments: [],
mcpPromptAttachments: []
};
if (!clipboardText.startsWith('"')) {
@@ -181,17 +208,28 @@ export function parseClipboardContent(clipboardText: string): ParsedClipboardCon
if (!remainingPart || !remainingPart.startsWith('[')) {
return {
message,
textAttachments: []
textAttachments: [],
mcpPromptAttachments: []
};
}
const attachments = JSON.parse(remainingPart) as unknown[];
const validAttachments: ClipboardTextAttachment[] = [];
const validTextAttachments: ClipboardTextAttachment[] = [];
const validMcpPromptAttachments: ClipboardMcpPromptAttachment[] = [];
for (const att of attachments) {
if (isValidTextAttachment(att)) {
validAttachments.push({
if (isValidMcpPromptAttachment(att)) {
validMcpPromptAttachments.push({
type: AttachmentType.MCP_PROMPT,
name: att.name,
serverName: att.serverName,
promptName: att.promptName,
content: att.content,
arguments: att.arguments
});
} else if (isValidTextAttachment(att)) {
validTextAttachments.push({
type: AttachmentType.TEXT,
name: att.name,
content: att.content
@@ -201,13 +239,42 @@ export function parseClipboardContent(clipboardText: string): ParsedClipboardCon
return {
message,
textAttachments: validAttachments
textAttachments: validTextAttachments,
mcpPromptAttachments: validMcpPromptAttachments
};
} catch {
return defaultResult;
}
}
/**
* Type guard to validate an MCP prompt attachment object
* @param obj The object to validate
* @returns true if the object is a valid MCP prompt attachment
*/
function isValidMcpPromptAttachment(obj: unknown): obj is {
type: string;
name: string;
serverName: string;
promptName: string;
content: string;
arguments?: Record<string, string>;
} {
if (typeof obj !== 'object' || obj === null) {
return false;
}
const record = obj as Record<string, unknown>;
return (
(record.type === AttachmentType.MCP_PROMPT || record.type === 'MCP_PROMPT') &&
typeof record.name === 'string' &&
typeof record.serverName === 'string' &&
typeof record.promptName === 'string' &&
typeof record.content === 'string'
);
}
/**
* Type guard to validate a text attachment object
* @param obj The object to validate
@@ -240,5 +307,5 @@ export function hasClipboardAttachments(clipboardText: string): boolean {
}
const parsed = parseClipboardContent(clipboardText);
return parsed.textAttachments.length > 0;
return parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0;
}
@@ -1,7 +1,7 @@
import { convertPDFToImage, convertPDFToText } from './pdf-processing';
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { FileTypeCategory, AttachmentType } from '$lib/enums';
import { FileTypeCategory, AttachmentType, SpecialFileType } from '$lib/enums';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { getFileTypeCategory } from '$lib/utils';
@@ -34,6 +34,19 @@ export async function parseFilesToMessageExtras(
const emptyFiles: string[] = [];
for (const file of files) {
if (file.type === SpecialFileType.MCP_PROMPT && file.mcpPrompt) {
extras.push({
type: AttachmentType.MCP_PROMPT,
name: file.name,
serverName: file.mcpPrompt.serverName,
promptName: file.mcpPrompt.promptName,
content: file.textContent ?? '',
arguments: file.mcpPrompt.arguments
});
continue;
}
if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) {
if (file.preview) {
let base64Url = file.preview;
@@ -0,0 +1,29 @@
/**
* CORS Proxy utility for routing requests through llama-server's CORS proxy.
*/
import { base } from '$app/paths';
import { CORS_PROXY_ENDPOINT, CORS_PROXY_URL_PARAM } from '$lib/constants';
/**
* Build a proxied URL that routes through llama-server's CORS proxy.
* @param targetUrl - The original URL to proxy
* @returns URL pointing to the CORS proxy with target encoded
*/
export function buildProxiedUrl(targetUrl: string): URL {
const proxyPath = `${base}${CORS_PROXY_ENDPOINT}`;
const proxyUrl = new URL(proxyPath, window.location.origin);
proxyUrl.searchParams.set(CORS_PROXY_URL_PARAM, targetUrl);
return proxyUrl;
}
/**
* Get a proxied URL string for use in fetch requests.
* @param targetUrl - The original URL to proxy
* @returns Proxied URL as string
*/
export function getProxiedUrlString(targetUrl: string): string {
return buildProxiedUrl(targetUrl).href;
}
@@ -0,0 +1,34 @@
/**
* Favicon utility functions for extracting favicons from URLs.
*/
import { getProxiedUrlString } from './cors-proxy';
import {
GOOGLE_FAVICON_BASE_URL,
DEFAULT_FAVICON_SIZE,
DOMAIN_SEPARATOR,
ROOT_DOMAIN_MIN_PARTS
} from '$lib/constants';
/**
* Gets a favicon URL for a given URL using Google's favicon service.
* Returns null if the URL is invalid.
*
* @param urlString - The URL to get the favicon for
* @returns The favicon URL or null if invalid
*/
export function getFaviconUrl(urlString: string): string | null {
try {
const url = new URL(urlString);
const hostnameParts = url.hostname.split(DOMAIN_SEPARATOR);
const rootDomain =
hostnameParts.length >= ROOT_DOMAIN_MIN_PARTS
? hostnameParts.slice(-ROOT_DOMAIN_MIN_PARTS).join(DOMAIN_SEPARATOR)
: url.hostname;
const googleFaviconUrl = `${GOOGLE_FAVICON_BASE_URL}?domain=${rootDomain}&sz=${DEFAULT_FAVICON_SIZE}`;
return getProxiedUrlString(googleFaviconUrl);
} catch {
return null;
}
}
@@ -0,0 +1,44 @@
/**
* Header utilities for parsing and serializing HTTP headers.
* Generic utilities not specific to MCP.
*/
/**
* Parses a JSON string of headers into an array of key-value pairs.
* Returns empty array if the JSON is invalid or empty.
*/
export function parseHeadersToArray(headersJson: string): { key: string; value: string }[] {
if (!headersJson?.trim()) return [];
try {
const parsed = JSON.parse(headersJson);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return Object.entries(parsed).map(([key, value]) => ({
key,
value: String(value)
}));
}
} catch {
return [];
}
return [];
}
/**
* Serializes an array of header key-value pairs to a JSON string.
* Filters out pairs with empty keys and returns empty string if no valid pairs.
*/
export function serializeHeaders(pairs: { key: string; value: string }[]): string {
const validPairs = pairs.filter((p) => p.key.trim());
if (validPairs.length === 0) return '';
const obj: Record<string, string> = {};
for (const pair of validPairs) {
obj[pair.key.trim()] = pair.value;
}
return JSON.stringify(obj);
}
+49
View File
@@ -31,9 +31,15 @@ export {
getPreviousSibling
} from './branching';
// Code
export { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from './code';
// Config helpers
export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
// CORS Proxy
export { buildProxiedUrl, getProxiedUrlString } from './cors-proxy';
// Conversation utilities
export { createMessageCountMap, getMessageCount } from './conversation-utils';
@@ -100,12 +106,51 @@ export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files
// Debounce utilities
export { debounce } from './debounce';
// Sanitization utilities
export { sanitizeKeyValuePairKey, sanitizeKeyValuePairValue } from './sanitize';
// Image error fallback utilities
export { getImageErrorFallbackHtml } from './image-error-fallback';
// MCP utilities
export {
detectMcpTransportFromUrl,
parseMcpServerSettings,
getMcpLogLevelIcon,
getMcpLogLevelClass,
isImageMimeType,
parseResourcePath,
getDisplayName,
getResourceDisplayName,
isCodeResource,
isImageResource,
getResourceIcon,
getResourceTextContent,
getResourceBlobContent,
downloadResourceContent
} from './mcp';
// URI Template utilities
export {
extractTemplateVariables,
expandTemplate,
isTemplateComplete,
normalizeResourceUri,
type UriTemplateVariable
} from './uri-template';
// Data URL utilities
export { createBase64DataUrl } from './data-url';
// Header utilities
export { parseHeadersToArray, serializeHeaders } from './headers';
// Favicon utilities
export { getFaviconUrl } from './favicon';
// Agentic content parsing utilities
export { parseAgenticContent, type AgenticSection } from './agentic';
// Cache utilities
export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
@@ -117,3 +162,7 @@ export {
createTimeoutSignal,
withAbortSignal
} from './abort';
// Cryptography utilities
export { uuid } from './uuid';
+304
View File
@@ -0,0 +1,304 @@
import type { MCPServerSettingsEntry, MCPResourceContent, MCPResourceInfo } from '$lib/types';
import {
MCPTransportType,
MCPLogLevel,
UrlProtocol,
MimeTypePrefix,
MimeTypeIncludes,
UriPattern,
MimeTypeText
} from '$lib/enums';
import {
DEFAULT_MCP_CONFIG,
MCP_SERVER_ID_PREFIX,
IMAGE_FILE_EXTENSION_REGEX,
CODE_FILE_EXTENSION_REGEX,
TEXT_FILE_EXTENSION_REGEX,
PROTOCOL_PREFIX_REGEX,
FILE_EXTENSION_REGEX,
DISPLAY_NAME_SEPARATOR_REGEX,
PATH_SEPARATOR,
RESOURCE_TEXT_CONTENT_SEPARATOR,
DEFAULT_RESOURCE_FILENAME
} from '$lib/constants';
import {
Database,
File,
FileText,
Image,
Code,
Info,
AlertTriangle,
XCircle
} from '@lucide/svelte';
import type { Component } from 'svelte';
import type { MimeTypeUnion } from '$lib/types/common';
/**
* Detects the MCP transport type from a URL.
* WebSocket URLs (ws:// or wss://) use 'websocket', others use 'streamable_http'.
*/
export function detectMcpTransportFromUrl(url: string): MCPTransportType {
const normalized = url.trim().toLowerCase();
return normalized.startsWith(UrlProtocol.WEBSOCKET) ||
normalized.startsWith(UrlProtocol.WEBSOCKET_SECURE)
? MCPTransportType.WEBSOCKET
: MCPTransportType.STREAMABLE_HTTP;
}
/**
* Parses MCP server settings from a JSON string or array.
* requestTimeoutSeconds is not user-configurable in the UI, so we always use the default value.
* @param rawServers - The raw servers to parse
* @returns An empty array if the input is invalid.
*/
export function parseMcpServerSettings(rawServers: unknown): 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 url = typeof entry?.url === 'string' ? entry.url.trim() : '';
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
const id =
typeof (entry as { id?: unknown })?.id === 'string' && (entry as { id?: string }).id?.trim()
? (entry as { id: string }).id.trim()
: `${MCP_SERVER_ID_PREFIX}-${index + 1}`;
return {
id,
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
url,
name: (entry as { name?: string })?.name,
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
headers: headers || undefined,
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
} satisfies MCPServerSettingsEntry;
});
}
/**
* Get the appropriate icon component for a log level
*
* @param level - MCP log level
* @returns Lucide icon component
*/
export function getMcpLogLevelIcon(level: MCPLogLevel): Component {
switch (level) {
case MCPLogLevel.ERROR:
return XCircle;
case MCPLogLevel.WARN:
return AlertTriangle;
default:
return Info;
}
}
/**
* Get the appropriate CSS class for a log level
*
* @param level - MCP log level
* @returns Tailwind CSS class string
*/
export function getMcpLogLevelClass(level: MCPLogLevel): string {
switch (level) {
case MCPLogLevel.ERROR:
return 'text-destructive';
case MCPLogLevel.WARN:
return 'text-yellow-600 dark:text-yellow-500';
default:
return 'text-muted-foreground';
}
}
/**
* Check if a MIME type represents an image.
*
* @param mimeType - The MIME type to check
* @returns True if the MIME type starts with 'image/'
*/
export function isImageMimeType(mimeType?: MimeTypeUnion): boolean {
return mimeType?.startsWith(MimeTypePrefix.IMAGE) ?? false;
}
/**
* Parse a resource URI into path segments, stripping the protocol prefix.
*
* @param uri - The resource URI to parse
* @returns Array of non-empty path segments
*/
export function parseResourcePath(uri: string): string[] {
try {
const withoutProtocol = uri.replace(PROTOCOL_PREFIX_REGEX, '');
return withoutProtocol.split(PATH_SEPARATOR).filter((p) => p.length > 0);
} catch {
return [uri];
}
}
/**
* Convert a path part into a human-readable display name.
* Strips file extensions and converts kebab-case/snake_case to Title Case.
*
* @param pathPart - The path segment to convert
* @returns Human-readable display name
*/
export function getDisplayName(pathPart: string): string {
const withoutExt = pathPart.replace(FILE_EXTENSION_REGEX, '');
return withoutExt
.split(DISPLAY_NAME_SEPARATOR_REGEX)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Get the display name from a resource, extracting the last path segment from the URI.
*
* @param resource - The MCP resource info
* @returns Display name string
*/
export function getResourceDisplayName(resource: MCPResourceInfo): string {
try {
const parts = parseResourcePath(resource.uri);
return parts[parts.length - 1] || resource.name || resource.uri;
} catch {
return resource.name || resource.uri;
}
}
/**
* Determine if a MIME type and/or URI represents code content.
*
* @param mimeType - Optional MIME type string
* @param uri - Optional URI string
* @returns True if the content is code
*/
export function isCodeResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
const mime = mimeType?.toLowerCase() || '';
const u = uri?.toLowerCase() || '';
return (
mime.includes(MimeTypeIncludes.JSON) ||
mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
mime.includes(MimeTypeIncludes.TYPESCRIPT) ||
CODE_FILE_EXTENSION_REGEX.test(u)
);
}
/**
* Determine if a MIME type and/or URI represents image content.
*
* @param mimeType - Optional MIME type string
* @param uri - Optional URI string
* @returns True if the content is an image
*/
export function isImageResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
const mime = mimeType?.toLowerCase() || '';
const u = uri?.toLowerCase() || '';
return mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u);
}
/**
* Get the appropriate Lucide icon component for an MCP resource based on its MIME type and URI.
*
* @param mimeType - Optional MIME type of the resource
* @param uri - Optional URI of the resource
* @returns Lucide icon component
*/
export function getResourceIcon(mimeType?: MimeTypeUnion, uri?: string): Component {
const mime = mimeType?.toLowerCase() || '';
const u = uri?.toLowerCase() || '';
if (mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u)) {
return Image;
}
if (
mime.includes(MimeTypeIncludes.JSON) ||
mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
mime.includes(MimeTypeIncludes.TYPESCRIPT) ||
CODE_FILE_EXTENSION_REGEX.test(u)
) {
return Code;
}
if (mime.includes(MimeTypePrefix.TEXT) || TEXT_FILE_EXTENSION_REGEX.test(u)) {
return FileText;
}
if (u.includes(UriPattern.DATABASE_KEYWORD) || u.includes(UriPattern.DATABASE_SCHEME)) {
return Database;
}
return File;
}
/**
* Extract text content from MCP resource content array.
*
* @param content - Array of MCP resource content items
* @returns Joined text content string
*/
export function getResourceTextContent(content: MCPResourceContent[] | null | undefined): string {
if (!content) return '';
return content
.filter((c): c is { uri: string; mimeType?: MimeTypeUnion; text: string } => 'text' in c)
.map((c) => c.text)
.join(RESOURCE_TEXT_CONTENT_SEPARATOR);
}
/**
* Extract blob content from MCP resource content array.
*
* @param content - Array of MCP resource content items
* @returns Array of blob content items
*/
export function getResourceBlobContent(
content: MCPResourceContent[] | null | undefined
): Array<{ uri: string; mimeType?: MimeTypeUnion; blob: string }> {
if (!content) return [];
return content.filter(
(c): c is { uri: string; mimeType?: MimeTypeUnion; blob: string } => 'blob' in c
);
}
/**
* Trigger a file download from text content.
*
* @param text - The text content to download
* @param mimeType - MIME type for the blob
* @param filename - Suggested filename
*/
export function downloadResourceContent(
text: string,
mimeType: MimeTypeUnion = MimeTypeText.PLAIN,
filename: string = DEFAULT_RESOURCE_FILENAME
): void {
const blob = new Blob([text], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
@@ -0,0 +1,23 @@
import {
KEY_VALUE_PAIR_KEY_MAX_LENGTH,
KEY_VALUE_PAIR_VALUE_MAX_LENGTH,
KEY_VALUE_PAIR_UNSAFE_KEY_RE,
KEY_VALUE_PAIR_UNSAFE_VALUE_RE
} from '$lib/constants';
/**
* Strip control characters unsafe in identifier/header-name contexts and cap length.
* Removes all C0 controls (including TAB) and DEL.
*/
export function sanitizeKeyValuePairKey(raw: string): string {
return raw.replace(KEY_VALUE_PAIR_UNSAFE_KEY_RE, '').slice(0, KEY_VALUE_PAIR_KEY_MAX_LENGTH);
}
/**
* Strip control characters that enable header injection; allow TAB; cap length.
* Removes null bytes, CR/LF and other C0/DEL controls while keeping TAB (\x09),
* which is a valid header-value continuation character per RFC 7230.
*/
export function sanitizeKeyValuePairValue(raw: string): string {
return raw.replace(KEY_VALUE_PAIR_UNSAFE_VALUE_RE, '').slice(0, KEY_VALUE_PAIR_VALUE_MAX_LENGTH);
}
@@ -0,0 +1,198 @@
import {
TEMPLATE_EXPRESSION_REGEX,
URI_SCHEME_SEPARATOR,
URI_TEMPLATE_OPERATORS,
URI_TEMPLATE_SEPARATORS,
VARIABLE_EXPLODE_MODIFIER_REGEX,
VARIABLE_PREFIX_MODIFIER_REGEX,
LEADING_SLASHES_REGEX
} from '../constants';
/**
* Normalize a resource URI for comparison.
*
* URI template expansion (especially with path operators like {/var})
* can produce URIs that differ from listed resource URIs in slash placement.
* For example, the template `svelte://{/slug*}.md` with slug="svelte/$effect"
* expands to `svelte:///svelte/$effect.md`, while the listed resource URI is
* `svelte://svelte/$effect.md`.
*
* This function strips extra leading slashes after the scheme to normalize
* both forms to the same string for comparison purposes.
*
* @param uri - The URI to normalize
* @returns Normalized URI string
*/
export function normalizeResourceUri(uri: string): string {
const schemeEnd = uri.indexOf(URI_SCHEME_SEPARATOR);
if (schemeEnd === -1) return uri;
const scheme = uri.substring(0, schemeEnd);
const rest = uri
.substring(schemeEnd + URI_SCHEME_SEPARATOR.length)
.replace(LEADING_SLASHES_REGEX, '');
return `${scheme}${URI_SCHEME_SEPARATOR}${rest}`;
}
/**
* A parsed variable from a URI template expression.
*/
export interface UriTemplateVariable {
/** Variable name */
name: string;
/** Operator prefix (+, #, /, etc.) or empty string */
operator: string;
}
/**
* Extract all variable names from a URI template string.
*
* @param template - URI template string (RFC 6570)
* @returns Array of unique variable descriptors
*
* @example
* ```ts
* extractTemplateVariables("file:///{path}")
* // => [{ name: "path", operator: "" }]
*
* extractTemplateVariables("db://{schema}/{table}")
* // => [{ name: "schema", operator: "" }, { name: "table", operator: "" }]
* ```
*/
export function extractTemplateVariables(template: string): UriTemplateVariable[] {
const variables: UriTemplateVariable[] = [];
const seen = new Set<string>();
let match;
TEMPLATE_EXPRESSION_REGEX.lastIndex = 0;
while ((match = TEMPLATE_EXPRESSION_REGEX.exec(template)) !== null) {
const operator = match[1] || '';
const varList = match[2];
// RFC 6570 allows comma-separated variable lists: {x,y,z}
for (const varSpec of varList.split(',')) {
// Strip explode modifier (*) and prefix modifier (:N)
const name = varSpec
.replace(VARIABLE_EXPLODE_MODIFIER_REGEX, '')
.replace(VARIABLE_PREFIX_MODIFIER_REGEX, '')
.trim();
if (name && !seen.has(name)) {
seen.add(name);
variables.push({ name, operator });
}
}
}
return variables;
}
/**
* Expand a URI template with the given variable values.
* Implements a simplified RFC 6570 Level 2 expansion.
*
* @param template - URI template string
* @param values - Map of variable name to value
* @returns Expanded URI string
*
* @example
* ```ts
* expandTemplate("file:///{path}", { path: "src/main.rs" })
* // => "file:///src/main.rs"
* ```
*/
export function expandTemplate(template: string, values: Record<string, string>): string {
TEMPLATE_EXPRESSION_REGEX.lastIndex = 0;
return template.replace(
TEMPLATE_EXPRESSION_REGEX,
(_match, operator: string, varList: string) => {
const varNames = varList
.split(',')
.map((v: string) =>
v
.replace(VARIABLE_EXPLODE_MODIFIER_REGEX, '')
.replace(VARIABLE_PREFIX_MODIFIER_REGEX, '')
.trim()
);
const expandedParts = varNames
.map((name: string) => values[name] ?? '')
.filter((v: string) => v !== '');
if (expandedParts.length === 0) return '';
switch (operator) {
case URI_TEMPLATE_OPERATORS.RESERVED:
// Reserved expansion: no encoding
return expandedParts.join(URI_TEMPLATE_SEPARATORS.COMMA);
case URI_TEMPLATE_OPERATORS.FRAGMENT:
// Fragment expansion
return (
URI_TEMPLATE_OPERATORS.FRAGMENT + expandedParts.join(URI_TEMPLATE_SEPARATORS.COMMA)
);
case URI_TEMPLATE_OPERATORS.PATH_SEGMENT:
// Path segments
return URI_TEMPLATE_SEPARATORS.SLASH + expandedParts.join(URI_TEMPLATE_SEPARATORS.SLASH);
case URI_TEMPLATE_OPERATORS.LABEL:
// Label expansion
return (
URI_TEMPLATE_SEPARATORS.PERIOD + expandedParts.join(URI_TEMPLATE_SEPARATORS.PERIOD)
);
case URI_TEMPLATE_OPERATORS.PATH_PARAM:
// Path-style parameters
return varNames
.filter((_: string, i: number) => expandedParts[i])
.map(
(name: string, i: number) =>
`${URI_TEMPLATE_SEPARATORS.SEMICOLON}${name}=${expandedParts[i]}`
)
.join('');
case URI_TEMPLATE_OPERATORS.FORM_QUERY:
// Form-style query
return (
URI_TEMPLATE_SEPARATORS.QUERY_PREFIX +
varNames
.filter((_: string, i: number) => expandedParts[i])
.map(
(name: string, i: number) =>
`${encodeURIComponent(name)}=${encodeURIComponent(expandedParts[i])}`
)
.join(URI_TEMPLATE_SEPARATORS.COMMA)
);
case URI_TEMPLATE_OPERATORS.FORM_CONTINUATION:
// Form-style query continuation
return (
URI_TEMPLATE_SEPARATORS.QUERY_CONTINUATION +
varNames
.filter((_: string, i: number) => expandedParts[i])
.map(
(name: string, i: number) =>
`${encodeURIComponent(name)}=${encodeURIComponent(expandedParts[i])}`
)
.join(URI_TEMPLATE_SEPARATORS.COMMA)
);
default:
// Simple string expansion (default operator)
return expandedParts
.map((v: string) => encodeURIComponent(v))
.join(URI_TEMPLATE_SEPARATORS.COMMA);
}
}
);
}
/**
* Check whether all required variables in a template have been provided.
*
* @param template - URI template string
* @param values - Map of variable name to value
* @returns true if all variables have non-empty values
*/
export function isTemplateComplete(template: string, values: Record<string, string>): boolean {
const variables = extractTemplateVariables(template);
return variables.every((v) => (values[v.name] ?? '').trim() !== '');
}
+3
View File
@@ -0,0 +1,3 @@
export function uuid(): string {
return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).substring(2);
}