Skip to content

Commit c711158

Browse files
committed
MCP: Add settings resource and tool
- Add `cmdr://settings` resource (all settings with values, defaults, types, constraints) - Add `set_setting` tool (validates via registry, same path as the UI) - New `mcp-main-bridge.ts` handles round-trips from the main window (always alive) - Remove old `mcp-settings-bridge.ts` that required the settings window to be open - Remove `SettingsStateStore`, `settings_state.rs`, and 6 `mcp_update_*` Tauri commands Entire-Checkpoint: e3e816d57f83
1 parent 3ea2335 commit c711158

18 files changed

Lines changed: 323 additions & 527 deletions

File tree

apps/desktop/coverage-allowlist.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
"reason": "UI section, pure logic extracted to license-section-utils.ts and tested"
104104
},
105105
"settings/sections/LoggingSection.svelte": { "reason": "UI section, simple rendering" },
106+
"settings/mcp-main-bridge.ts": { "reason": "MCP bridge, depends on Tauri event APIs (listen/emit)" },
106107
"settings/mcp-settings-bridge.ts": { "reason": "MCP bridge, depends on Tauri APIs and events" },
107108
"settings/sections/McpServerSection.svelte": { "reason": "UI section, depends on Tauri APIs" },
108109
"settings/sections/NetworkSection.svelte": { "reason": "UI section, simple rendering" },

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

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -388,9 +388,6 @@ pub fn run() {
388388
// Initialize soft dialog tracker for MCP (overlays like about, license, confirmations)
389389
app.manage(mcp::SoftDialogTracker::new());
390390

391-
// Initialize settings state store for MCP settings tools
392-
app.manage(mcp::SettingsStateStore::new());
393-
394391
// Start MCP server for AI agent integration
395392
// Use settings from user preferences, with env vars as override for dev
396393
let mcp_config = mcp::McpConfig::from_settings_and_env(
@@ -651,12 +648,6 @@ pub fn run() {
651648
mcp::dialog_state::notify_dialog_opened,
652649
mcp::dialog_state::notify_dialog_closed,
653650
mcp::dialog_state::register_known_dialogs,
654-
mcp::settings_state::mcp_update_settings_state,
655-
mcp::settings_state::mcp_update_settings_open,
656-
mcp::settings_state::mcp_update_settings_section,
657-
mcp::settings_state::mcp_update_settings_sections,
658-
mcp::settings_state::mcp_update_current_settings,
659-
mcp::settings_state::mcp_update_shortcuts,
660651
// Sync status (macOS uses real implementation, others use stub in commands)
661652
commands::sync_status::get_sync_status,
662653
// MTP commands (macOS + Linux - Android device support)

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

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Expose Cmdr functionality to AI agents via the Model Context Protocol (MCP). Age
2121

2222
### Tools (`tools.rs`)
2323

24-
24 semantic tools grouped by category:
24+
25 semantic tools grouped by category:
2525
- Navigation (6): `select_volume`, `nav_to_path`, `nav_to_parent`, `nav_back`, `nav_forward`, `scroll_to`
2626
- Cursor/Selection (3): `move_cursor`, `open_under_cursor`, `select`
2727
- File operations (5): `copy`, `delete`, `mkdir`, `mkfile`, `refresh`
@@ -30,18 +30,20 @@ Expose Cmdr functionality to AI agents via the Model Context Protocol (MCP). Age
3030
- Dialogs (1): `dialog` (unified open/focus/close)
3131
- App (3): `switch_pane`, `swap_panes`, `quit`
3232
- Search (2): `search` (structured file search across the drive index, optional `scope` for path/exclude filtering), `ai_search` (natural language search using configured LLM, optional `scope` merged with AI-inferred scope)
33+
- Settings (1): `set_setting` (change a setting value via round-trip to frontend)
3334

3435
### Resources (`resources.rs`)
3536

3637
- `cmdr://state`: Complete app state in YAML (both panes, volumes, dialogs)
3738
- `cmdr://dialogs/available`: Static metadata about available dialogs
3839
- `cmdr://indexing`: Drive indexing status in plain text (current phase, timeline history, DB stats). Calls `indexing::get_debug_status()` and formats as human-readable text.
40+
- `cmdr://settings`: All settings with current values, defaults, types, and constraints. Fetched via round-trip to the frontend (`mcp-get-all-settings` event).
3941

4042
### Executor (`executor.rs`)
4143

4244
Routes tool calls to implementations. Most tools are fire-and-forget: emit a Tauri event and return "OK" immediately.
4345

44-
Tools where the backend can't fully validate preconditions use `mcp_round_trip`: emit an event with a `requestId`, wait for the frontend to respond via `mcp-response` with `{ requestId, ok, error? }`, and return the actual outcome. Currently used by `nav_to_path` (the frontend knows whether the pane's volume supports local path navigation). Use this pattern for any new tool where the backend would otherwise need to replicate frontend knowledge.
46+
Tools where the backend can't fully validate preconditions use `mcp_round_trip`: emit an event with a `requestId`, wait for the frontend to respond via `mcp-response` with `{ requestId, ok, error? }`, and return the actual outcome. Used by `nav_to_path` and `set_setting`. Resources that need frontend data use `resource_round_trip` (same pattern but returns `data` from the response). Used by `cmdr://settings`. Use these patterns for any new tool/resource where the backend would otherwise need to replicate frontend knowledge.
4547

4648
### Configuration (`config.rs`)
4749

@@ -55,9 +57,8 @@ Constants and configuration for the MCP server (port, bind address, transport se
5557

5658
- `PaneStateStore`: Current state of left/right panes (path, files, cursor, selection, tabs)
5759
- `SoftDialogTracker`: Which dialogs MCP thinks are open (in `dialog_state.rs`)
58-
- `SettingsStateStore`: Current settings window state (section, settings, shortcuts)
5960

60-
Frontend syncs state to these stores via Tauri commands (`update_left_pane_state`, `update_pane_tabs`, `mcp_update_settings_sections`, etc.).
61+
Frontend syncs state to these stores via Tauri commands (`update_left_pane_state`, `update_pane_tabs`, etc.). Settings are fetched on-demand via round-trip to the frontend rather than stored in a state store.
6162

6263
## Key decisions
6364

@@ -87,7 +88,7 @@ Binding to `0.0.0.0` would expose the server to the network. An attacker could q
8788

8889
### Why separate state stores?
8990

90-
`PaneStateStore` is always synced (file pane changes frequently). `SettingsStateStore` is only synced when settings window is open (rare). `SoftDialogTracker` is updated by MCP tools themselves. Separating concerns keeps each store simple.
91+
`PaneStateStore` is always synced (file pane changes frequently). `SoftDialogTracker` is updated by MCP tools themselves. Separating concerns keeps each store simple. Settings are fetched on-demand via `resource_round_trip` rather than stored, since they rarely change and can be queried from the frontend when needed.
9192

9293
## Gotchas
9394

@@ -123,15 +124,9 @@ Large directories (50k+ files) are paginated. The `totalFiles`, `loadedStart`, `
123124

124125
Unlike tools (which need a session via `initialize`), resources can be read immediately after server start. This is by design for debugging with curl.
125126

126-
### Settings state sync is window-specific
127+
### Settings are fetched on-demand, not synced
127128

128-
The settings window calls `syncSettingsState()` on mount and section changes. The main window doesn't sync settings state (it doesn't need to). This means `cmdr://state` only includes settings when the settings window is open.
129-
130-
### MCP-settings bridge vs MCP-shortcuts listener
131-
132-
Settings window: full bridge (`mcp-settings-bridge.ts`) syncs all state and handles all MCP events.
133-
Main window: lightweight listener (`mcp-shortcuts-listener.ts`) only handles shortcut changes.
134-
This separation keeps main window overhead minimal.
129+
The `cmdr://settings` resource and `set_setting` tool both use round-trips to the main window frontend. This means settings are always fetched fresh from the source of truth, rather than being synced to a Rust-side store. The tradeoff is a ~5s timeout if the frontend is unresponsive, but this avoids stale state issues.
135130

136131
### Tool execution is async but mostly synchronous
137132

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

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -68,19 +68,20 @@ async fn mcp_round_trip<R: Runtime>(
6868
let listener_id = app.listen("mcp-response", move |event| {
6969
if let Ok(resp) = serde_json::from_str::<Value>(event.payload())
7070
&& resp.get("requestId").and_then(|v| v.as_str()) == Some(&expected_id)
71-
&& let Some(tx) = tx.lock().unwrap().take() {
72-
let result = if resp.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
73-
Ok(())
74-
} else {
75-
let err = resp
76-
.get("error")
77-
.and_then(|v| v.as_str())
78-
.unwrap_or("Unknown error")
79-
.to_string();
80-
Err(err)
81-
};
82-
let _ = tx.send(result);
83-
}
71+
&& let Some(tx) = tx.lock().unwrap().take()
72+
{
73+
let result = if resp.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
74+
Ok(())
75+
} else {
76+
let err = resp
77+
.get("error")
78+
.and_then(|v| v.as_str())
79+
.unwrap_or("Unknown error")
80+
.to_string();
81+
Err(err)
82+
};
83+
let _ = tx.send(result);
84+
}
8485
});
8586

8687
app.emit(event, payload)?;
@@ -128,6 +129,8 @@ pub async fn execute_tool<R: Runtime>(app: &AppHandle<R>, name: &str, params: &V
128129
// Search commands
129130
"search" => execute_search(params).await,
130131
"ai_search" => execute_ai_search(params).await,
132+
// Settings commands
133+
"set_setting" => execute_set_setting(app, params).await,
131134
_ => Err(ToolError::invalid_params(format!("Unknown tool: {name}"))),
132135
}
133136
}
@@ -430,7 +433,7 @@ async fn execute_nav_command_with_params<R: Runtime>(app: &AppHandle<R>, name: &
430433
return Err(ToolError::invalid_params(format!("Path does not exist: {}", path)));
431434
}
432435

433-
if let Some(store) = app.try_state::<PaneStateStore>() {
436+
if let Some(store) = app.try_state::<PaneStateStore>() {
434437
store.set_focused_pane(pane.to_string());
435438
}
436439

@@ -1262,6 +1265,26 @@ async fn execute_ai_search(params: &Value) -> ToolResult {
12621265
Ok(json!(output))
12631266
}
12641267

1268+
/// Execute set_setting command via round-trip to the frontend.
1269+
async fn execute_set_setting<R: Runtime>(app: &AppHandle<R>, params: &Value) -> ToolResult {
1270+
let id = params
1271+
.get("id")
1272+
.and_then(|v| v.as_str())
1273+
.ok_or_else(|| ToolError::invalid_params("Missing 'id' parameter"))?;
1274+
1275+
let value = params
1276+
.get("value")
1277+
.ok_or_else(|| ToolError::invalid_params("Missing 'value' parameter"))?;
1278+
1279+
mcp_round_trip(
1280+
app,
1281+
"mcp-set-setting",
1282+
json!({"settingId": id, "value": value}),
1283+
format!("OK: Set '{id}' to {value}"),
1284+
)
1285+
.await
1286+
}
1287+
12651288
#[cfg(test)]
12661289
mod tests {
12671290
use super::*;

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ pub mod pane_state;
1010
mod protocol;
1111
mod resources;
1212
mod server;
13-
pub mod settings_state;
1413
mod tools;
1514

1615
#[cfg(test)]
@@ -20,4 +19,3 @@ pub use config::McpConfig;
2019
pub use dialog_state::SoftDialogTracker;
2120
pub use pane_state::PaneStateStore;
2221
pub use server::{get_mcp_actual_port, is_mcp_running, start_mcp_server, start_mcp_server_background, stop_mcp_server};
23-
pub use settings_state::SettingsStateStore;

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

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
//! Resources are read-only state that agents can query.
55
66
use serde::{Deserialize, Serialize};
7-
use tauri::{Manager, Runtime, WebviewWindow};
7+
use serde_json::{Value, json};
8+
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
89

910
use super::dialog_state::SoftDialogTracker;
1011
use super::pane_state::{FileEntry, PaneState, PaneStateStore, TabInfo};
@@ -52,6 +53,12 @@ pub fn get_all_resources() -> Vec<Resource> {
5253
description: "Current drive indexing phase, timeline history, and database stats".to_string(),
5354
mime_type: "text/plain".to_string(),
5455
},
56+
Resource {
57+
uri: "cmdr://settings".to_string(),
58+
name: "Settings".to_string(),
59+
description: "All settings with current values, defaults, types, and constraints".to_string(),
60+
mime_type: "text/yaml".to_string(),
61+
},
5562
]
5663
}
5764

@@ -236,8 +243,57 @@ fn build_available_dialogs_yaml<R: Runtime>(app: &tauri::AppHandle<R>) -> String
236243
yaml
237244
}
238245

