diff --git a/DEV.md b/DEV.md index 6afc8ac9..1c6594c6 100644 --- a/DEV.md +++ b/DEV.md @@ -227,9 +227,9 @@ Source: [`crates/konnect-core/src/observability.rs`](crates/konnect-core/src/obs ## Tool Routing (Starter Kit + On-Demand Loading) -The server does NOT expose all 187 tools (193 total with the 6 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: +The server does NOT expose all 187 tools (194 total with the 7 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: -- **Startup**: only `STARTER_KIT` toolsets are pre-loaded (see `router/registry.rs::STARTER_KIT`). Currently: `project`, `config`. Combined with the 6 meta-tools, baseline `tools/list` is ~19 tools ≈ 2K tokens. +- **Startup**: only `STARTER_KIT` toolsets are pre-loaded (see `router/registry.rs::STARTER_KIT`). Currently: `project`, `config`. Combined with the 7 meta-tools, baseline `tools/list` is ~20 tools ≈ 2K tokens. - **On demand**: the LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose a toolset's tools in subsequent `tools/list` responses. `unload_toolset(name)` prunes them when the task shifts. - **`tools/list_changed` notification**: sent on every load/unload so MCP clients refresh their local tool cache. - **Error recovery**: if the LLM calls an unloaded tool, `handler.rs` returns an actionable error naming the toolset that owns it (so the LLM can load it and retry in one hop — no extra `list_toolboxes` round-trip). @@ -289,9 +289,9 @@ convention for other `kicad-cli`-calling code. ## Current Stats -- **18 toolsets, 187 tools** + 6 meta-tools (4 routing + 2 observability — see `tool-directory.md`) +- **18 toolsets, 187 tools** + 7 meta-tools (4 routing + 2 observability + 1 maintenance — see `tool-directory.md`) - Baseline `tools/list`: ~19 tools / ~2K tokens (starter kit + meta-tools) -- Full-catalog `tools/list` (all loaded): 193 tools (187 registered + 6 meta) / ~25K tokens +- Full-catalog `tools/list` (all loaded): 194 tools (187 registered + 7 meta) / ~25K tokens - **0 IPC stubs** (all protobuf methods implemented) - **0 unimplemented tools** - **3 CLI commands removed in KiCAD v10** (specctra DSN/SES, pcb sync — return clear errors) diff --git a/crates/konnect-core/src/router/meta_tools.rs b/crates/konnect-core/src/router/meta_tools.rs index 5489ee7f..3deea3ca 100644 --- a/crates/konnect-core/src/router/meta_tools.rs +++ b/crates/konnect-core/src/router/meta_tools.rs @@ -1,4 +1,4 @@ -//! The 6 always-visible meta-tools. +//! The 7 always-visible meta-tools. //! //! Discovery / routing: //! list_toolboxes() — show all 18 toolsets with descriptions and load state @@ -6,6 +6,9 @@ //! unload_toolset(name) — deactivate a toolset, remove its tools from tools/list //! get_active_toolsets() — list currently loaded toolsets //! +//! Maintenance: +//! reload_server(confirm) — exec into the binary on disk, keeping the connection +//! //! Observability: //! get_recent_calls(limit?) — last N tool calls (newest first) with timing + status //! server_stats() — uptime, per-tool totals/errors, JSONL log path @@ -19,7 +22,7 @@ use crate::mcp::protocol::{CallToolResult, McpToolDescription}; use crate::tools::ToolContext; use serde_json::{json, Value}; -/// Return the 6 meta-tool MCP descriptions (always in the tools/list response). +/// Return the 7 meta-tool MCP descriptions (always in the tools/list response). pub fn meta_tool_descriptions() -> Vec { vec![ McpToolDescription { @@ -120,6 +123,26 @@ pub fn meta_tool_descriptions() -> Vec { "required": [] }), }, + McpToolDescription { + name: "reload_server".to_string(), + description: "Restart the server in place from the binary on disk, so a freshly built \ + Konnect takes effect without restarting the MCP client. The process image \ + is replaced (same PID, same stdio pipes), so the connection survives. The \ + new binary is verified before the switch, and the call is refused if it \ + does not run. Loaded toolsets reset to the starter kit afterwards — reload \ + the ones you need. Unix only." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "confirm": { + "type": "boolean", + "description": "Must be true. Guards against an accidental reload mid-task." + } + }, + "required": ["confirm"] + }), + }, ] } @@ -136,10 +159,126 @@ pub async fn handle_meta_tool( "get_active_toolsets" => Some(handle_get_active_toolsets(ctx).await), "get_recent_calls" => Some(handle_get_recent_calls(args, ctx).await), "server_stats" => Some(handle_server_stats(ctx).await), + "reload_server" => Some(handle_reload_server(args).await), _ => None, } } +/// Replace this process with a fresh copy of the binary on disk. +/// +/// A stdio MCP server cannot meaningfully "restart itself": the client owns the +/// process, and exiting just drops the transport — the client does not respawn +/// it mid-session. `exec` sidesteps that. It replaces the process *image* while +/// keeping the PID and, crucially, the inherited stdin/stdout pipes, so the +/// client's connection is never broken and it goes on talking to what is now +/// the new build. +/// +/// Two things the caller should know: +/// +/// * Router state does not survive. The new image starts at the starter kit, so +/// previously loaded toolsets must be loaded again. A call to a tool that was +/// loaded before returns the usual `toolset_not_loaded` error naming its +/// toolset, so recovery is one hop. +/// * The reply is written before the switch. `exec` never returns on success, +/// so the response has to reach the client first; a short delay covers the +/// transport's flush. +async fn handle_reload_server(args: &Value) -> CallToolResult { + if !args + .get("confirm") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return CallToolResult::error_kind( + crate::mcp::error::ToolErrorKind::InvalidArgument { + field: "confirm".to_string(), + reason: "must be true — reload_server restarts the server in place".to_string(), + }, + "reload_server requires confirm=true.", + ); + } + + #[cfg(not(unix))] + { + CallToolResult::error_kind( + crate::mcp::error::ToolErrorKind::HandlerError { + reason: "exec-in-place is Unix only".to_string(), + }, + "reload_server is not supported on this platform: replacing the process image \ + while keeping the stdio pipes requires exec(), which Windows has no equivalent \ + for. Restart the MCP client instead.", + ) + } + + #[cfg(unix)] + { + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + return CallToolResult::error_kind( + crate::mcp::error::ToolErrorKind::HandlerError { + reason: format!("cannot determine current executable: {e}"), + }, + format!("reload_server could not find its own binary: {e}"), + ); + } + }; + + // Verify before switching. `exec` is a one-way door: if the binary on + // disk is broken — a half-written copy, a build that fails to link, an + // unsigned binary macOS will kill — the server is simply gone and the + // client has nothing to talk to. Running it once first turns that into + // a refused call. + match std::process::Command::new(&exe).arg("--version").output() { + Ok(out) if out.status.success() => { + let version = String::from_utf8_lossy(&out.stdout).trim().to_string(); + tracing::info!(exe = %exe.display(), %version, "reload_server: exec into new image"); + + let exe_for_task = exe.clone(); + tokio::spawn(async move { + // Let the transport flush this call's reply before the + // process image is replaced. + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + use std::os::unix::process::CommandExt; + let err = std::process::Command::new(&exe_for_task).exec(); + // exec only returns on failure. + tracing::error!(error = %err, "reload_server: exec failed; server still running old image"); + }); + + CallToolResult::json(&json!({ + "reloading": true, + "binary": exe.display().to_string(), + "version": version, + "note": "Server is replacing its process image. The connection survives \ + (same PID, same pipes). Loaded toolsets reset to the starter kit — \ + call load_toolset again for the ones you need.", + })) + } + Ok(out) => CallToolResult::error_kind( + crate::mcp::error::ToolErrorKind::HandlerError { + reason: format!("binary exited with {}", out.status), + }, + format!( + "Refusing to reload: {} does not run cleanly (exit {}). \ + stderr: {}", + exe.display(), + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ), + ), + Err(e) => CallToolResult::error_kind( + crate::mcp::error::ToolErrorKind::HandlerError { + reason: format!("cannot execute binary: {e}"), + }, + format!( + "Refusing to reload: {} could not be executed ({e}). On macOS a freshly \ + copied binary needs re-signing: codesign --force -s - ", + exe.display() + ), + ), + } + } +} + async fn handle_list_toolboxes(ctx: &std::sync::Arc) -> CallToolResult { use std::collections::HashSet; let active: HashSet = ctx.router.active_names().await.into_iter().collect(); @@ -317,3 +456,74 @@ async fn handle_get_active_toolsets(ctx: &std::sync::Arc) -> CallTo .sum::() })) } + +#[cfg(test)] +mod meta_tool_tests { + use super::*; + use crate::mcp::protocol::ToolContent; + + /// The meta-tool count is quoted in DEV.md, README.md and tool-directory.md + /// ("187 registered + 6 meta = 193"). Pin it so adding one forces those to + /// be updated in the same commit rather than drifting. + #[test] + fn meta_tool_count_is_pinned() { + let names: Vec = meta_tool_descriptions() + .iter() + .map(|d| d.name.clone()) + .collect(); + assert_eq!( + names.len(), + 7, + "meta-tool count changed — update DEV.md, README.md and tool-directory.md too. \ + Current: {names:?}" + ); + } + + /// Every meta-tool advertised in tools/list must actually dispatch, or the + /// model sees a tool it cannot call. + #[tokio::test] + async fn every_advertised_meta_tool_dispatches() { + use crate::router::ToolRouter; + use crate::tools::{ServerConfig, ToolContext}; + use std::sync::Arc; + + let ctx = Arc::new(ToolContext::new( + ServerConfig { + kicad_cli: String::new(), + kicad_binary: String::new(), + ipc_address: String::new(), + project_dir: None, + jlcpcb_db_path: None, + auto_load_toolsets: false, + }, + Arc::new(ToolRouter::new()), + )); + + for desc in meta_tool_descriptions() { + // reload_server would replace the process; check dispatch only, with + // confirm omitted so it returns the InvalidArgument guard instead. + let args = json!({}); + let handled = handle_meta_tool(&desc.name, &args, &ctx).await; + assert!( + handled.is_some(), + "meta-tool '{}' is advertised but not dispatched", + desc.name + ); + } + } + + /// The guard exists so a stray call cannot restart the server mid-task. + #[tokio::test] + async fn reload_server_refuses_without_confirm() { + let result = handle_reload_server(&json!({})).await; + assert!(result.is_error); + + let ToolContent::Text { text } = &result.content[0] else { + panic!("expected text content"); + }; + assert!( + text.contains("confirm"), + "error should name the missing confirm flag, got: {text}" + ); + } +} diff --git a/tool-directory.md b/tool-directory.md index 8aab049a..e1876f92 100644 --- a/tool-directory.md +++ b/tool-directory.md @@ -10,13 +10,13 @@ Canonical reference for every MCP tool exposed by Konnect. Generated from the Ru ## Overview - **18 toolsets** organized into 10 categories -- **187 registered tools** + **6 always-visible meta-tools** = **193 total** +- **187 registered tools** + **7 always-visible meta-tools** = **194 total** - **Discovery pattern**: the server pre-loads only the **starter kit** (`project`, `config`) so baseline `tools/list` costs ~2K tokens instead of ~23K. The LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose additional tools on demand; `unload_toolset(name)` prunes them. `tools/list_changed` is notified on every mutation. If the LLM calls a tool whose toolset isn't loaded, the error names the owning toolset so recovery is a single `load_toolset` hop. `load_toolset` also accepts an array of names to load several toolsets with a single `tools/list` refresh. - **Observability**: every `tools/call` is recorded — ring buffer of the last 100 calls + per-tool counters + JSONL at `/logs/calls.jsonl`. The LLM self-diagnoses via `get_recent_calls` and `server_stats`. ## Meta-tools (always visible) -Six tools, grouped into *discovery/routing* and *observability*. +Seven tools, grouped into *discovery/routing* and *observability*. ### Discovery / routing @@ -33,6 +33,7 @@ Six tools, grouped into *discovery/routing* and *observability*. |------|---------| | `get_recent_calls` | Last N tool calls (newest first) — `call_id`, tool, toolset, duration, status (ok/error/not_found), `error_kind`. The LLM's debug log. Default limit 20, max 100. | | `server_stats` | Uptime, total/error call counts, per-tool totals + errors, and the JSONL log path. | +| `reload_server` | Restart the server in place from the binary on disk, so a freshly built Konnect takes effect without restarting the MCP client. `exec`s the process image (same PID, same stdio pipes) so the connection survives; the new binary is verified first and the call is refused if it does not run. Unix only. | ---