Adds the DiagnosticsSource seam trait in harness-core::lsp (kept out of harness-tools so tools never link the LSP crate directly), and implements it in harness-lsp as a pool that lazily spawns one language server per file extension (rust-analyzer, typescript-language-server, gopls, pyright-langserver) only when the binary is on PATH. Adds harness-tools::diagnostics and wires diagnostics into edit/write/bash/glob/grep output.
Adds an rmcp-based stdio MCP client in harness-mcp plus adapters exposing a configured server's tools as namespaced harness-tools, with an echo-server fixture and integration test, and wiring through harness-app.
Adds harness-core::config::markdown to parse SKILL.md frontmatter/body, a skill tool in harness-tools to invoke them, and system-prompt wiring so available skills are advertised to the model. Includes the rustfmt pass over the M5 files touched by this and the preceding LSP/rmcp work.
Clean integration work: MCP servers are isolated (one failing server is logged and skipped, the rest connect), the LSP reader auto-acks server→client requests so rust-analyzer's registerCapability/progress requests don't stall the handshake, skills load on demand, and project-over-global layering is consistent across agents/commands/skills. A few issues, the LSP one worth fixing.
LSP wait_diagnostics has a Notify race that adds full-timeout latency (medium)
client.rs:1160:
loop{ifletSome(diags)=self.diagnostics.lock().unwrap().get(&uri){returndiags.clone();}letnotified=self.diag_notify.notified();// registered AFTER the map check
...iftokio::time::timeout(remaining,notified).await.is_err(){break;}}
tokio::Notify::notify_waiters() (used in dispatch) only wakes waiters already registered — it stores no permit. If the server's publishDiagnostics lands in the window between the map read and self.diag_notify.notified(), the notification is missed and this call blocks for the entirewait duration before falling through and returning the (by-then-present) diagnostics. So it's correct but pays the full timeout on the common fast-publish path, slowing every edit/write that waits on diagnostics. Fix: create the notified() future before checking the map (the standard tokio pattern), or switch to a watch/permit-based signal.
client.rs:1280 builds file:// URIs by raw string concat. The server echoes back a percent-encoded uri in publishDiagnostics, which is then used as the diagnostics map key. For any path containing a space, #, or non-ASCII char, our key (file:///a b.rs) won't match the server's (file:///a%20b.rs), so wait_diagnostics silently returns empty for that file. Percent-encode the path (and decode incoming URIs, or normalize both sides).
CommandDef::expand mangles literal $N in templates (low)
markdown.rs:483 does out.replace(&format!("${i}"), value) for i in 1..=9. replace("$1", …) also matches the $1 inside $100, $12, etc., so a command body containing a dollar figure like "costs $100" becomes "costs <arg>00". (And arguments that themselves contain $1/$ARGUMENTS get re-substituted since $ARGUMENTS is expanded first.) Consider a single-pass placeholder scan or word-boundary-aware replacement.
Minor
MCP qualified_name (mcp lib.rs:1735) truncates {server}_{tool} to 64 chars after building it; two remote tools sharing a 64-char prefix collapse to the same engine name and silently shadow each other in the registry (HashMap keyed by name). Rare, but a dedup/uniqueness check would be safer.
## Review: M5 — MCP, LSP, commands/skills
Clean integration work: MCP servers are isolated (one failing server is logged and skipped, the rest connect), the LSP reader auto-acks server→client requests so rust-analyzer's `registerCapability`/progress requests don't stall the handshake, skills load on demand, and project-over-global layering is consistent across agents/commands/skills. A few issues, the LSP one worth fixing.
### LSP `wait_diagnostics` has a `Notify` race that adds full-timeout latency (medium)
`client.rs:1160`:
```rust
loop {
if let Some(diags) = self.diagnostics.lock().unwrap().get(&uri) { return diags.clone(); }
let notified = self.diag_notify.notified(); // registered AFTER the map check
...
if tokio::time::timeout(remaining, notified).await.is_err() { break; }
}
```
`tokio::Notify::notify_waiters()` (used in `dispatch`) only wakes waiters already registered — it stores no permit. If the server's `publishDiagnostics` lands in the window between the map read and `self.diag_notify.notified()`, the notification is missed and this call blocks for the **entire** `wait` duration before falling through and returning the (by-then-present) diagnostics. So it's correct but pays the full timeout on the common fast-publish path, slowing every edit/write that waits on diagnostics. Fix: create the `notified()` future *before* checking the map (the standard tokio pattern), or switch to a `watch`/permit-based signal.
### LSP `path_to_uri` doesn't percent-encode (low-medium)
`client.rs:1280` builds `file://` URIs by raw string concat. The server echoes back a percent-encoded `uri` in `publishDiagnostics`, which is then used as the diagnostics map key. For any path containing a space, `#`, or non-ASCII char, our key (`file:///a b.rs`) won't match the server's (`file:///a%20b.rs`), so `wait_diagnostics` silently returns empty for that file. Percent-encode the path (and decode incoming URIs, or normalize both sides).
### `CommandDef::expand` mangles literal `$N` in templates (low)
`markdown.rs:483` does `out.replace(&format!("${i}"), value)` for `i in 1..=9`. `replace("$1", …)` also matches the `$1` inside `$100`, `$12`, etc., so a command body containing a dollar figure like `"costs $100"` becomes `"costs <arg>00"`. (And arguments that themselves contain `$1`/`$ARGUMENTS` get re-substituted since `$ARGUMENTS` is expanded first.) Consider a single-pass placeholder scan or word-boundary-aware replacement.
### Minor
- MCP `qualified_name` (mcp `lib.rs:1735`) truncates `{server}_{tool}` to 64 chars *after* building it; two remote tools sharing a 64-char prefix collapse to the same engine name and silently shadow each other in the registry (HashMap keyed by name). Rare, but a dedup/uniqueness check would be safer.
Nice tests throughout (real rust_analyzer + echo_server.py integration tests, diagnostics 1-based conversion, layering).
— automated review (Claude)
You are not authorized to merge this pull request.
This pull request can be merged automatically.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
harness-core::lspseam,harness-lsppool,harness-tools::diagnostics), surfaced in edit/write/bash/glob/grep outputSKILL.mdparsing + skill toolcommand/*.md)Review: M5 — MCP, LSP, commands/skills
Clean integration work: MCP servers are isolated (one failing server is logged and skipped, the rest connect), the LSP reader auto-acks server→client requests so rust-analyzer's
registerCapability/progress requests don't stall the handshake, skills load on demand, and project-over-global layering is consistent across agents/commands/skills. A few issues, the LSP one worth fixing.LSP
wait_diagnosticshas aNotifyrace that adds full-timeout latency (medium)client.rs:1160:tokio::Notify::notify_waiters()(used indispatch) only wakes waiters already registered — it stores no permit. If the server'spublishDiagnosticslands in the window between the map read andself.diag_notify.notified(), the notification is missed and this call blocks for the entirewaitduration before falling through and returning the (by-then-present) diagnostics. So it's correct but pays the full timeout on the common fast-publish path, slowing every edit/write that waits on diagnostics. Fix: create thenotified()future before checking the map (the standard tokio pattern), or switch to awatch/permit-based signal.LSP
path_to_uridoesn't percent-encode (low-medium)client.rs:1280buildsfile://URIs by raw string concat. The server echoes back a percent-encodeduriinpublishDiagnostics, which is then used as the diagnostics map key. For any path containing a space,#, or non-ASCII char, our key (file:///a b.rs) won't match the server's (file:///a%20b.rs), sowait_diagnosticssilently returns empty for that file. Percent-encode the path (and decode incoming URIs, or normalize both sides).CommandDef::expandmangles literal$Nin templates (low)markdown.rs:483doesout.replace(&format!("${i}"), value)fori in 1..=9.replace("$1", …)also matches the$1inside$100,$12, etc., so a command body containing a dollar figure like"costs $100"becomes"costs <arg>00". (And arguments that themselves contain$1/$ARGUMENTSget re-substituted since$ARGUMENTSis expanded first.) Consider a single-pass placeholder scan or word-boundary-aware replacement.Minor
qualified_name(mcplib.rs:1735) truncates{server}_{tool}to 64 chars after building it; two remote tools sharing a 64-char prefix collapse to the same engine name and silently shadow each other in the registry (HashMap keyed by name). Rare, but a dedup/uniqueness check would be safer.Nice tests throughout (real rust_analyzer + echo_server.py integration tests, diagnostics 1-based conversion, layering).
— automated review (Claude)
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.