Skip to content

feat(mcp): add stdio transport and MCP tool registration#192

Merged
qhkm merged 4 commits intomainfrom
feat/mcp-stdio-transport
Feb 28, 2026
Merged

feat(mcp): add stdio transport and MCP tool registration#192
qhkm merged 4 commits intomainfrom
feat/mcp-stdio-transport

Conversation

@qhkm
Copy link
Copy Markdown
Owner

@qhkm qhkm commented Feb 27, 2026

Summary\n- add transport abstraction for MCP with McpTransport, HttpTransport, and StdioTransport\n- refactor McpClient to be transport-agnostic and add new_stdio() + shutdown()\n- include stdio MCP entries in discovery with explicit McpTransportType\n- wire MCP server discovery/config registration into create_agent()\n- extend MCP config schema (url optional + command/args/env) and update docs\n\n## Verification\n- cargo fmt -- --check\n- cargo clippy -- -D warnings\n- cargo test\n\nCloses #191

Summary by CodeRabbit

  • New Features

    • Transport-agnostic MCP support: agents can discover/connect to both HTTP and stdio MCP servers, auto-list and register discovered tools during agent creation.
    • MCP tools are wrapped to adapt to the agent tool interface; per-server logging and resilient skipping of problematic servers/tools.
    • Agents now track MCP clients and perform graceful shutdown of MCP connections/stdio processes.
  • Documentation

    • Docs updated to describe HTTP and stdio transport support, new config fields, registration, and shutdown behavior.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 27, 2026

📝 Walkthrough

Walkthrough

Adds a transport abstraction for MCP (HTTP and stdio), refactors McpClient to be transport-agnostic, extends discovery/config to detect both transport types, wires MCP tool registration during CLI/agent startup, and adds lifecycle shutdown management for MCP clients.

Changes

