|
5 | 5 | import SettingNumberInput from '../components/SettingNumberInput.svelte' |
6 | 6 | import Button from '$lib/ui/Button.svelte' |
7 | 7 | import { getSetting, getSettingDefinition, setSetting, onSpecificSettingChange } from '$lib/settings' |
8 | | - import { checkPortAvailable, findAvailablePort, setMcpEnabled, setMcpPort } from '$lib/tauri-commands' |
| 8 | + import { |
| 9 | + checkPortAvailable, |
| 10 | + findAvailablePort, |
| 11 | + setMcpEnabled, |
| 12 | + setMcpPort, |
| 13 | + getMcpRunning, |
| 14 | + } from '$lib/tauri-commands' |
9 | 15 | import { createShouldShow } from '$lib/settings/settings-search' |
10 | 16 | import { getAppLogger } from '$lib/logging/logger' |
11 | 17 | import { onMount } from 'svelte' |
|
23 | 29 | const mcpEnabledDef = getSettingDefinition('developer.mcpEnabled') ?? defaultDef |
24 | 30 | const mcpPortDef = getSettingDefinition('developer.mcpPort') ?? defaultDef |
25 | 31 |
|
26 | | - const mcpEnabled = $derived(getSetting('developer.mcpEnabled')) |
| 32 | + let serverRunning = $state(false) |
| 33 | + /** The port the running server is actually bound to (may differ from the setting during changes) */ |
| 34 | + let runningPort = $state<number | null>(null) |
27 | 35 | let serverError = $state<string | null>(null) |
| 36 | + /** Warning shown when the server was stopped due to a port change failure */ |
| 37 | + let serverWarning = $state<string | null>(null) |
28 | 38 | let portStatus = $state<'checking' | 'available' | 'unavailable' | null>(null) |
29 | 39 | let suggestedPort = $state<number | null>(null) |
30 | 40 | let portDebounceTimer: ReturnType<typeof setTimeout> | undefined |
31 | | - // Skip exactly one change notification caused by our own revert on failure |
32 | | - let skipNextEnabledChange = false |
| 41 | +
|
| 42 | + // Serialize all MCP operations so rapid toggling can't cause inconsistent state |
| 43 | + let operationQueue = Promise.resolve() |
| 44 | +
|
| 45 | + function enqueue(fn: () => Promise<void>): void { |
| 46 | + operationQueue = operationQueue.then(fn, fn) |
| 47 | + } |
| 48 | +
|
| 49 | + /** Sync toggle + serverRunning from actual backend state */ |
| 50 | + async function syncState(): Promise<void> { |
| 51 | + const running = await getMcpRunning() |
| 52 | + serverRunning = running |
| 53 | + if (running) { |
| 54 | + runningPort = getSetting('developer.mcpPort') |
| 55 | + } else { |
| 56 | + runningPort = null |
| 57 | + } |
| 58 | + const settingEnabled = getSetting('developer.mcpEnabled') |
| 59 | + if (settingEnabled !== running) { |
| 60 | + setSetting('developer.mcpEnabled', running) |
| 61 | + } |
| 62 | + } |
33 | 63 |
|
34 | 64 | onMount(() => { |
| 65 | + void syncState() |
| 66 | +
|
35 | 67 | const unsubEnabled = onSpecificSettingChange('developer.mcpEnabled', (_id, value) => { |
36 | | - if (skipNextEnabledChange) { |
37 | | - skipNextEnabledChange = false |
38 | | - return |
39 | | - } |
40 | | - void applyMcpEnabled(value) |
| 68 | + // Ignore echoes from our own syncState calls (sync + cross-window). |
| 69 | + // A real user toggle always changes the value away from the current server state. |
| 70 | + if ((value as boolean) === serverRunning) return |
| 71 | + enqueue(() => applyMcpEnabled(value as boolean)) |
41 | 72 | }) |
42 | | - const unsubPort = onSpecificSettingChange('developer.mcpPort', (_id, value) => { |
43 | | - debounceMcpPortChange(value) |
| 73 | + const unsubPort = onSpecificSettingChange('developer.mcpPort', (_id, _value) => { |
| 74 | + debounceMcpPortChange() |
44 | 75 | }) |
45 | 76 | return () => { |
46 | 77 | unsubEnabled() |
|
51 | 82 |
|
52 | 83 | async function applyMcpEnabled(enabled: boolean): Promise<void> { |
53 | 84 | serverError = null |
| 85 | + serverWarning = null |
| 86 | + portStatus = null |
| 87 | + suggestedPort = null |
54 | 88 | const port = getSetting('developer.mcpPort') |
55 | 89 | try { |
56 | 90 | await setMcpEnabled(enabled, port) |
57 | 91 | } catch (error: unknown) { |
58 | 92 | const message = error instanceof Error ? error.message : String(error) |
59 | 93 | log.error('Failed to toggle MCP server: {error}', { error: message }) |
60 | 94 | serverError = message |
61 | | - // Revert the toggle so it reflects reality |
62 | | - skipNextEnabledChange = true |
63 | | - setSetting('developer.mcpEnabled', !enabled) |
64 | 95 | } |
| 96 | + await syncState() |
65 | 97 | } |
66 | 98 |
|
67 | | - function debounceMcpPortChange(port: number): void { |
| 99 | + function debounceMcpPortChange(): void { |
| 100 | + // While debouncing, clear stale status so "Server is running on port X" doesn't show the old port |
| 101 | + portStatus = null |
| 102 | + suggestedPort = null |
68 | 103 | clearTimeout(portDebounceTimer) |
69 | 104 | portDebounceTimer = setTimeout(() => { |
70 | | - void applyMcpPort(port) |
| 105 | + enqueue(() => applyMcpPort()) |
71 | 106 | }, 800) |
72 | 107 | } |
73 | 108 |
|
74 | | - async function applyMcpPort(port: number): Promise<void> { |
| 109 | + async function applyMcpPort(): Promise<void> { |
| 110 | + const port = getSetting('developer.mcpPort') |
75 | 111 | serverError = null |
76 | | - try { |
77 | | - await setMcpPort(port) |
78 | | - } catch (error: unknown) { |
79 | | - const message = error instanceof Error ? error.message : String(error) |
80 | | - log.error('Failed to change MCP port: {error}', { error: message }) |
81 | | - serverError = message |
| 112 | + serverWarning = null |
| 113 | + portStatus = null |
| 114 | + suggestedPort = null |
| 115 | +
|
| 116 | + // Check actual backend state, not the possibly-stale local flag |
| 117 | + const wasRunning = await getMcpRunning() |
| 118 | +
|
| 119 | + if (wasRunning) { |
| 120 | + // Server is running — restart on the new port |
| 121 | + try { |
| 122 | + await setMcpPort(port) |
| 123 | + } catch (error: unknown) { |
| 124 | + const message = error instanceof Error ? error.message : String(error) |
| 125 | + log.error('Failed to change MCP port: {error}', { error: message }) |
| 126 | + serverError = message |
| 127 | + } |
| 128 | + await syncState() |
| 129 | + // If the server was stopped because the new port failed, show a warning instead of the raw error |
| 130 | + if (!serverRunning) { |
| 131 | + serverError = null |
| 132 | + serverWarning = `Server turned off because port ${String(port)} is blocked` |
| 133 | + } |
| 134 | + return |
82 | 135 | } |
| 136 | +
|
| 137 | + // Server is off — just check availability |
| 138 | + await checkPort() |
83 | 139 | } |
84 | 140 |
|
85 | | - async function checkPort() { |
| 141 | + async function checkPort(): Promise<void> { |
86 | 142 | const port = getSetting('developer.mcpPort') |
87 | 143 | portStatus = 'checking' |
| 144 | + suggestedPort = null |
88 | 145 |
|
89 | 146 | try { |
90 | 147 | const available = await checkPortAvailable(port) |
91 | 148 | portStatus = available ? 'available' : 'unavailable' |
92 | 149 |
|
93 | 150 | if (!available) { |
94 | 151 | suggestedPort = await findAvailablePort(port) |
95 | | - } else { |
96 | | - suggestedPort = null |
97 | 152 | } |
98 | 153 | } catch { |
99 | 154 | portStatus = null |
100 | 155 | } |
101 | 156 | } |
102 | 157 |
|
103 | | - function useSuggestedPort() { |
| 158 | + function useSuggestedPort(): void { |
104 | 159 | if (suggestedPort) { |
105 | 160 | setSetting('developer.mcpPort', suggestedPort) |
106 | | - portStatus = 'available' |
| 161 | + portStatus = null |
107 | 162 | suggestedPort = null |
108 | 163 | } |
109 | 164 | } |
|
121 | 176 | </SettingRow> |
122 | 177 | {/if} |
123 | 178 |
|
124 | | - {#if serverError} |
125 | | - <div class="server-error">{serverError}</div> |
126 | | - {/if} |
127 | | - |
128 | 179 | {#if shouldShow('developer.mcpPort')} |
129 | 180 | <SettingRow |
130 | 181 | id="developer.mcpPort" |
131 | 182 | label={mcpPortDef.label} |
132 | 183 | description={mcpPortDef.description} |
133 | | - disabled={!mcpEnabled} |
134 | 184 | split |
135 | 185 | {searchQuery} |
136 | 186 | > |
137 | 187 | <div class="port-setting"> |
138 | | - <SettingNumberInput id="developer.mcpPort" disabled={!mcpEnabled} /> |
139 | | - <Button variant="secondary" size="mini" onclick={checkPort} disabled={!mcpEnabled}>Check port</Button> |
| 188 | + <SettingNumberInput id="developer.mcpPort" /> |
| 189 | + <Button variant="secondary" size="mini" onclick={checkPort}>Check port</Button> |
140 | 190 | </div> |
141 | 191 | </SettingRow> |
| 192 | + {/if} |
142 | 193 |
|
143 | | - {#if portStatus === 'checking'} |
| 194 | + {#if shouldShow('developer.mcpEnabled') || shouldShow('developer.mcpPort')} |
| 195 | + {#if serverError} |
| 196 | + <div class="port-status unavailable">{serverError}</div> |
| 197 | + {:else if serverWarning} |
| 198 | + <div class="port-status warning">{serverWarning}</div> |
| 199 | + {:else if serverRunning && runningPort} |
| 200 | + <div class="port-status active">Server is running on port {runningPort}</div> |
| 201 | + {:else if portStatus === 'checking'} |
144 | 202 | <div class="port-status checking">Checking port availability...</div> |
145 | 203 | {:else if portStatus === 'available'} |
146 | | - <div class="port-status available">Port is available</div> |
| 204 | + <div class="port-status available">Port {getSetting('developer.mcpPort')} is available</div> |
147 | 205 | {:else if portStatus === 'unavailable'} |
148 | 206 | <div class="port-status unavailable"> |
149 | | - Port is in use |
| 207 | + Port {getSetting('developer.mcpPort')} is in use |
150 | 208 | {#if suggestedPort} |
151 | 209 | <Button variant="primary" size="mini" onclick={useSuggestedPort}> |
152 | 210 | Use port {suggestedPort} instead |
|
176 | 234 | color: var(--color-text-tertiary); |
177 | 235 | } |
178 | 236 |
|
179 | | - .port-status.available { |
| 237 | + .port-status.available, |
| 238 | + .port-status.active { |
180 | 239 | background: color-mix(in srgb, var(--color-allow) 10%, transparent); |
181 | 240 | color: var(--color-allow); |
182 | 241 | } |
183 | 242 |
|
| 243 | + .port-status.warning { |
| 244 | + background: color-mix(in srgb, var(--color-warning) 10%, transparent); |
| 245 | + color: var(--color-warning); |
| 246 | + } |
| 247 | +
|
184 | 248 | .port-status.unavailable { |
185 | 249 | background: color-mix(in srgb, var(--color-error) 10%, transparent); |
186 | 250 | color: var(--color-error); |
187 | 251 | display: flex; |
188 | 252 | align-items: center; |
189 | 253 | gap: var(--spacing-sm); |
190 | 254 | } |
191 | | -
|
192 | | - .server-error { |
193 | | - padding: var(--spacing-xs) var(--spacing-sm); |
194 | | - border-radius: var(--radius-sm); |
195 | | - font-size: var(--font-size-sm); |
196 | | - background: color-mix(in srgb, var(--color-error) 10%, transparent); |
197 | | - color: var(--color-error); |
198 | | - } |
199 | 255 | </style> |
0 commit comments