Skip to content
This repository was archived by the owner on Apr 8, 2026. It is now read-only.

Commit f7d090b

Browse files
committed
Close the stdio MCP lifecycle parity gap
The runtime already had stdio MCP bootstrap, initialize, tool discovery, and tool call plumbing, but the user-facing MCP tools still returned stub payloads. This wires ListMcpResources, ReadMcpResource, McpAuth, and MCP through real config-loaded MCP manager lifecycles, adds resource listing/reading support to the manager, and updates parity notes to reflect the new stdio-only coverage.\n\nConstraint: Keep scope on the MCP parity lane and avoid behavior changes in already-landed task/team/cron/bash/file tool work beyond lint-safe compile integration\nRejected: Implement remote MCP transports and browser OAuth flow now | broader than the requested parity slice\nConfidence: high\nScope-risk: moderate\nDirective: Reuse the manager-backed lifecycle for future remote transport support instead of adding new tool-local stubs\nTested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace\nNot-tested: Live OAuth/browser auth flow; non-stdio MCP transports
1 parent 49653fe commit f7d090b

7 files changed

Lines changed: 769 additions & 25 deletions

File tree

PARITY.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ Canonical scenario map: `rust/mock_parity_scenarios.json`
4848
| **Sleep** | `tools` | delay execution — **good parity** |
4949
| **SendUserMessage/Brief** | `tools` | user-facing message — **good parity** |
5050
| **Config** | `tools` | config inspection — **moderate parity** |
51+
| **ListMcpResources** | `tools` + `runtime::mcp_stdio` | stdio MCP connect/list resources/disconnect — **moderate parity**. Missing: remote transports |
52+
| **ReadMcpResource** | `tools` + `runtime::mcp_stdio` | stdio MCP connect/read resource/disconnect — **moderate parity**. Missing: remote transports |
53+
| **McpAuth** | `tools` + `runtime::mcp_client` | stdio no-auth connect probe + OAuth requirement reporting — **partial parity**. Missing: interactive OAuth/browser flow |
54+
| **MCP** | `tools` + `runtime::mcp_stdio` | stdio MCP connect/list tools/call/disconnect — **moderate parity**. Missing: remote transports |
5155
| **EnterPlanMode** | `tools` | worktree plan mode toggle — **good parity** |
5256
| **ExitPlanMode** | `tools` | worktree plan mode restore — **good parity** |
5357
| **StructuredOutput** | `tools` | passthrough JSON — **good parity** |
@@ -71,10 +75,6 @@ Canonical scenario map: `rust/mock_parity_scenarios.json`
7175
| **CronDelete** | stub | needs cron registry |
7276
| **CronList** | stub | needs cron registry |
7377
| **LSP** | stub | needs language server client |
74-
| **ListMcpResources** | stub | needs MCP client |
75-
| **ReadMcpResource** | stub | needs MCP client |
76-
| **McpAuth** | stub | needs OAuth flow |
77-
| **MCP** | stub | needs MCP tool proxy |
7878
| **RemoteTrigger** | stub | needs HTTP client |
7979
| **TestingPermission** | stub | test-only, low priority |
8080

@@ -108,7 +108,9 @@ Harness note: milestone 2 validates bash success plus workspace-write escalation
108108
Harness note: read_file, grep_search, write_file allow/deny, and multi-tool same-turn assembly are now covered by the mock parity harness.
109109

110110
**Config/Plugin/MCP flows:**
111-
- [ ] Full MCP server lifecycle (connect, list tools, call tool, disconnect)
111+
- [x] Stdio MCP lifecycle (connect, list tools/resources, call tool, read resource, disconnect)
112+
- [ ] Remote MCP transports (HTTP/SSE/WS/managed proxy)
113+
- [ ] Interactive MCP OAuth/browser auth flow
112114
- [ ] Plugin install/enable/disable/uninstall full flow
113115
- [ ] Config merge precedence (user > project > local)
114116

