Skip to content

Commit 640c333

Browse files
committed
SMB: auto-upgrade on dev profiles, plus MCP visibility and trigger
Three fixes that all touch the same flow: existing OS-mounted SMB shares can now reach the fast direct-smb2 path automatically (or via MCP for agents), and agents can see which volumes are on which path. Auto-upgrade for dev profiles (#1): `upgrade_existing_smb_mounts` now takes an `AppHandle` and calls `crate::network::ensure_mdns_started` itself when it finds SMB mounts to upgrade. The `firstTriggerDone` gate is gone from this call site — the function is a no-op when there are no SMB mounts (no network activity, no macOS Local Network prompt), so the gate was protecting nothing useful. With mounts present and `network.directSmbConnection` on (default true), mDNS now starts at launch and the upgrade can resolve hostname-keyed Keychain creds the same way the manual "Connect directly" button does. Dev profiles with `firstTriggerDone == false` and an auto-reconnected SMB share previously stayed on the slow OS-mount path indefinitely; that's the bug. MCP visibility (#3): `cmdr://state`'s volumes section now emits structured entries for SMB volumes with `name`, `id`, and `smbConnectionState` (`direct` | `os_mount` | `disconnected`). Non-SMB volumes stay as bare `- {name}` lines for compactness. Agents can now distinguish which SMB shares are on the fast path. MCP trigger (#2): new `upgrade_smb_to_direct` tool, sibling of `connect_to_server` and `remove_manual_server` in the network category. Thin wrapper around the same upgrade flow the manual UI uses. Calls a newly-extracted `upgrade_to_smb_volume_inner` helper (the Tauri command's body minus the concrete-AppHandle mDNS kick) so the MCP executor — generic over `Runtime` — can reuse the logic. Tool description steers agents: if mDNS isn't running and hostname-keyed Keychain creds matter, take a network action first. Refactor along the way: `enrich_smb_connection_state` was duplicated across `commands/volumes.rs` (for `list_volumes`) and `volume_broadcast.rs` (for `volumes-changed`) with a "must stay in sync" gotcha. With MCP becoming a third caller, the sync risk grew. Extracted to `volumes::enrich_smb_connection_state`; all three call sites now share it. CLAUDE.md gotcha → decision. Tests / docs: - MCP `test_all_tools_count` / `test_network_tools_count` bumped (29 → 30, 2 → 3). - MCP `test_total_tool_count` bumped to 30. - file_system/volume/CLAUDE.md startup-upgrade lifecycle entry updated to reflect the new mDNS-kick path and no-gate semantics. - mcp/CLAUDE.md tool count, network category list, and `cmdr://state` volumes-shape description updated. - volumes/CLAUDE.md "two-site sync" gotcha replaced with a single-source decision entry. Verified: full `./scripts/check.sh` green (50 checks, 1983 unit + 32 integration + every linter), including the 189 MCP unit tests.
1 parent 719e4f9 commit 640c333

14 files changed

Lines changed: 202 additions & 79 deletions

File tree

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

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,24 @@ pub async fn mount_network_share(
354354
#[tauri::command]
355355
#[specta::specta]
356356
pub async fn upgrade_to_smb_volume(volume_id: String, app_handle: tauri::AppHandle) -> Result<UpgradeResult, String> {
357+
// Kick mDNS off so IP → hostname resolution has a shot before we hit the
358+
// Keychain. Idempotent; no-op if already running or `network.enabled` is off.
359+
// Kept here (and not in `upgrade_to_smb_volume_inner`) because
360+
// `ensure_mdns_started` requires a concrete `AppHandle`, while the inner
361+
// function needs to stay AppHandle-free so the MCP executor (generic over
362+
// `Runtime`) can call it. MCP relies on mDNS having been started elsewhere
363+
// (initial launch with `firstTriggerDone == true`, or any prior network
364+
// action); the MCP tool's description tells agents to take a network
365+
// action first if their target volume needs hostname-keyed creds.
366+
crate::network::ensure_mdns_started(app_handle);
367+
upgrade_to_smb_volume_inner(volume_id).await
368+
}
369+
370+
/// Body of `upgrade_to_smb_volume` minus the mDNS kick (which needs concrete
371+
/// `AppHandle`). Used by the Tauri command above and by the MCP
372+
/// `upgrade_smb_to_direct` executor — both routes share the same Keychain
373+
/// lookup, mDNS-cached hostname resolution, and `try_smb_upgrade` body.
374+
pub async fn upgrade_to_smb_volume_inner(volume_id: String) -> Result<UpgradeResult, String> {
357375
use crate::file_system::get_volume_manager;
358376
#[cfg(target_os = "macos")]
359377
use crate::volumes::get_smb_mount_info;
@@ -387,10 +405,6 @@ pub async fn upgrade_to_smb_volume(volume_id: String, app_handle: tauri::AppHand
387405
info.username
388406
);
389407

390-
// Kick mDNS off so IP → hostname resolution has a shot before we hit the
391-
// Keychain. Idempotent; no-op if already running or `network.enabled` is off.
392-
crate::network::ensure_mdns_started(app_handle);
393-
394408
// Try to get credentials from Keychain. The mount source has the IP, but Cmdr
395409
// stores Keychain credentials keyed by hostname (from mDNS). Try both. Briefly
396410
// wait for mDNS to warm up so we don't prompt for creds the user already saved.
@@ -691,7 +705,7 @@ pub fn remove_manual_server(server_id: String, app_handle: tauri::AppHandle) ->
691705
pub fn ensure_network_discovery_started(app_handle: tauri::AppHandle) {
692706
crate::network::start_discovery(app_handle.clone());
693707
manual_servers::load_manual_servers(&app_handle);
694-
crate::file_system::upgrade_existing_smb_mounts();
708+
crate::file_system::upgrade_existing_smb_mounts(app_handle.clone());
695709

696710
#[cfg(feature = "smb-e2e")]
697711
crate::network::virtual_smb_hosts::setup_virtual_smb_hosts(&app_handle);

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

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ use serde::Serialize;
44
use tokio::time::Duration;
55

66
use super::util::{TimedOut, blocking_with_timeout_flag};
7-
use crate::file_system::get_volume_manager;
87
use crate::volumes::{self, DEFAULT_VOLUME_ID, LocationCategory, VolumeInfo, VolumeSpaceInfo};
98

109
/// Result of resolving a path to its containing volume.
@@ -26,32 +25,10 @@ const VOLUME_TIMEOUT: Duration = Duration::from_secs(2);
2625
pub async fn list_volumes() -> TimedOut<Vec<VolumeInfo>> {
2726
let mut result = blocking_with_timeout_flag(VOLUME_TIMEOUT, vec![], volumes::list_mounted_volumes).await;
2827
append_mtp_volumes(&mut result.data).await;
29-
enrich_smb_connection_state(&mut result.data);
28+
volumes::enrich_smb_connection_state(&mut result.data);
3029
result
3130
}
3231

33-
/// Enriches volume entries with SMB connection state from the VolumeManager.
34-
///
35-
/// For each volume, looks up the registered Volume in VolumeManager and checks
36-
/// if it reports an `smb_connection_state`. This adds the green/yellow indicator
37-
/// for SMB shares in the frontend volume picker.
38-
fn enrich_smb_connection_state(volumes: &mut [VolumeInfo]) {
39-
use crate::volumes::SmbConnectionState;
40-
41-
let manager = get_volume_manager();
42-
for vol in volumes.iter_mut() {
43-
if let Some(registered) = manager.get(&vol.id) {
44-
vol.smb_connection_state = registered.smb_connection_state();
45-
}
46-
47-
// SMB shares without a direct smb2 connection show as OsMount (yellow).
48-
// This covers pre-existing mounts registered as LocalPosixVolume at startup.
49-
if vol.smb_connection_state.is_none() && volumes::is_smb_fs_type(vol.fs_type.as_deref()) {
50-
vol.smb_connection_state = Some(SmbConnectionState::OsMount);
51-
}
52-
}
53-
}
54-
5532
/// Gets the default volume ID (root filesystem).
5633
#[tauri::command]
5734
#[specta::specta]

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,22 @@ pub fn get_volume_manager() -> &'static VolumeManager {
186186
///
187187
/// Scans all registered volumes, finds those on `smbfs`, and tries to establish
188188
/// a parallel smb2 session for each. Non-blocking: failures are logged and skipped.
189+
///
190+
/// If any SMB mounts are found, kicks off mDNS via `ensure_mdns_started` so the
191+
/// upgrade's Keychain lookup (keyed by hostname, not IP) can find stored creds.
192+
/// This mirrors the manual "Connect directly" and mount-time auto-upgrade paths,
193+
/// so existing OS-mounted SMB shares get the same treatment as new ones — see
194+
/// the "SMB upgrade waits briefly for mDNS to warm" gotcha in
195+
/// `network/CLAUDE.md`. Kicking off mDNS will pop the macOS Local Network prompt
196+
/// once per app on first launch; that's the trade-off for not requiring users
197+
/// to click "Connect directly" on every relaunch when they have direct-SMB on
198+
/// and an existing mount.
199+
///
200+
/// Returns silently when:
201+
/// - direct-SMB is disabled (`network.directSmbConnection`),
202+
/// - or no SMB mounts are registered (no scan cost, no prompt).
189203
#[cfg(any(target_os = "macos", target_os = "linux"))]
190-
pub fn upgrade_existing_smb_mounts() {
204+
pub fn upgrade_existing_smb_mounts(app_handle: tauri::AppHandle) {
191205
#[cfg(target_os = "macos")]
192206
use crate::volumes::get_smb_mount_info;
193207
#[cfg(target_os = "linux")]
@@ -229,6 +243,13 @@ pub fn upgrade_existing_smb_mounts() {
229243
volumes_to_upgrade.len()
230244
);
231245

246+
// Kick off mDNS so `resolve_ip_to_hostname` can find the host. Without this,
247+
// the Keychain lookup misses on auth-required shares (creds are keyed by
248+
// hostname like `smb://naspolya/share`, not by IP). Same pattern as the
249+
// manual `upgrade_to_smb_volume` and mount-time `try_upgrade_smb_mount`
250+
// paths. Idempotent: no-op if mDNS is already running.
251+
crate::network::ensure_mdns_started(app_handle);
252+
232253
// Use tauri's runtime spawn (this runs during setup() before Tokio is fully available).
233254
// Wait for mDNS discovery to reach Active state (initial burst complete) so hostname
234255
// resolution is available for Keychain lookup.

apps/desktop/src-tauri/src/file_system/volume/CLAUDE.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -207,17 +207,22 @@ The same rule applies to write paths: `write_from_stream` must drive the backend
207207

208208
SMB mounts are automatically upgraded to `SmbVolume` (direct smb2 connection) in two scenarios:
209209

210-
1. **Startup** (`file_system::upgrade_existing_smb_mounts`): Scans registered volumes for `smbfs` type. Waits for mDNS
211-
discovery to reach `Active` state (polls every 500ms, up to 15s) because Keychain credentials are keyed by hostname
212-
(from mDNS), not IP (from `statfs`). Uses `tauri::async_runtime::spawn` (not `tokio::spawn`; runs during `setup()`
213-
before Tokio is fully available). Emits `volumes-changed` after upgrades so the frontend refreshes indicators.
210+
1. **Startup** (`file_system::upgrade_existing_smb_mounts(app_handle)`): Scans registered volumes for `smbfs` type. If
211+
any are found, calls `network::ensure_mdns_started` to kick off mDNS itself (creds are keyed by hostname, not IP),
212+
then waits for mDNS to reach `Active` state (polls every 500ms, up to 15s). Uses `tauri::async_runtime::spawn` (not
213+
`tokio::spawn`; runs during `setup()` before Tokio is fully available). Emits `volumes-changed` after upgrades so
214+
the frontend refreshes indicators. **No `firstTriggerDone` gate**: the function is a no-op when no SMB mounts are
215+
present (no network activity, no macOS Local Network prompt). When mounts are present AND `network.directSmbConnection`
216+
is on (default `true`), it kicks off mDNS — that's when the macOS prompt fires, once per app per data dir. Before
217+
this change the upgrade was gated behind `firstTriggerDone`, so dev profiles with auto-reconnected SMB shares stayed
218+
on the slow OS-mount path forever.
214219

215220
2. **Mount detection** (`volumes/watcher.rs::try_upgrade_smb_mount`): When FSEvents detects a new volume in `/Volumes/`
216-
and it's `smbfs`, spawns a background upgrade attempt. By this point mDNS is already active.
221+
and it's `smbfs`, spawns a background upgrade attempt. Calls `ensure_mdns_started` to kick off mDNS too.
217222

218223
Both paths check the `network.directSmbConnection` setting (global `AtomicBool`). Both are best-effort. Failures log a
219224
warning and the volume stays as `LocalPosixVolume`. The "Connect directly" UI action (`upgrade_to_smb_volume` command)
220-
provides a manual upgrade path.
225+
and the MCP `upgrade_smb_to_direct` tool provide manual upgrade paths.
221226

222227
## SMB live-reconnect lifecycle
223228

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

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -501,15 +501,14 @@ pub fn run() {
501501
space_poller::start();
502502

503503
// Upgrade existing SMB mounts to direct smb2 connections (background, non-blocking).
504-
// Gated on the same lazy-startup conditions as mDNS above. Opening a TCP socket to
505-
// a private-IP SMB server triggers macOS's Local Network prompt independently, so
506-
// we must not run this on fresh installs either. Returning users already answered
507-
// the prompt; lazy users wait until they click Network or use the picker's "Connect
508-
// directly" indicator.
504+
// No `firstTriggerDone` gate here: the function is a no-op when there are no SMB
505+
// mounts (no network activity, no prompt). When there ARE mounts and direct-SMB is
506+
// enabled, the function kicks off mDNS itself so the Keychain lookup can resolve
507+
// hostnames — same shape as the manual "Connect directly" and mount-time paths.
508+
// The macOS Local Network prompt fires once per app and only when an SMB mount is
509+
// present at launch; subsequent launches start mDNS eagerly via `firstTriggerDone`.
509510
#[cfg(any(target_os = "macos", target_os = "linux"))]
510-
if should_start_network_at_launch {
511-
file_system::upgrade_existing_smb_mounts();
512-
}
511+
file_system::upgrade_existing_smb_mounts(app.handle().clone());
513512

514513
// Check if there's an existing license (for menu text)
515514
let has_existing_license = licensing::get_license_info(app.handle()).is_some();

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

Lines changed: 4 additions & 4 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-
29 semantic tools grouped by category:
24+
30 semantic tools grouped by category:
2525
- Navigation (6): `select_volume` (also accepts MTP volume names), `nav_to_path` (supports `mtp://` paths, skips filesystem existence check), `nav_to_parent`, `nav_back`, `nav_forward`, `scroll_to`
2626
- Cursor/Selection (3): `move_cursor`, `open_under_cursor`, `select`
2727
- File operations (6): `copy`, `move`, `delete`, `mkdir`, `mkfile`, `refresh`. `copy`/`move` accept optional `autoConfirm` (bool) and `onConflict` (`skip_all`|`overwrite_all`|`rename_all`). `delete` accepts optional `autoConfirm`. When `autoConfirm` is true, the dialog opens and immediately confirms.
@@ -31,12 +31,12 @@ Expose Cmdr functionality to AI agents via the Model Context Protocol (MCP). Age
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)
3333
- Settings (1): `set_setting` (change a setting value via round-trip to frontend)
34-
- Network (2): `connect_to_server` (add a manual SMB server by address, checks TCP reachability), `remove_manual_server` (remove a manually-added server by host ID)
34+
- Network (3): `connect_to_server` (add a manual SMB server by address, checks TCP reachability), `remove_manual_server` (remove a manually-added server by host ID), `upgrade_smb_to_direct` (upgrade an OS-mounted SMB volume to a direct smb2 session for faster I/O; thin wrapper over the existing manual "Connect directly" Tauri command — tries Keychain creds, returns a typed result mirroring `UpgradeResult`)
3535
- Async (1): `await` (poll PaneStateStore until a condition is met: `has_item`, `item_count_gte`, `path`, or `path_contains`. Supports `after_generation` to avoid matching stale state)
3636

3737
### Resources (`resources.rs`)
3838

39-
- `cmdr://state`: Complete app state in YAML (both panes, volumes, dialogs, active `listings` cache, `recentErrors`). Includes MTP volumes with `name` and `id`, and per-pane `volumeId`. The `listings` section reflects every entry in `LISTING_CACHE` (id, volumeId, path, entry count, ageMs); `recentErrors` is the last 20 directory-listing failures with `atUnixMs`, `listingId`, `volumeId`, `path`, `message` (see `listing_errors.rs` and the freshness contract below). Supports `?include=panes,volumes,dialogs,listings,recentErrors` projection (defaults to all) and `?compact=true` (drops the `files:` list inside each pane while keeping every summary field). Example: `cmdr://state?include=listings,recentErrors` is the minimal payload for "did the last listing succeed?".
39+
- `cmdr://state`: Complete app state in YAML (both panes, volumes, dialogs, active `listings` cache, `recentErrors`). Includes MTP volumes with `name` and `id`, and per-pane `volumeId`. SMB volumes appear as structured entries with `name`, `id`, and `smbConnectionState` (`direct` | `os_mount` | `disconnected`) so agents can route the `upgrade_smb_to_direct` tool at the right volumes; non-SMB volumes stay as bare `- {name}` lines. The `listings` section reflects every entry in `LISTING_CACHE` (id, volumeId, path, entry count, ageMs); `recentErrors` is the last 20 directory-listing failures with `atUnixMs`, `listingId`, `volumeId`, `path`, `message` (see `listing_errors.rs` and the freshness contract below). Supports `?include=panes,volumes,dialogs,listings,recentErrors` projection (defaults to all) and `?compact=true` (drops the `files:` list inside each pane while keeping every summary field). Example: `cmdr://state?include=listings,recentErrors` is the minimal payload for "did the last listing succeed?".
4040
- `cmdr://dialogs/available`: Static metadata about available dialogs
4141
- `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.
4242
- `cmdr://settings`: All settings with current values, defaults, types, and constraints. Fetched via round-trip to the frontend (`mcp-get-all-settings` event).
@@ -50,7 +50,7 @@ Directory module split by tool category. `mod.rs` contains the main `execute_too
5050
- `nav.rs`: navigation commands (with and without params)
5151
- `file_ops.rs`: copy, move, delete, mkdir, mkfile, refresh, select
5252
- `dialogs.rs`: unified dialog open/focus/close/confirm
53-
- `async_tools.rs`: await, connect_to_server, remove_manual_server, set_setting
53+
- `async_tools.rs`: await, connect_to_server, remove_manual_server, upgrade_smb_to_direct, set_setting
5454
- `search.rs`: search index loading, search, ai_search
5555

5656
**Action-tool ack contract.** Every fire-and-forget action tool now waits for a backend ack signal before returning `OK`. Previously the tool returned `OK` the instant the event was dispatched; if the FE was stalled (modal blocking input, error pane up, race during startup), the action was silently dropped and MCP reported success anyway. The ack contract makes `OK` a meaningful promise: the FE has actually processed the dispatched action.

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,69 @@ pub fn execute_remove_manual_server<R: Runtime>(app: &AppHandle<R>, params: &Val
141141
}
142142
}
143143

144+
/// Execute `upgrade_smb_to_direct`: upgrade an OS-mounted SMB volume to a direct
145+
/// smb2 session. Thin wrapper around the existing `upgrade_to_smb_volume` Tauri
146+
/// command (same code path that powers the "Connect directly" UI button) so
147+
/// agents get the same behaviour as users: tries stored Keychain credentials,
148+
/// returns a typed result mirroring `UpgradeResult` (Success / CredentialsNeeded
149+
/// / NetworkError). On `CredentialsNeeded`, agents are out of luck for now —
150+
/// credential prompts are interactive; a future tool could accept credentials
151+
/// inline and call `upgrade_to_smb_volume_with_credentials`.
152+
///
153+
/// Only meaningful on macOS / Linux (the underlying command is platform-gated).
154+
/// On other platforms the Tauri stub returns an error; we surface it as an
155+
/// MCP internal error.
156+
pub async fn execute_upgrade_smb_to_direct<R: Runtime>(_app: &AppHandle<R>, params: &Value) -> ToolResult {
157+
let volume_id = params
158+
.get("volume_id")
159+
.and_then(|v| v.as_str())
160+
.ok_or_else(|| ToolError::invalid_params("Missing 'volume_id' parameter"))?;
161+
162+
#[cfg(any(target_os = "macos", target_os = "linux"))]
163+
{
164+
use crate::network::smb_upgrade::UpgradeResult;
165+
// Calls the inner helper rather than the Tauri command itself because
166+
// the Tauri command takes concrete `tauri::AppHandle` (= `AppHandle<Wry>`)
167+
// while the MCP executor's `app` is generic over `Runtime`. The inner
168+
// function carries all the upgrade logic minus the mDNS kick (which
169+
// needs a concrete handle). Agents wanting hostname-keyed Keychain
170+
// creds need mDNS already running — see the tool description.
171+
match crate::commands::network::upgrade_to_smb_volume_inner(volume_id.to_string()).await {
172+
Ok(UpgradeResult::Success) => Ok(json!(format!("OK: Upgraded {} to direct smb2", volume_id))),
173+
Ok(UpgradeResult::CredentialsNeeded {
174+
server,
175+
share,
176+
display_name,
177+
..
178+
}) => {
179+
let server_label = if display_name.is_empty() { server } else { display_name };
180+
Ok(json!(format!(
181+
"Needs credentials: share={} on {}. Cmdr's Keychain didn't have a working password for this share. \
182+
Agents can't prompt; the user has to enter credentials via the UI's 'Connect directly' button. \
183+
(If mDNS isn't running, hostname-keyed creds also won't be found; trigger any network UI action first.)",
184+
share, server_label
185+
)))
186+
}
187+
Ok(UpgradeResult::NetworkError { message }) => Err(ToolError::internal(format!(
188+
"Network error while upgrading {}: {}",
189+
volume_id, message
190+
))),
191+
Err(e) => Err(ToolError::internal(format!(
192+
"upgrade_to_smb_volume_inner({}) failed: {}",
193+
volume_id, e
194+
))),
195+
}
196+
}
197+
198+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
199+
{
200+
let _ = volume_id;
201+
Err(ToolError::internal(
202+
"upgrade_smb_to_direct is only supported on macOS and Linux".to_string(),
203+
))
204+
}
205+
}
206+
144207
// ── Settings ─────────────────────────────────────────────────────────
145208

146209
/// Execute set_setting command via round-trip to the frontend.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ pub async fn execute_tool<R: Runtime>(app: &AppHandle<R>, name: &str, params: &V
162162
// Network commands
163163
"connect_to_server" => async_tools::execute_connect_to_server(app, params).await,
164164
"remove_manual_server" => async_tools::execute_remove_manual_server(app, params),
165+
"upgrade_smb_to_direct" => async_tools::execute_upgrade_smb_to_direct(app, params).await,
165166
// Async wait
166167
"await" => async_tools::execute_await(app, params).await,
167168
_ => Err(ToolError::invalid_params(format!("Unknown tool: {name}"))),

0 commit comments

Comments
 (0)