M5: rmcp stdio client and tool adapters
Adds an rmcp-based stdio MCP client in harness-mcp plus adapters exposing a configured server's tools as namespaced harness-tools, with an echo-server fixture and integration test, and wiring through harness-app.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
//! End-to-end MCP integration test: spawn a real stdio MCP server (a small Python fixture),
|
||||
//! connect through the real rmcp client, and verify a discovered tool is callable and gated
|
||||
//! behind the `mcp` permission key. This is the M5 milestone's ✅ for MCP.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use harness_core::event::{AppEvent, EventBus};
|
||||
use harness_core::permission::{PermissionReply, PermissionService};
|
||||
use harness_core::tool::{MetadataSink, PermissionHandle, ToolCtx, ToolError};
|
||||
use harness_core::types::{MessageId, SessionId};
|
||||
use harness_mcp::{connect_all, ServerConfig};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// A permission frontend that replies `Once` to every ask and records the `permission`/`pattern`
|
||||
/// of each, so a test can assert the call was actually gated.
|
||||
fn recording_auto_approve(
|
||||
bus: EventBus,
|
||||
service: Arc<PermissionService>,
|
||||
) -> Arc<Mutex<Vec<(String, String)>>> {
|
||||
let asks = Arc::new(Mutex::new(Vec::new()));
|
||||
let asks_task = asks.clone();
|
||||
// Subscribe before spawning: a subscription created inside the task could miss the ask
|
||||
// (tokio broadcast only delivers to receivers that exist at publish time).
|
||||
let mut rx = bus.subscribe();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
if let AppEvent::PermissionAsked { request } = event {
|
||||
asks_task
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((request.permission.clone(), request.pattern.clone()));
|
||||
service.reply(&request.id, PermissionReply::Once);
|
||||
}
|
||||
}
|
||||
});
|
||||
asks
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
ctx_data_dir: std::path::PathBuf,
|
||||
service: Arc<PermissionService>,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn ctx(&self) -> ToolCtx {
|
||||
let (metadata, _rx) = MetadataSink::channel();
|
||||
ToolCtx {
|
||||
session_id: SessionId::new(),
|
||||
message_id: MessageId::new(),
|
||||
call_id: "call_1".into(),
|
||||
data_dir: self.ctx_data_dir.clone(),
|
||||
cwd: std::env::temp_dir(),
|
||||
cancel: CancellationToken::new(),
|
||||
ask: PermissionHandle::new(
|
||||
self.service.clone(),
|
||||
SessionId::new(),
|
||||
Vec::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
CancellationToken::new(),
|
||||
),
|
||||
metadata,
|
||||
spawner: None,
|
||||
context_reporter: None,
|
||||
diagnostics: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_server() -> HashMap<String, ServerConfig> {
|
||||
let script = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/echo_server.py");
|
||||
HashMap::from([(
|
||||
"fix".to_string(),
|
||||
ServerConfig {
|
||||
command: "python3".to_string(),
|
||||
args: vec![script.to_string()],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
)])
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovers_and_calls_a_real_mcp_tool_with_permission() {
|
||||
let tools = connect_all(fixture_server()).await;
|
||||
let names: Vec<_> = tools.iter().map(|t| t.name().to_string()).collect();
|
||||
assert!(
|
||||
names.contains(&"fix_echo".to_string()),
|
||||
"expected fix_echo among {names:?}"
|
||||
);
|
||||
assert!(names.contains(&"fix_boom".to_string()));
|
||||
|
||||
let echo = tools.iter().find(|t| t.name() == "fix_echo").unwrap();
|
||||
// Schema passes through untouched from the server.
|
||||
assert_eq!(echo.parameters()["properties"]["text"]["type"], "string");
|
||||
|
||||
let bus = EventBus::new();
|
||||
let service = Arc::new(PermissionService::new(bus.clone()));
|
||||
let asks = recording_auto_approve(bus, service.clone());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let harness = Harness {
|
||||
ctx_data_dir: dir.path().to_path_buf(),
|
||||
service,
|
||||
};
|
||||
|
||||
let out = echo
|
||||
.execute(serde_json::json!({"text": "hi there"}), harness.ctx())
|
||||
.await
|
||||
.expect("echo call succeeds");
|
||||
assert_eq!(out.output, "hi there");
|
||||
|
||||
// The call was gated on the `mcp` key with the qualified tool name as the pattern.
|
||||
let recorded = asks.lock().unwrap().clone();
|
||||
assert_eq!(recorded, vec![("mcp".to_string(), "fix_echo".to_string())]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_error_result_maps_to_tool_error() {
|
||||
let tools = connect_all(fixture_server()).await;
|
||||
let boom = tools.iter().find(|t| t.name() == "fix_boom").unwrap();
|
||||
|
||||
let bus = EventBus::new();
|
||||
let service = Arc::new(PermissionService::new(bus.clone()));
|
||||
let _asks = recording_auto_approve(bus, service.clone());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let harness = Harness {
|
||||
ctx_data_dir: dir.path().to_path_buf(),
|
||||
service,
|
||||
};
|
||||
|
||||
let err = boom
|
||||
.execute(serde_json::json!({}), harness.ctx())
|
||||
.await
|
||||
.expect_err("boom reports an error result");
|
||||
match err {
|
||||
ToolError::Other(msg) => assert_eq!(msg, "kaboom"),
|
||||
other => panic!("expected ToolError::Other, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_server_is_skipped_not_fatal() {
|
||||
let servers = HashMap::from([(
|
||||
"broken".to_string(),
|
||||
ServerConfig {
|
||||
command: "definitely-not-a-real-binary-xyz".to_string(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
// No panic, no tools — the missing server is logged and skipped.
|
||||
let tools = connect_all(servers).await;
|
||||
assert!(tools.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal MCP stdio server fixture for harness-mcp integration tests.
|
||||
|
||||
Speaks newline-delimited JSON-RPC (the framing rmcp's child-process transport uses) and
|
||||
implements just enough of the protocol to be discovered and called: `initialize`,
|
||||
`notifications/initialized`, `tools/list`, and `tools/call`. Exposes one tool, `echo`,
|
||||
which returns its `text` argument, plus `boom`, which returns an error result.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "echo",
|
||||
"description": "Returns the text it is given.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "boom",
|
||||
"description": "Always fails.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def reply(msg_id, result):
|
||||
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg_id, "result": result}) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main():
|
||||
# readline() rather than `for line in sys.stdin`: the latter's read-ahead buffer blocks
|
||||
# until it fills, which would stall the JSON-RPC handshake line-by-line.
|
||||
while True:
|
||||
line = sys.stdin.readline()
|
||||
if line == "": # EOF: parent closed stdin
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
msg = json.loads(line)
|
||||
method = msg.get("method")
|
||||
msg_id = msg.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
reply(msg_id, {
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "echo-fixture", "version": "0.1.0"},
|
||||
})
|
||||
elif method == "notifications/initialized":
|
||||
pass # notification: no response
|
||||
elif method == "tools/list":
|
||||
reply(msg_id, {"tools": TOOLS})
|
||||
elif method == "tools/call":
|
||||
params = msg.get("params") or {}
|
||||
name = params.get("name")
|
||||
args = params.get("arguments") or {}
|
||||
if name == "echo":
|
||||
reply(msg_id, {
|
||||
"content": [{"type": "text", "text": args.get("text", "")}],
|
||||
"isError": False,
|
||||
})
|
||||
elif name == "boom":
|
||||
reply(msg_id, {
|
||||
"content": [{"type": "text", "text": "kaboom"}],
|
||||
"isError": True,
|
||||
})
|
||||
else:
|
||||
reply(msg_id, {
|
||||
"content": [{"type": "text", "text": f"unknown tool {name}"}],
|
||||
"isError": True,
|
||||
})
|
||||
elif msg_id is not None:
|
||||
# Unknown request: empty result keeps the client happy.
|
||||
reply(msg_id, {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user