feat: Integrate with `llama-server` proxy + improve MCP Server Edit Form
This commit is contained in:
parent
406cb1dd99
commit
536c6866e3
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue