Skip to content

Commit d69f876

Browse files
committed
MCP: Auto-probe port and fix port display
- Unify default MCP port to 9224 for all builds (was 9225 for debug, 9224 for release) - Auto-probe to next available port if configured port is in use (scans up to 100 ports) - Store actual bound port in `MCP_ACTUAL_PORT` static, exposed via `get_mcp_port` command - Frontend `syncState()` fetches actual port from backend instead of trusting the setting value - Show "(port X was in use)" in Settings UI when the server auto-probed to a different port - Update MCP CLAUDE.md: document auto-probing, clarify that tool/resource output is for LLMs not parsers
1 parent 26d682c commit d69f876

File tree

13 files changed

+146
-63
lines changed

13 files changed

+146
-63
lines changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,9 @@ pub async fn set_mcp_port<R: Runtime + 'static>(app: AppHandle<R>, port: u16) ->
3535
pub fn get_mcp_running() -> bool {
3636
mcp::is_mcp_running()
3737
}
38+
39+
/// Returns the port the MCP server is actually listening on, or null if not running.
40+
#[tauri::command]
41+
pub fn get_mcp_port() -> Option<u16> {
42+
mcp::get_mcp_actual_port()
43+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,7 @@ pub fn run() {
861861
commands::mcp::set_mcp_enabled,
862862
commands::mcp::set_mcp_port,
863863
commands::mcp::get_mcp_running,
864+
commands::mcp::get_mcp_port,
864865
// Settings commands
865866
commands::settings::check_port_available,
866867
commands::settings::find_available_port,

apps/desktop/src-tauri/src/mcp/CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Expose Cmdr functionality to AI agents via the Model Context Protocol (MCP). Age
99
### Server (`server.rs`)
1010

1111
- Runs in a background tokio task spawned at app startup
12-
- Binds to `127.0.0.1:9225` in dev, `127.0.0.1:9224` in release (localhost only for security)
12+
- Binds to `127.0.0.1:9224` by default (localhost only for security). If the port is taken, auto-probes upward (up to 100 ports) to find an available one
1313
- Streamable HTTP transport (MCP spec 2025-11-25)
1414
- Endpoints: `POST /mcp` (JSON-RPC), `GET /mcp` (optional SSE), `GET /mcp/health`
1515

@@ -43,7 +43,7 @@ Routes tool calls to implementations. Most tools emit Tauri events that trigger
4343

4444
### Configuration (`config.rs`)
4545

46-
Constants and configuration for the MCP server (port, bind address, transport settings).
46+
Constants and configuration for the MCP server (port, bind address, transport settings). Default port is 9224 for all build types (dev and prod use separate data dirs, so no collision risk).
4747

4848
### Dialog state (`dialog_state.rs`)
4949

@@ -69,7 +69,7 @@ LLMs consume resources, not machines. YAML is 30-40% smaller and more readable.
6969

7070
### Why plain text responses?
7171

72-
Tool results are plain text (`"OK: Navigated to /Users"`, `"ERROR: Path not found"`), not JSON objects. This reduces token usage and is easier for LLMs to parse. Errors are still JSON-RPC error objects, but the `content` field is plain text.
72+
Tool results and resource content are consumed by LLMs, not parsed by code. Output doesn't need to be JSON, YAML, or any structured format — anything that reads well to a human and is concise works. Tool results are plain text (`"OK: Navigated to /Users"`, aligned columns for search results), resources use YAML or plain text. Errors are still JSON-RPC error objects, but the `content` field is plain text. Optimize for readability and token efficiency, not parseability.
7373

7474
### Why stateful architecture?
7575

@@ -91,7 +91,7 @@ Binding to `0.0.0.0` would expose the server to the network. An attacker could q
9191

9292
### Server lifecycle is managed at runtime
9393

94-
`start_mcp_server()` binds the port and spawns a tokio task, storing the `JoinHandle` in a static `MCP_HANDLE`. The server can be started/stopped live via `set_mcp_enabled` and `set_mcp_port` Tauri commands — no app restart needed. `stop_mcp_server()` aborts the task (instant). `is_mcp_running()` checks whether the handle exists. At startup, `start_mcp_server_background()` wraps the async start in a fire-and-forget spawn. If the server crashes, the app continues but MCP stops working. Check logs for "MCP server crashed" errors.
94+
`start_mcp_server()` binds the port and spawns a tokio task, storing the `JoinHandle` in a static `MCP_HANDLE`. If the configured port is taken, it auto-probes upward (up to 100 ports) and stores the actual bound port in `MCP_ACTUAL_PORT`. The frontend queries this via `get_mcp_port()` and shows a notice when it differs from the configured port. The server can be started/stopped live via `set_mcp_enabled` and `set_mcp_port` Tauri commands — no app restart needed. `stop_mcp_server()` aborts the task and resets `MCP_ACTUAL_PORT` to 0. `is_mcp_running()` checks whether the handle exists. At startup, `start_mcp_server_background()` wraps the async start in a fire-and-forget spawn. If the server crashes, the app continues but MCP stops working. Check logs for "MCP server crashed" errors.
9595

9696
### Live MCP control only works from the settings window
9797

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

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,12 @@ impl McpConfig {
3636
// Priority for port:
3737
// 1. CMDR_MCP_PORT env var (explicit dev override)
3838
// 2. User setting (developer.mcpPort)
39-
// 3. Default: 9225 in debug builds, 9224 in release
40-
let default_port: u16 = if cfg!(debug_assertions) { 9225 } else { 9224 };
39+
// 3. Default: 9224 (same for all build types — dev and prod use separate data dirs)
4140
let port = env::var("CMDR_MCP_PORT")
4241
.ok()
4342
.and_then(|v| v.parse().ok())
4443
.or(setting_port)
45-
.unwrap_or(default_port);
44+
.unwrap_or(9224);
4645

4746
Self { enabled, port }
4847
}
@@ -85,10 +84,7 @@ mod tests {
8584
fn test_from_settings_with_no_settings() {
8685
// When no settings are provided, should use defaults
8786
let config = McpConfig::from_settings_and_env(None, None);
88-
// Debug builds use port 9225 to avoid clashing with a running release build
89-
#[cfg(debug_assertions)]
90-
assert_eq!(config.port, 9225);
91-
#[cfg(not(debug_assertions))]
87+
// Default port is always 9224 regardless of build type
9288
assert_eq!(config.port, 9224);
9389
// In debug builds, enabled is true by default
9490
#[cfg(debug_assertions)]

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

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -669,11 +669,11 @@ fn execute_dialog_close<R: Runtime>(app: &AppHandle<R>, dialog_type: &str, path:
669669

670670
// ── Search tools ──────────────────────────────────────────────────────
671671

672+
use crate::indexing::search::PatternType;
672673
use crate::indexing::search::{
673-
self, DIALOG_OPEN, SEARCH_INDEX, SearchIndexState, SearchQuery, SearchResult,
674-
fill_directory_sizes, format_size, format_timestamp, summarize_query,
674+
self, DIALOG_OPEN, SEARCH_INDEX, SearchIndexState, SearchQuery, SearchResult, fill_directory_sizes, format_size,
675+
format_timestamp, summarize_query,
675676
};
676-
use crate::indexing::search::PatternType;
677677

678678
/// Ensure the search index is loaded. Returns the index or an error.
679679
async fn ensure_search_index() -> Result<Arc<search::SearchIndex>, ToolError> {
@@ -741,13 +741,17 @@ fn parse_human_size(s: &str) -> Result<u64, ToolError> {
741741
} else {
742742
// Try parsing as pure number (bytes)
743743
let n: u64 = s.trim().parse().map_err(|_| {
744-
ToolError::invalid_params(format!("Couldn't parse size: \"{s}\". Use a format like \"1 MB\" or \"500 KB\"."))
744+
ToolError::invalid_params(format!(
745+
"Couldn't parse size: \"{s}\". Use a format like \"1 MB\" or \"500 KB\"."
746+
))
745747
})?;
746748
return Ok(n);
747749
};
748750

749751
let num: f64 = num_str.trim().parse().map_err(|_| {
750-
ToolError::invalid_params(format!("Couldn't parse size: \"{s}\". Use a format like \"1 MB\" or \"500 KB\"."))
752+
ToolError::invalid_params(format!(
753+
"Couldn't parse size: \"{s}\". Use a format like \"1 MB\" or \"500 KB\"."
754+
))
751755
})?;
752756

753757
let multiplier: u64 = match unit {
@@ -772,14 +776,19 @@ fn format_search_results(result: &SearchResult, limit: u32) -> String {
772776
let entries = &result.entries[..shown];
773777

774778
// Compute column widths
775-
let max_name = entries.iter().map(|e| {
776-
let display_name = if e.is_directory {
777-
format!("{}/", e.name)
778-
} else {
779-
e.name.clone()
780-
};
781-
display_name.len()
782-
}).max().unwrap_or(0).max(4);
779+
let max_name = entries
780+
.iter()
781+
.map(|e| {
782+
let display_name = if e.is_directory {
783+
format!("{}/", e.name)
784+
} else {
785+
e.name.clone()
786+
};
787+
display_name.len()
788+
})
789+
.max()
790+
.unwrap_or(0)
791+
.max(4);
783792

784793
let max_parent = entries.iter().map(|e| e.parent_path.len()).max().unwrap_or(0).max(4);
785794

@@ -818,18 +827,14 @@ fn format_search_results(result: &SearchResult, limit: u32) -> String {
818827
}
819828

820829
/// Run search and post-process (fill dir sizes, post-filter, truncate).
821-
fn run_search_and_postprocess(
822-
index: &search::SearchIndex,
823-
query: &SearchQuery,
824-
) -> Result<SearchResult, ToolError> {
825-
let mut result = search::search(index, query).map_err(|e| ToolError::internal(e))?;
830+
fn run_search_and_postprocess(index: &search::SearchIndex, query: &SearchQuery) -> Result<SearchResult, ToolError> {
831+
let mut result = search::search(index, query).map_err(ToolError::internal)?;
826832

827833
// Fill directory sizes from the DB
828-
if result.entries.iter().any(|e| e.is_directory) {
829-
if let Some(pool) = crate::indexing::get_read_pool() {
834+
if result.entries.iter().any(|e| e.is_directory)
835+
&& let Some(pool) = crate::indexing::get_read_pool() {
830836
fill_directory_sizes(&mut result, &pool);
831837
}
832-
}
833838

834839
// Post-filter: remove directories that don't match size criteria
835840
let has_size_filter = query.min_size.is_some() || query.max_size.is_some();
@@ -886,13 +891,13 @@ async fn execute_search(params: &Value) -> ToolResult {
886891
.and_then(|v| v.as_str())
887892
.map(crate::commands::search::iso_date_to_timestamp)
888893
.transpose()
889-
.map_err(|e| ToolError::invalid_params(e))?;
894+
.map_err(ToolError::invalid_params)?;
890895
let modified_before = params
891896
.get("modified_before")
892897
.and_then(|v| v.as_str())
893898
.map(crate::commands::search::iso_date_to_timestamp)
894899
.transpose()
895-
.map_err(|e| ToolError::invalid_params(e))?;
900+
.map_err(ToolError::invalid_params)?;
896901
let is_directory = match params.get("type").and_then(|v| v.as_str()) {
897902
Some("file") => Some(false),
898903
Some("dir") => Some(true),
@@ -941,9 +946,8 @@ async fn execute_ai_search(params: &Value) -> ToolResult {
941946
));
942947
}
943948
"local" => {
944-
let port = crate::ai::manager::get_port().ok_or_else(|| {
945-
ToolError::internal("Local AI server isn't running. Start it in settings.")
946-
})?;
949+
let port = crate::ai::manager::get_port()
950+
.ok_or_else(|| ToolError::internal("Local AI server isn't running. Start it in settings."))?;
947951
crate::ai::client::AiBackend::Local { port }
948952
}
949953
"openai-compatible" => {
@@ -973,8 +977,7 @@ async fn execute_ai_search(params: &Value) -> ToolResult {
973977
.await
974978
.map_err(|e| ToolError::internal(format!("{e}")))?;
975979

976-
let mut ai_query = crate::commands::search::parse_ai_response(&response)
977-
.map_err(|e| ToolError::internal(e))?;
980+
let mut ai_query = crate::commands::search::parse_ai_response(&response).map_err(ToolError::internal)?;
978981

979982
// Validate regex patterns — retry once if invalid
980983
if let Err(regex_error) = crate::commands::search::validate_regex_pattern(&ai_query) {
@@ -989,8 +992,7 @@ async fn execute_ai_search(params: &Value) -> ToolResult {
989992
.await
990993
.map_err(|e| ToolError::internal(format!("{e}")))?;
991994

992-
ai_query = crate::commands::search::parse_ai_response(&retry_response)
993-
.map_err(|e| ToolError::internal(e))?;
995+
ai_query = crate::commands::search::parse_ai_response(&retry_response).map_err(ToolError::internal)?;
994996

995997
if let Err(retry_error) = crate::commands::search::validate_regex_pattern(&ai_query) {
996998
return Err(ToolError::internal(format!(
@@ -999,8 +1001,8 @@ async fn execute_ai_search(params: &Value) -> ToolResult {
9991001
}
10001002
}
10011003

1002-
let translate_result = crate::commands::search::build_translate_result(ai_query)
1003-
.map_err(|e| ToolError::internal(e))?;
1004+
let translate_result =
1005+
crate::commands::search::build_translate_result(ai_query).map_err(ToolError::internal)?;
10041006

10051007
let query = SearchQuery {
10061008
name_pattern: translate_result.query.name_pattern,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,5 @@ mod tests;
1919
pub use config::McpConfig;
2020
pub use dialog_state::SoftDialogTracker;
2121
pub use pane_state::PaneStateStore;
22-
pub use server::{is_mcp_running, start_mcp_server, start_mcp_server_background, stop_mcp_server};
22+
pub use server::{get_mcp_actual_port, is_mcp_running, start_mcp_server, start_mcp_server_background, stop_mcp_server};
2323
pub use settings_state::SettingsStateStore;

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

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use futures_util::stream;
1717
use serde_json::{Value, json};
1818
use std::convert::Infallible;
1919
use std::net::SocketAddr;
20+
use std::sync::atomic::{AtomicU16, Ordering};
2021
use std::sync::{Arc, Mutex, RwLock};
2122
use tauri::async_runtime::JoinHandle;
2223
use tauri::{AppHandle, Runtime};
@@ -32,6 +33,9 @@ use super::tools::get_all_tools;
3233
/// Handle to the running MCP server task, if any.
3334
static MCP_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
3435

36+
/// The port the server is actually listening on (0 when not running).
37+
static MCP_ACTUAL_PORT: AtomicU16 = AtomicU16::new(0);
38+
3539
/// The current MCP protocol version we support.
3640
pub const PROTOCOL_VERSION: &str = "2025-11-25";
3741

@@ -57,8 +61,19 @@ impl<R: Runtime> McpState<R> {
5761
}
5862
}
5963

64+
/// Find an available port starting from `start_port`, scanning up to 100 ports.
65+
fn find_available_port(start_port: u16) -> Option<u16> {
66+
for offset in 0..100 {
67+
let port = start_port.saturating_add(offset);
68+
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
69+
return Some(port);
70+
}
71+
}
72+
None
73+
}
74+
6075
/// Start the MCP server. Binds to the configured port and spawns the server task.
61-
/// Returns an error if the port is already in use or binding fails.
76+
/// If the configured port is taken, auto-probes upward to find the next available port.
6277
pub async fn start_mcp_server<R: Runtime + 'static>(app: AppHandle<R>, config: McpConfig) -> Result<(), String> {
6378
if !config.enabled {
6479
log::info!("MCP server is disabled");
@@ -71,7 +86,19 @@ pub async fn start_mcp_server<R: Runtime + 'static>(app: AppHandle<R>, config: M
7186
return Ok(());
7287
}
7388

74-
let port = config.port;
89+
let configured_port = config.port;
90+
91+
// Auto-probe: if the configured port is taken, find the next available one
92+
let port = find_available_port(configured_port)
93+
.ok_or_else(|| format!("No available port found starting from {}.", configured_port))?;
94+
if port != configured_port {
95+
log::info!(
96+
"MCP server: port {} is in use, using port {} instead",
97+
configured_port,
98+
port
99+
);
100+
}
101+
75102
let state = Arc::new(McpState::new(app));
76103

77104
let cors = CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any);
@@ -87,16 +114,14 @@ pub async fn start_mcp_server<R: Runtime + 'static>(app: AppHandle<R>, config: M
87114
log::debug!("MCP server attempting to bind on http://{}", addr);
88115

89116
// Bind before spawning so we can report errors synchronously
90-
let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
91-
if e.kind() == std::io::ErrorKind::AddrInUse {
92-
format!("Port {} is already in use. Try a different port?", port)
93-
} else {
94-
format!("Couldn't start the server on port {}. {}", port, e)
95-
}
96-
})?;
117+
let listener = tokio::net::TcpListener::bind(addr)
118+
.await
119+
.map_err(|e| format!("Couldn't start the server on port {}. {}", port, e))?;
97120

98121
log::info!("MCP server listening on http://{}", addr);
99122

123+
MCP_ACTUAL_PORT.store(port, Ordering::Relaxed);
124+
100125
let handle = tauri::async_runtime::spawn(async move {
101126
if let Err(e) = axum::serve(listener, router).await {
102127
log::error!("MCP server crashed: {}", e);
@@ -126,6 +151,7 @@ pub fn stop_mcp_server() {
126151
&& let Some(handle) = guard.take()
127152
{
128153
handle.abort();
154+
MCP_ACTUAL_PORT.store(0, Ordering::Relaxed);
129155
log::info!("MCP server stopped");
130156
}
131157
}
@@ -135,6 +161,12 @@ pub fn is_mcp_running() -> bool {
135161
MCP_HANDLE.lock().ok().is_some_and(|guard| guard.is_some())
136162
}
137163

164+
/// Returns the port the MCP server is actually listening on, or `None` if not running.
165+
pub fn get_mcp_actual_port() -> Option<u16> {
166+
let port = MCP_ACTUAL_PORT.load(Ordering::Relaxed);
167+
if port == 0 { None } else { Some(port) }
168+
}
169+
138170
/// Health check endpoint.
139171
async fn health_check() -> impl IntoResponse {
140172
Json(json!({"status": "ok"}))

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,8 +375,7 @@ fn get_search_tools() -> Vec<Tool> {
375375
},
376376
Tool {
377377
name: "ai_search".to_string(),
378-
description: "Natural language file search using the configured LLM to translate the query"
379-
.to_string(),
378+
description: "Natural language file search using the configured LLM to translate the query".to_string(),
380379
input_schema: json!({
381380
"type": "object",
382381
"properties": {

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//! Network utilities shared across modules.
2+
3+
use std::net::TcpListener;
4+
5+
/// Check if a port is available for binding on localhost.
6+
pub fn is_port_available(port: u16) -> bool {
7+
TcpListener::bind(("127.0.0.1", port)).is_ok()
8+
}
9+
10+
/// Find an available port on localhost starting from `start_port`, scanning up to 100 ports.
11+
pub fn find_available_port(start_port: u16) -> Option<u16> {
12+
for offset in 0..100 {
13+
let port = start_port.saturating_add(offset);
14+
if is_port_available(port) {
15+
return Some(port);
16+
}
17+
}
18+
None
19+
}
20+
21+
#[cfg(test)]
22+
mod tests {
23+
use super::*;
24+
25+
#[test]
26+
fn test_is_port_available() {
27+
// High port that's likely free — just verify it doesn't panic
28+
let _ = is_port_available(49999);
29+
}
30+
31+
#[test]
32+
fn test_find_available_port() {
33+
let result = find_available_port(49000);
34+
assert!(result.is_some());
35+
if let Some(port) = result {
36+
assert!(port >= 49000);
37+
assert!(port < 49100);
38+
}
39+
}
40+
}

0 commit comments

Comments
 (0)