diff --git a/crates/gateway-registry/src/audit.rs b/crates/gateway-registry/src/audit.rs index d3c1d358..bbe5ab08 100644 --- a/crates/gateway-registry/src/audit.rs +++ b/crates/gateway-registry/src/audit.rs @@ -1,4 +1,4 @@ -//! Minimal append-only audit trail for `gateway_execute` calls: timestamp, +//! Minimal append-only audit trail for `tool_execute` calls: timestamp, //! server, tool, a hash of the args (never the raw args — they may contain //! secrets), and outcome. Same category of guard forgemax's //! `forge-audit` documents; written fresh here, not ported — see diff --git a/crates/gateway-registry/src/error.rs b/crates/gateway-registry/src/error.rs index c7860a09..f1bfd76b 100644 --- a/crates/gateway-registry/src/error.rs +++ b/crates/gateway-registry/src/error.rs @@ -19,7 +19,7 @@ pub enum GatewayError { /// A local, pre-flight validation failure (e.g. malformed `args`) that /// happens entirely before any downstream I/O — distinct from /// `Upstream`, which is reserved for the downstream server itself - /// failing. Callers (see `src/mcp_server.rs::gateway_execute`) map this + /// failing. Callers (see `src/mcp_server.rs::tool_execute`) map this /// to `invalid_params` like `ServerNotFound`/`ToolNotFound`, since it's /// a caller-fixable mistake, not an infrastructure failure. #[error("{0}")] diff --git a/crates/gateway-registry/src/mcp_stdio.rs b/crates/gateway-registry/src/mcp_stdio.rs index f857c0b1..1b137c2b 100644 --- a/crates/gateway-registry/src/mcp_stdio.rs +++ b/crates/gateway-registry/src/mcp_stdio.rs @@ -196,7 +196,7 @@ impl McpStdioBackend { // Local, pre-flight validation — happens before any // downstream I/O, so this is the caller's mistake, not the // downstream server's. `InvalidArgument`, not `Upstream`, - // so `gateway_execute` maps it to `invalid_params`. + // so `tool_execute` maps it to `invalid_params`. return Err(GatewayError::InvalidArgument(format!( "args must be a JSON object, got {other}" ))); diff --git a/crates/gateway-registry/src/sanitize.rs b/crates/gateway-registry/src/sanitize.rs index de89555f..f486413e 100644 --- a/crates/gateway-registry/src/sanitize.rs +++ b/crates/gateway-registry/src/sanitize.rs @@ -1,5 +1,5 @@ //! Bounds and cleans up whatever a downstream `tools/list` response hands -//! back before it's indexed and surfaced to the LLM via `gateway_search`. +//! back before it's indexed and surfaced to the LLM via `tool_search`. //! A downstream server (especially a third-party or compromised one) fully //! controls its own tool names/descriptions — this is the one place that //! data crosses into our own storage and eventually into LLM context, so @@ -15,7 +15,7 @@ const MAX_DESCRIPTION_LEN: usize = 1024; /// Restrict a tool name to `[A-Za-z0-9._-]`, truncated to [`MAX_NAME_LEN`]. /// Falls back to `"unnamed"` if nothing safe survives — an empty server- -/// or tool-name would otherwise break `gateway_execute`'s addressing. +/// or tool-name would otherwise break `tool_execute`'s addressing. fn sanitize_name(name: &str) -> String { let cleaned: String = name .chars() @@ -32,7 +32,7 @@ fn sanitize_name(name: &str) -> String { /// Strip control characters (kept: newline/tab, since descriptions are /// legitimately multi-line) and truncate to [`MAX_DESCRIPTION_LEN`] chars — /// bounds how much of a single tool's description a hostile downstream -/// server can push into `gateway_search` results / LLM context. +/// server can push into `tool_search` results / LLM context. fn sanitize_description(description: &str) -> String { description .chars() diff --git a/crates/gateway-registry/src/search.rs b/crates/gateway-registry/src/search.rs index 1d3693ed..7ae96bfa 100644 --- a/crates/gateway-registry/src/search.rs +++ b/crates/gateway-registry/src/search.rs @@ -25,7 +25,7 @@ pub struct ToolHit { /// Ceiling applied to a caller-supplied `limit` before it's used in /// SQLite's `LIMIT` clause. Two reasons: (1) casting a huge `usize` (e.g. -/// `usize::MAX`, reachable via `gateway_search`'s MCP request) straight to +/// `usize::MAX`, reachable via `tool_search`'s MCP request) straight to /// `i64` can wrap around to a negative number in two's-complement, and /// SQLite treats a negative `LIMIT` as "no limit" — silently defeating the /// cap; (2) even ignoring the cast, tool search results are meant to be a diff --git a/crates/gateway-registry/src/truncate.rs b/crates/gateway-registry/src/truncate.rs index f95762ed..274c92d5 100644 --- a/crates/gateway-registry/src/truncate.rs +++ b/crates/gateway-registry/src/truncate.rs @@ -1,4 +1,4 @@ -//! Caps oversized `gateway_execute` results so one chatty downstream tool +//! Caps oversized `tool_execute` results so one chatty downstream tool //! can't blow out the LLM's context. Same motivation as forgemax's //! `MAX_RESULT_CHARS` envelope, written fresh (no code shared — FSL). @@ -51,7 +51,7 @@ fn build_envelope(json: &str, cut: usize) -> Value { } /// The ACTUAL serialized size of a candidate envelope — matches -/// `gateway_execute` (`src/mcp_server.rs`), which serializes the capped +/// `tool_execute` (`src/mcp_server.rs`), which serializes the capped /// value with `serde_json::to_string_pretty` before returning it. fn envelope_len(envelope: &Value) -> usize { serde_json::to_string_pretty(envelope) diff --git a/crates/gateway-registry/tests/mcp_stdio_call.rs b/crates/gateway-registry/tests/mcp_stdio_call.rs index fcc95307..19b33df2 100644 --- a/crates/gateway-registry/tests/mcp_stdio_call.rs +++ b/crates/gateway-registry/tests/mcp_stdio_call.rs @@ -44,7 +44,7 @@ async fn call_with_non_object_args_is_invalid_argument_not_upstream() { // Malformed `args` (not a JSON object or null) is rejected entirely // locally, before any downstream I/O — a caller mistake, not a // downstream/infrastructure failure, so it must be `InvalidArgument` - // (which `gateway_execute` maps to `invalid_params`), not `Upstream` + // (which `tool_execute` maps to `invalid_params`), not `Upstream` // (which maps to `internal_error`). let backend = McpStdioBackend::new(fixture_path(), vec![], HashMap::new()); let err = backend diff --git a/src/components.rs b/src/components.rs index c79590ef..fccbb61a 100644 --- a/src/components.rs +++ b/src/components.rs @@ -47,6 +47,27 @@ fn claude_json() -> Value { .unwrap_or(Value::Null) } +/// Removes a server entry from `~/.claude.json`'s `mcpServers` map, if +/// present — used to undo a native MCP registration another tool's own +/// installer created (e.g. lean-ctx's `onboard`) once that server has been +/// re-registered behind the agentflare gateway instead. Returns true only if +/// an entry was actually found and removed. +fn remove_claude_mcp_server(name: &str) -> bool { + let path = home().join(".claude.json"); + let mut root = claude_json(); + let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) else { + return false; + }; + if servers.remove(name).is_none() { + return false; + } + fs::write( + &path, + serde_json::to_string_pretty(&root).unwrap_or_default() + "\n", + ) + .is_ok() +} + fn json_at(path: &PathBuf) -> Value { fs::read_to_string(path) .ok() @@ -333,26 +354,40 @@ pub fn get_components(host: &str) -> Vec { id: "leanctx", needs_consent: true, // lean-ctx's own installer (and `onboard`) wires MCP into whichever - // supported tool it detects, so no per-host branching needed here — - // trust the upstream tool's own setup. Installed via its native - // prebuilt-binary installer (see tool_install), not mise: - // lean-ctx ships a proper `curl | sh` that downloads, verifies, and - // onboards on its own. - describe: "lean-ctx (context compression) — native installer (curl | sh, or brew) + onboard".to_string(), - check: Box::new(|| crate::tool_install::installed(&crate::tool_install::LEAN_CTX)), + // supported tool it detects natively — exactly the always-on + // tool-list bloat the agentflare gateway exists to avoid. Right + // after installing, register it behind the gateway instead + // (`gateway_integrations::LEANCTX`) and, for claude-code, strip + // whatever native entry the upstream onboarder already created so + // the same ~80 ctx_* tools aren't declared twice. + describe: "lean-ctx (context compression) — native installer (curl | sh, or brew), registered behind the agentflare gateway (tool_search/tool_execute), not the host's native tool list".to_string(), + check: Box::new(|| { + crate::tool_install::installed(&crate::tool_install::LEAN_CTX) + && crate::gateway_integrations::already_registered("leanctx") + }), apply: { let log = leanctx_log.clone(); + let host = host_owned.clone(); Box::new(move || { - if log.exists() { - return format!("lean-ctx install already triggered — check {}", log.display()); - } - let _ = fs::create_dir_all(log.parent().unwrap()); - let outcome = crate::tool_install::install(&crate::tool_install::LEAN_CTX); - let _ = fs::write(&log, format!("{:?}", std::time::SystemTime::now())); - match outcome { - Ok(m) => format!("{m} + onboarded"), - Err(e) => e, + let mut msg = if log.exists() { + format!("lean-ctx install already triggered — check {}", log.display()) + } else { + let _ = fs::create_dir_all(log.parent().unwrap()); + let outcome = crate::tool_install::install(&crate::tool_install::LEAN_CTX); + let _ = fs::write(&log, format!("{:?}", std::time::SystemTime::now())); + match outcome { + Ok(m) => m, + Err(e) => return e, + } + }; + msg = format!( + "{msg} + {}", + crate::gateway_integrations::register(&crate::gateway_integrations::LEANCTX) + ); + if host == "claude-code" && remove_claude_mcp_server("lean-ctx") { + msg = format!("{msg} + removed native claude-code MCP entry (now gateway-only)"); } + msg }) }, }, @@ -956,4 +991,35 @@ mod tests { assert_eq!(apply_skill_overrides(&names, &mut settings).unwrap(), 1); assert_eq!(apply_skill_overrides(&names, &mut settings).unwrap(), 0); } + + #[test] + fn remove_claude_mcp_server_removes_only_the_named_entry() { + crate::paths::test_support::with_temp_home(|| { + let path = home().join(".claude.json"); + fs::write( + &path, + serde_json::json!({ + "mcpServers": { + "lean-ctx": {"command": "lean-ctx"}, + "flare": {"command": "agentflare"} + } + }) + .to_string(), + ) + .unwrap(); + + assert!(remove_claude_mcp_server("lean-ctx")); + + let value = json_at(&path); + assert!(value["mcpServers"]["lean-ctx"].is_null()); + assert!(value["mcpServers"]["flare"].is_object()); + }); + } + + #[test] + fn remove_claude_mcp_server_is_a_noop_when_absent() { + crate::paths::test_support::with_temp_home(|| { + assert!(!remove_claude_mcp_server("lean-ctx")); + }); + } } diff --git a/src/gateway_integrations.rs b/src/gateway_integrations.rs index 3c697cb2..34d6b862 100644 --- a/src/gateway_integrations.rs +++ b/src/gateway_integrations.rs @@ -1,7 +1,7 @@ // During `init`, detect project context (e.g. a GitHub remote) and, with the // user's OK, register the matching MCP server BEHIND agentflare's own gateway // (`~/.agentflare/gateway.toml`) — so its tools stay reachable through -// `gateway_search`/`gateway_execute` instead of bloating the host's always-on +// `tool_search`/`tool_execute` instead of bloating the host's always-on // tool list. Adding another gateway-fronted MCP later is one more entry in // `INTEGRATIONS`; the plumbing (detect → consent → idempotent append) is shared. use crate::paths::home; @@ -24,12 +24,12 @@ pub struct GatewayIntegration { pub post_note: fn() -> Vec, } -pub const INTEGRATIONS: &[GatewayIntegration] = &[GITHUB]; +pub const INTEGRATIONS: &[GatewayIntegration] = &[GITHUB, LEANCTX]; const GITHUB: GatewayIntegration = GatewayIntegration { name: "github", detect: git_remote_is_github, - prompt: "⚑ GitHub repo detected. github-mcp-server can sit behind the agentflare gateway\n (its tools stay under gateway_search/gateway_execute, not the host's tool list).", + prompt: "⚑ GitHub repo detected. github-mcp-server can sit behind the agentflare gateway\n (its tools stay under tool_search/tool_execute, not the host's tool list).", // Remote HTTP backend — zero-install (no docker/binary). The gateway // sends `auth_header` verbatim, so the stored secret is the full header // value (`Bearer `), see `post_note`. @@ -45,6 +45,34 @@ fn github_post_note() -> Vec { ] } +/// lean-ctx's own installer/`onboard` wires its ~80 `ctx_*` tools straight +/// into the host's native MCP config — exactly the always-on tool-list bloat +/// this gateway exists to avoid. Once the binary is on PATH, this puts it +/// behind the gateway instead; `components.rs`'s `"leanctx"` component also +/// strips whatever native registration the upstream onboarder already +/// created, so the same tools don't end up declared twice. +pub const LEANCTX: GatewayIntegration = GatewayIntegration { + name: "leanctx", + detect: leanctx_installed, + prompt: "⚑ lean-ctx detected. Its ~80 ctx_* tools can sit behind the agentflare gateway\n (reachable via tool_search/tool_execute) instead of bloating the host's tool list.", + // Local stdio backend — same binary lean-ctx's own installer already put + // on PATH; the gateway just spawns it instead of the host declaring it + // natively. No auth needed (local process). + toml_block: "[servers.leanctx]\nkind = \"mcp_stdio\"\ncommand = \"lean-ctx\"\nargs = []", + post_note: leanctx_post_note, +}; + +fn leanctx_installed() -> bool { + crate::tool_install::installed(&crate::tool_install::LEAN_CTX) +} + +fn leanctx_post_note() -> Vec { + vec![ + " next its ctx_* tools are now reached via tool_search/tool_execute, not called natively" + .to_string(), + ] +} + pub fn gateway_toml_path() -> PathBuf { home().join(".agentflare").join("gateway.toml") } @@ -195,4 +223,38 @@ mod tests { assert!(cfg.servers.contains_key("github")); }); } + + #[test] + fn register_writes_a_valid_parseable_leanctx_server() { + with_temp_home(|| { + assert!(!already_registered("leanctx")); + let msg = register(&LEANCTX); + assert!(msg.starts_with("ok"), "unexpected: {msg}"); + + let content = fs::read_to_string(gateway_toml_path()).unwrap(); + assert!(content.contains("[servers.leanctx]")); + let cfg = gateway_registry::parse_config(&content).unwrap(); + assert!(cfg.servers.contains_key("leanctx")); + assert!(already_registered("leanctx")); + }); + } + + #[test] + fn register_leanctx_is_idempotent_and_never_duplicates() { + with_temp_home(|| { + let msg1 = register(&LEANCTX); + assert!(msg1.starts_with("ok"), "first: {msg1}"); + let msg2 = register(&LEANCTX); + assert!(msg2.starts_with("skip"), "second: {msg2}"); + let content = fs::read_to_string(gateway_toml_path()).unwrap(); + assert_eq!(content.matches("[servers.leanctx]").count(), 1); + }); + } + + #[test] + fn integrations_list_includes_github_and_leanctx() { + let names: Vec<&str> = INTEGRATIONS.iter().map(|i| i.name).collect(); + assert!(names.contains(&"github")); + assert!(names.contains(&"leanctx")); + } } diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 8e8f5977..c88f798b 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -60,7 +60,7 @@ struct SkillLoadRequest { } #[derive(Debug, Deserialize, schemars::JsonSchema)] -struct GatewaySearchRequest { +struct ToolSearchRequest { #[schemars(description = "What tool you need; keyword-style works best")] query: String, #[schemars(description = "Max results (default 5)")] @@ -74,14 +74,14 @@ struct GatewaySearchRequest { } #[derive(Debug, Deserialize, schemars::JsonSchema)] -struct GatewayExecuteRequest { - #[schemars(description = "Server name from gateway_search")] +struct ToolExecuteRequest { + #[schemars(description = "Server name from tool_search")] server: String, - #[schemars(description = "Tool name from gateway_search")] + #[schemars(description = "Tool name from tool_search")] tool: String, // A bare `serde_json::Value` here made schemars emit a typeless schema // (Value can be anything), so callers had no signal to send a nested - // JSON object rather than a stringified one — gateway_execute couldn't + // JSON object rather than a stringified one — tool_execute couldn't // actually be invoked with arguments. `Map` renders as `{"type": // ["object", "null"]}`, a real hint. #[schemars(description = "Arguments object matching the tool's input_schema")] @@ -2105,7 +2105,7 @@ impl AgentflareMcp { /// `gateway_registry` is a `tokio::sync::Mutex` and `skills_registry` /// isn't. (An earlier draft tried to fold `Registry::execute` — an /// async fn — into a plain `FnOnce(&Registry) -> T` callback shared - /// with `gateway_search`; that doesn't compile without unstable + /// with `tool_search`; that doesn't compile without unstable /// async-closure/HRTB machinery, so each tool method just calls this /// helper and then works with the guard itself.) async fn ensure_gateway_registry( @@ -2134,11 +2134,11 @@ impl AgentflareMcp { } #[tool( - description = "Search downstream MCP servers' tools by task description. Returns server, tool, description, and input_schema; call gateway_execute to run one." + description = "Search downstream MCP servers' tools by task description. Returns server, tool, description, and input_schema; call tool_execute to run one." )] - async fn gateway_search( + async fn tool_search( &self, - Parameters(GatewaySearchRequest { query, limit, mode }): Parameters, + Parameters(ToolSearchRequest { query, limit, mode }): Parameters, ) -> Result { if query.trim().is_empty() { return Err(ErrorData::invalid_params("query is required", None)); @@ -2162,11 +2162,11 @@ impl AgentflareMcp { } #[tool( - description = "Execute a tool on a downstream MCP server found via gateway_search. args must match that tool's input_schema." + description = "Execute a tool on a downstream MCP server found via tool_search. args must match that tool's input_schema." )] - async fn gateway_execute( + async fn tool_execute( &self, - Parameters(GatewayExecuteRequest { server, tool, args }): Parameters, + Parameters(ToolExecuteRequest { server, tool, args }): Parameters, ) -> Result { if server.trim().is_empty() || tool.trim().is_empty() { return Err(ErrorData::invalid_params( @@ -3674,7 +3674,7 @@ mod tests { } #[tokio::test] - async fn gateway_search_empty_query_is_invalid_params() { + async fn tool_search_empty_query_is_invalid_params() { // Isolated DB path so the test never opens/refreshes the shared gateway.db. let tmp = tempfile::tempdir().unwrap(); let s = AgentflareMcp { @@ -3682,7 +3682,7 @@ mod tests { ..Default::default() }; let err = s - .gateway_search(Parameters(GatewaySearchRequest { + .tool_search(Parameters(ToolSearchRequest { query: "".into(), limit: None, mode: None, @@ -3693,14 +3693,14 @@ mod tests { } #[tokio::test] - async fn gateway_search_mode_rejects_unknown_value() { + async fn tool_search_mode_rejects_unknown_value() { let tmp = tempfile::tempdir().unwrap(); let s = AgentflareMcp { gateway_db_override: Some(tmp.path().join("gateway.db")), ..Default::default() }; let err = s - .gateway_search(Parameters(GatewaySearchRequest { + .tool_search(Parameters(ToolSearchRequest { query: "x".into(), limit: None, mode: Some("bogus".into()), @@ -3711,14 +3711,14 @@ mod tests { } #[tokio::test] - async fn gateway_execute_requires_server_and_tool() { + async fn tool_execute_requires_server_and_tool() { let tmp = tempfile::tempdir().unwrap(); let s = AgentflareMcp { gateway_db_override: Some(tmp.path().join("gateway.db")), ..Default::default() }; let err = s - .gateway_execute(Parameters(GatewayExecuteRequest { + .tool_execute(Parameters(ToolExecuteRequest { server: "".into(), tool: "x".into(), args: Some(serde_json::Map::new()), @@ -3729,7 +3729,7 @@ mod tests { } #[tokio::test] - async fn gateway_execute_unknown_server_is_invalid_params() { + async fn tool_execute_unknown_server_is_invalid_params() { // Isolated DB path, no servers configured — `Registry::execute` is // guaranteed to hit `GatewayError::ServerNotFound`, which must map to // `invalid_params` (a caller-fixable mistake), not `internal_error`. @@ -3739,7 +3739,7 @@ mod tests { ..Default::default() }; let err = s - .gateway_execute(Parameters(GatewayExecuteRequest { + .tool_execute(Parameters(ToolExecuteRequest { server: "definitely-not-a-configured-server".into(), tool: "x".into(), args: Some(serde_json::Map::new()), @@ -3751,8 +3751,8 @@ mod tests { } #[test] - fn gateway_execute_args_schema_is_object_or_null() { - let schema = schemars::schema_for!(GatewayExecuteRequest); + fn tool_execute_args_schema_is_object_or_null() { + let schema = schemars::schema_for!(ToolExecuteRequest); let schema_json = serde_json::to_value(&schema).unwrap(); let args_schema = schema_json .get("properties") diff --git a/src/rule_text.rs b/src/rule_text.rs index 26ce483e..a6377c5c 100644 --- a/src/rule_text.rs +++ b/src/rule_text.rs @@ -28,10 +28,12 @@ pub const GIT_SUPERSEDED: &[&str] = &[ pub const LEANCTX: &str = r#"@use: lean-ctx over native tools — ctx_read>Read/cat, ctx_shell>Bash, ctx_search>Grep, ctx_glob>Glob, ctx_callgraph>grep for "who calls X" @when: unfamiliar code — ctx_compose FIRST, one call vs search→read→search chain +@fallback: ctx_* missing from your tool list? It's behind the gateway — tool_search("ctx_read") then tool_execute(server="leanctx", tool=, args={...}) @scope: every subagent"#; pub const LEANCTX_SUPERSEDED: &[&str] = &[ "Prefer lean-ctx over native tools: ctx_read > Read/cat, ctx_shell > Bash, ctx_search > Grep, ctx_glob > Glob. Orient with ctx_compose before exploring unfamiliar code — one call instead of a search-read-search chain. ctx_callgraph answers \"who calls X\", not grep. Same rule for every subagent.", + "@use: lean-ctx over native tools — ctx_read>Read/cat, ctx_shell>Bash, ctx_search>Grep, ctx_glob>Glob, ctx_callgraph>grep for \"who calls X\"\n@when: unfamiliar code — ctx_compose FIRST, one call vs search→read→search chain\n@scope: every subagent", ]; pub fn all() -> Vec<&'static str> {