rust/crates/runtime/src/file_ops.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ fn is_binary_file(path: &Path) -> io::Result<bool> {
2828
/// Validate that a resolved path stays within the given workspace root.
2929
/// Returns the canonical path on success, or an error if the path escapes
3030
/// the workspace boundary (e.g. via `../` traversal or symlink).
31+
#[allow(dead_code)]
3132
fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Result<()> {
3233
if !resolved.starts_with(workspace_root) {
3334
return Err(io::Error::new(
@@ -544,6 +545,7 @@ fn normalize_path_allow_missing(path: &str) -> io::Result<PathBuf> {
544545
}
545546

546547
/// Read a file with workspace boundary enforcement.
548+
#[allow(dead_code)]
547549
pub fn read_file_in_workspace(
548550
path: &str,
549551
offset: Option<usize>,
@@ -559,6 +561,7 @@ pub fn read_file_in_workspace(
559561
}
560562

561563
/// Write a file with workspace boundary enforcement.
564+
#[allow(dead_code)]
562565
pub fn write_file_in_workspace(
563566
path: &str,
564567
content: &str,
@@ -573,6 +576,7 @@ pub fn write_file_in_workspace(
573576
}
574577

575578
/// Edit a file with workspace boundary enforcement.
579+
#[allow(dead_code)]
576580
pub fn edit_file_in_workspace(
577581
path: &str,
578582
old_string: &str,
@@ -589,6 +593,7 @@ pub fn edit_file_in_workspace(
589593
}
590594

591595
/// Check whether a path is a symlink that resolves outside the workspace.
596+
#[allow(dead_code)]
592597
pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool> {
593598
let metadata = fs::symlink_metadata(path)?;
594599
if !metadata.is_symlink() {

rust/crates/runtime/src/lib.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,11 @@ pub use mcp_client::{
5757
};
5858
pub use mcp_stdio::{
5959
spawn_mcp_stdio_process, JsonRpcError, JsonRpcId, JsonRpcRequest, JsonRpcResponse,
60-
ManagedMcpTool, McpInitializeClientInfo, McpInitializeParams, McpInitializeResult,
61-
McpInitializeServerInfo, McpListResourcesParams, McpListResourcesResult, McpListToolsParams,
62-
McpListToolsResult, McpReadResourceParams, McpReadResourceResult, McpResource,
63-
McpResourceContents, McpServerManager, McpServerManagerError, McpStdioProcess, McpTool,
64-
McpToolCallContent, McpToolCallParams, McpToolCallResult, UnsupportedMcpServer,
60+
ManagedMcpResource, ManagedMcpTool, McpInitializeClientInfo, McpInitializeParams,
61+
McpInitializeResult, McpInitializeServerInfo, McpListResourcesParams, McpListResourcesResult,
62+
McpListToolsParams, McpListToolsResult, McpReadResourceParams, McpReadResourceResult,
63+
McpResource, McpResourceContents, McpServerManager, McpServerManagerError, McpStdioProcess,
64+
McpTool, McpToolCallContent, McpToolCallParams, McpToolCallResult, UnsupportedMcpServer,
6565
};
6666
pub use oauth::{
6767
clear_oauth_credentials, code_challenge_s256, credentials_path, generate_pkce_pair,

rust/crates/runtime/src/mcp_stdio.rs

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ const MCP_LIST_TOOLS_TIMEOUT_MS: u64 = 300;
2525
#[cfg(not(test))]
2626
const MCP_LIST_TOOLS_TIMEOUT_MS: u64 = 30_000;
2727

28+
#[cfg(test)]
29+
const MCP_LIST_RESOURCES_TIMEOUT_MS: u64 = 300;
30+
#[cfg(not(test))]
31+
const MCP_LIST_RESOURCES_TIMEOUT_MS: u64 = 30_000;
32+
2833
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2934
#[serde(untagged)]
3035
pub enum JsonRpcId {
@@ -223,6 +228,12 @@ pub struct ManagedMcpTool {
223228
pub tool: McpTool,
224229
}
225230

231+
#[derive(Debug, Clone, PartialEq)]
232+
pub struct ManagedMcpResource {
233+
pub server_name: String,
234+
pub resource: McpResource,
235+
}
236+
226237
#[derive(Debug, Clone, PartialEq, Eq)]
227238
pub struct UnsupportedMcpServer {
228239
pub server_name: String,
@@ -420,6 +431,65 @@ impl McpServerManager {
420431
Ok(discovered_tools)
421432
}
422433

434+
pub async fn list_resources(
435+
&mut self,
436+
server_name: Option<&str>,
437+
) -> Result<Vec<ManagedMcpResource>, McpServerManagerError> {
438+
let server_names = match server_name {
439+
Some(server_name) => vec![server_name.to_string()],
440+
None => self.servers.keys().cloned().collect::<Vec<_>>(),
441+
};
442+
let mut resources = Vec::new();
443+
444+
for server_name in server_names {
445+
resources.extend(self.list_resources_for_server(&server_name).await?);
446+
}
447+
448+
Ok(resources)
449+
}
450+
451+
pub async fn read_resource(
452+
&mut self,
453+
server_name: &str,
454+
uri: &str,
455+
) -> Result<JsonRpcResponse<McpReadResourceResult>, McpServerManagerError> {
456+
let timeout_ms = self.tool_call_timeout_ms(server_name)?;
457+
458+
self.ensure_server_ready(server_name).await?;
459+
let request_id = self.take_request_id();
460+
let response =
461+
{
462+
let server = self.server_mut(server_name)?;
463+
let process = server.process.as_mut().ok_or_else(|| {
464+
McpServerManagerError::InvalidResponse {
465+
server_name: server_name.to_string(),
466+
method: "resources/read",
467+
details: "server process missing after initialization".to_string(),
468+
}
469+
})?;
470+
Self::run_process_request(
471+
server_name,
472+
"resources/read",
473+
timeout_ms,
474+
process.read_resource(
475+
request_id,
476+
McpReadResourceParams {
477+
uri: uri.to_string(),
478+
},
479+
),
480+
)
481+
.await
482+
};
483+
484+
if let Err(error) = &response {
485+
if Self::should_reset_server(error) {
486+
self.reset_server(server_name).await?;
487+
}
488+
}
489+
490+
response
491+
}
492+
423493
pub async fn call_tool(
424494
&mut self,
425495
qualified_tool_name: &str,
@@ -623,6 +693,94 @@ impl McpServerManager {
623693
Ok(discovered_tools)
624694
}
625695

696+
async fn list_resources_for_server(
697+
&mut self,
698+
server_name: &str,
699+
) -> Result<Vec<ManagedMcpResource>, McpServerManagerError> {
700+
let mut attempts = 0;
701+
702+
loop {
703+
match self.list_resources_for_server_once(server_name).await {
704+
Ok(resources) => return Ok(resources),
705+
Err(error) if attempts == 0 && Self::is_retryable_error(&error) => {
706+
self.reset_server(server_name).await?;
707+
attempts += 1;
708+
}
709+
Err(error) => {
710+
if Self::should_reset_server(&error) {
711+
self.reset_server(server_name).await?;
712+
}
713+
return Err(error);
714+
}
715+
}
716+
}
717+
}
718+
719+
async fn list_resources_for_server_once(
720+
&mut self,
721+
server_name: &str,
722+
) -> Result<Vec<ManagedMcpResource>, McpServerManagerError> {
723+
self.ensure_server_ready(server_name).await?;
724+
725+
let mut discovered_resources = Vec::new();
726+
let mut cursor = None;
727+
loop {
728+
let request_id = self.take_request_id();
729+
let response = {
730+
let server = self.server_mut(server_name)?;
731+
let process = server.process.as_mut().ok_or_else(|| {
732+
McpServerManagerError::InvalidResponse {
733+
server_name: server_name.to_string(),
734+
method: "resources/list",
735+
details: "server process missing after initialization".to_string(),
736+
}
737+
})?;
738+
Self::run_process_request(
739+
server_name,
740+
"resources/list",
741+
MCP_LIST_RESOURCES_TIMEOUT_MS,
742+
process.list_resources(
743+
request_id,
744+
Some(McpListResourcesParams {
745+
cursor: cursor.clone(),
746+
}),
747+
),
748+
)
749+
.await?
750+
};
751+
752+
if let Some(error) = response.error {
753+
return Err(McpServerManagerError::JsonRpc {
754+
server_name: server_name.to_string(),
755+
method: "resources/list",
756+
error,
757+
});
758+
}
759+
760+
let result = response
761+
.result
762+
.ok_or_else(|| McpServerManagerError::InvalidResponse {
763+
server_name: server_name.to_string(),
764+
method: "resources/list",
765+
details: "missing result payload".to_string(),
766+
})?;
767+
768+
for resource in result.resources {
769+
discovered_resources.push(ManagedMcpResource {
770+
server_name: server_name.to_string(),
771+
resource,
772+
});
773+
}
774+
775+
match result.next_cursor {
776+
Some(next_cursor) => cursor = Some(next_cursor),
777+
None => break,
778+
}
779+
}
780+
781+
Ok(discovered_resources)
782+
}
783+
626784
async fn reset_server(&mut self, server_name: &str) -> Result<(), McpServerManagerError> {
627785
let mut process = {
628786
let server = self.server_mut(server_name)?;
@@ -1386,6 +1544,36 @@ mod tests {
13861544
" 'isError': False",
13871545
" }",
13881546
" })",
1547+
" elif method == 'resources/list':",
1548+
" send_message({",
1549+
" 'jsonrpc': '2.0',",
1550+
" 'id': request['id'],",
1551+
" 'result': {",
1552+
" 'resources': [",
1553+
" {",
1554+
" 'uri': f'resource://{LABEL}/guide.txt',",
1555+
" 'name': f'{LABEL}-guide',",
1556+
" 'description': f'Guide for {LABEL}',",
1557+
" 'mimeType': 'text/plain'",
1558+
" }",
1559+
" ]",
1560+
" }",
1561+
" })",
1562+
" elif method == 'resources/read':",
1563+
" uri = request['params']['uri']",
1564+
" send_message({",
1565+
" 'jsonrpc': '2.0',",
1566+
" 'id': request['id'],",
1567+
" 'result': {",
1568+
" 'contents': [",
1569+
" {",
1570+
" 'uri': uri,",
1571+
" 'mimeType': 'text/plain',",
1572+
" 'text': f'{LABEL} contents for {uri}'",
1573+
" }",
1574+
" ]",
1575+
" }",
1576+
" })",
13891577
" else:",
13901578
" send_message({",
13911579
" 'jsonrpc': '2.0',",
@@ -1936,6 +2124,60 @@ mod tests {
19362124
});
19372125
}
19382126

2127+
#[test]
2128+
fn manager_lists_and_reads_resources_from_stdio_servers() {
2129+
let runtime = Builder::new_current_thread()
2130+
.enable_all()
2131+
.build()
2132+
.expect("runtime");
2133+
runtime.block_on(async {
2134+
let script_path = write_manager_mcp_server_script();
2135+
let root = script_path.parent().expect("script parent");
2136+
let log_path = root.join("resources.log");
2137+
let servers = BTreeMap::from([(
2138+
"alpha".to_string(),
2139+
manager_server_config(&script_path, "alpha", &log_path),
2140+
)]);
2141+
let mut manager = McpServerManager::from_servers(&servers);
2142+
2143+
let resources = manager
2144+
.list_resources(Some("alpha"))
2145+
.await
2146+
.expect("list resources");
2147+
2148+
assert_eq!(resources.len(), 1);
2149+
assert_eq!(resources[0].server_name, "alpha");
2150+
assert_eq!(resources[0].resource.uri, "resource://alpha/guide.txt");
2151+
assert_eq!(resources[0].resource.name.as_deref(), Some("alpha-guide"));
2152+
2153+
let read = manager
2154+
.read_resource("alpha", "resource://alpha/guide.txt")
2155+
.await
2156+
.expect("read resource");
2157+
2158+
assert_eq!(
2159+
read.result.as_ref().map(|result| result.contents.len()),
2160+
Some(1)
2161+
);
2162+
assert_eq!(
2163+
read.result
2164+
.as_ref()
2165+
.and_then(|result| result.contents.first())
2166+
.and_then(|content| content.text.as_deref()),
2167+
Some("alpha contents for resource://alpha/guide.txt")
2168+
);
2169+
2170+
let log = fs::read_to_string(&log_path).expect("read log");
2171+
assert_eq!(
2172+
log.lines().collect::<Vec<_>>(),
2173+
vec!["initialize", "resources/list", "resources/read"]
2174+
);
2175+
2176+
manager.shutdown().await.expect("shutdown");
2177+
cleanup_script(&script_path);
2178+
});
2179+
}
2180+
19392181
#[test]
19402182
fn manager_times_out_slow_tool_calls() {
19412183
let runtime = Builder::new_current_thread()

0 commit comments

Comments
 (0)