246+
/// Emit an event to the frontend and wait for a response containing data.
247+
///
248+
/// Similar to `mcp_round_trip` in executor.rs, but returns the `data` field from the response
249+
/// instead of a fixed success message. The frontend must emit `mcp-response` with
250+
/// `{ requestId, ok, data?, error? }`. Times out after 5 seconds.
251+
async fn resource_round_trip<R: Runtime>(
252+
app: &tauri::AppHandle<R>,
253+
event: &str,
254+
mut payload: Value,
255+
) -> Result<String, String> {
256+
let request_id = uuid::Uuid::new_v4().to_string();
257+
payload["requestId"] = json!(request_id);
258+
259+
let (tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
260+
let expected_id = request_id.clone();
261+
262+
let tx = std::sync::Mutex::new(Some(tx));
263+
let listener_id = app.listen("mcp-response", move |event| {
264+
if let Ok(resp) = serde_json::from_str::<Value>(event.payload())
265+
&& resp.get("requestId").and_then(|v| v.as_str()) == Some(&expected_id)
266+
&& let Some(tx) = tx.lock().unwrap().take()
267+
{
268+
let result = if resp.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
269+
let data = resp.get("data").and_then(|v| v.as_str()).unwrap_or("").to_string();
270+
Ok(data)
271+
} else {
272+
let err = resp
273+
.get("error")
274+
.and_then(|v| v.as_str())
275+
.unwrap_or("Unknown error")
276+
.to_string();
277+
Err(err)
278+
};
279+
let _ = tx.send(result);
280+
}
281+
});
282+
283+
app.emit(event, payload).map_err(|e| e.to_string())?;
284+
285+
let result = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await;
286+
app.unlisten(listener_id);
287+
288+
match result {
289+
Ok(Ok(data)) => data,
290+
Ok(Err(_)) => Err("Frontend response channel dropped".to_string()),
291+
Err(_) => Err("Frontend did not respond within 5 seconds".to_string()),
292+
}
293+
}
294+
239295
/// Read a resource by URI.
240-
pub fn read_resource<R: Runtime>(app: &tauri::AppHandle<R>, uri: &str) -> Result<ResourceContent, String> {
296+
pub async fn read_resource<R: Runtime>(app: &tauri::AppHandle<R>, uri: &str) -> Result<ResourceContent, String> {
241297
let (content, mime_type) = match uri {
242298
"cmdr://state" => {
243299
let store = app.try_state::<PaneStateStore>().ok_or("Pane state not available")?;
@@ -324,6 +380,10 @@ pub fn read_resource<R: Runtime>(app: &tauri::AppHandle<R>, uri: &str) -> Result
324380
let text = build_indexing_status_text();
325381
(text, "text/plain")
326382
}
383+
"cmdr://settings" => {
384+
let text = resource_round_trip(app, "mcp-get-all-settings", json!({})).await?;
385+
(text, "text/yaml")
386+
}
327387
_ => return Err(format!("Unknown resource URI: {}", uri)),
328388
};
329389

@@ -472,7 +532,7 @@ mod tests {
472532
#[test]
473533
fn test_resource_count() {
474534
let resources = get_all_resources();
475-
assert_eq!(resources.len(), 3);
535+
assert_eq!(resources.len(), 4);
476536
}
477537

478538
#[test]

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,7 @@ async fn process_request<R: Runtime>(
513513
}
514514
};
515515

516-
match read_resource(&state.app, uri) {
516+
match read_resource(&state.app, uri).await {
517517
Ok(content) => (McpResponse::success(request.id, json!({"contents": [content]})), None),
518518
Err(e) => (McpResponse::error(request.id, INVALID_PARAMS, e), None),
519519
}

0 commit comments

Comments
 (0)