From 26875a479b0dec0afa5a44840cdff8bf9ae7e192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Fri, 14 Aug 2026 17:12:07 +0200 Subject: [PATCH 1/3] Fix previous_response_not_found after WebSocket reconnect The incremental-input delta (previous_response_id + delta items) was computed before ensure_connection() ran. When the server had closed the socket in the meantime, ensure_connection() correctly dropped the stale connection and cleared the delta state - but the request had already been built against the dead connection's response id, which the fresh connection does not know (store: false keeps response state only for the lifetime of one connection). The server then rejected the request with 400 previous_response_not_found. Ensure the connection is live before computing the delta, so a reconnect automatically falls back to sending the full transcript. --- crates/llm/src/openai_responses_ws.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/llm/src/openai_responses_ws.rs b/crates/llm/src/openai_responses_ws.rs index 899a3c2b..640a16e7 100644 --- a/crates/llm/src/openai_responses_ws.rs +++ b/crates/llm/src/openai_responses_ws.rs @@ -1099,6 +1099,14 @@ impl OpenAIResponsesWsClient { None }; + // Ensure the connection is live BEFORE computing the incremental + // delta: a stale connection is dropped here, which clears + // `last_response_id`. The server only knows previous responses for + // the lifetime of one WebSocket connection (store: false), so a + // delta computed against a dead connection's response id would be + // rejected with `previous_response_not_found`. + self.ensure_connection().await?; + // Compute incremental delta if possible let (previous_response_id, send_input) = if let Some((prev_id, delta)) = self.compute_delta(&input) { @@ -1144,9 +1152,6 @@ impl OpenAIResponsesWsClient { &request_text[..request_text.len().min(1000)] ); - // Ensure connection - self.ensure_connection().await?; - // Send the request via the shared sink { let conn = self From bfca6c1fba2f48e545a6d92b3a07d0586b3e2fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Sun, 16 Aug 2026 08:51:02 +0200 Subject: [PATCH 2/3] mcp_client: add HTTP (streamable) transport Extend MCP client mode with an HTTP streamable transport alongside stdio. McpServerConfig now carries a typed, untagged McpTransport enum: an object with a "url" is HTTP (with optional "headers"), one with a "command" is stdio -- so existing mcp-servers.json files stay valid. ${VAR} substitution now covers HTTP header values as well as stdio env values. The client builds a reqwest-backed StreamableHttpClientTransport with custom headers (rmcp feature transport-streamable-http-client-reqwest). Added an in-process axum HTTP server integration test. The gpui MCP settings section gains a transport selector (command/args/env vs url/headers). --- AGENTS.md | 13 +- Cargo.lock | 337 ++++++++++++++++-- crates/code_assistant_core/src/tools/mcp.rs | 8 +- crates/mcp_client/Cargo.toml | 7 + crates/mcp_client/src/client.rs | 70 +++- crates/mcp_client/src/config.rs | 163 +++++++-- crates/mcp_client/src/lib.rs | 12 +- crates/mcp_client/src/tests.rs | 47 +++ .../src/settings_screen/mcp_section.rs | 261 ++++++++++---- docs/configuration.md | 26 +- docs/mcp-client-mode.md | 36 +- 11 files changed, 819 insertions(+), 161 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d2ddaa03..e8c20791 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,13 +74,14 @@ headless binary without gpui. - Integrates with Claude Desktop as MCP server ### MCP Client Mode -- Connects to configured MCP servers (stdio, official `rmcp` SDK) and - registers their tools in the `ToolRegistry` as `mcp____` - with scope tags `scope:agent`/`scope:agent-diff` plus `mcp` and - `scope:mcp-` +- Connects to configured MCP servers over stdio (child process) or HTTP + (streamable transport, official `rmcp` SDK) and registers their tools in + the `ToolRegistry` as `mcp____` with scope tags + `scope:agent`/`scope:agent-diff` plus `mcp` and `scope:mcp-` - Configured in `/mcp-servers.json` (per-server `enabled`, - `enabled_tools` allowlist, `disabled_tools` denylist; `${ENV_VAR}` - substitution in `env` values) or programmatically via + `enabled_tools` allowlist, `disabled_tools` denylist; a stdio server has + `command`/`args`/`env`, an HTTP server has `url`/`headers`; `${ENV_VAR}` + substitution in `env`/`headers` values) or programmatically via `mcp_client::register_mcp_tools` - The tool registry is rebuilt from the current config at the start of every agent run via the `ToolRegistryProvider` seam diff --git a/Cargo.lock b/Cargo.lock index 69efd41c..2e093ade 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -933,6 +933,29 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.7.9" @@ -940,7 +963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", - "axum-core", + "axum-core 0.4.5", "bytes", "futures-util", "http 1.4.0", @@ -949,7 +972,7 @@ dependencies = [ "hyper 1.9.0", "hyper-util", "itoa", - "matchit", + "matchit 0.7.3", "memchr", "mime", "percent-encoding", @@ -967,6 +990,39 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core 0.5.6", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-core" version = "0.4.5" @@ -988,6 +1044,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -1462,7 +1537,7 @@ dependencies = [ "futures", "futures-timer", "pin-project-lite", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "thiserror 1.0.69", @@ -1606,6 +1681,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cocoa" version = "0.25.0" @@ -1696,7 +1780,7 @@ dependencies = [ "anyhow", "async-channel 2.5.0", "async-trait", - "axum", + "axum 0.7.9", "base64 0.22.1", "chrono", "clap", @@ -1739,7 +1823,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width 0.2.1", ] [[package]] @@ -1764,6 +1848,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "command_executor" version = "0.1.0" @@ -2212,7 +2306,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.10.1", + "phf 0.11.3", "smallvec", ] @@ -3192,6 +3286,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -5292,13 +5392,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http 1.4.0", "http-body 1.0.1", "hyper 1.9.0", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2 0.6.3", "tokio", @@ -5785,6 +5888,36 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -6102,7 +6235,7 @@ version = "0.2.15" dependencies = [ "anyhow", "async-trait", - "axum", + "axum 0.7.9", "base64 0.21.7", "bytes", "chrono", @@ -6114,7 +6247,7 @@ dependencies = [ "oauth2", "rand 0.8.6", "regex", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "sha2 0.11.0", @@ -6415,6 +6548,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "matrixmultiply" version = "0.3.10" @@ -6452,7 +6591,9 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "axum 0.8.9", "command_executor", + "http 1.4.0", "rmcp", "serde", "serde_json", @@ -6983,7 +7124,7 @@ dependencies = [ "getrandom 0.2.17", "http 0.2.12", "rand 0.8.6", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "serde_path_to_error", @@ -7664,9 +7805,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ - "phf_macros 0.10.0", "phf_shared 0.10.0", - "proc-macro-hack", ] [[package]] @@ -7740,20 +7879,6 @@ dependencies = [ "phf_shared 0.13.1", ] -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "phf_macros" version = "0.11.3" @@ -8120,12 +8245,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" version = "1.0.106" @@ -8340,6 +8459,7 @@ version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.4.2", "lru-slab", @@ -8772,12 +8892,52 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "webpki-roots 0.25.4", "winreg 0.50.0", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.40", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + [[package]] name = "resvg" version = "0.45.1" @@ -8843,19 +9003,28 @@ checksum = "f00a32c3b81b7b254076a65abd5ab2551209146713ba38f73818657e865e9433" dependencies = [ "async-trait", "base64 0.22.1", + "bytes", "chrono", "futures", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", "pastey 0.2.3", "pin-project-lite", "process-wrap", + "rand 0.10.2", + "reqwest 0.13.4", "schemars 1.2.1", "serde", "serde_json", + "sse-stream", "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-util", + "tower-service", "tracing", + "uuid", ] [[package]] @@ -9090,6 +9259,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -9138,6 +9308,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.0", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.13", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -9154,6 +9351,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -9501,7 +9699,7 @@ checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" dependencies = [ "ahash", "annotate-snippets", - "base64 0.21.7", + "base64 0.22.1", "encoding_rs_io", "getrandom 0.3.4", "granit-parser", @@ -9782,7 +9980,7 @@ version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ - "dirs 5.0.1", + "dirs 6.0.0", ] [[package]] @@ -9841,6 +10039,16 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -10004,6 +10212,19 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -10976,6 +11197,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -11860,6 +12099,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm_thread" version = "0.3.3" @@ -11987,7 +12239,7 @@ version = "0.2.15" dependencies = [ "anyhow", "async-trait", - "axum", + "axum 0.7.9", "base64 0.22.1", "chromiumoxide", "futures", @@ -11995,7 +12247,7 @@ dependencies = [ "percent-encoding", "rand 0.9.4", "regex", - "reqwest", + "reqwest 0.11.27", "scraper 0.18.1", "serde", "serde_json", @@ -12024,6 +12276,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.25.4" @@ -12300,7 +12561,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -13504,7 +13765,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "windows-registry 0.4.0", ] diff --git a/crates/code_assistant_core/src/tools/mcp.rs b/crates/code_assistant_core/src/tools/mcp.rs index 1dc85ebf..bb318193 100644 --- a/crates/code_assistant_core/src/tools/mcp.rs +++ b/crates/code_assistant_core/src/tools/mcp.rs @@ -10,7 +10,8 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; pub use mcp_client::{ - DiscoveredTool, McpServerConfig, McpServerStatus, McpServersConfig, discover_tools, + DiscoveredTool, McpServerConfig, McpServerStatus, McpServersConfig, McpTransport, + discover_tools, }; /// Scope tags every MCP tool carries in code-assistant: offered to the main @@ -185,7 +186,10 @@ mod tests { ) .unwrap(); let loaded = load_mcp_servers_config_from(&path).unwrap(); - assert_eq!(loaded.servers["jira"].env["API_TOKEN"], "secret-123"); + let McpTransport::Stdio { env, .. } = &loaded.servers["jira"].transport else { + panic!("expected stdio transport"); + }; + assert_eq!(env["API_TOKEN"], "secret-123"); }); } diff --git a/crates/mcp_client/Cargo.toml b/crates/mcp_client/Cargo.toml index 48ee6a39..2b769ce1 100644 --- a/crates/mcp_client/Cargo.toml +++ b/crates/mcp_client/Cargo.toml @@ -9,9 +9,12 @@ rmcp = { version = "2.1", default-features = false, features = [ "base64", "client", "transport-child-process", + "transport-streamable-http-client-reqwest", + "reqwest", ] } anyhow = "1.0" async-trait = "0.1" +http = "1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.48", features = ["macros", "process", "rt", "io-util", "time", "sync"] } @@ -24,5 +27,9 @@ rmcp = { version = "2.1", default-features = false, features = [ "client", "server", "transport-child-process", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", + "reqwest", ] } +axum = "0.8" tokio = { version = "1.48", features = ["full"] } diff --git a/crates/mcp_client/src/client.rs b/crates/mcp_client/src/client.rs index bc2519b7..3472851b 100644 --- a/crates/mcp_client/src/client.rs +++ b/crates/mcp_client/src/client.rs @@ -1,15 +1,18 @@ //! Connection to a single MCP server, built on the official rmcp SDK. //! -//! One connection per configured server; the child process lives as long as -//! the connection. Wrapped tools hold the connection behind an `Arc`, so a -//! dead server degrades to tool errors, never a crashed agent. +//! One connection per configured server. For a stdio server the child process +//! lives as long as the connection; for an HTTP server it is a streamable HTTP +//! session. Wrapped tools hold the connection behind an `Arc`, so a dead +//! server degrades to tool errors, never a crashed agent. -use crate::config::McpServerConfig; +use crate::config::{McpServerConfig, McpTransport}; use anyhow::{Context, Result}; use rmcp::ServiceExt; use rmcp::model::{CallToolRequestParams, CallToolResult, JsonObject, Tool as McpToolDescriptor}; use rmcp::service::{RoleClient, RunningService}; use rmcp::transport::IntoTransport; +use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; +use std::collections::HashMap; use std::time::Duration; /// Timeout for the initialize handshake and for tool discovery. @@ -26,18 +29,61 @@ pub struct McpServerConnection { } impl McpServerConnection { - /// Launch the configured command as a child process and run the MCP - /// initialize handshake over its stdio. + /// Connect to the configured server, running the MCP initialize handshake + /// over its transport: a launched child process (stdio) or an HTTP + /// streamable endpoint. pub async fn connect(name: &str, config: &McpServerConfig) -> Result { - let mut command = tokio::process::Command::new(&config.command); - command.args(&config.args).envs(&config.env); - let transport = rmcp::transport::child_process::TokioChildProcess::new(command) - .with_context(|| { - format!("failed to launch MCP server '{name}' ({})", config.command) - })?; + match &config.transport { + McpTransport::Stdio { command, args, env } => { + Self::connect_stdio(name, command, args, env).await + } + McpTransport::Http { url, headers } => Self::connect_http(name, url, headers).await, + } + } + + /// Launch `command` as a child process and run the MCP initialize + /// handshake over its stdio. + async fn connect_stdio( + name: &str, + command: &str, + args: &[String], + env: &HashMap, + ) -> Result { + let mut process = tokio::process::Command::new(command); + process.args(args).envs(env); + let transport = rmcp::transport::child_process::TokioChildProcess::new(process) + .with_context(|| format!("failed to launch MCP server '{name}' ({command})"))?; Self::connect_transport(name, transport).await } + /// Connect to an HTTP (streamable) MCP server at `url`, sending the given + /// custom headers (e.g. `Authorization`) with every request. + async fn connect_http( + name: &str, + url: &str, + headers: &HashMap, + ) -> Result { + let mut config = StreamableHttpClientTransportConfig::with_uri(url.to_string()); + if !headers.is_empty() { + let mut header_map = HashMap::with_capacity(headers.len()); + for (key, value) in headers { + let name = http::HeaderName::from_bytes(key.as_bytes()) + .with_context(|| format!("invalid HTTP header name '{key}'"))?; + let value = http::HeaderValue::from_str(value) + .with_context(|| format!("invalid value for HTTP header '{key}'"))?; + header_map.insert(name, value); + } + config = config.custom_headers(header_map); + } + let transport = + rmcp::transport::streamable_http_client::StreamableHttpClientTransport::from_config( + config, + ); + Self::connect_transport(name, transport) + .await + .with_context(|| format!("failed to connect to HTTP MCP server '{name}' ({url})")) + } + /// Run the MCP initialize handshake over an arbitrary transport. Used by /// tests (in-process duplex streams); embedders normally use /// [`Self::connect`]. diff --git a/crates/mcp_client/src/config.rs b/crates/mcp_client/src/config.rs index 011e8740..7b876e93 100644 --- a/crates/mcp_client/src/config.rs +++ b/crates/mcp_client/src/config.rs @@ -24,16 +24,27 @@ impl McpServersConfig { self.servers.iter().filter(|(_, server)| server.enabled) } - /// Substitute `${VAR}` patterns in every server's env values, so config - /// files can reference secrets instead of baking them in. `lookup` - /// resolves a variable name (typically `|name| std::env::var(name).ok()`); - /// an unresolvable variable or an unclosed `${` is an error naming the + /// Substitute `${VAR}` patterns in every server's secret-carrying values, + /// so config files can reference secrets instead of baking them in. That + /// means stdio env values and HTTP header values. `lookup` resolves a + /// variable name (typically `|name| std::env::var(name).ok()`); an + /// unresolvable variable or an unclosed `${` is an error naming the /// offending server. pub fn substitute_env_values(&mut self, lookup: impl Fn(&str) -> Option) -> Result<()> { for (name, server) in self.servers.iter_mut() { - for value in server.env.values_mut() { - *value = substitute_variables(value, &lookup) - .with_context(|| format!("in env of MCP server '{name}'"))?; + match &mut server.transport { + McpTransport::Stdio { env, .. } => { + for value in env.values_mut() { + *value = substitute_variables(value, &lookup) + .with_context(|| format!("in env of MCP server '{name}'"))?; + } + } + McpTransport::Http { headers, .. } => { + for value in headers.values_mut() { + *value = substitute_variables(value, &lookup) + .with_context(|| format!("in headers of MCP server '{name}'"))?; + } + } } } Ok(()) @@ -61,20 +72,18 @@ pub fn substitute_variables( Ok(result) } -/// One configured MCP server, launched as a child process speaking MCP over -/// stdio. +/// One configured MCP server. Reached either as a child process speaking MCP +/// over stdio, or over an HTTP (streamable) endpoint — see [`McpTransport`]. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct McpServerConfig { - /// Executable to launch. - pub command: String, - /// Arguments passed to the executable. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub args: Vec, - /// Extra environment variables for the child process. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub env: HashMap, + /// How to reach the server. Flattened into the server object so a stdio + /// server keeps its historical `command`/`args`/`env` shape and an HTTP + /// server simply carries a `url` (plus optional `headers`); the presence + /// of `url` selects the HTTP transport. + #[serde(flatten)] + pub transport: McpTransport, /// Whether this server is switched on. Disabled servers are not - /// launched and contribute no tools. + /// launched/connected and contribute no tools. #[serde(default = "default_true")] pub enabled: bool, /// Optional allowlist: when set, only the named tools are registered. @@ -87,6 +96,60 @@ pub struct McpServerConfig { pub disabled_tools: Vec, } +/// The wire transport used to reach a server. Untagged: the shape of the JSON +/// selects the variant — an object with `url` is HTTP, one with `command` is +/// stdio. This keeps existing (`command`-based) configuration files valid. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum McpTransport { + /// HTTP streamable transport: the server is reached at `url`. + Http { + /// Endpoint URL of the MCP server (e.g. `https://host/mcp`). + url: String, + /// Extra HTTP headers sent with every request (e.g. `Authorization`). + /// Values support `${VAR}` substitution for secrets. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + headers: HashMap, + }, + /// stdio transport: the configured command is launched as a child + /// process speaking MCP over its stdio. + Stdio { + /// Executable to launch. + command: String, + /// Arguments passed to the executable. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + args: Vec, + /// Extra environment variables for the child process. Values support + /// `${VAR}` substitution for secrets. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + env: HashMap, + }, +} + +impl McpTransport { + /// A stdio transport launching `command` with no args and no extra env. + pub fn stdio(command: impl Into) -> Self { + McpTransport::Stdio { + command: command.into(), + args: Vec::new(), + env: HashMap::new(), + } + } + + /// An HTTP transport pointing at `url` with no extra headers. + pub fn http(url: impl Into) -> Self { + McpTransport::Http { + url: url.into(), + headers: HashMap::new(), + } + } + + /// `true` for the HTTP transport. + pub fn is_http(&self) -> bool { + matches!(self, McpTransport::Http { .. }) + } +} + fn default_true() -> bool { true } @@ -111,9 +174,7 @@ mod tests { let config: McpServersConfig = serde_json::from_str(r#"{ "servers": { "jira": { "command": "npx" } } }"#).unwrap(); let jira = &config.servers["jira"]; - assert_eq!(jira.command, "npx"); - assert!(jira.args.is_empty()); - assert!(jira.env.is_empty()); + assert_eq!(jira.transport, McpTransport::stdio("npx")); assert!(jira.enabled); assert!(jira.enabled_tools.is_none()); assert!(jira.disabled_tools.is_empty()); @@ -172,11 +233,54 @@ mod tests { config .substitute_env_values(|name| (name == "JIRA_TOKEN").then(|| "s3cret".to_string())) .unwrap(); - let env = &config.servers["jira"].env; + let McpTransport::Stdio { env, .. } = &config.servers["jira"].transport else { + panic!("expected stdio transport"); + }; assert_eq!(env["TOKEN"], "Bearer s3cret"); assert_eq!(env["PLAIN"], "as-is"); } + #[test] + fn http_header_values_get_variables_substituted() { + let mut config: McpServersConfig = serde_json::from_str( + r#"{ "servers": { "remote": { + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer ${API_TOKEN}", "X-Env": "prod" } + } } }"#, + ) + .unwrap(); + config + .substitute_env_values(|name| (name == "API_TOKEN").then(|| "s3cret".to_string())) + .unwrap(); + let McpTransport::Http { url, headers } = &config.servers["remote"].transport else { + panic!("expected http transport"); + }; + assert_eq!(url, "https://example.com/mcp"); + assert_eq!(headers["Authorization"], "Bearer s3cret"); + assert_eq!(headers["X-Env"], "prod"); + } + + #[test] + fn http_server_round_trips() { + let config: McpServersConfig = serde_json::from_str( + r#"{ "servers": { "remote": { + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer x" } + } } }"#, + ) + .unwrap(); + assert!(config.servers["remote"].transport.is_http()); + let json = serde_json::to_value(&config).unwrap(); + assert_eq!( + json, + serde_json::json!({ "servers": { "remote": { + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer x" }, + "enabled": true + } } }) + ); + } + #[test] fn unresolvable_variable_errors_with_server_name() { let mut config: McpServersConfig = serde_json::from_str( @@ -203,15 +307,22 @@ mod tests { #[test] fn commands_and_args_are_left_alone() { - // Substitution is deliberately limited to env values — commands and - // args come from the same trusted file, but only env carries secrets. + // Substitution is deliberately limited to env/header values — commands + // and args come from the same trusted file, but only env carries + // secrets. let mut config: McpServersConfig = serde_json::from_str( r#"{ "servers": { "jira": { "command": "${CMD}", "args": ["${ARG}"] } } }"#, ) .unwrap(); config.substitute_env_values(|_| None).unwrap(); - assert_eq!(config.servers["jira"].command, "${CMD}"); - assert_eq!(config.servers["jira"].args, ["${ARG}"]); + assert_eq!( + config.servers["jira"].transport, + McpTransport::Stdio { + command: "${CMD}".to_string(), + args: vec!["${ARG}".to_string()], + env: HashMap::new(), + } + ); } #[test] diff --git a/crates/mcp_client/src/lib.rs b/crates/mcp_client/src/lib.rs index 2cbc56a4..8ad9f877 100644 --- a/crates/mcp_client/src/lib.rs +++ b/crates/mcp_client/src/lib.rs @@ -1,8 +1,8 @@ -//! MCP client mode: connect to configured MCP servers (stdio transport) and -//! register each offered MCP tool as a regular [`tools_core::ToolRegistry`] -//! tool. MCP stays a registry *source*, never an architecture — everything -//! downstream of the registry (dialects, scoping, the agent loop, permission -//! checks) keeps working unchanged. +//! MCP client mode: connect to configured MCP servers (stdio or HTTP +//! streamable transport) and register each offered MCP tool as a regular +//! [`tools_core::ToolRegistry`] tool. MCP stays a registry *source*, never an +//! architecture — everything downstream of the registry (dialects, scoping, +//! the agent loop, permission checks) keeps working unchanged. //! //! Built on the official Rust MCP SDK (`rmcp`). @@ -17,7 +17,7 @@ pub mod tool; mod tests; pub use client::McpServerConnection; -pub use config::{McpServerConfig, McpServersConfig, substitute_variables}; +pub use config::{McpServerConfig, McpServersConfig, McpTransport, substitute_variables}; pub use registry::{ DiscoveredTool, MCP_CAPABILITY, McpServerStatus, discover_tools, register_mcp_tools, }; diff --git a/crates/mcp_client/src/tests.rs b/crates/mcp_client/src/tests.rs index 048fa0a3..3558fa9a 100644 --- a/crates/mcp_client/src/tests.rs +++ b/crates/mcp_client/src/tests.rs @@ -269,3 +269,50 @@ async fn non_object_params_are_rejected() { assert!(result.is_err()); } + +/// Serve `TestServer` over the streamable HTTP transport on an ephemeral +/// localhost port, returning its `/mcp` URL and the server task (aborted on +/// drop). +async fn spawn_http_server() -> (String, tokio::task::JoinHandle<()>) { + use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; + use rmcp::transport::streamable_http_server::tower::{ + StreamableHttpServerConfig, StreamableHttpService, + }; + + let service = StreamableHttpService::new( + || Ok(TestServer), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + (format!("http://{addr}/mcp"), server) +} + +#[tokio::test] +async fn connects_and_calls_over_http() { + let (url, _server) = spawn_http_server().await; + let config = server_config(json!({ "url": url })); + + let connection = Arc::new( + McpServerConnection::connect("http-test", &config) + .await + .expect("client failed to connect over HTTP"), + ); + let mut registry = ToolRegistry::new(); + let registered = register_connection_tools(&mut registry, connection, &config, &[]) + .await + .unwrap(); + assert_eq!(registered, ["mcp__http-test__echo", "mcp__http-test__fail"]); + + let tool = registry.get("mcp__http-test__echo").unwrap(); + let mut params = json!({ "message": "over http" }); + let output = tool.invoke(&mut test_context(), &mut params).await.unwrap(); + assert!(output.is_success()); + let rendered = output.as_render().render(&mut ResourcesTracker::new()); + assert_eq!(rendered, "echo: over http"); +} diff --git a/crates/ui_gpui/src/settings_screen/mcp_section.rs b/crates/ui_gpui/src/settings_screen/mcp_section.rs index 001ad0fc..1cc58d1c 100644 --- a/crates/ui_gpui/src/settings_screen/mcp_section.rs +++ b/crates/ui_gpui/src/settings_screen/mcp_section.rs @@ -6,7 +6,9 @@ //! placeholders); only the discovery connection resolves them. Servers are //! connected when the app starts, so config changes apply after a restart. -use code_assistant_core::tools::mcp::{self, DiscoveredTool, McpServerConfig, McpServersConfig}; +use code_assistant_core::tools::mcp::{ + self, DiscoveredTool, McpServerConfig, McpServersConfig, McpTransport, +}; use gpui::{App, Context, Entity, FocusHandle, Focusable, SharedString, div, prelude::*, px}; use gpui_component::input::{Input, InputState}; use gpui_component::switch::Switch; @@ -21,6 +23,13 @@ enum FormMode { Editing(String), } +/// Which transport the add/edit form is currently editing. +#[derive(Clone, Copy, PartialEq, Eq)] +enum FormTransport { + Stdio, + Http, +} + /// Result of the async tool discovery for one server. enum DiscoveryState { Loading, @@ -37,10 +46,14 @@ pub struct McpSection { discovered: HashMap, form_mode: FormMode, + /// Transport selected in the add/edit form. + form_transport: FormTransport, form_name_input: Entity, form_command_input: Entity, form_args_input: Entity, form_env_input: Entity, + form_url_input: Entity, + form_headers_input: Entity, } impl McpSection { @@ -57,16 +70,27 @@ impl McpSection { .auto_grow(2, 6) .placeholder("one per line, e.g. API_TOKEN=${MY_TOKEN}") }); + let form_url_input = + cx.new(|cx| InputState::new(window, cx).placeholder("e.g. https://example.com/mcp")); + let form_headers_input = cx.new(|cx| { + InputState::new(window, cx) + .multi_line(true) + .auto_grow(2, 6) + .placeholder("one per line, e.g. Authorization=Bearer ${MY_TOKEN}") + }); Self { focus_handle: cx.focus_handle(), config: load_config(), expanded: HashSet::new(), discovered: HashMap::new(), form_mode: FormMode::Hidden, + form_transport: FormTransport::Stdio, form_name_input, form_command_input, form_args_input, form_env_input, + form_url_input, + form_headers_input, } } @@ -172,19 +196,24 @@ impl McpSection { window: &mut gpui::Window, cx: &mut Context, ) { - let command = server.map(|s| s.command.clone()).unwrap_or_default(); - let args = server.map(|s| s.args.join(" ")).unwrap_or_default(); - let env = server - .map(|s| { - let mut entries: Vec<_> = s.env.iter().collect(); - entries.sort(); - entries - .into_iter() - .map(|(key, value)| format!("{key}={value}")) - .collect::>() - .join("\n") - }) - .unwrap_or_default(); + // Pick the transport tab from the existing server, defaulting new + // servers to stdio. + self.form_transport = match server.map(|s| &s.transport) { + Some(McpTransport::Http { .. }) => FormTransport::Http, + _ => FormTransport::Stdio, + }; + + let (command, args, env) = match server.map(|s| &s.transport) { + Some(McpTransport::Stdio { command, args, env }) => { + (command.clone(), args.join(" "), format_map(env)) + } + _ => (String::new(), String::new(), String::new()), + }; + let (url, headers) = match server.map(|s| &s.transport) { + Some(McpTransport::Http { url, headers }) => (url.clone(), format_map(headers)), + _ => (String::new(), String::new()), + }; + self.form_name_input.update(cx, |state, cx| { state.set_value(SharedString::from(name.to_string()), window, cx) }); @@ -197,36 +226,48 @@ impl McpSection { self.form_env_input.update(cx, |state, cx| { state.set_value(SharedString::from(env), window, cx) }); + self.form_url_input.update(cx, |state, cx| { + state.set_value(SharedString::from(url), window, cx) + }); + self.form_headers_input.update(cx, |state, cx| { + state.set_value(SharedString::from(headers), window, cx) + }); } fn save_form(&mut self, cx: &mut Context) { let name = self.form_name_input.read(cx).value().trim().to_string(); - let command = self.form_command_input.read(cx).value().trim().to_string(); - if name.is_empty() || command.is_empty() { - warn!("MCP server needs both a name and a command"); + if name.is_empty() { + warn!("MCP server needs a name"); return; } - let args: Vec = self - .form_args_input - .read(cx) - .value() - .split_whitespace() - .map(str::to_string) - .collect(); - let env: HashMap = self - .form_env_input - .read(cx) - .value() - .lines() - .filter_map(|line| { - let line = line.trim(); - if line.is_empty() { - return None; + + let transport = match self.form_transport { + FormTransport::Stdio => { + let command = self.form_command_input.read(cx).value().trim().to_string(); + if command.is_empty() { + warn!("A stdio MCP server needs a command"); + return; } - let (key, value) = line.split_once('=')?; - Some((key.trim().to_string(), value.trim().to_string())) - }) - .collect(); + let args: Vec = self + .form_args_input + .read(cx) + .value() + .split_whitespace() + .map(str::to_string) + .collect(); + let env = parse_map(&self.form_env_input.read(cx).value()); + McpTransport::Stdio { command, args, env } + } + FormTransport::Http => { + let url = self.form_url_input.read(cx).value().trim().to_string(); + if url.is_empty() { + warn!("An HTTP MCP server needs a URL"); + return; + } + let headers = parse_map(&self.form_headers_input.read(cx).value()); + McpTransport::Http { url, headers } + } + }; // Renaming moves the entry (and its tool filter) to the new key. let previous = match &self.form_mode { @@ -234,16 +275,12 @@ impl McpSection { _ => None, }; let mut server = previous.unwrap_or_else(|| McpServerConfig { - command: String::new(), - args: Vec::new(), - env: HashMap::new(), + transport: McpTransport::stdio(String::new()), enabled: true, enabled_tools: None, disabled_tools: Vec::new(), }); - server.command = command; - server.args = args; - server.env = env; + server.transport = transport; self.config.servers.insert(name.clone(), server); self.form_mode = FormMode::Hidden; @@ -272,10 +309,15 @@ impl McpSection { let name_for_expand = name.to_string(); let name_for_switch = name.to_string(); - let summary = if server.args.is_empty() { - server.command.clone() - } else { - format!("{} {}", server.command, server.args.join(" ")) + let summary = match &server.transport { + McpTransport::Stdio { command, args, .. } => { + if args.is_empty() { + command.clone() + } else { + format!("{} {}", command, args.join(" ")) + } + } + McpTransport::Http { url, .. } => url.clone(), }; div() @@ -537,12 +579,14 @@ impl McpSection { ) } - /// The add/edit form: name, command, args, env. + /// The add/edit form: name, a transport selector, and the fields for the + /// selected transport (command/args/env for stdio, url/headers for HTTP). fn render_inline_form(&self, cx: &mut Context) -> impl IntoElement { let editing_name = match &self.form_mode { FormMode::Editing(name) => Some(name.clone()), _ => None, }; + let is_http = self.form_transport == FormTransport::Http; div() .flex() @@ -558,21 +602,36 @@ impl McpSection { Input::new(&self.form_name_input).into_any_element(), cx, )) - .child(self.render_form_row( - "Command", - Input::new(&self.form_command_input).into_any_element(), - cx, - )) - .child(self.render_form_row( - "Arguments", - Input::new(&self.form_args_input).into_any_element(), - cx, - )) - .child(self.render_form_row( - "Env", - Input::new(&self.form_env_input).into_any_element(), - cx, - )) + .child(self.render_form_row("Transport", self.render_transport_selector(cx), cx)) + .when(!is_http, |el| { + el.child(self.render_form_row( + "Command", + Input::new(&self.form_command_input).into_any_element(), + cx, + )) + .child(self.render_form_row( + "Arguments", + Input::new(&self.form_args_input).into_any_element(), + cx, + )) + .child(self.render_form_row( + "Env", + Input::new(&self.form_env_input).into_any_element(), + cx, + )) + }) + .when(is_http, |el| { + el.child(self.render_form_row( + "URL", + Input::new(&self.form_url_input).into_any_element(), + cx, + )) + .child(self.render_form_row( + "Headers", + Input::new(&self.form_headers_input).into_any_element(), + cx, + )) + }) // Action buttons .child( div() @@ -641,6 +700,56 @@ impl McpSection { ) } + /// Two toggle pills to pick the transport type in the add/edit form. + fn render_transport_selector(&self, cx: &mut Context) -> gpui::AnyElement { + let pill = |label: &str, + id: &str, + selected: bool, + target: FormTransport, + cx: &mut Context| { + div() + .id(SharedString::from(id.to_string())) + .px_3() + .py_1() + .rounded_md() + .cursor_pointer() + .text_xs() + .when(selected, |s| { + s.bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + }) + .when(!selected, |s| { + s.text_color(cx.theme().muted_foreground) + .hover(|s| s.bg(cx.theme().muted)) + }) + .child(SharedString::from(label.to_string())) + .on_click(cx.listener(move |this, _, _window, cx| { + this.form_transport = target; + cx.notify(); + })) + }; + + div() + .flex() + .items_center() + .gap_2() + .child(pill( + "Command (stdio)", + "mcp-transport-stdio", + self.form_transport == FormTransport::Stdio, + FormTransport::Stdio, + cx, + )) + .child(pill( + "HTTP", + "mcp-transport-http", + self.form_transport == FormTransport::Http, + FormTransport::Http, + cx, + )) + .into_any_element() + } + fn render_form_row( &self, label: &str, @@ -717,6 +826,32 @@ fn load_config() -> McpServersConfig { }) } +/// Render a `KEY=value` map as one sorted `KEY=value` line per entry, for +/// display in a multi-line input. +fn format_map(map: &HashMap) -> String { + let mut entries: Vec<_> = map.iter().collect(); + entries.sort(); + entries + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("\n") +} + +/// Parse a multi-line `KEY=value` input back into a map, ignoring blank lines. +fn parse_map(text: &str) -> HashMap { + text.lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + let (key, value) = line.split_once('=')?; + Some((key.trim().to_string(), value.trim().to_string())) + }) + .collect() +} + impl Focusable for McpSection { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() diff --git a/docs/configuration.md b/docs/configuration.md index 40acb9cc..1fc5d0eb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -155,9 +155,29 @@ code-assistant can connect to external Model Context Protocol servers and register their tools. Configure them via the Settings screen ("MCP Servers") or in `~/.config/code-assistant/mcp-servers.json` (per-server `enabled`, `enabled_tools` allowlist, `disabled_tools` denylist, and `${ENV_VAR}` -substitution in `env` values). Configuration changes apply on the next agent -run — no restart required. A running agent keeps the tool set it started with; -the next message picks up added, removed or re-configured servers. +substitution in `env`/`headers` values). Configuration changes apply on the +next agent run — no restart required. A running agent keeps the tool set it +started with; the next message picks up added, removed or re-configured servers. + +Each server is reached over one of two transports, selected by the fields you +give it — a `command` runs it as a child process over stdio, a `url` connects +over HTTP (streamable transport): + +```json +{ + "servers": { + "jira": { + "command": "npx", + "args": ["-y", "some-jira-server"], + "env": { "JIRA_TOKEN": "${JIRA_TOKEN}" } + }, + "remote": { + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer ${REMOTE_TOKEN}" } + } + } +} +``` ## Advanced CLI options diff --git a/docs/mcp-client-mode.md b/docs/mcp-client-mode.md index 694c3af5..714c9443 100644 --- a/docs/mcp-client-mode.md +++ b/docs/mcp-client-mode.md @@ -106,15 +106,40 @@ What landed (step 2 of the order above, plus code-assistant UI): - **Protocol via the official SDK.** Instead of extracting `mcp_server`'s types into a shared crate, the client is built on `rmcp` (`modelcontextprotocol/rust-sdk`, features `client` + - `transport-child-process`) — same choice as codex and vtcode. Transport - is stdio only, as planned. + `transport-child-process` + + `transport-streamable-http-client-reqwest`) — same choice as codex and + vtcode. Both stdio and HTTP (streamable) transports are supported. - **New generic crate `crates/mcp_client`** (depends only on `tools_core` + `rmcp`): `McpServersConfig`/`McpServerConfig` (pure data, file I/O is - the embedder's), `McpServerConnection`, the `DynTool` proxy `McpTool` + the embedder's), `McpTransport` (the untagged stdio/HTTP transport + choice), `McpServerConnection`, the `DynTool` proxy `McpTool` (registry name `mcp____`, sanitized, 64-byte cap with deterministic hash suffix), and `register_mcp_tools(&mut registry, &config, extra_tags)`. A dead server degrades to error tool outputs. `discover_tools` provides ephemeral discovery for configuration UIs. + +### Transports + +A server is reached either as a child process over **stdio** or over an +**HTTP (streamable)** endpoint. The transport is chosen by the shape of the +server's JSON object — an object with a `url` is HTTP, one with a `command` +is stdio (so existing stdio configs stay valid): + +```json +{ + "servers": { + "jira": { "command": "npx", "args": ["-y", "some-jira-server"] }, + "remote": { + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer ${REMOTE_TOKEN}" } + } + } +} +``` + +`${VAR}` substitution applies to stdio `env` values and HTTP `headers` +values (both carry secrets); the raw placeholders are what the settings UI +shows and preserves. - **Tool filter**: per-server `enabled` flag, `enabled_tools` allowlist (None = all, deviating from the opt-in-only default proposed above — code-assistant is interactive, the settings UI makes per-tool disabling @@ -132,8 +157,9 @@ What landed (step 2 of the order above, plus code-assistant UI): apply on the next run, not only on restart. A running agent keeps the registry it started with. - **gpui settings page** ("MCP Servers"): expandable card per server with - enable switch, add/edit/delete, live tool discovery and per-tool - toggles persisted to `disabled_tools`. + enable switch, add/edit/delete (including a transport selector for + stdio command/args/env vs HTTP url/headers), live tool discovery and + per-tool toggles persisted to `disabled_tools`. Still open from this note: additive scope selection (step 1), read-only/outward classification and the outward-confirmation From 4ca243de70be9e061e020af89c63efe95d345ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Sun, 16 Aug 2026 08:51:34 +0200 Subject: [PATCH 3/3] ui_gpui: render MCP tool blocks inline MCP tools have dynamic mcp____ names, so no renderer was registered for them and they fell back to a raw "[name]" placeholder. Add a McpToolRenderer (inline style) installed as the registry's MCP fallback (ToolBlockRendererRegistry::resolve). It shows a generic MCP icon, the bare tool name, and the server name as a right-aligned pill via a new ToolBlockRenderer::header_tag() hook. JSON output is pretty-printed and rendered in a monospace font; non-JSON output is shown verbatim. --- crates/ui_gpui/src/blocks/container.rs | 2 +- crates/ui_gpui/src/blocks/render.rs | 20 ++- crates/ui_gpui/src/lib.rs | 5 +- crates/ui_gpui/src/shared/file_icons.rs | 8 + .../ui_gpui/src/tool_cards/inline_renderer.rs | 152 ++++++++++-------- crates/ui_gpui/src/tool_cards/mcp_tool.rs | 140 ++++++++++++++++ crates/ui_gpui/src/tool_cards/mod.rs | 31 ++++ 7 files changed, 291 insertions(+), 67 deletions(-) create mode 100644 crates/ui_gpui/src/tool_cards/mcp_tool.rs diff --git a/crates/ui_gpui/src/blocks/container.rs b/crates/ui_gpui/src/blocks/container.rs index 8de7ad3f..2338d3a8 100644 --- a/crates/ui_gpui/src/blocks/container.rs +++ b/crates/ui_gpui/src/blocks/container.rs @@ -333,7 +333,7 @@ impl MessageContainer { // renderer (cards expanded by default, browser cards collapsed). let starts_collapsed = crate::tool_cards::ToolBlockRendererRegistry::global() .as_ref() - .and_then(|registry| registry.get(&name).cloned()) + .and_then(|registry| registry.resolve(&name)) .map(|r| r.starts_collapsed()) .unwrap_or(true); if starts_collapsed { diff --git a/crates/ui_gpui/src/blocks/render.rs b/crates/ui_gpui/src/blocks/render.rs index 4b03fc09..a4ffd66c 100644 --- a/crates/ui_gpui/src/blocks/render.rs +++ b/crates/ui_gpui/src/blocks/render.rs @@ -181,6 +181,9 @@ impl BlockView { renderer.describe(block) }; + // Optional right-aligned pill (e.g. the MCP server name). + let header_tag = renderer.header_tag(block); + // Determine expansion state — purely based on ToolBlockState, no is_generating override let is_expanded = block.state == ToolBlockState::Expanded; let has_output = @@ -281,6 +284,21 @@ impl BlockView { .child(description), ), ) + // Optional pill (e.g. MCP server name), right-aligned before the + // chevron. + .when_some(header_tag, |d, tag| { + d.child( + div() + .flex_none() + .px(px(6.)) + .py(px(1.)) + .rounded(px(4.)) + .bg(theme.muted) + .text_size(rems(0.6875)) + .text_color(theme.muted_foreground) + .child(tag), + ) + }) // Chevron area — always laid out to prevent height changes when // output becomes available. The icon itself is only visible when // expandable, with a highlight on hover. @@ -550,7 +568,7 @@ impl gpui::Render for BlockView { BlockData::ToolUse(block) => { // Unified tool block rendering via ToolBlockRendererRegistry if let Some(registry) = crate::tool_cards::ToolBlockRendererRegistry::global() { - if let Some(renderer) = registry.get(&block.name) { + if let Some(renderer) = registry.resolve(&block.name) { match renderer.style() { crate::tool_cards::ToolBlockStyle::Inline => { let block_clone = block.clone(); diff --git a/crates/ui_gpui/src/lib.rs b/crates/ui_gpui/src/lib.rs index 7b2cf25c..61babd93 100644 --- a/crates/ui_gpui/src/lib.rs +++ b/crates/ui_gpui/src/lib.rs @@ -343,7 +343,7 @@ impl Gpui { // Initialize tool block renderer registry { - use tool_cards::{InlineToolRenderer, ToolBlockRendererRegistry}; + use tool_cards::{InlineToolRenderer, McpToolRenderer, ToolBlockRendererRegistry}; let mut tbr_registry = ToolBlockRendererRegistry::default(); tbr_registry.register(Arc::new(InlineToolRenderer::new())); tbr_registry.register(Arc::new(tool_cards::terminal_card::TerminalCardRenderer)); @@ -351,6 +351,9 @@ impl Gpui { tbr_registry.register(Arc::new(tool_cards::sub_agent_card::SubAgentCardRenderer)); tbr_registry.register(Arc::new(tool_cards::code_card::CodeCardRenderer)); tbr_registry.register(Arc::new(tool_cards::browser_card::BrowserCardRenderer)); + // MCP tools have dynamic `mcp____` names; one inline + // fallback renderer handles all of them. + tbr_registry.set_mcp_fallback(Arc::new(McpToolRenderer::new())); ToolBlockRendererRegistry::set_global(Arc::new(tbr_registry)); } diff --git a/crates/ui_gpui/src/shared/file_icons.rs b/crates/ui_gpui/src/shared/file_icons.rs index 16ccd06e..332f92a5 100644 --- a/crates/ui_gpui/src/shared/file_icons.rs +++ b/crates/ui_gpui/src/shared/file_icons.rs @@ -51,6 +51,7 @@ pub const TOOL_USER_INPUT: &str = "person"; // person.svg pub const TOOL_UPDATE_PLAN: &str = "todo_list"; // todo_list.svg pub const TOOL_SPAWN_AGENT: &str = "rerun"; // rerun.svg - for spawning sub-agents pub const TOOL_VIEW_DOCUMENTS: &str = "file_generic"; // file_generic.svg - for viewing documents +pub const TOOL_MCP: &str = "mcp"; // link.svg - generic icon for MCP server tools pub const TOOL_GENERIC: &str = "file_code"; // file_code.svg const FILE_TYPES_ASSET: &str = "icons/file_icons/file_types.json"; @@ -143,6 +144,7 @@ impl FileIcons { TOOL_UPDATE_PLAN => Some("icons/file_generic.svg"), TOOL_SPAWN_AGENT => Some("icons/rerun.svg"), TOOL_VIEW_DOCUMENTS => Some("icons/file_generic.svg"), + TOOL_MCP => Some("icons/link.svg"), TOOL_GENERIC => Some("icons/file_code.svg"), // For file_types.json types we missed _ => None, @@ -180,6 +182,7 @@ impl FileIcons { TOOL_UPDATE_PLAN => Some(SharedString::from("📝")), TOOL_SPAWN_AGENT => Some(SharedString::from("🔄")), TOOL_VIEW_DOCUMENTS => Some(SharedString::from("📑")), + TOOL_MCP => Some(SharedString::from("🔌")), TOOL_GENERIC => Some(SharedString::from("🔧")), _ => Some(SharedString::from("📄")), // Default fallback } @@ -187,6 +190,11 @@ impl FileIcons { /// Get tool-specific icon based on tool name pub fn get_tool_icon(&self, tool_name: &str) -> Option { + // MCP server tools (`mcp____`) share one generic icon; + // the server identity is shown as a pill in the block header instead. + if tool_name.starts_with("mcp__") { + return self.get_type_icon(TOOL_MCP); + } let icon_type = match tool_name { "read_files" => TOOL_READ_FILES, "list_files" => TOOL_LIST_FILES, diff --git a/crates/ui_gpui/src/tool_cards/inline_renderer.rs b/crates/ui_gpui/src/tool_cards/inline_renderer.rs index 3d674958..15d9d06f 100644 --- a/crates/ui_gpui/src/tool_cards/inline_renderer.rs +++ b/crates/ui_gpui/src/tool_cards/inline_renderer.rs @@ -155,79 +155,103 @@ impl ToolBlockRenderer for InlineToolRenderer { _window: &mut Window, _cx: &mut Context, ) -> Option { - // Inline tools: render the output text with a left-border style when - // expanded. If there's no output yet, return None. - let output = tool.output.as_deref().unwrap_or(""); - let has_images = !tool.images.is_empty(); + render_inline_output(tool, theme) + } +} - if output.is_empty() && !has_images { - return None; - } +/// Render the expandable output area shared by all inline tool renderers: +/// the text output with a left-border style, plus any images. Returns `None` +/// when there is nothing to show yet. +pub(crate) fn render_inline_output( + tool: &ToolUseBlock, + theme: &gpui_component::theme::Theme, +) -> Option { + let output = tool.output.as_deref().unwrap_or(""); + render_inline_output_text(output, false, tool, theme) +} - let output_color = if tool.status == ToolStatus::Error { - theme.danger - } else { - theme.muted_foreground - }; - - let mut container = div() - .pl(px(8.)) - .ml(px(8.)) - .border_l_2() - .border_color(theme.border) - .py(px(4.)) +/// Like [`render_inline_output`], but renders `output` (which may differ from +/// `tool.output`, e.g. pretty-printed) and optionally in a monospace font — +/// used by the MCP renderer to show formatted JSON. Images still come from the +/// tool. +pub(crate) fn render_inline_output_text( + output: &str, + monospace: bool, + tool: &ToolUseBlock, + theme: &gpui_component::theme::Theme, +) -> Option { + let has_images = !tool.images.is_empty(); + + if output.is_empty() && !has_images { + return None; + } + + let output_color = if tool.status == ToolStatus::Error { + theme.danger + } else { + theme.muted_foreground + }; + + let mut container = div() + .pl(px(8.)) + .ml(px(8.)) + .border_l_2() + .border_color(theme.border) + .py(px(4.)) + .overflow_hidden(); + + // Text output + if !output.is_empty() { + let mut text_el = div() .text_size(rems(0.8125)) .text_color(output_color) .overflow_hidden(); - - // Text output - if !output.is_empty() { - container = container.child(output.to_string()); + if monospace { + text_el = text_el.font_family("Menlo").line_height(rems(0.8125 * 1.4)); } + container = container.child(text_el.child(output.to_string())); + } - // Render images when expanded (for view_images tool) - if has_images { - let mut gallery = div().flex().flex_wrap().gap_2().mt_2(); - - for (media_type, base64_data) in &tool.images { - if let Some(image) = - crate::shared::image::parse_base64_image(media_type, base64_data) - { - gallery = gallery.child( - div() - .flex_none() - .border_1() - .border_color(theme.border) - .rounded_md() - .overflow_hidden() - .bg(theme.popover) - .shadow_sm() - .child( - img(ImageSource::Image(image)) - .max_h(px(200.)) - .max_w(px(400.)) - .object_fit(ObjectFit::Contain), - ), - ); - } else { - gallery = gallery.child( - div() - .flex_none() - .p_2() - .bg(theme.warning.opacity(0.1)) - .border_1() - .border_color(theme.warning.opacity(0.3)) - .rounded_md() - .text_color(theme.warning_foreground.opacity(0.8)) - .text_xs() - .child(format!("Failed to decode: {}", media_type)), - ); - } - } + // Render images when expanded (for view_images tool) + if has_images { + let mut gallery = div().flex().flex_wrap().gap_2().mt_2(); - container = container.child(gallery); + for (media_type, base64_data) in &tool.images { + if let Some(image) = crate::shared::image::parse_base64_image(media_type, base64_data) { + gallery = gallery.child( + div() + .flex_none() + .border_1() + .border_color(theme.border) + .rounded_md() + .overflow_hidden() + .bg(theme.popover) + .shadow_sm() + .child( + img(ImageSource::Image(image)) + .max_h(px(200.)) + .max_w(px(400.)) + .object_fit(ObjectFit::Contain), + ), + ); + } else { + gallery = gallery.child( + div() + .flex_none() + .p_2() + .bg(theme.warning.opacity(0.1)) + .border_1() + .border_color(theme.warning.opacity(0.3)) + .rounded_md() + .text_color(theme.warning_foreground.opacity(0.8)) + .text_xs() + .child(format!("Failed to decode: {}", media_type)), + ); + } } - Some(container.into_any()) + container = container.child(gallery); } + + Some(container.into_any()) } diff --git a/crates/ui_gpui/src/tool_cards/mcp_tool.rs b/crates/ui_gpui/src/tool_cards/mcp_tool.rs new file mode 100644 index 00000000..0c38d685 --- /dev/null +++ b/crates/ui_gpui/src/tool_cards/mcp_tool.rs @@ -0,0 +1,140 @@ +//! Inline renderer for MCP server tools. +//! +//! MCP tools are registered at runtime with dynamic names of the form +//! `mcp____` (see `mcp_client::naming`). They can't be +//! registered in the [`ToolBlockRendererRegistry`](super::ToolBlockRendererRegistry) +//! by a fixed name, so a single instance of this renderer is installed as the +//! registry's MCP fallback and handles every `mcp__…` block. +//! +//! Visually they follow the lightweight/explore-tool pattern: a generic MCP +//! icon, the bare tool name (`get_me`), and the server name (`github_tools_sap`) +//! as a pill on the right of the header — with the usual chevron to expand the +//! tool output. + +use super::inline_renderer::{render_inline_output, render_inline_output_text}; +use super::{CardRenderContext, ToolBlockRenderer, ToolBlockStyle}; +use crate::blocks::{BlockView, ToolUseBlock}; +use gpui::{AnyElement, Context, Window}; + +/// Split a registry tool name `mcp____` into `(server, tool)`. +/// +/// The server segment is everything between the `mcp__` prefix and the next +/// `__`; the tool is the remainder (kept verbatim, so tool names containing +/// underscores survive). Returns `None` for names that don't match the shape. +pub(crate) fn parse_mcp_name(name: &str) -> Option<(&str, &str)> { + name.strip_prefix("mcp__")?.split_once("__") +} + +/// Pretty-print `raw` if the whole (trimmed) string is a JSON object or array. +/// Most MCP tools return their result as a single compact JSON string, which +/// is far more readable formatted. Returns `None` for non-JSON output (plain +/// text, error messages), which is then shown verbatim. +fn pretty_json(raw: &str) -> Option { + let trimmed = raw.trim(); + if !(trimmed.starts_with('{') || trimmed.starts_with('[')) { + return None; + } + let value: serde_json::Value = serde_json::from_str(trimmed).ok()?; + serde_json::to_string_pretty(&value).ok() +} + +/// Renders any `mcp__…` tool block inline. +#[derive(Default)] +pub struct McpToolRenderer; + +impl McpToolRenderer { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self + } +} + +impl ToolBlockRenderer for McpToolRenderer { + fn supported_tools(&self) -> Vec { + // Registered as the registry's MCP fallback, not by fixed name. + Vec::new() + } + + fn style(&self) -> ToolBlockStyle { + ToolBlockStyle::Inline + } + + fn describe(&self, tool: &ToolUseBlock) -> String { + match parse_mcp_name(&tool.name) { + Some((_, tool_name)) => tool_name.to_string(), + None => tool.name.clone(), + } + } + + fn header_tag(&self, tool: &ToolUseBlock) -> Option { + parse_mcp_name(&tool.name).map(|(server, _)| server.to_string()) + } + + fn render( + &self, + tool: &ToolUseBlock, + _is_generating: bool, + theme: &gpui_component::theme::Theme, + _card_ctx: Option<&CardRenderContext>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + // Show formatted JSON in monospace when the output is JSON; otherwise + // fall back to the plain inline rendering. + match tool.output.as_deref().and_then(pretty_json) { + Some(pretty) => render_inline_output_text(&pretty, true, tool, theme), + None => render_inline_output(tool, theme), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_server_and_tool() { + assert_eq!( + parse_mcp_name("mcp__github_tools_sap__get_me"), + Some(("github_tools_sap", "get_me")) + ); + } + + #[test] + fn tool_name_keeps_its_underscores() { + assert_eq!( + parse_mcp_name("mcp__jira__search_issues"), + Some(("jira", "search_issues")) + ); + } + + #[test] + fn server_name_may_contain_hyphens() { + assert_eq!( + parse_mcp_name("mcp__http-test__echo"), + Some(("http-test", "echo")) + ); + } + + #[test] + fn non_mcp_name_is_rejected() { + assert_eq!(parse_mcp_name("read_files"), None); + assert_eq!(parse_mcp_name("mcp__no_tool_separator"), None); + } + + #[test] + fn pretty_json_formats_objects_and_arrays() { + let pretty = pretty_json(r#"{"a":1,"b":[2,3]}"#).expect("valid json"); + assert!(pretty.contains('\n'), "should be multi-line: {pretty}"); + assert!(pretty.contains("\"a\": 1")); + assert!(pretty_json(" [1, 2, 3] ").is_some()); + } + + #[test] + fn pretty_json_leaves_non_json_alone() { + assert_eq!(pretty_json("plain text output"), None); + assert_eq!(pretty_json(""), None); + // A bare number/string is not pretty-printed (nothing to gain). + assert_eq!(pretty_json("42"), None); + } +} diff --git a/crates/ui_gpui/src/tool_cards/mod.rs b/crates/ui_gpui/src/tool_cards/mod.rs index b558aff8..fb40d1dd 100644 --- a/crates/ui_gpui/src/tool_cards/mod.rs +++ b/crates/ui_gpui/src/tool_cards/mod.rs @@ -19,6 +19,7 @@ pub mod browser_card; pub mod code_card; pub mod diff_card; pub mod inline_renderer; +pub mod mcp_tool; pub mod sub_agent_card; pub mod terminal_card; @@ -33,6 +34,7 @@ use std::sync::{Arc, Mutex, OnceLock}; // Re-exports for backward compatibility pub use animated_card::animated_card_body; pub use inline_renderer::InlineToolRenderer; +pub use mcp_tool::McpToolRenderer; // --------------------------------------------------------------------------- // CardRenderContext — passed to Card-style renderers @@ -105,6 +107,13 @@ pub trait ToolBlockRenderer: Send + Sync { tool.name.clone() } + /// Optional short tag shown as a pill on the right of an inline block's + /// header (before the chevron). Used by MCP tools to surface the server + /// name; defaults to `None` (no pill). + fn header_tag(&self, _tool: &ToolUseBlock) -> Option { + None + } + /// Render the tool block content. /// /// For **Inline** renderers this returns the expanded output area @@ -133,6 +142,9 @@ pub trait ToolBlockRenderer: Send + Sync { #[derive(Default)] pub struct ToolBlockRendererRegistry { renderers: HashMap>, + /// Fallback renderer for dynamically-named MCP tools (`mcp____`), + /// which cannot be registered by a fixed name. + mcp_fallback: Option>, } static GLOBAL_REGISTRY: OnceLock>>> = OnceLock::new(); @@ -145,12 +157,31 @@ impl ToolBlockRendererRegistry { } } + /// Install the fallback renderer used for MCP tools, whose names are + /// discovered at runtime (`mcp____`) and therefore can't be + /// registered by name. + pub fn set_mcp_fallback(&mut self, renderer: Arc) { + self.mcp_fallback = Some(renderer); + } + /// Look up the renderer for a tool. Returns `None` if no renderer is /// registered (fall back to existing rendering). pub fn get(&self, tool_name: &str) -> Option<&Arc> { self.renderers.get(tool_name) } + /// Resolve the renderer for a tool: an exact by-name match, or — for + /// `mcp__…` tool names — the MCP fallback renderer if one is installed. + pub fn resolve(&self, tool_name: &str) -> Option> { + if let Some(renderer) = self.renderers.get(tool_name) { + return Some(renderer.clone()); + } + if tool_name.starts_with("mcp__") { + return self.mcp_fallback.clone(); + } + None + } + // -- global singleton -- pub fn set_global(registry: Arc) {