Skip to content

Commit f4c107a

Browse files
committed
MCP live start/stop: Fix up the UX
- Add `get_mcp_running` Tauri command to expose actual server state as ground truth - `McpServerSection` now queries backend state via `syncState()` after every operation, keeping the toggle in sync with reality - Serialize all MCP operations through a promise queue so rapid toggling can't cause inconsistent state - Auto-check port availability on port change when server is off; when server is on, the restart result is the check - Show "Server is running on port {port}" (green) when active, with the confirmed bound port, not the live setting - Show yellow warning "Server turned off because port {port} is blocked" when a port change kills the server - Port controls always enabled (no longer disabled when server is off) - Unified all status messages (error, warning, running, port check) into a single slot below both settings — visible whenever either setting matches the search - Ignore setting-change echoes from `syncState` by comparing incoming value to `serverRunning` instead of a skip-counter (fixes cross-window double-fire wiping the warning)
1 parent 80ea9d3 commit f4c107a

File tree

5 files changed

+116
-47
lines changed

5 files changed

+116
-47
lines changed

apps/desktop/src-tauri/src/commands/mcp.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,9 @@ pub async fn set_mcp_port<R: Runtime + 'static>(app: AppHandle<R>, port: u16) ->
2929
let config = mcp::McpConfig::from_settings_and_env(Some(true), Some(port));
3030
mcp::start_mcp_server(app, config).await
3131
}
32+
33+
/// Returns whether the MCP server is currently running.
34+
#[tauri::command]
35+
pub fn get_mcp_running() -> bool {
36+
mcp::is_mcp_running()
37+
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,7 @@ pub fn run() {
836836
// MCP server live control
837837
commands::mcp::set_mcp_enabled,
838838
commands::mcp::set_mcp_port,
839+
commands::mcp::get_mcp_running,
839840
// Settings commands
840841
commands::settings::check_port_available,
841842
commands::settings::find_available_port,

apps/desktop/src/lib/settings/sections/McpServerSection.svelte

Lines changed: 103 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
import SettingNumberInput from '../components/SettingNumberInput.svelte'
66
import Button from '$lib/ui/Button.svelte'
77
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'
915
import { createShouldShow } from '$lib/settings/settings-search'
1016
import { getAppLogger } from '$lib/logging/logger'
1117
import { onMount } from 'svelte'
@@ -23,24 +29,49 @@
2329
const mcpEnabledDef = getSettingDefinition('developer.mcpEnabled') ?? defaultDef
2430
const mcpPortDef = getSettingDefinition('developer.mcpPort') ?? defaultDef
2531
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)
2735
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)
2838
let portStatus = $state<'checking' | 'available' | 'unavailable' | null>(null)
2939
let suggestedPort = $state<number | null>(null)
3040
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+
}
3363
3464
onMount(() => {
65+
void syncState()
66+
3567
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))
4172
})
42-
const unsubPort = onSpecificSettingChange('developer.mcpPort', (_id, value) => {
43-
debounceMcpPortChange(value)
73+
const unsubPort = onSpecificSettingChange('developer.mcpPort', (_id, _value) => {
74+
debounceMcpPortChange()
4475
})
4576
return () => {
4677
unsubEnabled()
@@ -51,59 +82,83 @@
5182
5283
async function applyMcpEnabled(enabled: boolean): Promise<void> {
5384
serverError = null
85+
serverWarning = null
86+
portStatus = null
87+
suggestedPort = null
5488
const port = getSetting('developer.mcpPort')
5589
try {
5690
await setMcpEnabled(enabled, port)
5791
} catch (error: unknown) {
5892
const message = error instanceof Error ? error.message : String(error)
5993
log.error('Failed to toggle MCP server: {error}', { error: message })
6094
serverError = message
61-
// Revert the toggle so it reflects reality
62-
skipNextEnabledChange = true
63-
setSetting('developer.mcpEnabled', !enabled)
6495
}
96+
await syncState()
6597
}
6698
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
68103
clearTimeout(portDebounceTimer)
69104
portDebounceTimer = setTimeout(() => {
70-
void applyMcpPort(port)
105+
enqueue(() => applyMcpPort())
71106
}, 800)
72107
}
73108
74-
async function applyMcpPort(port: number): Promise<void> {
109+
async function applyMcpPort(): Promise<void> {
110+
const port = getSetting('developer.mcpPort')
75111
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
82135
}
136+
137+
// Server is off — just check availability
138+
await checkPort()
83139
}
84140
85-
async function checkPort() {
141+
async function checkPort(): Promise<void> {
86142
const port = getSetting('developer.mcpPort')
87143
portStatus = 'checking'
144+
suggestedPort = null
88145
89146
try {
90147
const available = await checkPortAvailable(port)
91148
portStatus = available ? 'available' : 'unavailable'
92149
93150
if (!available) {
94151
suggestedPort = await findAvailablePort(port)
95-
} else {
96-
suggestedPort = null
97152
}
98153
} catch {
99154
portStatus = null
100155
}
101156
}
102157
103-
function useSuggestedPort() {
158+
function useSuggestedPort(): void {
104159
if (suggestedPort) {
105160
setSetting('developer.mcpPort', suggestedPort)
106-
portStatus = 'available'
161+
portStatus = null
107162
suggestedPort = null
108163
}
109164
}
@@ -121,32 +176,35 @@
121176
</SettingRow>
122177
{/if}
123178

124-
{#if serverError}
125-
<div class="server-error">{serverError}</div>
126-
{/if}
127-
128179
{#if shouldShow('developer.mcpPort')}
129180
<SettingRow
130181
id="developer.mcpPort"
131182
label={mcpPortDef.label}
132183
description={mcpPortDef.description}
133-
disabled={!mcpEnabled}
134184
split
135185
{searchQuery}
136186
>
137187
<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>
140190
</div>
141191
</SettingRow>
192+
{/if}
142193

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'}
144202
<div class="port-status checking">Checking port availability...</div>
145203
{: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>
147205
{:else if portStatus === 'unavailable'}
148206
<div class="port-status unavailable">
149-
Port is in use
207+
Port {getSetting('developer.mcpPort')} is in use
150208
{#if suggestedPort}
151209
<Button variant="primary" size="mini" onclick={useSuggestedPort}>
152210
Use port {suggestedPort} instead
@@ -176,24 +234,22 @@
176234
color: var(--color-text-tertiary);
177235
}
178236
179-
.port-status.available {
237+
.port-status.available,
238+
.port-status.active {
180239
background: color-mix(in srgb, var(--color-allow) 10%, transparent);
181240
color: var(--color-allow);
182241
}
183242
243+
.port-status.warning {
244+
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
245+
color: var(--color-warning);
246+
}
247+
184248
.port-status.unavailable {
185249
background: color-mix(in srgb, var(--color-error) 10%, transparent);
186250
color: var(--color-error);
187251
display: flex;
188252
align-items: center;
189253
gap: var(--spacing-sm);
190254
}
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-
}
199255
</style>

apps/desktop/src/lib/tauri-commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ export {
253253
findAvailablePort,
254254
setMcpEnabled,
255255
setMcpPort,
256+
getMcpRunning,
256257
updateFileWatcherDebounce,
257258
updateServiceResolveTimeout,
258259
setIndexingEnabled,

apps/desktop/src/lib/tauri-commands/settings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export async function setMcpPort(port: number): Promise<void> {
5757
await invoke('set_mcp_port', { port })
5858
}
5959

60+
/** Returns whether the MCP server is currently running. */
61+
export async function getMcpRunning(): Promise<boolean> {
62+
return invoke<boolean>('get_mcp_running')
63+
}
64+
6065
// ============================================================================
6166
// Indexing commands
6267
// ============================================================================

0 commit comments

Comments
 (0)