ui: fix mcp panel for toggle + timeout + proxy + ON/OFF state (#25631)
* ui: fix MCP panel regressions after settings rework Restore the llama-server proxy switch in the Add New Server dialog. The dialog never passed useProxy/onUseProxyChange to McpServerForm, which only renders the proxy switch when the handler is provided. The flag is now wired, persisted on addServer, and reset on close. Bound the MCP connection handshake with the configured timeout. handshakeTimeoutMs was set in the server config but never consumed. The SDK timeout only covers the initialize request, not transport.start(), which can hang forever on an unreachable host. The whole handshake now races against the timeout and closes the transport on expiry so the underlying fetch or socket is aborted. Keep disabled MCP servers visible in management and chat-add UIs. Collapsing mcpDefaultServerOverrides into mcpServers[i].enabled turned the visibleMcpServers enabled filter into a visibility trap: toggling a server off outside a conversation hid it from every surface with no way to re-enable it. The filter is dropped, tools derived from health checks still skip disabled servers, and the settings page and server card render the real card instead of a skeleton for disabled servers that never receive a startup health check. * ui: clarify MCP server list semantics and add regression test Remove the visibleMcpServers getter, a filterless alias of getServers whose name invites the next refactor to put a filter back. Call sites read getServers directly, the duplicate list in the chat submenu is merged, and the misleading local variable in the sheet is renamed. A parser unit test pins the invariant: enabled is an on/off state, never a visibility filter, so disabled servers stay listed and toggleable. * ui: apply the MCP request timeout setting live to all servers The per-server requestTimeoutSeconds field was never editable in any UI and froze the global setting at server creation time, so changing the timeout in Settings was a no-op for existing servers. The field is removed from the data model and parsers, the timeout is read live from the global setting wherever a request config is built, and the misleading "Can be overridden per server" help text is dropped. A parser unit test guards against reintroducing the stored field. * ui: move the MCP request timeout into the Agentic settings section The MCP section held a single setting. The timeout is a global tool execution parameter like the other Agentic entries, so it moves there and the section is removed. Same settings key, no migration needed. * ui: remove the dead tool preview lines setting The agenticMaxToolPreviewLines setting was read into AgenticConfig and consumed by nothing: the agentic loop only uses enabled and maxTurns. Its help text described a previous architecture where only truncated previews and the final response survived the loop; tool results and intermediate turns now persist as full DB messages, so the setting had no effect at any value. Stale keys in localStorage or a server ui-config are ignored. * ui: resolve absent MCP per-chat overrides to the server enabled flag New conversations started with every MCP server off: the settings rework stopped seeding a per-conversation override list, assuming the enabled check would fall back to mcpServers[i].enabled, but it fell back to false, and the send path passed the raw stored list with no fallback at all. The per-conversation list is now sparse by contract, holding only explicit toggles, and every access point resolves a missing entry to the server's own enabled flag: the toggle display, the resolved list handed to the agentic flow, and the enabled check itself.
This commit is contained in:
+4
-4
@@ -17,10 +17,10 @@
|
|||||||
let { onMcpSettingsClick }: Props = $props();
|
let { onMcpSettingsClick }: Props = $props();
|
||||||
|
|
||||||
let mcpSearchQuery = $state('');
|
let mcpSearchQuery = $state('');
|
||||||
let allMcpServers = $derived(mcpStore.getServers());
|
// Every configured server is listed; `enabled` is an on/off state,
|
||||||
let mcpServers = $derived(mcpStore.visibleMcpServers);
|
// not a visibility filter, so a disabled server stays toggleable.
|
||||||
|
let mcpServers = $derived(mcpStore.getServers());
|
||||||
let hasMcpServers = $derived(mcpServers.length > 0);
|
let hasMcpServers = $derived(mcpServers.length > 0);
|
||||||
// let hasAnyMcpServers = $derived(allMcpServers.length > 0);
|
|
||||||
let filteredMcpServers = $derived.by(() => {
|
let filteredMcpServers = $derived.by(() => {
|
||||||
const query = mcpSearchQuery.toLowerCase().trim();
|
const query = mcpSearchQuery.toLowerCase().trim();
|
||||||
if (!query) return mcpServers;
|
if (!query) return mcpServers;
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
function handleMcpSubMenuOpen(open: boolean) {
|
function handleMcpSubMenuOpen(open: boolean) {
|
||||||
if (open) {
|
if (open) {
|
||||||
mcpSearchQuery = '';
|
mcpSearchQuery = '';
|
||||||
mcpStore.runHealthChecksForServers(allMcpServers);
|
mcpStore.runHealthChecksForServers(mcpServers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -84,7 +84,7 @@
|
|||||||
const sheetItemRowClass =
|
const sheetItemRowClass =
|
||||||
'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent';
|
'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent';
|
||||||
|
|
||||||
let visibleMcpServers = $derived(mcpStore.visibleMcpServers);
|
let mcpServers = $derived(mcpStore.getServers());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 {className}">
|
<div class="flex items-center gap-1 {className}">
|
||||||
@@ -218,13 +218,13 @@
|
|||||||
<span class="flex-1">MCP Servers</span>
|
<span class="flex-1">MCP Servers</span>
|
||||||
|
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground">
|
||||||
{visibleMcpServers.length} server{visibleMcpServers.length !== 1 ? 's' : ''}
|
{mcpServers.length} server{mcpServers.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
|
|
||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
<div class="flex flex-col gap-0.5 pl-4">
|
<div class="flex flex-col gap-0.5 pl-4">
|
||||||
{#each visibleMcpServers as server (server.id)}
|
{#each mcpServers as server (server.id)}
|
||||||
{@const healthState = mcpStore.getHealthCheckState(server.id)}
|
{@const healthState = mcpStore.getHealthCheckState(server.id)}
|
||||||
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
|
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
|
||||||
{@const displayName = mcpStore.getServerLabel(server)}
|
{@const displayName = mcpStore.getServerLabel(server)}
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if visibleMcpServers.length === 0}
|
{#if mcpServers.length === 0}
|
||||||
<div class="px-3 py-2 text-center text-sm text-muted-foreground">
|
<div class="px-3 py-2 text-center text-sm text-muted-foreground">
|
||||||
No MCP servers configured
|
No MCP servers configured
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
let newServerUrl = $state('');
|
let newServerUrl = $state('');
|
||||||
let newServerHeaders = $state('');
|
let newServerHeaders = $state('');
|
||||||
|
let newServerUseProxy = $state(false);
|
||||||
let newServerUrlError = $derived.by(() => {
|
let newServerUrlError = $derived.by(() => {
|
||||||
if (!newServerUrl.trim()) return 'URL is required';
|
if (!newServerUrl.trim()) return 'URL is required';
|
||||||
try {
|
try {
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
newServerUrl = '';
|
newServerUrl = '';
|
||||||
newServerHeaders = '';
|
newServerHeaders = '';
|
||||||
|
newServerUseProxy = false;
|
||||||
}
|
}
|
||||||
open = value;
|
open = value;
|
||||||
onOpenChange?.(value);
|
onOpenChange?.(value);
|
||||||
@@ -49,7 +51,8 @@
|
|||||||
id: newServerId,
|
id: newServerId,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url: newServerUrl.trim(),
|
url: newServerUrl.trim(),
|
||||||
headers: newServerHeaders.trim() || undefined
|
headers: newServerHeaders.trim() || undefined,
|
||||||
|
useProxy: newServerUseProxy
|
||||||
});
|
});
|
||||||
|
|
||||||
conversationsStore.setMcpServerOverride(newServerId, true);
|
conversationsStore.setMcpServerOverride(newServerId, true);
|
||||||
@@ -74,8 +77,10 @@
|
|||||||
<McpServerForm
|
<McpServerForm
|
||||||
url={newServerUrl}
|
url={newServerUrl}
|
||||||
headers={newServerHeaders}
|
headers={newServerHeaders}
|
||||||
|
useProxy={newServerUseProxy}
|
||||||
onUrlChange={(v) => (newServerUrl = v)}
|
onUrlChange={(v) => (newServerUrl = v)}
|
||||||
onHeadersChange={(v) => (newServerHeaders = v)}
|
onHeadersChange={(v) => (newServerHeaders = v)}
|
||||||
|
onUseProxyChange={(v) => (newServerUseProxy = v)}
|
||||||
urlError={newServerUrl ? newServerUrlError : null}
|
urlError={newServerUrl ? newServerUrlError : null}
|
||||||
id="new-server"
|
id="new-server"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -32,7 +32,9 @@
|
|||||||
let isHealthChecking = $derived(healthState.status === HealthCheckStatus.CONNECTING);
|
let isHealthChecking = $derived(healthState.status === HealthCheckStatus.CONNECTING);
|
||||||
let isConnected = $derived(healthState.status === HealthCheckStatus.SUCCESS);
|
let isConnected = $derived(healthState.status === HealthCheckStatus.SUCCESS);
|
||||||
let isError = $derived(healthState.status === HealthCheckStatus.ERROR);
|
let isError = $derived(healthState.status === HealthCheckStatus.ERROR);
|
||||||
let showSkeleton = $derived(isIdle || isHealthChecking);
|
// Disabled servers stay IDLE (no startup health check), so the body
|
||||||
|
// skeleton only applies while a check is running or expected to run.
|
||||||
|
let showSkeleton = $derived(isHealthChecking || (isIdle && server.enabled));
|
||||||
let errorMessage = $derived(
|
let errorMessage = $derived(
|
||||||
healthState.status === HealthCheckStatus.ERROR ? healthState.message : undefined
|
healthState.status === HealthCheckStatus.ERROR ? healthState.message : undefined
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,9 @@
|
|||||||
|
|
||||||
let { class: className }: Props = $props();
|
let { class: className }: Props = $props();
|
||||||
|
|
||||||
let servers = $derived(mcpStore.visibleMcpServers);
|
// Every configured server is listed; `enabled` is an on/off state,
|
||||||
|
// not a visibility filter, so a disabled server stays toggleable.
|
||||||
|
let servers = $derived(mcpStore.getServers());
|
||||||
|
|
||||||
let isAddingServer = $state(false);
|
let isAddingServer = $state(false);
|
||||||
|
|
||||||
@@ -58,9 +60,14 @@
|
|||||||
// Each card decides for itself whether to render based on its own
|
// Each card decides for itself whether to render based on its own
|
||||||
// health-check state, so adding a server only flashes the new card
|
// health-check state, so adding a server only flashes the new card
|
||||||
// (not every other already-loaded card) until its health check resolves.
|
// (not every other already-loaded card) until its health check resolves.
|
||||||
function isServerPending(serverId: string): boolean {
|
// Disabled servers never receive a startup health check, so IDLE only
|
||||||
|
// counts as pending when the server is enabled; otherwise the real card
|
||||||
|
// renders and keeps the enable toggle reachable.
|
||||||
|
function isServerPending(serverId: string, enabled: boolean): boolean {
|
||||||
const status = mcpStore.getHealthCheckState(serverId).status;
|
const status = mcpStore.getHealthCheckState(serverId).status;
|
||||||
return status === HealthCheckStatus.IDLE || status === HealthCheckStatus.CONNECTING;
|
return (
|
||||||
|
status === HealthCheckStatus.CONNECTING || (status === HealthCheckStatus.IDLE && enabled)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -109,7 +116,7 @@
|
|||||||
style="grid-template-columns: repeat(auto-fill, minmax(min(32rem, calc(100dvw - 2rem)), 1fr));"
|
style="grid-template-columns: repeat(auto-fill, minmax(min(32rem, calc(100dvw - 2rem)), 1fr));"
|
||||||
>
|
>
|
||||||
{#each servers as server (server.id)}
|
{#each servers as server (server.id)}
|
||||||
{#if isServerPending(server.id)}
|
{#if isServerPending(server.id, server.enabled)}
|
||||||
<McpServerCardSkeleton />
|
<McpServerCardSkeleton />
|
||||||
{:else}
|
{:else}
|
||||||
<McpServerCard
|
<McpServerCard
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ export const NEWLINE_SEPARATOR = '\n';
|
|||||||
|
|
||||||
export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = {
|
export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
maxTurns: 100,
|
maxTurns: 100
|
||||||
maxToolPreviewLines: 25
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const REASONING_TAGS = {
|
export const REASONING_TAGS = {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export const SETTINGS_SECTION_SLUGS = {
|
|||||||
PENALTIES: 'penalties',
|
PENALTIES: 'penalties',
|
||||||
AGENTIC: 'agentic',
|
AGENTIC: 'agentic',
|
||||||
DEVELOPER: 'developer',
|
DEVELOPER: 'developer',
|
||||||
MCP: 'mcp',
|
|
||||||
TOOLS: 'tools',
|
TOOLS: 'tools',
|
||||||
IMPORT_EXPORT: 'import-export'
|
IMPORT_EXPORT: 'import-export'
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export const SETTINGS_KEYS = {
|
|||||||
MCP_SERVERS: 'mcpServers',
|
MCP_SERVERS: 'mcpServers',
|
||||||
MCP_REQUEST_TIMEOUT_SECONDS: 'mcpRequestTimeoutSeconds',
|
MCP_REQUEST_TIMEOUT_SECONDS: 'mcpRequestTimeoutSeconds',
|
||||||
AGENTIC_MAX_TURNS: 'agenticMaxTurns',
|
AGENTIC_MAX_TURNS: 'agenticMaxTurns',
|
||||||
AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines',
|
|
||||||
SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress',
|
SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress',
|
||||||
// Performance
|
// Performance
|
||||||
PRE_ENCODE_CONVERSATION: 'preEncodeConversation',
|
PRE_ENCODE_CONVERSATION: 'preEncodeConversation',
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import type {
|
|||||||
SettingsSection
|
SettingsSection
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
import { CLI_FLAGS, DEFAULT_MCP_CONFIG } from '$lib/constants';
|
import { CLI_FLAGS, DEFAULT_MCP_CONFIG } from '$lib/constants';
|
||||||
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
|
|
||||||
import { SETTINGS_KEYS } from './settings-keys';
|
import { SETTINGS_KEYS } from './settings-keys';
|
||||||
import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
|
import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
|
||||||
import { TITLE_GENERATION } from './title-generation';
|
import { TITLE_GENERATION } from './title-generation';
|
||||||
@@ -36,7 +35,6 @@ export const SETTINGS_SECTION_TITLES = {
|
|||||||
PENALTIES: 'Penalties',
|
PENALTIES: 'Penalties',
|
||||||
AGENTIC: 'Agentic',
|
AGENTIC: 'Agentic',
|
||||||
TOOLS: 'Tools',
|
TOOLS: 'Tools',
|
||||||
MCP: 'MCP',
|
|
||||||
IMPORT_EXPORT: 'Import/Export',
|
IMPORT_EXPORT: 'Import/Export',
|
||||||
DEVELOPER: 'Developer'
|
DEVELOPER: 'Developer'
|
||||||
} as const;
|
} as const;
|
||||||
@@ -635,15 +633,15 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
|
key: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
|
||||||
label: 'Max lines per tool preview',
|
label: 'MCP request timeout (seconds)',
|
||||||
help: 'Number of lines shown in tool output previews (last N lines). Only these previews and the final LLM response persist after the agentic loop completes.',
|
help: 'Timeout for individual MCP tool calls.',
|
||||||
defaultValue: 25,
|
defaultValue: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||||
type: SettingsFieldType.INPUT,
|
type: SettingsFieldType.INPUT,
|
||||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||||
isPositiveInteger: true,
|
isPositiveInteger: true,
|
||||||
sync: {
|
sync: {
|
||||||
serverKey: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
|
serverKey: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
|
||||||
paramType: SyncableParameterType.NUMBER
|
paramType: SyncableParameterType.NUMBER
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -735,26 +733,6 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
[SETTINGS_SECTION_SLUGS.MCP]: {
|
|
||||||
title: SETTINGS_SECTION_TITLES.MCP,
|
|
||||||
slug: SETTINGS_SECTION_SLUGS.MCP,
|
|
||||||
icon: McpLogo,
|
|
||||||
settings: [
|
|
||||||
{
|
|
||||||
key: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
|
|
||||||
label: 'Request timeout (seconds)',
|
|
||||||
help: 'Default timeout for individual MCP tool calls. Can be overridden per server.',
|
|
||||||
defaultValue: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
|
||||||
type: SettingsFieldType.INPUT,
|
|
||||||
section: SETTINGS_SECTION_SLUGS.MCP,
|
|
||||||
isPositiveInteger: true,
|
|
||||||
sync: {
|
|
||||||
serverKey: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
|
|
||||||
paramType: SyncableParameterType.NUMBER
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -692,8 +692,31 @@ export class MCPService {
|
|||||||
this.createLog(MCPConnectionPhase.INITIALIZING, 'Sending initialize request...')
|
this.createLog(MCPConnectionPhase.INITIALIZING, 'Sending initialize request...')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The SDK timeout only covers the initialize request, not transport.start(),
|
||||||
|
// which can hang forever on an unreachable host (SSE endpoint wait, WebSocket
|
||||||
|
// handshake, proxied fetch). This race bounds the whole handshake and closes
|
||||||
|
// the transport on expiry so the underlying fetch or socket is aborted.
|
||||||
|
const handshakeTimeoutMs =
|
||||||
|
serverConfig.handshakeTimeoutMs ?? DEFAULT_MCP_CONFIG.connectionTimeoutMs;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect(transport);
|
let handshakeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const handshakeDeadline = new Promise<never>((_, reject) => {
|
||||||
|
handshakeTimer = setTimeout(() => {
|
||||||
|
void transport.close().catch(() => {});
|
||||||
|
reject(new Error(`Connection timed out after ${Math.round(handshakeTimeoutMs / 1000)}s`));
|
||||||
|
}, handshakeTimeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
client.connect(transport, { timeout: handshakeTimeoutMs }),
|
||||||
|
handshakeDeadline
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(handshakeTimer);
|
||||||
|
}
|
||||||
|
|
||||||
// Transport diagnostics are only for the initial handshake, not long-lived traffic.
|
// Transport diagnostics are only for the initial handshake, not long-lived traffic.
|
||||||
stopPhaseLogging();
|
stopPhaseLogging();
|
||||||
client.onerror = runtimeErrorHandler;
|
client.onerror = runtimeErrorHandler;
|
||||||
|
|||||||
@@ -280,16 +280,13 @@ class AgenticStore {
|
|||||||
|
|
||||||
getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig {
|
getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig {
|
||||||
const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
|
const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
|
||||||
const maxToolPreviewLines =
|
|
||||||
Number(settings.agenticMaxToolPreviewLines) || DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines;
|
|
||||||
const hasTools =
|
const hasTools =
|
||||||
mcpStore.hasEnabledServers(perChatOverrides) ||
|
mcpStore.hasEnabledServers(perChatOverrides) ||
|
||||||
toolsStore.builtinTools.length > 0 ||
|
toolsStore.builtinTools.length > 0 ||
|
||||||
toolsStore.customTools.length > 0;
|
toolsStore.customTools.length > 0;
|
||||||
return {
|
return {
|
||||||
enabled: hasTools && DEFAULT_AGENTIC_CONFIG.enabled,
|
enabled: hasTools && DEFAULT_AGENTIC_CONFIG.enabled,
|
||||||
maxTurns,
|
maxTurns
|
||||||
maxToolPreviewLines
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1334,7 +1334,7 @@ class ChatStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides;
|
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||||
|
|
||||||
{
|
{
|
||||||
const agenticResult = await agenticStore.runAgenticFlow({
|
const agenticResult = await agenticStore.runAgenticFlow({
|
||||||
|
|||||||
@@ -243,9 +243,9 @@ class ConversationsStore {
|
|||||||
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
||||||
const conversation = await DatabaseService.createConversation(conversationName);
|
const conversation = await DatabaseService.createConversation(conversationName);
|
||||||
|
|
||||||
// New conversations inherit per-server enabled defaults directly from
|
// No MCP override list is seeded: getAllMcpServerOverrides resolves
|
||||||
// `mcpServers[i].enabled` (see #checkServerEnabled). No per-conversation
|
// servers without a per-conversation override to `mcpServers[i].enabled`,
|
||||||
// override list needs to be seeded.
|
// and only explicit toggles are stored on the conversation.
|
||||||
|
|
||||||
// Inherit global thinking/reasoning defaults into the new conversation
|
// Inherit global thinking/reasoning defaults into the new conversation
|
||||||
const thinkingEnabled = this.getThinkingEnabled();
|
const thinkingEnabled = this.getThinkingEnabled();
|
||||||
@@ -601,48 +601,41 @@ class ConversationsStore {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
/**
|
* Resolve the default enabled value for a server: its own `enabled`
|
||||||
* Resolve the per-server enabled value when no active conversation exists.
|
* flag in `mcpServers`, so the global on/off state lives in one place.
|
||||||
* The default for new chats is the server's own `enabled` flag in `mcpServers`.
|
|
||||||
*/
|
*/
|
||||||
#getDefaultOverrideForNoConversation(serverId: string): McpServerOverride | undefined {
|
#getDefaultOverride(serverId: string): McpServerOverride | undefined {
|
||||||
const server = mcpStore.getServers().find((s) => s.id === serverId);
|
const server = mcpStore.getServers().find((s) => s.id === serverId);
|
||||||
if (!server) return undefined;
|
if (!server) return undefined;
|
||||||
return { serverId, enabled: server.enabled };
|
return { serverId, enabled: server.enabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default overrides for new chats are derived from `mcpServers[i].enabled`,
|
* Gets the effective MCP server override for a specific server.
|
||||||
* so the global on/off state lives in one place.
|
* A per-conversation override wins when present; a server without one
|
||||||
*/
|
* resolves to its `mcpServers[i].enabled` default.
|
||||||
#getAllDefaultOverridesForNoConversation(): McpServerOverride[] {
|
|
||||||
return mcpStore.getServers().map((s) => ({ serverId: s.id, enabled: s.enabled }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets MCP server override for a specific server in the active conversation.
|
|
||||||
* Falls back to `mcpServers[i].enabled` if no active conversation exists.
|
|
||||||
* @param serverId - The server ID to check
|
* @param serverId - The server ID to check
|
||||||
* @returns The override if set, undefined if no matching server
|
* @returns The effective override, undefined if no matching server
|
||||||
*/
|
*/
|
||||||
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
||||||
if (this.activeConversation) {
|
const override = this.activeConversation?.mcpServerOverrides?.find(
|
||||||
return this.activeConversation.mcpServerOverrides?.find(
|
(o: McpServerOverride) => o.serverId === serverId
|
||||||
(o: McpServerOverride) => o.serverId === serverId
|
);
|
||||||
);
|
if (override) return override;
|
||||||
}
|
return this.#getDefaultOverride(serverId);
|
||||||
return this.#getDefaultOverrideForNoConversation(serverId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all MCP server overrides for the current conversation.
|
* Gets the effective override list for the current conversation:
|
||||||
* When no active conversation, derives from `mcpServers[i].enabled`.
|
* one entry per configured server, resolved per server. The stored
|
||||||
|
* per-conversation list is sparse and only holds explicit toggles.
|
||||||
*/
|
*/
|
||||||
getAllMcpServerOverrides(): McpServerOverride[] {
|
getAllMcpServerOverrides(): McpServerOverride[] {
|
||||||
if (this.activeConversation?.mcpServerOverrides) {
|
const overrides = this.activeConversation?.mcpServerOverrides;
|
||||||
return this.activeConversation.mcpServerOverrides;
|
return mcpStore.getServers().map((s) => {
|
||||||
}
|
const override = overrides?.find((o: McpServerOverride) => o.serverId === s.id);
|
||||||
return this.#getAllDefaultOverridesForNoConversation();
|
return { serverId: s.id, enabled: override?.enabled ?? s.enabled };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -148,15 +148,22 @@ class MCPStore {
|
|||||||
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
||||||
url,
|
url,
|
||||||
name: (entry as { name?: string })?.name,
|
name: (entry as { name?: string })?.name,
|
||||||
requestTimeoutSeconds:
|
|
||||||
(entry as { requestTimeoutSeconds?: number })?.requestTimeoutSeconds ??
|
|
||||||
DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
|
||||||
headers: headers || undefined,
|
headers: headers || undefined,
|
||||||
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
|
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
|
||||||
} satisfies MCPServerSettingsEntry;
|
} satisfies MCPServerSettingsEntry;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request timeout in milliseconds, read live from the global setting
|
||||||
|
* so a change in Settings applies to every server immediately.
|
||||||
|
*/
|
||||||
|
#requestTimeoutMs(): number {
|
||||||
|
const seconds =
|
||||||
|
Number(config().mcpRequestTimeoutSeconds) || DEFAULT_MCP_CONFIG.requestTimeoutSeconds;
|
||||||
|
return Math.round(seconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds server configuration from a settings entry.
|
* Builds server configuration from a settings entry.
|
||||||
*/
|
*/
|
||||||
@@ -183,7 +190,7 @@ class MCPStore {
|
|||||||
url: entry.url,
|
url: entry.url,
|
||||||
transport: detectMcpTransportFromUrl(entry.url),
|
transport: detectMcpTransportFromUrl(entry.url),
|
||||||
handshakeTimeoutMs: connectionTimeoutMs,
|
handshakeTimeoutMs: connectionTimeoutMs,
|
||||||
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
|
requestTimeoutMs: this.#requestTimeoutMs(),
|
||||||
headers,
|
headers,
|
||||||
useProxy: entry.useProxy
|
useProxy: entry.useProxy
|
||||||
};
|
};
|
||||||
@@ -191,15 +198,15 @@ class MCPStore {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a server is enabled for a given chat.
|
* Checks if a server is enabled for a given chat.
|
||||||
* Only per-chat overrides (persisted in localStorage for new chats,
|
* A per-chat override wins when present; a server without one resolves
|
||||||
* or in IndexedDB for existing conversations) control enabled state.
|
* to its own `enabled` flag in `mcpServers`.
|
||||||
*/
|
*/
|
||||||
#checkServerEnabled(
|
#checkServerEnabled(
|
||||||
server: MCPServerSettingsEntry,
|
server: MCPServerSettingsEntry,
|
||||||
perChatOverrides?: McpServerOverride[]
|
perChatOverrides?: McpServerOverride[]
|
||||||
): boolean {
|
): boolean {
|
||||||
const override = perChatOverrides?.find((o) => o.serverId === server.id);
|
const override = perChatOverrides?.find((o) => o.serverId === server.id);
|
||||||
return override?.enabled ?? false;
|
return override?.enabled ?? server.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,7 +237,7 @@ class MCPStore {
|
|||||||
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
|
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
|
||||||
capabilities: DEFAULT_MCP_CONFIG.capabilities,
|
capabilities: DEFAULT_MCP_CONFIG.capabilities,
|
||||||
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
|
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
|
||||||
requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000),
|
requestTimeoutMs: this.#requestTimeoutMs(),
|
||||||
servers
|
servers
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -500,7 +507,7 @@ class MCPStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addServer(
|
addServer(
|
||||||
serverData: Omit<MCPServerSettingsEntry, 'id' | 'requestTimeoutSeconds'> & { id?: string }
|
serverData: Omit<MCPServerSettingsEntry, 'id'> & { id?: string }
|
||||||
): MCPServerSettingsEntry {
|
): MCPServerSettingsEntry {
|
||||||
const servers = this.getServers();
|
const servers = this.getServers();
|
||||||
const newServer: MCPServerSettingsEntry = {
|
const newServer: MCPServerSettingsEntry = {
|
||||||
@@ -509,8 +516,6 @@ class MCPStore {
|
|||||||
url: serverData.url.trim(),
|
url: serverData.url.trim(),
|
||||||
name: serverData.name,
|
name: serverData.name,
|
||||||
headers: serverData.headers?.trim() || undefined,
|
headers: serverData.headers?.trim() || undefined,
|
||||||
requestTimeoutSeconds:
|
|
||||||
Number(config().mcpRequestTimeoutSeconds) || DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
|
||||||
useProxy: serverData.useProxy
|
useProxy: serverData.useProxy
|
||||||
};
|
};
|
||||||
settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, JSON.stringify([...servers, newServer]));
|
settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, JSON.stringify([...servers, newServer]));
|
||||||
@@ -551,14 +556,6 @@ class MCPStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* MCP servers selectable in chat-add UIs and the settings page,
|
|
||||||
* in the order they were added to the config.
|
|
||||||
*/
|
|
||||||
get visibleMcpServers(): MCPServerSettingsEntry[] {
|
|
||||||
return this.getServers().filter((server) => server.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
|
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
|
||||||
if (!browser) {
|
if (!browser) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1226,7 +1223,6 @@ class MCPStore {
|
|||||||
id: string;
|
id: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
requestTimeoutSeconds: number;
|
|
||||||
headers?: string;
|
headers?: string;
|
||||||
}[],
|
}[],
|
||||||
skipIfChecked = true,
|
skipIfChecked = true,
|
||||||
@@ -1317,7 +1313,7 @@ class MCPStore {
|
|||||||
logs: []
|
logs: []
|
||||||
});
|
});
|
||||||
|
|
||||||
const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000);
|
const timeoutMs = this.#requestTimeoutMs();
|
||||||
const headers = this.parseHeaders(server.headers);
|
const headers = this.parseHeaders(server.headers);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -412,7 +412,8 @@ class ToolsStore {
|
|||||||
tools: { name: string; description?: string }[];
|
tools: { name: string; description?: string }[];
|
||||||
}[] {
|
}[] {
|
||||||
const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
|
const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
|
||||||
for (const server of mcpStore.visibleMcpServers) {
|
for (const server of mcpStore.getServers()) {
|
||||||
|
if (!server.enabled) continue;
|
||||||
const health = mcpStore.getHealthCheckState(server.id);
|
const health = mcpStore.getHealthCheckState(server.id);
|
||||||
if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
|
if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
|
||||||
result.push({
|
result.push({
|
||||||
|
|||||||
Vendored
-1
@@ -15,7 +15,6 @@ import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from '.
|
|||||||
export interface AgenticConfig {
|
export interface AgenticConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
maxTurns: number;
|
maxTurns: number;
|
||||||
maxToolPreviewLines: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Vendored
-2
@@ -174,7 +174,6 @@ export interface HealthCheckParams {
|
|||||||
id: string;
|
id: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
requestTimeoutSeconds: number;
|
|
||||||
headers?: string;
|
headers?: string;
|
||||||
useProxy?: boolean;
|
useProxy?: boolean;
|
||||||
}
|
}
|
||||||
@@ -220,7 +219,6 @@ export interface MCPServerDisplayInfo {
|
|||||||
|
|
||||||
export type MCPServerSettingsEntry = MCPServerDisplayInfo & {
|
export type MCPServerSettingsEntry = MCPServerDisplayInfo & {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
requestTimeoutSeconds: number;
|
|
||||||
headers?: string;
|
headers?: string;
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
useProxy?: boolean;
|
useProxy?: boolean;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
MimeTypeText
|
MimeTypeText
|
||||||
} from '$lib/enums';
|
} from '$lib/enums';
|
||||||
import {
|
import {
|
||||||
DEFAULT_MCP_CONFIG,
|
|
||||||
MCP_SERVER_ID_PREFIX,
|
MCP_SERVER_ID_PREFIX,
|
||||||
IMAGE_FILE_EXTENSION_REGEX,
|
IMAGE_FILE_EXTENSION_REGEX,
|
||||||
CODE_FILE_EXTENSION_REGEX,
|
CODE_FILE_EXTENSION_REGEX,
|
||||||
@@ -64,7 +63,6 @@ export function detectMcpTransportFromUrl(url: string): MCPTransportType {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses MCP server settings from a JSON string or array.
|
* Parses MCP server settings from a JSON string or array.
|
||||||
* Preserves per-server requestTimeoutSeconds if stored, otherwise falls back to the global default.
|
|
||||||
* @param rawServers - The raw servers to parse
|
* @param rawServers - The raw servers to parse
|
||||||
* @returns An empty array if the input is invalid.
|
* @returns An empty array if the input is invalid.
|
||||||
*/
|
*/
|
||||||
@@ -103,9 +101,6 @@ export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEn
|
|||||||
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
||||||
url,
|
url,
|
||||||
name: (entry as { name?: string })?.name,
|
name: (entry as { name?: string })?.name,
|
||||||
requestTimeoutSeconds:
|
|
||||||
(entry as { requestTimeoutSeconds?: number })?.requestTimeoutSeconds ??
|
|
||||||
DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
|
||||||
headers: headers || undefined,
|
headers: headers || undefined,
|
||||||
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
|
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
|
||||||
} satisfies MCPServerSettingsEntry;
|
} satisfies MCPServerSettingsEntry;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { parseMcpServerSettings } from '$lib/utils/mcp';
|
import { parseMcpServerSettings } from '$lib/utils/mcp';
|
||||||
import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
|
import { MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for the mcpServers settings parser.
|
* Tests for the mcpServers settings parser.
|
||||||
@@ -58,24 +58,16 @@ describe('parseMcpServerSettings', () => {
|
|||||||
expect(parsed[2]?.id).toBe('custom-3');
|
expect(parsed[2]?.id).toBe('custom-3');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the configured default requestTimeoutSeconds only for nullish values', () => {
|
it('does not emit a per-server timeout, the request timeout is a live global setting', () => {
|
||||||
const fallback = DEFAULT_MCP_CONFIG.requestTimeoutSeconds;
|
// A stored per-server requestTimeoutSeconds was never editable in
|
||||||
|
// any UI and froze the global setting at server creation time,
|
||||||
|
// making the Settings value a no-op for existing servers. The
|
||||||
|
// parser drops the field so the global applies live everywhere.
|
||||||
const parsed = parseMcpServerSettings(
|
const parsed = parseMcpServerSettings(
|
||||||
JSON.stringify([
|
JSON.stringify([{ id: 'a', url: 'https://a.test', requestTimeoutSeconds: 45 }])
|
||||||
{ 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
|
expect(parsed[0]).not.toHaveProperty('requestTimeoutSeconds');
|
||||||
// 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', () => {
|
it('treats whitespace-only headers strings as undefined', () => {
|
||||||
@@ -108,6 +100,22 @@ describe('parseMcpServerSettings', () => {
|
|||||||
expect(parsed[3]?.useProxy).toBe(true);
|
expect(parsed[3]?.useProxy).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps disabled entries in the list, enabled is state and never a visibility filter', () => {
|
||||||
|
// Regression guard for issue #25625: filtering the server list on
|
||||||
|
// `enabled` hides a toggled-off server from every UI surface with
|
||||||
|
// no way to re-enable it. Any list derived from this parser must
|
||||||
|
// contain disabled entries.
|
||||||
|
const parsed = parseMcpServerSettings(
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: 'on', url: 'https://on.test', enabled: true },
|
||||||
|
{ id: 'off', url: 'https://off.test', enabled: false }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsed.map((entry) => entry.id)).toEqual(['on', 'off']);
|
||||||
|
expect(parsed[1]?.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves input order when mapping entries', () => {
|
it('preserves input order when mapping entries', () => {
|
||||||
const source = [
|
const source = [
|
||||||
{ id: 'gamma', url: 'https://c.test' },
|
{ id: 'gamma', url: 'https://c.test' },
|
||||||
|
|||||||
Reference in New Issue
Block a user