Cohort / File(s) Summary
Transport Abstraction
src/tools/mcp/transport.rs
Adds McpTransport trait plus HttpTransport and StdioTransport implementations, stdio child spawn and content-length framing, send/shutdown APIs, Drop cleanup, and tests.
MCP Client (transport-agnostic)
src/tools/mcp/client.rs
Replaces HTTP-specific client with transport-backed McpClient; adds new_http, new_stdio, with_transport, transport_type, shutdown, and routes all requests through the transport.
Discovery & Config Types
src/tools/mcp/discovery.rs, src/config/types.rs
Introduces McpTransportType (Http
Module Exports
src/tools/mcp/mod.rs
Adds transport module and re-exports McpTransport, HttpTransport, StdioTransport, and McpTransportType.
CLI wiring (registration)
src/cli/common.rs
Auto-discovers MCP servers (workspace + config), chooses transport (HTTP or stdio), constructs/initializes clients, lists tools, wraps them with McpToolWrapper, registers tools, tracks clients for shutdown, and skips per-server/tool errors.
Agent lifecycle
src/agent/loop.rs
Adds mcp_clients storage, register_mcp_client, and shutdown_mcp_clients to manage transport lifecycles (e.g., terminate stdio children) during teardown.
Tool wrapper & docs
src/tools/mcp/wrapper.rs, CLAUDE.md, AGENTS.md
Adds McpToolWrapper to adapt MCP tools to the Tool trait and updates docs to note HTTP and stdio support and that tools are registered during agent creation.
Misc tests & docs
docs/*, tests/*, src/tools/mcp/*
Adjusted and added tests to cover discovery, transport behavior, stdio framing, client shutdown, and config deserialization.

Sequence Diagram

sequenceDiagram
    participant CLI as CLI Init
    participant Discovery as MCP Discovery
    participant Config as Config/Workspace
    participant TransportFactory as Transport Factory
    participant Client as McpClient
    participant Server as MCP Server (HTTP/Stdio)
    participant Registry as Tool Registry

    CLI->>Discovery: discover_mcp_servers()
    Discovery->>Config: load_mcp_config()
    Config-->>Discovery: DiscoveredMcpServer[]{transport}

    loop For each MCP server
        Discovery->>TransportFactory: determine transport
        alt HTTP
            TransportFactory->>Client: new_http(url, timeout)
        else Stdio
            TransportFactory->>Client: new_stdio(command,args,env,timeout)
            Client->>Server: spawn process (stdio)
        end

        Client->>Server: initialize()
        Server-->>Client: init_response

        Client->>Server: list_tools()
        Server-->>Client: [tool_defs]

        loop For each enabled tool
            Client->>Registry: register tool via McpToolWrapper
            Registry-->>CLI: tool_registered
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hop upon the MCP trail,

HTTP hums or stdio's gale.
I spawn and wrap each handy tool,
then tuck them in when loops grow cool.
Happy hops — transports now prevail!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(mcp): add stdio transport and MCP tool registration' clearly summarizes the main changes: introducing stdio transport support and integrating MCP tool registration into the agent creation flow.
Linked Issues check ✅ Passed All objectives from issue #191 are met: McpTransport trait with HttpTransport/StdioTransport implementations [transport.rs], McpClient refactored to be transport-agnostic with new_stdio() and shutdown() [client.rs], stdio entries included in discovery with McpTransportType [discovery.rs], McpServerConfig extended with optional url and stdio fields [types.rs], MCP discovery wired into create_agent() [common.rs], and documentation updated [AGENTS.md, CLAUDE.md].
Out of Scope Changes check ✅ Passed All changes directly support the PR objectives: transport abstraction, MCP client refactoring, discovery updates, configuration schema extensions, CLI integration, and agent lifecycle management. No unrelated changes detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/mcp-stdio-transport

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLAUDE.md`:
- Line 24: The quick-reference test-count line ("# lib: 2635, main: 97,
cli_smoke: 23, e2e: 13, integration: 70, doc: 121 passed (27 ignored)") and the
later "Testing" section totals in CLAUDE.md are inconsistent; update the totals
in the "Testing" section to exactly match the quick-reference numbers (or
vice-versa) by copying the authoritative counts from the same test-run source,
and ensure both locations use the same phrasing and totals so they remain
synchronized; look for the exact quick-reference string and the "Testing"
heading in CLAUDE.md to find and update the mismatched counts.

In `@src/cli/common.rs`:
- Around line 1097-1122: The loop currently appends config-specified MCP servers
into all_servers (populated by discover_mcp_servers) causing duplicate server
names; change the logic in this block so config entries override discovered ones
by name: for each server_cfg from config.mcp.servers, check all_servers for an
existing DiscoveredMcpServer with the same name (from discover_mcp_servers), and
if found replace that entry with the new transport/source ("config"), otherwise
push the new DiscoveredMcpServer as before; use the existing symbols
discover_mcp_servers, all_servers, DiscoveredMcpServer, and server_cfg.name to
locate where to perform the lookup-and-replace.

In `@src/tools/mcp/discovery.rs`:
- Around line 92-108: When constructing DiscoveredMcpServer items in
discovery.rs, skip entries whose entry.url or entry.command are empty strings
(e.g., url.is_empty() or command.is_empty() after trimming) instead of accepting
them; modify the branch that builds McpTransportType::Http to only push when
entry.url.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false) (or
equivalent) and likewise only build McpTransportType::Stdio when entry.command
is non-empty, otherwise continue (and optionally log a warning mentioning the
entry name/source) so blank values are rejected at discovery time.

In `@src/tools/mcp/transport.rs`:
- Around line 99-104: The code pipes child stderr (created with let mut cmd =
tokio::process::Command::new(...) and .stderr(std::process::Stdio::piped())) but
never reads it, which can block if stderr fills; fix by either not piping stderr
(use .stderr(std::process::Stdio::null()) or
.stderr(std::process::Stdio::inherit())) or, if you need stderr, after spawning
the child take child.stderr, spawn a tokio task to continuously read/drain it
(e.g., read_to_end or copy into /dev/null or log it) so the pipe never fills and
the child cannot block; modify the code around the Command creation and the
child spawn/await logic that handles cmd to implement the chosen approach.
- Around line 133-163: The send() method currently locks stdin and stdout
separately, allowing concurrent callers to interleave and corrupt
request/response pairs; fix by serializing the entire request/response exchange
with a single in-flight guard (e.g., add and use a single async Mutex or similar
token on the Transport struct) so each call to send() acquires that guard before
serializing the request, writing/flushing to self.stdin, and reading a full
response_line from self.stdout (respect existing timeout_secs), then release the
guard after parsing the response to ensure no cross-request corruption.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2071fc7 and 63a934e.

📒 Files selected for processing (8)
  • AGENTS.md
  • CLAUDE.md
  • src/cli/common.rs
  • src/config/types.rs
  • src/tools/mcp/client.rs
  • src/tools/mcp/discovery.rs
  • src/tools/mcp/mod.rs
  • src/tools/mcp/transport.rs

Comment thread CLAUDE.md

# Test counts (cargo test)
# lib: 2612, main: 97, cli_smoke: 23, e2e: 13, integration: 70, doc: 122 passed (33 ignored)
# lib: 2635, main: 97, cli_smoke: 23, e2e: 13, integration: 70, doc: 121 passed (27 ignored)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test-count documentation is internally inconsistent.

You updated the quick-reference count here, but the later Testing section still shows older totals. Please sync both count locations from the same source to avoid stale instructions.

As per coding guidelines: *.md: "Keep README and documentation claims aligned with executable behavior" and CLAUDE.md: "Update CLAUDE.md ... test counts ... after completing any feature".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` at line 24, The quick-reference test-count line ("# lib: 2635,
main: 97, cli_smoke: 23, e2e: 13, integration: 70, doc: 121 passed (27
ignored)") and the later "Testing" section totals in CLAUDE.md are inconsistent;
update the totals in the "Testing" section to exactly match the quick-reference
numbers (or vice-versa) by copying the authoritative counts from the same
test-run source, and ensure both locations use the same phrasing and totals so
they remain synchronized; look for the exact quick-reference string and the
"Testing" heading in CLAUDE.md to find and update the mismatched counts.

Comment thread src/cli/common.rs
Comment on lines +1097 to +1122
let workspace = config.workspace_path();
let mut all_servers = discover_mcp_servers(Some(&workspace));

for server_cfg in &config.mcp.servers {
let transport = if let Some(url) = server_cfg.url.clone() {
McpTransportType::Http { url }
} else if let Some(command) = server_cfg.command.clone() {
McpTransportType::Stdio {
command,
args: server_cfg.args.clone().unwrap_or_default(),
env: server_cfg.env.clone().unwrap_or_default(),
}
} else {
warn!(
server = %server_cfg.name,
"MCP server config has neither url nor command, skipping"
);
continue;
};

all_servers.push(DiscoveredMcpServer {
name: server_cfg.name.clone(),
transport,
source: "config".to_string(),
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Deduplicate MCP servers by name before registration.

When the same server name appears in discovery and config.mcp.servers, this block registers both, which can create duplicate/conflicting {server}_{tool} entries.

💡 Suggested fix (config entry overrides discovered entry)
         let workspace = config.workspace_path();
         let mut all_servers = discover_mcp_servers(Some(&workspace));
+        let mut seen_names = HashSet::new();
+        all_servers.retain(|s| seen_names.insert(s.name.to_ascii_lowercase()));

         for server_cfg in &config.mcp.servers {
+            // Config-defined server should override discovered server with same name.
+            all_servers.retain(|s| !s.name.eq_ignore_ascii_case(&server_cfg.name));
             let transport = if let Some(url) = server_cfg.url.clone() {
                 McpTransportType::Http { url }
             } else if let Some(command) = server_cfg.command.clone() {
                 McpTransportType::Stdio {
                     command,
                     args: server_cfg.args.clone().unwrap_or_default(),
                     env: server_cfg.env.clone().unwrap_or_default(),
                 }
             } else {
                 warn!(
                     server = %server_cfg.name,
                     "MCP server config has neither url nor command, skipping"
                 );
                 continue;
             };

             all_servers.push(DiscoveredMcpServer {
                 name: server_cfg.name.clone(),
                 transport,
                 source: "config".to_string(),
             });
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let workspace = config.workspace_path();
let mut all_servers = discover_mcp_servers(Some(&workspace));
for server_cfg in &config.mcp.servers {
let transport = if let Some(url) = server_cfg.url.clone() {
McpTransportType::Http { url }
} else if let Some(command) = server_cfg.command.clone() {
McpTransportType::Stdio {
command,
args: server_cfg.args.clone().unwrap_or_default(),
env: server_cfg.env.clone().unwrap_or_default(),
}
} else {
warn!(
server = %server_cfg.name,
"MCP server config has neither url nor command, skipping"
);
continue;
};
all_servers.push(DiscoveredMcpServer {
name: server_cfg.name.clone(),
transport,
source: "config".to_string(),
});
}
let workspace = config.workspace_path();
let mut all_servers = discover_mcp_servers(Some(&workspace));
let mut seen_names = HashSet::new();
all_servers.retain(|s| seen_names.insert(s.name.to_ascii_lowercase()));
for server_cfg in &config.mcp.servers {
// Config-defined server should override discovered server with same name.
all_servers.retain(|s| !s.name.eq_ignore_ascii_case(&server_cfg.name));
let transport = if let Some(url) = server_cfg.url.clone() {
McpTransportType::Http { url }
} else if let Some(command) = server_cfg.command.clone() {
McpTransportType::Stdio {
command,
args: server_cfg.args.clone().unwrap_or_default(),
env: server_cfg.env.clone().unwrap_or_default(),
}
} else {
warn!(
server = %server_cfg.name,
"MCP server config has neither url nor command, skipping"
);
continue;
};
all_servers.push(DiscoveredMcpServer {
name: server_cfg.name.clone(),
transport,
source: "config".to_string(),
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/common.rs` around lines 1097 - 1122, The loop currently appends
config-specified MCP servers into all_servers (populated by
discover_mcp_servers) causing duplicate server names; change the logic in this
block so config entries override discovered ones by name: for each server_cfg
from config.mcp.servers, check all_servers for an existing DiscoveredMcpServer
with the same name (from discover_mcp_servers), and if found replace that entry
with the new transport/source ("config"), otherwise push the new
DiscoveredMcpServer as before; use the existing symbols discover_mcp_servers,
all_servers, DiscoveredMcpServer, and server_cfg.name to locate where to perform
the lookup-and-replace.

Comment on lines +92 to +108
if let Some(url) = entry.url {
result.push(DiscoveredMcpServer {
name,
transport: McpTransportType::Http { url },
source: source.to_string(),
});
} else if let Some(command) = entry.command {
result.push(DiscoveredMcpServer {
name,
transport: McpTransportType::Stdio {
command,
args: entry.args.unwrap_or_default(),
env: entry.env.unwrap_or_default(),
},
source: source.to_string(),
});
} else {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Skip blank url/command values during discovery.

Entries with url: "" or command: "" currently pass discovery and fail later. Rejecting them here gives cleaner behavior and logs.

💡 Suggested fix
-        if let Some(url) = entry.url {
+        if let Some(url) = entry
+            .url
+            .as_deref()
+            .map(str::trim)
+            .filter(|u| !u.is_empty())
+            .map(str::to_string)
+        {
             result.push(DiscoveredMcpServer {
                 name,
                 transport: McpTransportType::Http { url },
                 source: source.to_string(),
             });
-        } else if let Some(command) = entry.command {
+        } else if let Some(command) = entry
+            .command
+            .as_deref()
+            .map(str::trim)
+            .filter(|c| !c.is_empty())
+            .map(str::to_string)
+        {
             result.push(DiscoveredMcpServer {
                 name,
                 transport: McpTransportType::Stdio {
                     command,
                     args: entry.args.unwrap_or_default(),
                     env: entry.env.unwrap_or_default(),
                 },
                 source: source.to_string(),
             });
         } else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let Some(url) = entry.url {
result.push(DiscoveredMcpServer {
name,
transport: McpTransportType::Http { url },
source: source.to_string(),
});
} else if let Some(command) = entry.command {
result.push(DiscoveredMcpServer {
name,
transport: McpTransportType::Stdio {
command,
args: entry.args.unwrap_or_default(),
env: entry.env.unwrap_or_default(),
},
source: source.to_string(),
});
} else {
if let Some(url) = entry
.url
.as_deref()
.map(str::trim)
.filter(|u| !u.is_empty())
.map(str::to_string)
{
result.push(DiscoveredMcpServer {
name,
transport: McpTransportType::Http { url },
source: source.to_string(),
});
} else if let Some(command) = entry
.command
.as_deref()
.map(str::trim)
.filter(|c| !c.is_empty())
.map(str::to_string)
{
result.push(DiscoveredMcpServer {
name,
transport: McpTransportType::Stdio {
command,
args: entry.args.unwrap_or_default(),
env: entry.env.unwrap_or_default(),
},
source: source.to_string(),
});
} else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/mcp/discovery.rs` around lines 92 - 108, When constructing
DiscoveredMcpServer items in discovery.rs, skip entries whose entry.url or
entry.command are empty strings (e.g., url.is_empty() or command.is_empty()
after trimming) instead of accepting them; modify the branch that builds
McpTransportType::Http to only push when entry.url.as_ref().map(|s|
!s.trim().is_empty()).unwrap_or(false) (or equivalent) and likewise only build
McpTransportType::Stdio when entry.command is non-empty, otherwise continue (and
optionally log a warning mentioning the entry name/source) so blank values are
rejected at discovery time.

Comment thread src/tools/mcp/transport.rs
Comment thread src/tools/mcp/transport.rs Outdated
C1: Track MCP clients on AgentLoop and shut them down gracefully in
stop(). Previously shutdown() was never called, relying only on a
best-effort Drop impl that could leave zombie child processes.

C2: Combine separate stdin/stdout mutexes into a single Mutex<StdioIo>
so the entire write→read cycle is serialized. Separate locks allowed
concurrent send() calls to interleave requests and misroute responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/cli/common.rs (1)

1100-1122: ⚠️ Potential issue | 🟠 Major

Deduplicate MCP servers by name before registration.

Line 1100 appends config-defined servers on top of discovered ones without removing same-name entries first. This can register duplicate/conflicting {server}_{tool} names and create duplicate clients for one logical server.

💡 Proposed fix (config entries override discovered entries)
         let workspace = config.workspace_path();
         let mut all_servers = discover_mcp_servers(Some(&workspace));
+        let mut seen_names = HashSet::new();
+        all_servers.retain(|s| seen_names.insert(s.name.to_ascii_lowercase()));

         for server_cfg in &config.mcp.servers {
+            // Config-defined entry overrides discovered entry with same name.
+            all_servers.retain(|s| !s.name.eq_ignore_ascii_case(&server_cfg.name));
             let transport = if let Some(url) = server_cfg.url.clone() {
                 McpTransportType::Http { url }
             } else if let Some(command) = server_cfg.command.clone() {
                 McpTransportType::Stdio {
                     command,
                     args: server_cfg.args.clone().unwrap_or_default(),
                     env: server_cfg.env.clone().unwrap_or_default(),
                 }
             } else {
                 warn!(
                     server = %server_cfg.name,
                     "MCP server config has neither url nor command, skipping"
                 );
                 continue;
             };

             all_servers.push(DiscoveredMcpServer {
                 name: server_cfg.name.clone(),
                 transport,
                 source: "config".to_string(),
             });
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/common.rs` around lines 1100 - 1122, The loop that pushes
config-defined servers into all_servers may produce duplicates because
discovered servers with the same name are not removed; update the logic that
iterates config.mcp.servers (using server_cfg and creating DiscoveredMcpServer
instances) to ensure config entries override discovered ones—either deduplicate
by converting all_servers into a map keyed by name before adding config items
and replace any existing entry with the config variant, or explicitly remove any
existing all_servers entry with the same name prior to pushing the new
DiscoveredMcpServer; ensure the resulting behavior keeps config-defined
server_cfg entries and prevents duplicate {server}_{tool} registrations.
src/tools/mcp/transport.rs (1)

109-114: ⚠️ Potential issue | 🟠 Major

Avoid piping child stderr when it is never consumed.

Line 113 pipes stderr but nothing reads it. If the MCP server writes enough logs, stderr backpressure can block the child and stall responses.

💡 Proposed fix
         cmd.args(args)
             .stdin(std::process::Stdio::piped())
             .stdout(std::process::Stdio::piped())
-            .stderr(std::process::Stdio::piped());
+            .stderr(std::process::Stdio::inherit());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/mcp/transport.rs` around lines 109 - 114, The child process
currently configures stderr as piped on the Command builder (the `cmd` created
with `tokio::process::Command::new(command)` and modified with `.args(args)`),
but nothing reads from that pipe which can cause backpressure and stall the
child; change the stderr setup to not pipe it (e.g., use
`std::process::Stdio::null()` or inherit it) by replacing the
`.stderr(std::process::Stdio::piped())` call with a non-piped alternative so the
child cannot block on unwritten stderr.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/tools/mcp/transport.rs`:
- Around line 182-184: The match on
tokio::time::timeout(std::time::Duration::from_secs(3), child.wait()).await
currently treats any Ok(_) as success and therefore swallows errors from
child.wait(); change the arms to inspect the nested Result from timeout so that
Ok(Ok(status)) is treated as success (and you can optionally check
status.success()), Ok(Err(e)) returns or logs/propagates that error from
child.wait(), and Err(_) still handles the timeout case—update the match in
transport.rs around the child.wait() call to explicitly handle Ok(Ok(...)),
Ok(Err(e)) and Err(_) instead of collapsing all Ok variants.

---

Duplicate comments:
In `@src/cli/common.rs`:
- Around line 1100-1122: The loop that pushes config-defined servers into
all_servers may produce duplicates because discovered servers with the same name
are not removed; update the logic that iterates config.mcp.servers (using
server_cfg and creating DiscoveredMcpServer instances) to ensure config entries
override discovered ones—either deduplicate by converting all_servers into a map
keyed by name before adding config items and replace any existing entry with the
config variant, or explicitly remove any existing all_servers entry with the
same name prior to pushing the new DiscoveredMcpServer; ensure the resulting
behavior keeps config-defined server_cfg entries and prevents duplicate
{server}_{tool} registrations.

In `@src/tools/mcp/transport.rs`:
- Around line 109-114: The child process currently configures stderr as piped on
the Command builder (the `cmd` created with
`tokio::process::Command::new(command)` and modified with `.args(args)`), but
nothing reads from that pipe which can cause backpressure and stall the child;
change the stderr setup to not pipe it (e.g., use `std::process::Stdio::null()`
or inherit it) by replacing the `.stderr(std::process::Stdio::piped())` call
with a non-piped alternative so the child cannot block on unwritten stderr.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 63a934e and df52020.

📒 Files selected for processing (3)
  • src/agent/loop.rs
  • src/cli/common.rs
  • src/tools/mcp/transport.rs

Comment on lines +182 to +184
match tokio::time::timeout(std::time::Duration::from_secs(3), child.wait()).await {
Ok(_) => Ok(()),
Err(_) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not swallow wait() errors during shutdown.

Line 183 treats any Ok(_) as success, including Ok(Err(e)), which hides real process wait failures.

💡 Proposed fix
-        match tokio::time::timeout(std::time::Duration::from_secs(3), child.wait()).await {
-            Ok(_) => Ok(()),
+        match tokio::time::timeout(std::time::Duration::from_secs(3), child.wait()).await {
+            Ok(Ok(_)) => Ok(()),
+            Ok(Err(e)) => Err(format!("Failed waiting for MCP server exit: {}", e)),
             Err(_) => {
                 child
                     .kill()
                     .await
                     .map_err(|e| format!("Failed to kill MCP server: {}", e))?;
                 Ok(())
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
match tokio::time::timeout(std::time::Duration::from_secs(3), child.wait()).await {
Ok(_) => Ok(()),
Err(_) => {
match tokio::time::timeout(std::time::Duration::from_secs(3), child.wait()).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err(format!("Failed waiting for MCP server exit: {}", e)),
Err(_) => {
child
.kill()
.await
.map_err(|e| format!("Failed to kill MCP server: {}", e))?;
Ok(())
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/mcp/transport.rs` around lines 182 - 184, The match on
tokio::time::timeout(std::time::Duration::from_secs(3), child.wait()).await
currently treats any Ok(_) as success and therefore swallows errors from
child.wait(); change the arms to inspect the nested Result from timeout so that
Ok(Ok(status)) is treated as success (and you can optionally check
status.success()), Ok(Err(e)) returns or logs/propagates that error from
child.wait(), and Err(_) still handles the timeout case—update the match in
transport.rs around the child.wait() call to explicitly handle Ok(Ok(...)),
Ok(Err(e)) and Err(_) instead of collapsing all Ok variants.

…adlock

1. stop() now checks Handle::try_current() before spawning MCP shutdown.
   Falls back to Drop-based cleanup when called outside a Tokio runtime
   (library embedding, shutdown hooks).

2. StdioTransport stderr changed from Stdio::piped() to Stdio::null().
   Piped stderr was never consumed, so noisy MCP servers could fill the
   pipe buffer, block, and hang requests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/tools/mcp/transport.rs (1)

179-192: ⚠️ Potential issue | 🟡 Minor

child.wait() errors are still swallowed.

Line 183 matches Ok(_) which includes both Ok(Ok(exit_status)) and Ok(Err(io_error)). If child.wait() returns an I/O error, it's silently treated as success.

🔧 Proposed fix to propagate wait errors
         match tokio::time::timeout(std::time::Duration::from_secs(3), child.wait()).await {
-            Ok(_) => Ok(()),
+            Ok(Ok(_)) => Ok(()),
+            Ok(Err(e)) => Err(format!("Failed waiting for MCP server exit: {}", e)),
             Err(_) => {
                 child
                     .kill()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/mcp/transport.rs` around lines 179 - 192, The shutdown method
currently treats any Ok from tokio::time::timeout as success, which hides I/O
errors from child.wait(); update the match on
tokio::time::timeout(std::time::Duration::from_secs(3), child.wait()).await in
async fn shutdown(&self) to explicitly handle the nested Result: on Ok(Ok(_))
return Ok(()), on Ok(Err(e)) return Err(format!("Failed waiting for MCP server:
{}", e)), and on Err(_) perform the existing child.kill().await error mapping;
reference child.wait(), child.kill(), and async fn shutdown to locate the code
to change.
🧹 Nitpick comments (1)
src/tools/mcp/transport.rs (1)

240-255: Test uses cat as echo server — fragile assertion.

The test at line 249-252 allows both success and parse error, which is correct since cat echoes the JSON back verbatim (valid MCP response structure but not semantically valid). However, consider adding a comment explaining why this behavior is expected.

📝 Suggested clarifying comment
         let req = McpRequest::new(1, "initialize", None);
         let resp = t.send(&req).await;
+        // `cat` echoes our request back verbatim. Since McpRequest and McpResponse
+        // have different structures, parsing may succeed (if fields overlap) or
+        // fail with a parse error — either outcome proves the I/O path works.
         assert!(
             resp.is_ok() || resp.unwrap_err().contains("parse"),
             "Should get I/O success or parse error, not a crash"
         );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/mcp/transport.rs` around lines 240 - 255, The test
test_stdio_transport_echo_server uses StdioTransport::spawn("cat", ...) and then
sends an MCP request via McpRequest::new and t.send(&req).await; update the test
to include a short clarifying comment above the assertion explaining that using
"cat" simply echoes bytes back (so we may get a successful I/O result or a JSON
parse error because the echoed payload is not a semantically valid MCP
response), and that the assertion allowing either resp.is_ok() or a parse error
is intentional to avoid flakiness when using a raw echo server; leave the
existing assert logic (and calls to transport_type() and t.shutdown())
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/tools/mcp/transport.rs`:
- Around line 179-192: The shutdown method currently treats any Ok from
tokio::time::timeout as success, which hides I/O errors from child.wait();
update the match on tokio::time::timeout(std::time::Duration::from_secs(3),
child.wait()).await in async fn shutdown(&self) to explicitly handle the nested
Result: on Ok(Ok(_)) return Ok(()), on Ok(Err(e)) return Err(format!("Failed
waiting for MCP server: {}", e)), and on Err(_) perform the existing
child.kill().await error mapping; reference child.wait(), child.kill(), and
async fn shutdown to locate the code to change.

---

Nitpick comments:
In `@src/tools/mcp/transport.rs`:
- Around line 240-255: The test test_stdio_transport_echo_server uses
StdioTransport::spawn("cat", ...) and then sends an MCP request via
McpRequest::new and t.send(&req).await; update the test to include a short
clarifying comment above the assertion explaining that using "cat" simply echoes
bytes back (so we may get a successful I/O result or a JSON parse error because
the echoed payload is not a semantically valid MCP response), and that the
assertion allowing either resp.is_ok() or a parse error is intentional to avoid
flakiness when using a raw echo server; leave the existing assert logic (and
calls to transport_type() and t.shutdown()) unchanged.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between df52020 and 0ee1924.

📒 Files selected for processing (2)
  • src/agent/loop.rs
  • src/tools/mcp/transport.rs

1. StdioTransport now uses MCP-spec Content-Length header framing
   instead of newline-delimited JSON. This matches the official MCP
   stdio transport specification and interoperates with standard SDK
   servers (TypeScript, Python, etc.).

2. Moved MCP client shutdown out of stop() into a separate public
   shutdown_mcp_clients() method. stop() must remain restart-safe;
   killing MCP clients there would leave registered tools pointing at
   dead transports after restart. Gateway teardown and hot-reload paths
   now call shutdown_mcp_clients() explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/tools/mcp/transport.rs (1)

185-198: ⚠️ Potential issue | 🟡 Minor

child.wait() errors are still being swallowed.

The match on line 188-189 treats any Ok(_) as success, but child.wait() returns Result<ExitStatus, io::Error>. When the timeout succeeds but wait() itself fails (i.e., Ok(Err(e))), the error is discarded.

,

🔧 Proposed fix to propagate wait errors
     async fn shutdown(&self) -> Result<(), String> {
         let mut child = self.child.lock().await;

         match tokio::time::timeout(std::time::Duration::from_secs(3), child.wait()).await {
-            Ok(_) => Ok(()),
+            Ok(Ok(_)) => Ok(()),
+            Ok(Err(e)) => Err(format!("Failed waiting for MCP server exit: {}", e)),
             Err(_) => {
                 child
                     .kill()
                     .await
                     .map_err(|e| format!("Failed to kill MCP server: {}", e))?;
                 Ok(())
             }
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/mcp/transport.rs` around lines 185 - 198, The shutdown function is
swallowing errors from child.wait(): when awaiting tokio::time::timeout you must
handle the nested Result<Result<ExitStatus, io::Error>, Elapsed>; change the
match in shutdown (referencing shutdown, child.wait(), and tokio::time::timeout)
to explicitly pattern-match Ok(Ok(_)) => Ok(()), Ok(Err(e)) => return
Err(format!("Failed waiting for MCP server: {}", e)) (or attempt kill then
return the wait error), and Err(_) => perform the kill as currently implemented;
this ensures child.wait() errors are propagated instead of discarded.
🧹 Nitpick comments (1)
src/tools/mcp/transport.rs (1)

32-49: Consider handling client builder failure more explicitly.

Line 38 uses unwrap_or_default() which silently falls back to a default client if the builder fails (e.g., due to invalid timeout configuration). While this won't crash, it could mask configuration issues.

💡 Optional: Log a warning on builder failure
     pub fn new(url: &str, timeout_secs: u64) -> Self {
         let http = reqwest::Client::builder()
             .timeout(std::time::Duration::from_secs(timeout_secs))
             .build()
-            .unwrap_or_default();
+            .unwrap_or_else(|e| {
+                tracing::warn!("Failed to build HTTP client with timeout, using default: {}", e);
+                reqwest::Client::default()
+            });
         Self {
             url: url.to_string(),
             http,
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/mcp/transport.rs` around lines 32 - 49, The code in
HttpTransport::new currently calls
reqwest::Client::builder().timeout(...).build().unwrap_or_default(), which
silently masks builder failures; change HttpTransport::new to handle build()'s
Result explicitly by either returning Result<Self, reqwest::Error> or logging
the build error and failing fast instead of falling back silently. Concretely,
replace the unwrap_or_default() with a match or ? propagation on
reqwest::Client::builder().build() inside HttpTransport::new (or use
.map_err(...) to convert the error) so that failures are reported (via process
logger or error return) and only construct Self { url: ..., http } when build()
succeeds.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/tools/mcp/transport.rs`:
- Around line 185-198: The shutdown function is swallowing errors from
child.wait(): when awaiting tokio::time::timeout you must handle the nested
Result<Result<ExitStatus, io::Error>, Elapsed>; change the match in shutdown
(referencing shutdown, child.wait(), and tokio::time::timeout) to explicitly
pattern-match Ok(Ok(_)) => Ok(()), Ok(Err(e)) => return Err(format!("Failed
waiting for MCP server: {}", e)) (or attempt kill then return the wait error),
and Err(_) => perform the kill as currently implemented; this ensures
child.wait() errors are propagated instead of discarded.

---

Nitpick comments:
In `@src/tools/mcp/transport.rs`:
- Around line 32-49: The code in HttpTransport::new currently calls
reqwest::Client::builder().timeout(...).build().unwrap_or_default(), which
silently masks builder failures; change HttpTransport::new to handle build()'s
Result explicitly by either returning Result<Self, reqwest::Error> or logging
the build error and failing fast instead of falling back silently. Concretely,
replace the unwrap_or_default() with a match or ? propagation on
reqwest::Client::builder().build() inside HttpTransport::new (or use
.map_err(...) to convert the error) so that failures are reported (via process
logger or error return) and only construct Self { url: ..., http } when build()
succeeds.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0ee1924 and 3debfc5.

📒 Files selected for processing (3)
  • src/agent/loop.rs
  • src/cli/gateway.rs
  • src/tools/mcp/transport.rs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant