diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dffbcae8..53a5edf0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,9 @@ permissions: {} env: CARGO_TERM_COLOR: always BINARY_NAME: konnect + # Embedded into get_installation_info so release artifacts identify the + # exact source commit even when built outside a Git working tree. + KONNECT_BUILD_COMMIT: ${{ github.sha }} jobs: build: diff --git a/DEV.md b/DEV.md index 088991ae..b1eae223 100644 --- a/DEV.md +++ b/DEV.md @@ -77,7 +77,7 @@ Konnect/ │ │ ├── router/ │ │ │ ├── mod.rs # ToolRouter: load/unload toolsets │ │ │ ├── registry.rs # Static toolset metadata + tools_for() dispatcher -│ │ │ └── meta_tools.rs # 6 always-visible meta-tools +│ │ │ └── meta_tools.rs # 7 always-visible meta-tools │ │ └── tools/ │ │ ├── mod.rs # ToolDef, ToolContext, tool! macro, helpers, kicad_config_dir() │ │ ├── cli.rs # kicad-cli v10 subprocess wrapper (verified against actual binary) @@ -316,9 +316,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 221 tools (227 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 221 tools (228 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 20 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 21 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). @@ -391,9 +391,9 @@ convention for other `kicad-cli`-calling code. ## Current Stats -- **20 toolsets, 221 tools** + 6 meta-tools (4 routing + 2 observability — see `tool-directory.md`) -- Baseline `tools/list`: 20 tools / ~2K tokens (starter kit + meta-tools) -- Full-catalog `tools/list` (all loaded): 227 tools (221 registered + 6 meta) / ~25K tokens +- **20 toolsets, 221 tools** + 7 meta-tools (4 routing + 2 observability + 1 runtime diagnostic — see `tool-directory.md`) +- Baseline `tools/list`: 21 tools / ~2K tokens (starter kit + meta-tools) +- Full-catalog `tools/list` (all loaded): 228 tools (221 registered + 7 meta) / ~25K tokens - **0 IPC stubs** (all protobuf methods implemented) - **0 unimplemented tools** - **Specctra DSN/SES are PCB-editor operations**, not `kicad-cli` commands. diff --git a/README.md b/README.md index 734ec8f0..ffb1e15b 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,13 @@ argument Konnect does not recognise is an error rather than being ignored, so a typo such as `--cleint codex` stops instead of quietly installing for the default client. +To verify which Konnect process an MCP client is actually using, call the +always-visible `get_installation_info` tool. It reports the serving build, +executable path, verified installation source when one can be proven, KiCad +CLI and IPC detection, and restart guidance. A missing build commit or an +`unknown` installation source means the available evidence was insufficient; +it is not silently guessed from a directory name. + ### macOS The [Releases](https://github.com/mixelpixx/Konnect/releases) page ships diff --git a/crates/konnect-core/build.rs b/crates/konnect-core/build.rs new file mode 100644 index 00000000..bfdcec9b --- /dev/null +++ b/crates/konnect-core/build.rs @@ -0,0 +1,91 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() { + println!("cargo:rerun-if-env-changed=KONNECT_BUILD_COMMIT"); + + let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("Cargo sets CARGO_MANIFEST_DIR"); + let repo_root = Path::new(&manifest_dir) + .join("../..") + .canonicalize() + .unwrap_or_else(|_| Path::new(&manifest_dir).join("../..")); + + let (commit, source) = match env::var("KONNECT_BUILD_COMMIT") + .ok() + .filter(|value| is_commit_id(value)) + { + Some(commit) => (Some(commit), "build_environment"), + None => (commit_from_git_files(&repo_root), "git_head"), + }; + + if let Some(commit) = commit { + println!("cargo:rustc-env=KONNECT_BUILD_COMMIT={commit}"); + println!("cargo:rustc-env=KONNECT_BUILD_COMMIT_SOURCE={source}"); + } +} + +/// Read Git's public repository metadata directly so source builds do not +/// depend on a `git` executable being available to Cargo build scripts. +fn commit_from_git_files(repo_root: &Path) -> Option { + let git_dir = resolve_git_dir(repo_root)?; + let head_path = git_dir.join("HEAD"); + println!("cargo:rerun-if-changed={}", head_path.display()); + let head = fs::read_to_string(&head_path).ok()?; + let head = head.trim(); + if is_commit_id(head) { + return Some(head.to_string()); + } + + let reference = head.strip_prefix("ref: ")?.trim(); + let common_dir = resolve_common_dir(&git_dir); + for root in [&git_dir, &common_dir] { + let reference_path = root.join(reference); + println!("cargo:rerun-if-changed={}", reference_path.display()); + if let Ok(value) = fs::read_to_string(&reference_path) { + let value = value.trim(); + if is_commit_id(value) { + return Some(value.to_string()); + } + } + } + + let packed_refs = common_dir.join("packed-refs"); + println!("cargo:rerun-if-changed={}", packed_refs.display()); + let packed = fs::read_to_string(packed_refs).ok()?; + packed.lines().find_map(|line| { + let (commit, name) = line.split_once(' ')?; + (name == reference && is_commit_id(commit)).then(|| commit.to_string()) + }) +} + +fn resolve_git_dir(repo_root: &Path) -> Option { + let dot_git = repo_root.join(".git"); + if dot_git.is_dir() { + return Some(dot_git); + } + let pointer = fs::read_to_string(dot_git).ok()?; + let value = pointer.trim().strip_prefix("gitdir: ")?; + let path = PathBuf::from(value); + Some(if path.is_absolute() { + path + } else { + repo_root.join(path) + }) +} + +fn resolve_common_dir(git_dir: &Path) -> PathBuf { + let Ok(value) = fs::read_to_string(git_dir.join("commondir")) else { + return git_dir.to_path_buf(); + }; + let path = PathBuf::from(value.trim()); + if path.is_absolute() { + path + } else { + git_dir.join(path) + } +} + +fn is_commit_id(value: &str) -> bool { + (7..=64).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} diff --git a/crates/konnect-core/src/lib.rs b/crates/konnect-core/src/lib.rs index 5a6877a4..5c098faa 100644 --- a/crates/konnect-core/src/lib.rs +++ b/crates/konnect-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod mcp; pub(crate) mod native_specctra_bridge; pub mod observability; pub mod router; +pub(crate) mod runtime_info; pub(crate) mod specctra; pub(crate) mod specctra_ses; pub mod tools; diff --git a/crates/konnect-core/src/router/meta_tools.rs b/crates/konnect-core/src/router/meta_tools.rs index 9d1ff8cd..b9e3aa65 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 every toolset with descriptions and load state @@ -9,6 +9,7 @@ //! Observability: //! get_recent_calls(limit?) — last N tool calls (newest first) with timing + status //! server_stats() — uptime, per-tool totals/errors, JSONL log path +//! get_installation_info() — serving build, binary, install, KiCad, and IPC provenance //! //! At server startup only the STARTER_KIT (`project`, `config`) is pre-loaded so //! baseline context stays small. The LLM reads `list_toolboxes` and calls @@ -19,7 +20,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 +121,20 @@ pub fn meta_tool_descriptions() -> Vec { "required": [] }), }, + McpToolDescription { + name: "get_installation_info".to_string(), + description: + "Report read-only provenance for the Konnect process serving this call: build \ + version and commit when available, executable path, conservatively detected \ + install source, on-disk binary version, KiCad CLI version, redacted IPC \ + endpoint, proven newer-binary state, and platform-specific restart guidance." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, ] } @@ -136,6 +151,7 @@ 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), + "get_installation_info" => Some(handle_get_installation_info(ctx).await), _ => None, } } @@ -293,6 +309,11 @@ async fn handle_server_stats(ctx: &std::sync::Arc) -> CallToolResul CallToolResult::json(&snap) } +async fn handle_get_installation_info(ctx: &std::sync::Arc) -> CallToolResult { + let info = crate::runtime_info::collect(&ctx.config).await; + CallToolResult::json(&info) +} + async fn handle_get_active_toolsets(ctx: &std::sync::Arc) -> CallToolResult { let active = ctx.router.active_names().await; let all = ctx.router.all_toolsets(); diff --git a/crates/konnect-core/src/runtime_info.rs b/crates/konnect-core/src/runtime_info.rs new file mode 100644 index 00000000..f4e540ed --- /dev/null +++ b/crates/konnect-core/src/runtime_info.rs @@ -0,0 +1,393 @@ +//! Read-only runtime and installation provenance for the serving process. + +use crate::tools::ServerConfig; +use serde_json::{json, Value}; +use std::cmp::Ordering; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::process::Command; + +const COMMAND_TIMEOUT: Duration = Duration::from_secs(5); +const PCM_IDENTIFIER: &str = "com.github.mixelpixx.konnect"; + +pub(crate) async fn collect(config: &ServerConfig) -> Value { + let running_version = env!("CARGO_PKG_VERSION"); + let executable_path = std::env::current_exe().ok(); + let installation = executable_path + .as_deref() + .map(classify_installation) + .unwrap_or_else(InstallSource::unavailable); + + let binary_probe = match executable_path.as_deref() { + Some(path) => probe_command_version(path, VersionCommand::Konnect).await, + None => VersionProbe::unavailable(), + }; + let newer_than_running = binary_probe + .version + .as_deref() + .and_then(|version| stable_version_cmp(version, running_version)) + .map(|ordering| ordering == Ordering::Greater); + + let kicad_cli_path = crate::kicad_install::find_cli(&config.kicad_cli); + let kicad_probe = match kicad_cli_path.as_deref() { + Some(path) => probe_command_version(path, VersionCommand::KiCad).await, + None => VersionProbe::not_found(), + }; + + let ipc_endpoint = if config.ipc_address.trim().is_empty() { + None + } else { + Some(redact_endpoint(config.ipc_address.trim())) + }; + + json!({ + "build": { + "version": running_version, + "commit": option_env!("KONNECT_BUILD_COMMIT"), + "commit_source": option_env!("KONNECT_BUILD_COMMIT_SOURCE"), + "working_tree_state": "not_recorded", + "profile": if cfg!(debug_assertions) { "debug" } else { "release" }, + "target_os": std::env::consts::OS, + "target_arch": std::env::consts::ARCH, + }, + "runtime": { + "executable_path": executable_path.as_deref().map(display_path), + }, + "installation": { + "source": installation.name, + "evidence": installation.evidence, + "manifest_path": installation.manifest_path.as_deref().map(display_path), + "binary_on_disk": { + "probe_status": binary_probe.status, + "version": binary_probe.version, + "newer_than_running": newer_than_running, + }, + }, + "kicad": { + "cli_path": kicad_cli_path.as_deref().map(display_path), + "probe_status": kicad_probe.status, + "version": kicad_probe.version, + }, + "ipc": { + "configured": ipc_endpoint.is_some(), + "source": "resolved_server_config", + "endpoint": ipc_endpoint, + }, + "restart_guidance": restart_guidance(installation.name, newer_than_running), + }) +} + +#[derive(Debug)] +struct InstallSource { + name: &'static str, + evidence: &'static str, + manifest_path: Option, +} + +impl InstallSource { + fn unavailable() -> Self { + Self { + name: "unknown", + evidence: + "The serving executable path could not be resolved; no install source was inferred.", + manifest_path: None, + } + } +} + +fn classify_installation(executable_path: &Path) -> InstallSource { + let manifest_path = executable_path + .parent() + .and_then(Path::parent) + .map(|plugin_dir| plugin_dir.join("plugin.json")); + + if let Some(path) = manifest_path.filter(|path| is_konnect_pcm_manifest(path)) { + return InstallSource { + name: "kicad_pcm", + evidence: + "A sibling KiCad executable-plugin manifest has Konnect's exact public identifier.", + manifest_path: Some(path), + }; + } + + InstallSource { + name: "unknown", + evidence: "No verified KiCad PCM manifest was found beside this executable; standalone and source builds are intentionally not guessed from path names.", + manifest_path: None, + } +} + +fn is_konnect_pcm_manifest(path: &Path) -> bool { + let Ok(raw) = std::fs::read_to_string(path) else { + return false; + }; + let Ok(manifest) = serde_json::from_str::(&raw) else { + return false; + }; + manifest.get("identifier").and_then(Value::as_str) == Some(PCM_IDENTIFIER) + && manifest + .get("runtime") + .and_then(|runtime| runtime.get("type")) + .and_then(Value::as_str) + == Some("exec") +} + +#[derive(Clone, Copy)] +enum VersionCommand { + Konnect, + KiCad, +} + +struct VersionProbe { + status: &'static str, + version: Option, +} + +impl VersionProbe { + fn unavailable() -> Self { + Self { + status: "executable_path_unavailable", + version: None, + } + } + + fn not_found() -> Self { + Self { + status: "not_found", + version: None, + } + } +} + +async fn probe_command_version(path: &Path, command_kind: VersionCommand) -> VersionProbe { + let mut command = Command::new(path); + command.arg("--version").kill_on_drop(true); + let output = match tokio::time::timeout(COMMAND_TIMEOUT, command.output()).await { + Ok(Ok(output)) => output, + Ok(Err(_)) => { + return VersionProbe { + status: "launch_failed", + version: None, + }; + } + Err(_) => { + return VersionProbe { + status: "timed_out", + version: None, + }; + } + }; + + if !output.status.success() { + return VersionProbe { + status: "nonzero_exit", + version: None, + }; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let line = stdout + .lines() + .chain(stderr.lines()) + .map(str::trim) + .find(|line| !line.is_empty()); + + let version = match command_kind { + VersionCommand::Konnect => line.and_then(parse_konnect_version).map(str::to_string), + VersionCommand::KiCad => line.and_then(sanitize_version_line), + }; + VersionProbe { + status: if version.is_some() { + "ok" + } else { + "unrecognized_output" + }, + version, + } +} + +fn parse_konnect_version(line: &str) -> Option<&str> { + let version = line.strip_prefix("konnect ")?.trim(); + (!version.is_empty() && !version.chars().any(char::is_whitespace)).then_some(version) +} + +fn sanitize_version_line(line: &str) -> Option { + let value: String = line + .chars() + .filter(|ch| !ch.is_control()) + .take(200) + .collect(); + (!value.is_empty()).then_some(value) +} + +fn stable_version_cmp(candidate: &str, running: &str) -> Option { + fn stable_triplet(version: &str) -> Option<[u64; 3]> { + if version.contains(['-', '+']) { + return None; + } + let values = version + .strip_prefix('v') + .unwrap_or(version) + .split('.') + .map(str::parse::) + .collect::, _>>() + .ok()?; + (values.len() == 3).then(|| [values[0], values[1], values[2]]) + } + + Some(stable_triplet(candidate)?.cmp(&stable_triplet(running)?)) +} + +fn redact_endpoint(endpoint: &str) -> String { + let (without_fragment, had_fragment) = endpoint + .split_once('#') + .map_or((endpoint, false), |(head, _)| (head, true)); + let (without_query, had_query) = without_fragment + .split_once('?') + .map_or((without_fragment, false), |(head, _)| (head, true)); + + let without_credentials = if let Some((scheme, rest)) = without_query.split_once("://") { + if let Some((_, authority_and_path)) = rest.split_once('@') { + format!("{scheme}://[redacted]@{authority_and_path}") + } else { + without_query.to_string() + } + } else { + without_query.to_string() + }; + + if had_query || had_fragment { + format!("{without_credentials} [query/fragment redacted]") + } else { + without_credentials + } +} + +fn restart_guidance(source: &str, newer_than_running: Option) -> Vec { + let mut guidance = Vec::new(); + if newer_than_running == Some(true) { + guidance.push( + "A newer binary is proven at the serving executable path; restart the process before relying on the new build." + .to_string(), + ); + } + + #[cfg(target_os = "windows")] + guidance.push( + "Windows: exit every MCP client or KiCad session that launched Konnect, then reopen the owning application; running executables may remain locked during an update." + .to_string(), + ); + #[cfg(target_os = "macos")] + guidance.push( + "macOS: restart the MCP client that launched Konnect; if KiCad launched it, quit and reopen KiCad after the update." + .to_string(), + ); + #[cfg(target_os = "linux")] + guidance.push( + "Linux: restart the MCP client that launched Konnect; if KiCad launched it, stop the plugin server or quit and reopen KiCad after the update." + .to_string(), + ); + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + guidance.push( + "Restart the MCP client or KiCad session that launched Konnect after replacing the binary." + .to_string(), + ); + + if source == "kicad_pcm" { + guidance.push( + "KiCad PCM install detected: complete the Plugin and Content Manager update, then restart KiCad and any separately configured MCP client." + .to_string(), + ); + } + guidance.push( + "Call get_installation_info again after restart and verify the serving version, commit, and executable path." + .to_string(), + ); + guidance +} + +fn display_path(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verified_pcm_manifest_is_required_for_pcm_classification() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins"); + let bin_dir = plugin_dir.join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let executable = bin_dir.join(if cfg!(windows) { + "konnect.exe" + } else { + "konnect" + }); + std::fs::write(&executable, b"").unwrap(); + + assert_eq!(classify_installation(&executable).name, "unknown"); + std::fs::write( + plugin_dir.join("plugin.json"), + r#"{"identifier":"someone.else","runtime":{"type":"exec"}}"#, + ) + .unwrap(); + assert_eq!(classify_installation(&executable).name, "unknown"); + std::fs::write( + plugin_dir.join("plugin.json"), + r#"{"identifier":"com.github.mixelpixx.konnect","runtime":{"type":"exec"}}"#, + ) + .unwrap(); + + let source = classify_installation(&executable); + assert_eq!(source.name, "kicad_pcm"); + assert_eq!(source.manifest_path, Some(plugin_dir.join("plugin.json"))); + } + + #[test] + fn endpoint_redaction_removes_credentials_query_and_fragment() { + assert_eq!( + redact_endpoint("tcp://user:secret@127.0.0.1:9000/api?token=hidden#detail"), + "tcp://[redacted]@127.0.0.1:9000/api [query/fragment redacted]" + ); + assert_eq!( + redact_endpoint("ipc:///tmp/kicad/api.sock"), + "ipc:///tmp/kicad/api.sock" + ); + } + + #[test] + fn newer_claim_requires_comparable_stable_versions() { + assert_eq!( + stable_version_cmp("0.12.0", "0.11.9"), + Some(Ordering::Greater) + ); + assert_eq!( + stable_version_cmp("0.11.0", "0.11.0"), + Some(Ordering::Equal) + ); + assert_eq!(stable_version_cmp("0.11.0-beta.1", "0.10.0"), None); + assert_eq!(stable_version_cmp("not-a-version", "0.11.0"), None); + } + + #[test] + fn embedded_commit_is_hex_when_available() { + if let Some(commit) = option_env!("KONNECT_BUILD_COMMIT") { + assert!((7..=64).contains(&commit.len())); + assert!(commit.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + } + + #[test] + fn restart_guidance_names_the_current_platform() { + let guidance = restart_guidance("unknown", None).join("\n"); + #[cfg(target_os = "windows")] + assert!(guidance.contains("Windows:")); + #[cfg(target_os = "macos")] + assert!(guidance.contains("macOS:")); + #[cfg(target_os = "linux")] + assert!(guidance.contains("Linux:")); + } +} diff --git a/crates/konnect/tests/asset_references.rs b/crates/konnect/tests/asset_references.rs index 23445f81..236e39d2 100644 --- a/crates/konnect/tests/asset_references.rs +++ b/crates/konnect/tests/asset_references.rs @@ -649,6 +649,7 @@ fn backticked_tool_names_in_prose_exist_in_the_registry() { "get_active_toolsets", "get_recent_calls", "server_stats", + "get_installation_info", "auto_load_toolsets", "eager_toolsets", "kicad_cli", diff --git a/crates/konnect/tests/protocol_stdio.rs b/crates/konnect/tests/protocol_stdio.rs index 62285cf1..5894980c 100644 --- a/crates/konnect/tests/protocol_stdio.rs +++ b/crates/konnect/tests/protocol_stdio.rs @@ -180,6 +180,56 @@ fn handshake_baseline_and_full_registry_loads() { ); } +#[test] +fn installation_info_reports_the_serving_process_without_leaking_endpoint_secrets() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("konnect.toml"), + "ipc_address = \"ipc://diagnostic-test.sock?token=secret#fragment\"\n", + ) + .unwrap(); + let mut p = McpProcess::spawn_in_dir(Some(tmp.path())); + + let list = p.request("tools/list", json!({})); + assert!(list["result"]["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "get_installation_info")); + + let result = p.call_tool("get_installation_info", json!({})); + assert_ne!(result["isError"], json!(true), "{result:#?}"); + let body = McpProcess::tool_body(&result); + + assert_eq!(body["build"]["version"], env!("CARGO_PKG_VERSION")); + if let Some(commit) = body["build"]["commit"].as_str() { + assert!((7..=64).contains(&commit.len())); + assert!(commit.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert!(body["build"]["commit_source"].is_string()); + } else { + assert!(body["build"]["commit_source"].is_null()); + } + assert!(body["runtime"]["executable_path"].is_string()); + assert_eq!(body["installation"]["binary_on_disk"]["probe_status"], "ok"); + assert_eq!( + body["installation"]["binary_on_disk"]["version"], + env!("CARGO_PKG_VERSION") + ); + assert_eq!( + body["installation"]["binary_on_disk"]["newer_than_running"], + false + ); + assert_eq!(body["ipc"]["configured"], true); + assert_eq!( + body["ipc"]["endpoint"], + "ipc://diagnostic-test.sock [query/fragment redacted]" + ); + let serialized = serde_json::to_string(&body).unwrap(); + assert!(!serialized.contains("token=secret"), "{body:#?}"); + assert!(!serialized.contains("#fragment"), "{body:#?}"); + assert!(!body["restart_guidance"].as_array().unwrap().is_empty()); +} + #[test] fn file_based_tool_roundtrip_in_temp_project() { let tmp = tempfile::tempdir().unwrap(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4e13dbb..27098ccb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,7 +28,8 @@ records calls through `observability.rs`. `crates/konnect-core/src/router/registry.rs` declares toolsets and resolves each toolset to its definitions. `router/mod.rs` tracks loaded definitions, and `router/meta_tools.rs` implements the always-visible discovery, loading, and -observability tools. +observability tools. `runtime_info.rs` supplies the read-only serving-build, +installation, KiCad, and IPC evidence returned by `get_installation_info`. `crates/konnect-core/src/tools/mod.rs` owns `ToolDef`, `ToolContext`, `ServerConfig`, the `tool!` macro, required-argument helpers, and shared path and diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 5b0fdb06..c3a9ba6b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -1,5 +1,24 @@ # Troubleshooting +## Which Konnect binary is this client using? + +Call the always-visible `get_installation_info` tool in the affected MCP +session. The result comes from the process serving that call and includes its +version, build commit when available, executable path, conservatively detected +install source, the version produced by the binary currently on disk at that +same path, KiCad CLI version, redacted IPC endpoint, and restart guidance. + +`installation.binary_on_disk.newer_than_running: true` is reported only when +both stable versions can be parsed and the on-disk binary is newer. `null` +means the comparison could not be proven, not that the process is current. +Likewise, `installation.source: "unknown"` means no trusted package manifest +identified the channel; Konnect does not guess from directory names. Endpoint +credentials and query or fragment data are redacted. + +Follow the returned platform-specific guidance, restart the MCP client (and +KiCad when it owns the server process), then call `get_installation_info` again +to verify the process that actually restarted. This diagnostic writes nothing. + ## "KiCAD IPC socket path not configured" Any tool that talks to a live KiCAD session (`save_project`, PCB editing, @@ -246,7 +265,7 @@ callable tools, the fix is to make the *first* listing complete: ``` in `konnect.toml` in the working directory, or a `settings.json` beside the binary. Every toolset is then loaded at -startup, so `tools/list` carries all 227 tools from the first call. +startup, so `tools/list` carries all 228 tools from the first call. It is off by default because it costs what the router exists to save: roughly 25K tokens per listing instead of ~2K. Turn it on only if your client needs it. diff --git a/tool-directory.md b/tool-directory.md index 2c2d4900..b4fcc532 100644 --- a/tool-directory.md +++ b/tool-directory.md @@ -13,13 +13,13 @@ Compatibility notes for removed or narrowed arguments are recorded in ## Overview - **20 toolsets** organized into 10 categories -- **221 registered tools** + **6 always-visible meta-tools** = **227 total** +- **221 registered tools** + **7 always-visible meta-tools** = **228 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*, *observability*, and *runtime diagnostics*. ### Discovery / routing @@ -37,6 +37,12 @@ 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. | +### Runtime diagnostics + +| Tool | Purpose | +|------|---------| +| `get_installation_info` | Report the serving build version and commit, executable path, verified install source, on-disk binary version, KiCad CLI version, redacted IPC endpoint, proven stale-process evidence, and platform-specific restart guidance. | + --- ## Project