Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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 226 tools (233 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, 226 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): 233 tools (226 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
**226 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 226 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;
85 changes: 85 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,49 @@ 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>,
},
/// 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 +113,14 @@ 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::UnsafeFileFallback { .. } => "unsafe_file_fallback",
Self::HandlerError { .. } => "handler_error",
}
Expand Down Expand Up @@ -170,6 +221,40 @@ 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::UnsafeFileFallback { path: "p".into() },
ToolErrorKind::HandlerError { reason: "r".into() },
];
Expand Down
Loading