Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 12 additions & 7 deletions DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Konnect/
│ │ ├── stdio.rs # Line-by-line JSON-RPC over stdin/stdout (default)
│ │ └── http.rs # Streamable HTTP: POST + GET (SSE) on /mcp (transport = "http" / "both")
│ │
│ ├── konnect-core/ # All tool logic (20 toolsets)
│ ├── konnect-core/ # All tool logic (21 toolsets)
│ │ └── src/
│ │ ├── mcp/
│ │ │ ├── protocol.rs # MCP JSON-RPC 2.0 types
Expand All @@ -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)
Expand Down Expand Up @@ -192,6 +192,11 @@ Konnect/
- Cooperative lock files live under `KONNECT_STATE_DIR/locks` when that
absolute override is set, otherwise under the platform local-data directory
(`konnect/locks`). Reads never create files in the KiCad project.
- Schematic writes also refuse while KiCad's sibling `~<name>.kicad_sch.lck`
exists. KiCad records only a username and hostname, so Konnect cannot prove
that a same-host or remote lock is stale; valid, foreign, empty, and malformed
locks all fail closed. The check runs before a transaction journal is created
and again at the final target-write boundary.
- Multi-file schematic changes use project-local
`.konnect-transaction-*.json` write-ahead journals. These journals contain
complete before/after images and must be treated as sensitive project data.
Expand Down Expand Up @@ -311,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 227 tools (234 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).
Expand Down Expand Up @@ -386,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
- **21 toolsets, 227 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): 234 tools (227 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.
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
Rust binary — that lets Claude and other AI assistants design schematics and PCBs
through the [Model Context Protocol](https://modelcontextprotocol.io) (MCP).

**221 tools across 20 on-demand toolsets.** Schematic capture, PCB layout and
**227 tools across 21 on-demand toolsets.** Schematic capture, PCB layout and
routing, ERC/DRC, design-review audits, JLCPCB part search, reference
circuits, and a full manufacturing export pipeline — with bundled skills and agents
that teach Claude KiCAD conventions out of the box.
Expand Down Expand Up @@ -70,7 +70,7 @@ through its own S-expression engine with atomic writes (write, fsync, rename), U
preservation, and round-trip tests — no third-party schematic library with known
gaps, no text-manipulation workarounds.

**Context economy is a feature.** Exposing all 221 tools to an LLM costs roughly 23K
**Context economy is a feature.** Exposing all 227 tools to an LLM costs roughly 23K
tokens of context on every listing. Konnect's router loads a starter kit (~2K
tokens) and lets the model pull in toolsets on demand — plus built-in observability
(`get_recent_calls`, `server_stats`, JSONL call logs) so the model can diagnose its
Expand Down Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions crates/konnect-core/build.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<PathBuf> {
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())
}
1 change: 1 addition & 0 deletions crates/konnect-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
97 changes: 97 additions & 0 deletions crates/konnect-core/src/mcp/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,55 @@ pub enum ToolErrorKind {
FileNotFound { path: String },
/// A mutation would replace one or more existing filesystem targets.
Conflict { paths: Vec<String> },
/// More than one project, document, or hierarchy instance can satisfy the
/// requested target and choosing one would be nondeterministic.
AmbiguousTarget {
target: String,
candidates: Vec<String>,
},
/// The requested document is not the document set observed in the target
/// editor, so proceeding would answer about or mutate another file.
WrongDocument {
requested: String,
open_documents: Vec<String>,
},
/// No open document belongs to the explicitly requested project.
WrongProject {
requested: String,
open_projects: Vec<String>,
},
/// The requested schematic hierarchy instance is not the instance set
/// observed in the exact live schematic editor context.
WrongSheetInstance {
requested: String,
open_sheet_instances: Vec<String>,
},
/// The caller named a target, but its observed editor or document state
/// no longer agrees with the state required to mutate it safely.
StaleTarget { target: String, reason: String },
/// No live editor endpoint is configured or reachable for the requested
/// semantic operation.
EditorUnavailable { editor: String, reason: String },
/// The running KiCad version or the bundled stable protocol does not
/// provide a capability the caller requested.
UnsupportedCapability {
capability: String,
kicad_version: Option<String>,
},
/// KiCad accepted a semantic mutation, but a fresh observation did not
/// prove the exact requested post-operation state.
ReadbackMismatch {
operation: String,
requested_kiids: Vec<String>,
before_kiids: Vec<String>,
after_kiids: Vec<String>,
},
/// Saved KiCad linkage did not identify exactly one cross-probe target.
UnresolvedCrossProbeDestination {
source_kiid: String,
candidates: Vec<String>,
reason: String,
},
/// A board was live earlier in this server process, but IPC is now gone;
/// its saved file may be stale relative to lost editor state.
UnsafeFileFallback { path: String },
Expand All @@ -70,6 +119,15 @@ impl ToolErrorKind {
Self::InvalidArgument { .. } => "invalid_argument",
Self::FileNotFound { .. } => "file_not_found",
Self::Conflict { .. } => "conflict",
Self::AmbiguousTarget { .. } => "ambiguous_target",
Self::WrongDocument { .. } => "wrong_document",
Self::WrongProject { .. } => "wrong_project",
Self::WrongSheetInstance { .. } => "wrong_sheet_instance",
Self::StaleTarget { .. } => "stale_target",
Self::EditorUnavailable { .. } => "editor_unavailable",
Self::UnsupportedCapability { .. } => "unsupported_capability",
Self::ReadbackMismatch { .. } => "readback_mismatch",
Self::UnresolvedCrossProbeDestination { .. } => "unresolved_cross_probe_destination",
Self::UnsafeFileFallback { .. } => "unsafe_file_fallback",
Self::HandlerError { .. } => "handler_error",
}
Expand Down Expand Up @@ -170,6 +228,45 @@ mod tests {
ToolErrorKind::Conflict {
paths: vec!["p".into()],
},
ToolErrorKind::AmbiguousTarget {
target: "p".into(),
candidates: vec!["a".into(), "b".into()],
},
ToolErrorKind::WrongDocument {
requested: "p".into(),
open_documents: vec!["a".into()],
},
ToolErrorKind::WrongProject {
requested: "p".into(),
open_projects: vec!["a".into()],
},
ToolErrorKind::WrongSheetInstance {
requested: "/root/child".into(),
open_sheet_instances: vec!["/root/other".into()],
},
ToolErrorKind::StaleTarget {
target: "p".into(),
reason: "r".into(),
},
ToolErrorKind::EditorUnavailable {
editor: "pcb".into(),
reason: "closed".into(),
},
ToolErrorKind::UnsupportedCapability {
capability: "activate_sheet".into(),
kicad_version: Some("10.0.5".into()),
},
ToolErrorKind::ReadbackMismatch {
operation: "add".into(),
requested_kiids: vec!["b".into()],
before_kiids: vec!["a".into()],
after_kiids: vec!["a".into()],
},
ToolErrorKind::UnresolvedCrossProbeDestination {
source_kiid: "sym".into(),
candidates: vec!["fp-a".into(), "fp-b".into()],
reason: "ambiguous".into(),
},
ToolErrorKind::UnsafeFileFallback { path: "p".into() },
ToolErrorKind::HandlerError { reason: "r".into() },
];
Expand Down
Loading