feat: Integrate with `llama-server` proxy + improve MCP Server Edit Form

This commit is contained in:
Aleksander Grygier 2026-01-29 14:59:28 +01:00
parent 406cb1dd99
commit 536c6866e3
10 changed files with 116 additions and 36 deletions

View File

@ -1,10 +1,11 @@
<script lang="ts">
import { Plus, X } from '@lucide/svelte';
import { Plus, Trash2 } from '@lucide/svelte';
import { Input } from '$lib/components/ui/input';
import { autoResizeTextarea } from '$lib/utils';
import type { KeyValuePair } from '$lib/types';
interface Props {
class?: string;
pairs: KeyValuePair[];
onPairsChange: (pairs: KeyValuePair[]) => void;
keyPlaceholder?: string;
@ -16,6 +17,7 @@
}
let {
class: className = '',
pairs,
onPairsChange,
keyPlaceholder = 'Key',
@ -47,7 +49,7 @@
}
</script>
<div>
<div class={className}>
<div class="mb-2 flex items-center justify-between">
{#if sectionLabel}
<span class="text-xs font-medium">
@ -70,7 +72,7 @@
{#if pairs.length > 0}
<div class="space-y-3">
{#each pairs as pair, index (index)}
<div class="flex items-center gap-2">
<div class="flex items-start gap-2">
<Input
type="text"
placeholder={keyPlaceholder}
@ -93,11 +95,11 @@
<button
type="button"
class="shrink-0 cursor-pointer rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
class="mt-1.5 shrink-0 cursor-pointer rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onclick={() => removePair(index)}
aria-label="Remove item"
>
<X class="h-3.5 w-3.5" />
<Trash2 class="h-3.5 w-3.5" />
</button>
</div>
{/each}

View File

@ -67,7 +67,7 @@
async function startEditing() {
isEditing = true;
await tick();
editFormRef?.setInitialValues(server.url, server.headers || '');
editFormRef?.setInitialValues(server.url, server.headers || '', server.useProxy || false);
}
function cancelEditing() {
@ -78,15 +78,16 @@
}
}
function saveEditing(url: string, headers: string) {
function saveEditing(url: string, headers: string, useProxy: boolean) {
onUpdate({
url: url,
headers: headers || undefined
headers: headers || undefined,
useProxy: useProxy
});
isEditing = false;
if (server.enabled && url) {
setTimeout(() => mcpStore.runHealthCheck({ ...server, url }), 100);
setTimeout(() => mcpStore.runHealthCheck({ ...server, url, useProxy }), 100);
}
}
@ -101,6 +102,7 @@
bind:this={editFormRef}
serverId={server.id}
serverUrl={server.url}
serverUseProxy={server.useProxy}
onSave={saveEditing}
onCancel={cancelEditing}
/>

View File

@ -1,19 +1,20 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { McpServerForm } from '$lib/components/app/mcp';
interface Props {
serverId: string;
serverUrl: string;
onSave: (url: string, headers: string) => void;
serverUseProxy?: boolean;
onSave: (url: string, headers: string, useProxy: boolean) => void;
onCancel: () => void;
}
let { serverId, serverUrl, onSave, onCancel }: Props = $props();
let { serverId, serverUrl, serverUseProxy = false, onSave, onCancel }: Props = $props();
let editUrl = $state(serverUrl);
let editHeaders = $state('');
let editUseProxy = $state(serverUseProxy);
let urlError = $derived.by(() => {
if (!editUrl.trim()) return 'URL is required';
@ -29,34 +30,34 @@
function handleSave() {
if (!canSave) return;
onSave(editUrl.trim(), editHeaders.trim());
onSave(editUrl.trim(), editHeaders.trim(), editUseProxy);
}
export function setInitialValues(url: string, headers: string) {
export function setInitialValues(url: string, headers: string, useProxy: boolean) {
editUrl = url;
editHeaders = headers;
editUseProxy = useProxy;
}
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<p class="font-medium">Configure Server</p>
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={onCancel} aria-label="Cancel">
<X class="h-3.5 w-3.5" />
</Button>
</div>
<p class="font-medium">Configure Server</p>
<McpServerForm
url={editUrl}
headers={editHeaders}
useProxy={editUseProxy}
onUrlChange={(v) => (editUrl = v)}
onHeadersChange={(v) => (editHeaders = v)}
onUseProxyChange={(v) => (editUseProxy = v)}
urlError={editUrl ? urlError : null}
id={serverId}
/>
<div class="flex items-center justify-end">
<Button variant="default" size="sm" onclick={handleSave} disabled={!canSave} aria-label="Save">
<div class="flex items-center justify-end gap-2">
<Button variant="secondary" size="sm" onclick={onCancel}>Cancel</Button>
<Button size="sm" onclick={handleSave} disabled={!canSave}>
{serverUrl.trim() ? 'Update' : 'Add'}
</Button>
</div>

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Switch } from '$lib/components/ui/switch';
import { KeyValuePairs } from '$lib/components/app';
import type { KeyValuePair } from '$lib/types';
import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
@ -7,8 +8,10 @@
interface Props {
url: string;
headers: string;
useProxy?: boolean;
onUrlChange: (url: string) => void;
onHeadersChange: (headers: string) => void;
onUseProxyChange?: (useProxy: boolean) => void;
urlError?: string | null;
id?: string;
}
@ -16,12 +19,18 @@
let {
url,
headers,
useProxy = false,
onUrlChange,
onHeadersChange,
onUseProxyChange,
urlError = null,
id = 'server'
}: Props = $props();
let isWebSocket = $derived(
url.toLowerCase().startsWith('ws://') || url.toLowerCase().startsWith('wss://')
);
let headerPairs = $derived<KeyValuePair[]>(parseHeadersToArray(headers));
function updateHeaderPairs(newPairs: KeyValuePair[]) {
@ -48,9 +57,21 @@
{#if urlError}
<p class="mt-1.5 text-xs text-destructive">{urlError}</p>
{/if}
{#if !isWebSocket && onUseProxyChange}
<label class="mt-3 flex cursor-pointer items-center gap-2">
<Switch
id="use-proxy-{id}"
checked={useProxy}
onCheckedChange={(checked) => onUseProxyChange?.(checked)}
/>
<span class="text-xs text-muted-foreground">Use llama-server proxy</span>
</label>
{/if}
</div>
<KeyValuePairs
class="mt-2"
pairs={headerPairs}
onPairsChange={updateHeaderPairs}
keyPlaceholder="Header name"

View File

@ -7,14 +7,25 @@
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { McpServerCard, McpServerForm } from '$lib/components/app/mcp';
import { Skeleton } from '$lib/components/ui/skeleton';
let servers = $derived(mcpStore.getServersSorted());
let allServersHealthChecked = $derived(
servers.length > 0 &&
let initialLoadComplete = $state(false);
$effect(() => {
if (initialLoadComplete) return;
const allChecked =
servers.length > 0 &&
servers.every((server) => {
const state = mcpStore.getHealthCheckState(server.id);
return state.status === 'success' || state.status === 'error';
})
);
});
if (allChecked) {
initialLoadComplete = true;
}
});
let isAddingServer = $state(false);
let newServerUrl = $state('');
@ -118,7 +129,7 @@
{#if servers.length > 0}
<div class="space-y-3">
{#each servers as server (server.id)}
{#if !allServersHealthChecked}
{#if !initialLoadComplete}
<Card.Root class="grid gap-3 p-4">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">

View File

@ -42,6 +42,7 @@ import type {
import { MCPConnectionPhase, MCPLogLevel, MCPTransportType } from '$lib/enums';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { throwIfAborted, isAbortError } from '$lib/utils';
import { base } from '$app/paths';
interface ToolResultContentItem {
type: string;
@ -76,9 +77,24 @@ export class MCPService {
};
}
/**
* Build a proxied URL that routes through llama-server's CORS proxy.
* @param targetUrl - The original MCP server URL
* @returns URL pointing to the CORS proxy with target encoded
*/
private static buildProxiedUrl(targetUrl: string): URL {
const proxyPath = `${base}/cors-proxy`;
const proxyUrl = new URL(proxyPath, window.location.origin);
proxyUrl.searchParams.set('url', targetUrl);
return proxyUrl;
}
/**
* Create transport based on server configuration.
* Supports WebSocket, StreamableHTTP (modern), and SSE (legacy) transports.
* When useProxy is enabled, routes HTTP requests through llama-server's CORS proxy.
* Returns both transport and the type used.
*/
static createTransport(config: MCPServerConfig): {
@ -89,7 +105,7 @@ export class MCPService {
throw new Error('MCP server configuration is missing url');
}
const url = new URL(config.url);
const useProxy = config.useProxy ?? false;
const requestInit: RequestInit = {};
if (config.headers) {
@ -101,7 +117,17 @@ export class MCPService {
}
if (config.transport === 'websocket') {
console.log(`[MCPService] Creating WebSocket transport for ${url.href}`);
if (useProxy) {
throw new Error(
'WebSocket transport is not supported when using CORS proxy. Use HTTP transport instead.'
);
}
const url = new URL(config.url);
if (import.meta.env.DEV) {
console.log(`[MCPService] Creating WebSocket transport for ${url.href}`);
}
return {
transport: new WebSocketClientTransport(url),
@ -109,8 +135,16 @@ export class MCPService {
};
}
const url = useProxy ? this.buildProxiedUrl(config.url) : new URL(config.url);
if (useProxy && import.meta.env.DEV) {
console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`);
}
try {
console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`);
if (import.meta.env.DEV) {
console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`);
}
return {
transport: new StreamableHTTPClientTransport(url, {

View File

@ -79,7 +79,8 @@ function parseServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
url,
name: (entry as { name?: string })?.name,
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
headers: headers || undefined
headers: headers || undefined,
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
} satisfies MCPServerSettingsEntry;
});
}
@ -104,7 +105,8 @@ function buildServerConfig(
transport: detectMcpTransportFromUrl(entry.url),
handshakeTimeoutMs: connectionTimeoutMs,
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
headers
headers,
useProxy: entry.useProxy
};
}
@ -304,7 +306,8 @@ class MCPStore {
url: serverData.url.trim(),
name: serverData.name,
headers: serverData.headers?.trim() || undefined,
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
useProxy: serverData.useProxy
};
settingsStore.updateConfig('mcpServers', JSON.stringify([...servers, newServer]));
}
@ -845,7 +848,8 @@ class MCPStore {
transport: detectMcpTransportFromUrl(trimmedUrl),
handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs,
requestTimeoutMs: timeoutMs,
headers
headers,
useProxy: server.useProxy
},
DEFAULT_MCP_CONFIG.clientInfo,
DEFAULT_MCP_CONFIG.capabilities,

View File

@ -171,6 +171,7 @@ export interface HealthCheckParams {
url: string;
requestTimeoutSeconds: number;
headers?: string;
useProxy?: boolean;
}
export type MCPServerConfig = {
@ -182,6 +183,7 @@ export type MCPServerConfig = {
handshakeTimeoutMs?: number;
requestTimeoutMs?: number;
capabilities?: ClientCapabilities;
useProxy?: boolean;
};
export type MCPClientConfig = {
@ -210,6 +212,7 @@ export type MCPServerSettingsEntry = {
headers?: string;
name?: string;
iconUrl?: string;
useProxy?: boolean;
};
export interface MCPHostManagerConfig {

View File

@ -58,7 +58,8 @@ export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEn
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
url,
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
headers: headers || undefined
headers: headers || undefined,
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
} satisfies MCPServerSettingsEntry;
});
}

View File

@ -156,7 +156,8 @@ export default defineConfig({
proxy: {
'/v1': 'http://localhost:8080',
'/props': 'http://localhost:8080',
'/models': 'http://localhost:8080'
'/models': 'http://localhost:8080',
'/cors-proxy': 'http://localhost:8080'
},
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',