- {#each getEnabledMcpServers() as server (server.id)}
+ {#each visibleMcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const displayName = mcpStore.getServerLabel(server)}
@@ -202,7 +200,7 @@
{/each}
- {#if getEnabledMcpServers().length === 0}
+ {#if visibleMcpServers.length === 0}
No MCP servers configured
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
index 7189ce1c76..dadcae0c49 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
@@ -43,7 +43,7 @@
assistantMessages: number;
messageTypes: string[];
} | null>(null);
- let editedContent = $state(message.content);
+ let editedContent = $derived(message.content);
let rawEditContent = $derived.by(() => {
if (message.role !== MessageRole.ASSISTANT) return undefined;
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte
index e466c84ee2..4337bb6a1e 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte
@@ -1,8 +1,9 @@
@@ -60,29 +69,27 @@
Add New Server
-
- (newServerUrl = v)}
- onHeadersChange={(v) => (newServerHeaders = v)}
- urlError={newServerUrl ? newServerUrlError : null}
- id="new-server"
- />
-
+
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte
new file mode 100644
index 0000000000..9b4489b828
--- /dev/null
+++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte
@@ -0,0 +1,180 @@
+
+
+
+
+
+ Do more with MCP
+
+ Power-up your experience by adding tools, resources and more capabilities provided by MCP
+ servers.
+
+
+
+
+
Quickly get started with
+
+ {#each RECOMMENDED_MCP_SERVERS as server (server.id)}
+
(selected[server.id] = enabled)}
+ />
+ {/each}
+
+ {#if addedServers.length > 0}
+ {#each addedServers as server (server.id)}
+
+ {/each}
+ {/if}
+
+ {#if showAddForm}
+
+ (newServerUrl = v)}
+ onHeadersChange={(v) => (newServerHeaders = v)}
+ urlError={newServerUrl ? newServerUrlError : null}
+ id="recommendation-new-server"
+ />
+
+
+ Cancel
+
+
+ Add
+
+
+
+ {:else}
+
+ (showAddForm = true)}
+ aria-label="Add your own MCP server"
+ >
+
+ Add your own server
+
+
+ {/if}
+
+
+
+ handleOpenChange(false)}>Not now
+
+ Add selected
+
+
+
diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts
index 29136308ce..73f22c5651 100644
--- a/tools/ui/src/lib/components/app/dialogs/index.ts
+++ b/tools/ui/src/lib/components/app/dialogs/index.ts
@@ -18,6 +18,15 @@
*/
export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte';
+/**
+ * **DialogMcpServerRecommendations** - Suggested MCP servers opt-in dialog
+ *
+ * Prompts the user to enable pre-defined recommended MCP servers on first launch.
+ * Shows one switch per suggested server and persists the choice as a per-chat
+ * override so the selected servers become available in conversations.
+ */
+export { default as DialogMcpServerRecommendations } from './DialogMcpServerRecommendations.svelte';
+
/**
* **DialogExportSettings** - Settings export dialog with sensitive data warning
*
diff --git a/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte b/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte
index e0bd8d98e8..fd6e59a5b8 100644
--- a/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte
+++ b/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte
@@ -1,4 +1,5 @@
@@ -103,6 +123,7 @@
{#each pairs as pair, index (index)}
-
+
{#if showSkeleton}
{:else if protocolVersion}
diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte
new file mode 100644
index 0000000000..6cb3e18b65
--- /dev/null
+++ b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte
@@ -0,0 +1,156 @@
+
+
+
+
+
+ {#if showSkeleton}
+
+
+
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+ {#if isError && errorMessage}
+ {errorMessage}
+ {/if}
+
+ {#if showSkeleton}
+
+
+
+
+
+
+
+
+
+
+ {:else}
+ {#if description}
+ {#if description.lines === 2}
+
+ {description.text}
+
+ {:else}
+
+ {description.text}
+
+ {/if}
+ {/if}
+
+ {#if tools.length > 0}
+
+ {#each visibleTools as tool (tool.name)}
+
+
+
+ {tool.name}
+
+
+
+
+
+ {tool.description ?? 'No description'}
+
+
+
+ {/each}
+
+ {#if hiddenToolCount > 0}
+
+
+
+ + {hiddenToolCount} more tools
+
+
+
+
+
+ {hiddenTools.map((tool) => tool.name).join(', ')}
+
+
+
+ {/if}
+
+ {/if}
+ {/if}
+
diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte
index 6727a90006..8ed4ee8b80 100644
--- a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte
+++ b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte
@@ -1,6 +1,7 @@
-
-
Configure Server
+
diff --git a/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte b/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte
index 79738e30dd..7f05d5fef3 100644
--- a/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte
+++ b/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte
@@ -38,14 +38,87 @@
let headerPairs = $derived
(parseHeadersToArray(headers));
+ const AUTHORIZATION_HEADER = 'Authorization';
+ const BEARER_PREFIX = 'Bearer ';
+
+ // Heuristic: this dedicated UI only owns Authorization headers that already
+ // carry a Bearer scheme. Anything else (e.g. Basic, raw tokens) stays in the
+ // KV section so the user can still edit those values verbatim.
+ const matchesAuthorizationKey = (key: string): boolean =>
+ key.trim().toLowerCase() === AUTHORIZATION_HEADER.toLowerCase();
+
+ const isBearerScheme = (value: string): boolean =>
+ value.trim().toLowerCase().startsWith(BEARER_PREFIX.toLowerCase());
+
+ const ownedByBearerUi = (p: KeyValuePair): boolean =>
+ matchesAuthorizationKey(p.key) && isBearerScheme(p.value);
+
+ let hasAuthorization = $derived(headerPairs.some(ownedByBearerUi));
+
+ let wantsAuthorization = $state(false);
+
+ let showAuthorization = $derived(hasAuthorization || wantsAuthorization);
+
+ let urlInput: HTMLInputElement | null = $state(null);
+ let bearerInput: HTMLInputElement | null = $state(null);
+
+ $effect(() => {
+ urlInput?.focus();
+ });
+
+ $effect(() => {
+ if (wantsAuthorization && bearerInput) {
+ bearerInput.focus();
+ }
+ });
+
+ let bearerToken = $derived.by(() => {
+ const auth = headerPairs.find(ownedByBearerUi);
+ if (!auth) return '';
+ return auth.value.trim().slice(BEARER_PREFIX.length).trim();
+ });
+
+ $effect(() => {
+ if (!headers.trim()) {
+ wantsAuthorization = false;
+ }
+ });
+
function updateHeaderPairs(newPairs: KeyValuePair[]) {
headerPairs = newPairs;
onHeadersChange(serializeHeaders(newPairs));
}
+
+ // The dedicated UI owns the Authorization slot end-to-end when the user
+ // engages it: any prior Authorization row (Bearer or otherwise) is replaced
+ // by exactly one { Authorization: "Bearer " } entry. JSON's last-key
+ // behavior would otherwise pick one arbitrarily, so we strip first.
+ function updateBearerToken(token: string) {
+ const filtered = headerPairs.filter((p) => !matchesAuthorizationKey(p.key));
+
+ const trimmed = token.trim();
+
+ if (trimmed) {
+ filtered.push({ key: AUTHORIZATION_HEADER, value: `${BEARER_PREFIX}${trimmed}` });
+ }
+
+ updateHeaderPairs(filtered);
+ }
+
+ function setUseAuthorization(checked: boolean) {
+ wantsAuthorization = checked;
+
+ if (!checked) {
+ // Only drop the entry this UI owns; a non-Bearer Authorization row
+ // authored in the KV section must survive a toggle off untouched.
+ const filtered = headerPairs.filter((p) => !ownedByBearerUi(p));
+ updateHeaderPairs(filtered);
+ }
+ }
-
-
+
+
Server URL *
@@ -57,50 +130,52 @@
value={url}
oninput={(e) => onUrlChange(e.currentTarget.value)}
class={urlError ? 'border-destructive' : ''}
+ bind:ref={urlInput}
/>
{#if urlError}
{urlError}
{/if}
-
- {#if !isWebSocket && onUseProxyChange}
-
- onUseProxyChange?.(checked)}
- />
-
-
- Use llama-server proxy
-
-
-
- {#if !mcpStore.isProxyAvailable}
- (Run llama-server
- with
- {CLI_FLAGS.MCP_PROXY}
- flag)
- {/if}
-
-
- {/if}
+
+
+
+ Authorization
+
+
+ {#if showAuthorization}
+
+ updateBearerToken(e.currentTarget.value)}
+ class="pl-16"
+ bind:ref={bearerInput}
+ />
+
+
+ Bearer
+
+
+ {/if}
+
!ownedByBearerUi(p))}
+ onPairsChange={(pairs) => {
+ const auth = headerPairs.find(ownedByBearerUi);
+ updateHeaderPairs(auth ? [...pairs, auth] : pairs);
+ }}
keyPlaceholder="Header name"
valuePlaceholder="Value"
addButtonLabel="Add"
@@ -108,4 +183,37 @@
sectionLabel="Custom Headers"
sectionLabelOptional
/>
+
+ {#if !isWebSocket && onUseProxyChange}
+
+ onUseProxyChange?.(checked)}
+ />
+
+
+ Use llama-server proxy
+
+
+
+ {#if !mcpStore.isProxyAvailable}
+ (Run llama-server
+ with
+ {CLI_FLAGS.MCP_PROXY}
+ flag)
+ {/if}
+
+
+ {/if}
diff --git a/tools/ui/src/lib/components/app/mcp/McpServerIdentity.svelte b/tools/ui/src/lib/components/app/mcp/McpServerIdentity.svelte
index feafc5d811..3f128e02c9 100644
--- a/tools/ui/src/lib/components/app/mcp/McpServerIdentity.svelte
+++ b/tools/ui/src/lib/components/app/mcp/McpServerIdentity.svelte
@@ -1,6 +1,7 @@
+
+
+
{}}
+ onHeadersChange={(value) => {
+ headersState = value;
+ }}
+ id="mcp-server-form-test"
+/>
+
+
diff --git a/tools/ui/tests/client/mcp-server-form.svelte.test.ts b/tools/ui/tests/client/mcp-server-form.svelte.test.ts
new file mode 100644
index 0000000000..b4bd892347
--- /dev/null
+++ b/tools/ui/tests/client/mcp-server-form.svelte.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import McpServerFormWrapper from './components/McpServerFormWrapper.svelte';
+
+const AUTHORIZATION_HEADER = 'Authorization';
+const BEARER_PREFIX = 'Bearer ';
+const BEARER_PLACEHOLDER = 'Paste token here';
+
+/**
+ * Client-side tests for the McpServerForm bearer UI.
+ *
+ * The dedicated UI only "owns" Authorization headers that already carry a
+ * Bearer scheme (heuristic check on the value). Other Authorization values
+ * stay in the KV section so the user can still edit them verbatim. Storage
+ * always goes through the same custom-headers slot, so a round-trip via this
+ * UI produces exactly one `Authorization: Bearer ` entry.
+ *
+ * Equivalent parser coverage lives in `tests/unit/headers.test.ts`.
+ */
+describe('McpServerForm - Authorization / bearer UI', () => {
+ function bearerInput(screen: Awaited>) {
+ return screen.locator.getByPlaceholder(BEARER_PLACEHOLDER);
+ }
+
+ function capturedHeaders(screen: Awaited>) {
+ return screen.getByTestId('captured-headers');
+ }
+
+ it('mounts with the bearer input hidden when no auth header is present', async () => {
+ const screen = await render(McpServerFormWrapper, { headers: '' });
+
+ await expect.element(screen.getByRole('textbox', { name: /server url/i })).toBeVisible();
+
+ await expect.element(bearerInput(screen)).not.toBeInTheDocument();
+ });
+
+ it('toggling Authorization shows the bearer input', async () => {
+ const screen = await render(McpServerFormWrapper, { headers: '' });
+
+ await screen.getByRole('switch', { name: /authorization/i }).click();
+
+ await expect.element(bearerInput(screen)).toBeVisible();
+ });
+
+ it('typing a token writes the Authorization row with the Bearer prefix prepended', async () => {
+ const screen = await render(McpServerFormWrapper, { headers: '' });
+
+ await screen.getByRole('switch', { name: /authorization/i }).click();
+
+ const token = 'super-secret';
+ await bearerInput(screen).fill(token);
+
+ const expected = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${token}` });
+ await expect
+ .element(capturedHeaders(screen))
+ .toHaveAttribute('data-captured-headers', expected);
+ });
+
+ it('pre-existing Bearer header pre-fills the bearer input with the token stripped', async () => {
+ const existing = JSON.stringify({
+ 'X-Trace-Id': 'abc',
+ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}preexisting`
+ });
+
+ const screen = await render(McpServerFormWrapper, { headers: existing });
+
+ await expect.element(bearerInput(screen)).toBeVisible();
+ await expect.element(bearerInput(screen)).toHaveValue('preexisting');
+ });
+
+ it('non-Bearer Authorization is ignored by the dedicated UI and stays in the KV section', async () => {
+ const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic czNjcjpwYXNz' });
+
+ const screen = await render(McpServerFormWrapper, { headers: existing });
+
+ await expect.element(bearerInput(screen)).not.toBeInTheDocument();
+
+ const headerKeyInput = screen.getByPlaceholder('Header name');
+ await expect.element(headerKeyInput).toBeVisible();
+ });
+
+ it('engaging the token UI replaces a non-Bearer Authorization with the Bearer scheme', async () => {
+ const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic old' });
+
+ const screen = await render(McpServerFormWrapper, { headers: existing });
+
+ await screen.getByRole('switch', { name: /authorization/i }).click();
+ await bearerInput(screen).fill('new');
+
+ const expected = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}new` });
+ await expect
+ .element(capturedHeaders(screen))
+ .toHaveAttribute('data-captured-headers', expected);
+ });
+
+ it('toggling Authorization off with no token drops the Bearer row but keeps non-Bearer schemes', async () => {
+ const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
+ const screen = await render(McpServerFormWrapper, { headers: existing });
+
+ await screen.getByRole('switch', { name: /authorization/i }).click();
+
+ await expect.element(capturedHeaders(screen)).toHaveAttribute('data-captured-headers', '');
+ });
+
+ it('toggling Authorization off when no Bearer row is present leaves headers untouched', async () => {
+ const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic czNjcjpwYXNz' });
+ const screen = await render(McpServerFormWrapper, { headers: existing });
+
+ await screen.getByRole('switch', { name: /authorization/i }).click();
+ await screen.getByRole('switch', { name: /authorization/i }).click();
+
+ await expect
+ .element(capturedHeaders(screen))
+ .toHaveAttribute('data-captured-headers', existing);
+ });
+
+ it('clearing the bearer input drops the Authorization row', async () => {
+ const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
+ const screen = await render(McpServerFormWrapper, { headers: existing });
+
+ await bearerInput(screen).fill('');
+
+ await expect.element(capturedHeaders(screen)).toHaveAttribute('data-captured-headers', '');
+ });
+
+ it('does not surface Bearer Authorization in the KV section even when pre-existing', async () => {
+ const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
+ const screen = await render(McpServerFormWrapper, { headers: existing });
+
+ const headerKeyInput = screen.getByPlaceholder('Header name');
+ await expect.element(headerKeyInput).not.toBeInTheDocument();
+ });
+});
diff --git a/tools/ui/tests/unit/headers.test.ts b/tools/ui/tests/unit/headers.test.ts
new file mode 100644
index 0000000000..33619547c6
--- /dev/null
+++ b/tools/ui/tests/unit/headers.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from 'vitest';
+import { parseHeadersToArray, serializeHeaders } from '$lib/utils/headers';
+
+/**
+ * Tests for the header serialization helpers used by the MCP server form
+ * (custom header rows) and the new Authorization/Bearer-token flow.
+ */
+describe('parseHeadersToArray', () => {
+ it('returns an empty array for empty or whitespace-only input', () => {
+ expect(parseHeadersToArray('')).toEqual([]);
+ expect(parseHeadersToArray(' ')).toEqual([]);
+ expect(parseHeadersToArray(undefined as unknown as string)).toEqual([]);
+ });
+
+ it('returns an empty array for invalid JSON input', () => {
+ expect(parseHeadersToArray('{not-json')).toEqual([]);
+ expect(parseHeadersToArray('[]')).toEqual([]);
+ expect(parseHeadersToArray('"plain-string"')).toEqual([]);
+ });
+
+ it('converts an object into ordered key/value pairs', () => {
+ expect(parseHeadersToArray('{"X-Foo":"bar","Authorization":"Bearer abc"}')).toEqual([
+ { key: 'X-Foo', value: 'bar' },
+ { key: 'Authorization', value: 'Bearer abc' }
+ ]);
+ });
+
+ it('stringifies non-string values', () => {
+ expect(parseHeadersToArray('{"count":"42","flag":"true"}')).toEqual([
+ { key: 'count', value: '42' },
+ { key: 'flag', value: 'true' }
+ ]);
+ });
+});
+
+describe('serializeHeaders', () => {
+ it('returns an empty string when there are no valid pairs', () => {
+ expect(serializeHeaders([])).toBe('');
+ expect(serializeHeaders([{ key: '', value: 'value' }])).toBe('');
+ expect(serializeHeaders([{ key: ' ', value: 'value' }])).toBe('');
+ });
+
+ it('returns an empty string when every pair has a blank key', () => {
+ expect(
+ serializeHeaders([
+ { key: '', value: 'drop-me' },
+ { key: ' ', value: 'drop-me-too' },
+ { key: '\t', value: 'tab-key' }
+ ])
+ ).toBe('');
+ });
+
+ it('drops pairs with empty keys but keeps the rest', () => {
+ expect(
+ serializeHeaders([
+ { key: '', value: 'drop-me' },
+ { key: 'X-Keep', value: 'ok' }
+ ])
+ ).toBe('{"X-Keep":"ok"}');
+ });
+
+ it('trims keys before serializing', () => {
+ expect(serializeHeaders([{ key: ' X-Space ', value: 'ok' }])).toBe('{"X-Space":"ok"}');
+ });
+
+ it('preserves the input order of surviving pairs', () => {
+ const serialized = serializeHeaders([
+ { key: 'X-C', value: '3' },
+ { key: 'X-A', value: '1' },
+ { key: 'X-B', value: '2' }
+ ]);
+
+ // Object key order follows insertion order in modern JS engines, so
+ // the serialized JSON writes keys in our input order.
+ expect(JSON.parse(serialized)).toEqual({ 'X-C': '3', 'X-A': '1', 'X-B': '2' });
+ });
+});
+
+describe('parseHeadersToArray / serializeHeaders roundtrip', () => {
+ it('serializes back to an equal header object after a parse', () => {
+ const original = JSON.stringify({
+ 'Content-Type': 'application/json',
+ 'X-Trace-Id': 'abc-123'
+ });
+
+ const roundtrip = serializeHeaders(parseHeadersToArray(original));
+
+ expect(JSON.parse(roundtrip)).toEqual(JSON.parse(original));
+ });
+
+ it('drops rows whose keys are blank after trimming during serialization', () => {
+ const pairs = parseHeadersToArray('{"X-Keep":"ok","":"drop-me"}');
+
+ // parseHeadersToArray keeps raw key strings (the consumer is expected to
+ // filter blanks, not the parser); serialization must strip them.
+ expect(pairs).toEqual([
+ { key: 'X-Keep', value: 'ok' },
+ { key: '', value: 'drop-me' }
+ ]);
+ expect(serializeHeaders(pairs)).toBe('{"X-Keep":"ok"}');
+ });
+
+ it('preserves upstream keys untouched (does not lowercase them)', () => {
+ const upperCased = '{"Authorization":"Bearer xyz"}';
+
+ const parsed = parseHeadersToArray(upperCased);
+
+ expect(parsed).toEqual([{ key: 'Authorization', value: 'Bearer xyz' }]);
+ });
+
+ it('bearer-token write survives a re-parse when paired with regular custom headers', () => {
+ // The McpServerForm bearer UI writes {Authorization: `Bearer `}
+ // into the same headers string as the custom KV section. The round
+ // trip below mirrors the exact shape the form produces so a future
+ // refactor of either code path cannot silently change the on-disk key.
+ const pairs = [
+ { key: 'X-Trace-Id', value: 'abc-123' },
+ { key: 'Authorization', value: 'Bearer super-secret' }
+ ];
+
+ const serialized = serializeHeaders(pairs);
+
+ expect(serialized).toBe('{"X-Trace-Id":"abc-123","Authorization":"Bearer super-secret"}');
+ expect(parseHeadersToArray(serialized)).toEqual(pairs);
+ });
+});
diff --git a/tools/ui/tests/unit/parse-mcp-server-settings.test.ts b/tools/ui/tests/unit/parse-mcp-server-settings.test.ts
new file mode 100644
index 0000000000..956c677d56
--- /dev/null
+++ b/tools/ui/tests/unit/parse-mcp-server-settings.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it, vi } from 'vitest';
+import { parseMcpServerSettings } from '$lib/utils/mcp';
+import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
+
+/**
+ * Tests for the mcpServers settings parser.
+ *
+ * The branch seeds the MCP servers setting with a default value of
+ * `JSON.stringify(RECOMMENDED_MCP_SERVERS)`, so the parser has to be
+ * resilient to anything that may live in the user's localStorage: malformed
+ * JSON, wrong shapes, missing fields, falsy-but-not-zero numbers, and entry
+ * arrays that have been mutated by the user via the settings form.
+ */
+describe('parseMcpServerSettings', () => {
+ it('returns an empty array for falsy or whitespace-only input', () => {
+ expect(parseMcpServerSettings(null)).toEqual([]);
+ expect(parseMcpServerSettings(undefined)).toEqual([]);
+ expect(parseMcpServerSettings('')).toEqual([]);
+ expect(parseMcpServerSettings(' ')).toEqual([]);
+ });
+
+ it('returns an empty array and logs a warning for invalid JSON strings', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ expect(parseMcpServerSettings('{not-json')).toEqual([]);
+ expect(warn).toHaveBeenCalled();
+
+ warn.mockRestore();
+ });
+
+ it('returns an empty array for valid JSON that is not an array', () => {
+ expect(parseMcpServerSettings('"plain-string"')).toEqual([]);
+ expect(parseMcpServerSettings('{"id":"foo"}')).toEqual([]);
+ expect(parseMcpServerSettings('42')).toEqual([]);
+ expect(parseMcpServerSettings('null')).toEqual([]);
+ });
+
+ it('drops entries with no parseable id and substitutes a stable fallback', () => {
+ const parsed = parseMcpServerSettings(
+ JSON.stringify([{ url: 'https://a.test', enabled: true }, { url: 'https://b.test' }])
+ );
+
+ expect(parsed).toHaveLength(2);
+ expect(parsed[0]?.id).toBe(`${MCP_SERVER_ID_PREFIX}-1`);
+ expect(parsed[1]?.id).toBe(`${MCP_SERVER_ID_PREFIX}-2`);
+ });
+
+ it('reuses the first id when it is present and falls back only for missing ones', () => {
+ const parsed = parseMcpServerSettings(
+ JSON.stringify([
+ { id: 'custom-1', url: 'https://a.test' },
+ { url: 'https://b.test' },
+ { id: 'custom-3', url: 'https://c.test' }
+ ])
+ );
+
+ expect(parsed[0]?.id).toBe('custom-1');
+ expect(parsed[1]?.id).toBe(`${MCP_SERVER_ID_PREFIX}-2`);
+ expect(parsed[2]?.id).toBe('custom-3');
+ });
+
+ it('falls back to the configured default requestTimeoutSeconds only for nullish values', () => {
+ const fallback = DEFAULT_MCP_CONFIG.requestTimeoutSeconds;
+
+ const parsed = parseMcpServerSettings(
+ JSON.stringify([
+ { id: 'a', url: 'https://a.test' },
+ { id: 'b', url: 'https://b.test', requestTimeoutSeconds: undefined },
+ { id: 'c', url: 'https://c.test', requestTimeoutSeconds: 0 },
+ { id: 'd', url: 'https://d.test', requestTimeoutSeconds: 45 }
+ ])
+ );
+
+ // The parser uses ?? for timeout fallback, which only triggers on
+ // null/undefined. Explicit 0 is preserved at face value.
+ expect(parsed[0]?.requestTimeoutSeconds).toBe(fallback);
+ expect(parsed[1]?.requestTimeoutSeconds).toBe(fallback);
+ expect(parsed[2]?.requestTimeoutSeconds).toBe(0);
+ expect(parsed[3]?.requestTimeoutSeconds).toBe(45);
+ });
+
+ it('treats whitespace-only headers strings as undefined', () => {
+ const parsed = parseMcpServerSettings(
+ JSON.stringify([
+ { id: 'a', url: 'https://a.test', headers: ' ' },
+ { id: 'b', url: 'https://b.test', headers: '{"X-Foo":"bar"}' }
+ ])
+ );
+
+ // The parser trims headers and coerces empty/whitespace to undefined.
+ expect(parsed[0]?.headers).toBeUndefined();
+ expect(parsed[1]?.headers).toBe('{"X-Foo":"bar"}');
+ });
+
+ it('defaults coercion for booleans (undefined -> false, true -> true)', () => {
+ const parsed = parseMcpServerSettings(
+ JSON.stringify([
+ { id: 'a', url: 'https://a.test' },
+ { id: 'b', url: 'https://b.test', enabled: true },
+ { id: 'c', url: 'https://c.test', enabled: false },
+ { id: 'd', url: 'https://d.test', useProxy: true }
+ ])
+ );
+
+ expect(parsed[0]?.enabled).toBe(false);
+ expect(parsed[1]?.enabled).toBe(true);
+ expect(parsed[2]?.enabled).toBe(false);
+ expect(parsed[0]?.useProxy).toBe(false);
+ expect(parsed[3]?.useProxy).toBe(true);
+ });
+
+ it('preserves input order when mapping entries', () => {
+ const source = [
+ { id: 'gamma', url: 'https://c.test' },
+ { id: 'alpha', url: 'https://a.test' },
+ { id: 'beta', url: 'https://b.test' }
+ ];
+
+ const parsed = parseMcpServerSettings(JSON.stringify(source));
+
+ expect(parsed.map((entry) => entry.id)).toEqual(['gamma', 'alpha', 'beta']);
+ });
+
+ it('passes non-string raw input through the JSON-equality path', () => {
+ const parsed = parseMcpServerSettings([
+ { id: 'a', url: 'https://a.test' },
+ { id: 'b', url: 'https://b.test', enabled: true }
+ ]);
+
+ expect(parsed).toHaveLength(2);
+ expect(parsed[0]?.id).toBe('a');
+ expect(parsed[1]?.enabled).toBe(true);
+ });
+
+ it('coerces non-string url values to an empty string rather than throwing', () => {
+ const parsed = parseMcpServerSettings(
+ JSON.stringify([{ id: 'a', url: 42 }, { id: 'b' }, { id: 'c', url: 'https://c.test' }])
+ );
+
+ expect(parsed[0]?.url).toBe('');
+ expect(parsed[1]?.url).toBe('');
+ expect(parsed[2]?.url).toBe('https://c.test');
+ });
+});
diff --git a/tools/ui/tests/unit/recommended-mcp-servers.test.ts b/tools/ui/tests/unit/recommended-mcp-servers.test.ts
new file mode 100644
index 0000000000..3f6fd8f116
--- /dev/null
+++ b/tools/ui/tests/unit/recommended-mcp-servers.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest';
+import {
+ RECOMMENDED_MCP_SERVER_IDS,
+ RECOMMENDED_MCP_SERVERS
+} from '$lib/constants/recommended-mcp-servers';
+import { parseMcpServerSettings } from '$lib/utils/mcp';
+import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
+
+/**
+ * Tests for the predefined recommended MCP servers.
+ *
+ * These are surfaced to first-time users via
+ * DialogMcpServerRecommendations and used as the default value of the MCP
+ * servers setting, so a regression that breaks the round-trip through the
+ * settings parser would silently break onboarding for new users.
+ */
+describe('RECOMMENDED_MCP_SERVERS', () => {
+ it('lists at least one entry and uses stable, unique ids', () => {
+ expect(RECOMMENDED_MCP_SERVERS.length).toBeGreaterThan(0);
+
+ const ids = RECOMMENDED_MCP_SERVERS.map((server) => server.id);
+ expect(new Set(ids).size).toBe(ids.length);
+
+ for (const id of ids) {
+ expect(id).toMatch(/^[a-z0-9-]+$/);
+ expect(id.toLowerCase()).not.toContain(MCP_SERVER_ID_PREFIX.toLowerCase());
+ }
+ });
+
+ it('requires a name, description and url for every entry', () => {
+ for (const server of RECOMMENDED_MCP_SERVERS) {
+ expect(server.name?.trim().length ?? 0).toBeGreaterThan(0);
+ expect(server.description.trim().length).toBeGreaterThan(0);
+ expect(server.url.trim().length).toBeGreaterThan(0);
+ expect(() => new URL(server.url)).not.toThrow();
+ }
+ });
+});
+
+describe('RECOMMENDED_MCP_SERVER_IDS', () => {
+ it('matches the ids declared in RECOMMENDED_MCP_SERVERS', () => {
+ expect(RECOMMENDED_MCP_SERVER_IDS.size).toBe(RECOMMENDED_MCP_SERVERS.length);
+
+ for (const server of RECOMMENDED_MCP_SERVERS) {
+ expect(RECOMMENDED_MCP_SERVER_IDS.has(server.id)).toBe(true);
+ }
+ });
+});
+
+describe('recommended-mcp-servers default value', () => {
+ it('round-trips cleanly through parseMcpServerSettings', () => {
+ const serialized = JSON.stringify(RECOMMENDED_MCP_SERVERS);
+ const parsed = parseMcpServerSettings(serialized);
+
+ expect(parsed).toHaveLength(RECOMMENDED_MCP_SERVERS.length);
+
+ for (let index = 0; index < RECOMMENDED_MCP_SERVERS.length; index++) {
+ const source = RECOMMENDED_MCP_SERVERS[index];
+ const entry = parsed[index];
+
+ expect(entry).toBeDefined();
+ expect(entry?.id).toBe(source.id);
+ expect(entry?.url).toBe(source.url);
+ expect(entry?.enabled).toBe(source.enabled);
+ expect(entry?.requestTimeoutSeconds).toBe(source.requestTimeoutSeconds);
+ expect(entry?.name).toBe(source.name);
+
+ // Headers and useProxy are not set on recommended servers; the
+ // parser must fall back to the inactive defaults rather than
+ // surfacing undefined-boundary states.
+ expect(entry?.headers).toBeUndefined();
+ expect(entry?.useProxy).toBe(false);
+ }
+ });
+
+ it('uses the global default timeout when one is not specified on an entry', () => {
+ const sourceOnlyRequired = {
+ id: 'roundtrip-only',
+ name: 'Only required fields',
+ url: 'https://example.test/mcp',
+ description: 'Smoke entry for parser roundtrip with default timeout.',
+ enabled: true
+ };
+
+ const parsed = parseMcpServerSettings(JSON.stringify([sourceOnlyRequired]));
+ const entry = parsed[0];
+
+ expect(entry?.requestTimeoutSeconds).toBe(DEFAULT_MCP_CONFIG.requestTimeoutSeconds);
+ });
+});