First Version
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
//! End-to-end test of the elicitation path against a real stdio MCP subprocess.
|
||||
//!
|
||||
//! A tiny Python "server" exposes one tool that, when called, issues a
|
||||
//! server→client `elicitation/create` and echoes back whatever value it receives.
|
||||
//! This exercises the read-loop request branch, the spawned reply writer, the
|
||||
//! capability handshake, and `content` passthrough. Skipped if `python3` is absent.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use mcp_client::config::{McpServerConfig, McpTransport};
|
||||
use mcp_client::server::{
|
||||
ElicitationAction, ElicitationHandler, ElicitationReply, ElicitationRequest, McpServer,
|
||||
};
|
||||
use mcp_client::McpCallResult;
|
||||
|
||||
const FAKE_SERVER: &str = r#"
|
||||
import sys, json
|
||||
|
||||
def send(obj):
|
||||
sys.stdout.write(json.dumps(obj) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def readline():
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
return None
|
||||
line = line.strip()
|
||||
return line if line else readline()
|
||||
|
||||
while True:
|
||||
raw = readline()
|
||||
if raw is None:
|
||||
break
|
||||
msg = json.loads(raw)
|
||||
mid = msg.get("id")
|
||||
method = msg.get("method")
|
||||
if method == "initialize":
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
||||
"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"serverInfo": {"name": "fake", "version": "0"}}})
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
elif method == "tools/list":
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {"tools": [
|
||||
{"name": "need_secret", "description": "asks for a secret",
|
||||
"inputSchema": {"type": "object", "properties": {}}}]}})
|
||||
elif method == "tools/call":
|
||||
eid = "elicit-1"
|
||||
send({"jsonrpc": "2.0", "id": eid, "method": "elicitation/create", "params": {
|
||||
"message": "Enter password",
|
||||
"requestedSchema": {"type": "object", "properties": {
|
||||
"password": {"type": "string", "format": "password"}}}}})
|
||||
reply = None
|
||||
while reply is None:
|
||||
r = readline()
|
||||
if r is None:
|
||||
break
|
||||
rr = json.loads(r)
|
||||
if rr.get("id") == eid:
|
||||
reply = rr
|
||||
val = (reply or {}).get("result", {}).get("content", {}).get("password", "<none>")
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
||||
"content": [{"type": "text", "text": "got:" + val}], "isError": False}})
|
||||
elif mid is not None:
|
||||
send({"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": "unknown"}})
|
||||
"#;
|
||||
|
||||
struct AcceptHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl ElicitationHandler for AcceptHandler {
|
||||
async fn handle(&self, _server: &str, req: ElicitationRequest) -> ElicitationReply {
|
||||
assert_eq!(req.message, "Enter password");
|
||||
assert!(req.requested_schema.get("properties").is_some());
|
||||
ElicitationReply {
|
||||
action: ElicitationAction::Accept,
|
||||
content: Some(json!({ "password": "hunter2" })),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn python3_available() -> bool {
|
||||
Command::new("python3").arg("--version").output().is_ok()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn elicitation_roundtrip_returns_secret_to_server() {
|
||||
if !python3_available() {
|
||||
eprintln!("python3 not found — skipping elicitation integration test");
|
||||
return;
|
||||
}
|
||||
|
||||
let script_path = std::env::temp_dir().join(format!("skald_elicit_{}.py", std::process::id()));
|
||||
std::fs::File::create(&script_path)
|
||||
.unwrap()
|
||||
.write_all(FAKE_SERVER.as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let cfg = McpServerConfig {
|
||||
name: "fake".to_string(),
|
||||
transport: McpTransport::Stdio,
|
||||
command: Some("python3".to_string()),
|
||||
args: Some(vec![script_path.to_string_lossy().to_string()]),
|
||||
env: None,
|
||||
url: None,
|
||||
api_key: None,
|
||||
};
|
||||
|
||||
let server = McpServer::start(&cfg, None, None, Some(Arc::new(AcceptHandler)))
|
||||
.await
|
||||
.expect("server should start");
|
||||
|
||||
let result = server
|
||||
.call_tool("need_secret", json!({}))
|
||||
.await
|
||||
.expect("tool call should succeed");
|
||||
|
||||
// The fake server returns text content (no structuredContent) → Text variant.
|
||||
match result {
|
||||
McpCallResult::Text(s) => assert_eq!(s, "got:hunter2"),
|
||||
other => panic!("expected Text, got {other:?}"),
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&script_path);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! End-to-end test of per-server diagnostic capture against a real stdio MCP
|
||||
//! subprocess.
|
||||
//!
|
||||
//! A tiny Python "server" prints a banner to **stderr** and, right after
|
||||
//! `notifications/initialized`, emits both a `notifications/message` log record and
|
||||
//! a business `event/ping` notification to stdout. The test asserts that:
|
||||
//! - the stderr banner arrives on `log_tx` tagged `stderr`,
|
||||
//! - the `notifications/message` arrives on `log_tx` with its MCP level, and is
|
||||
//! **not** delivered to `notification_tx` (it's diverted away from TIC),
|
||||
//! - the business `event/ping` still arrives on `notification_tx`.
|
||||
//! Skipped if `python3` is absent.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use mcp_client::config::{McpServerConfig, McpTransport};
|
||||
use mcp_client::server::{McpNotification, McpServer};
|
||||
use mcp_client::McpLogLine;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
const FAKE_SERVER: &str = r#"
|
||||
import sys, json
|
||||
|
||||
def send(obj):
|
||||
sys.stdout.write(json.dumps(obj) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def readline():
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
return None
|
||||
line = line.strip()
|
||||
return line if line else readline()
|
||||
|
||||
while True:
|
||||
raw = readline()
|
||||
if raw is None:
|
||||
break
|
||||
msg = json.loads(raw)
|
||||
mid = msg.get("id")
|
||||
method = msg.get("method")
|
||||
if method == "initialize":
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
||||
"protocolVersion": "2025-11-25", "capabilities": {},
|
||||
"serverInfo": {"name": "fake", "version": "0"}}})
|
||||
elif method == "notifications/initialized":
|
||||
# A diagnostic banner on stderr (the primary, future-proof log source).
|
||||
print("startup banner on stderr", file=sys.stderr, flush=True)
|
||||
# An MCP logging record (should be diverted to the log file, NOT TIC).
|
||||
send({"jsonrpc": "2.0", "method": "notifications/message",
|
||||
"params": {"level": "warning", "logger": "test", "data": "disk almost full"}})
|
||||
# A business event (should still reach the notification queue / TIC).
|
||||
send({"jsonrpc": "2.0", "method": "event/ping", "params": {"n": 1}})
|
||||
elif method == "tools/list":
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {"tools": []}})
|
||||
elif mid is not None:
|
||||
send({"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": "unknown"}})
|
||||
"#;
|
||||
|
||||
fn python3_available() -> bool {
|
||||
Command::new("python3").arg("--version").output().is_ok()
|
||||
}
|
||||
|
||||
fn write_script() -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("skald_logging_{}.py", std::process::id()));
|
||||
std::fs::File::create(&path).unwrap().write_all(FAKE_SERVER.as_bytes()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// Drains a channel for up to `budget`, returning everything received.
|
||||
async fn drain<T>(rx: &mut mpsc::UnboundedReceiver<T>, budget: Duration) -> Vec<T> {
|
||||
let mut out = Vec::new();
|
||||
let deadline = tokio::time::Instant::now() + budget;
|
||||
while let Ok(Some(item)) = tokio::time::timeout_at(deadline, rx.recv()).await {
|
||||
out.push(item);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stderr_and_log_records_are_captured_and_diverted() {
|
||||
if !python3_available() {
|
||||
eprintln!("python3 not found — skipping per-server logging integration test");
|
||||
return;
|
||||
}
|
||||
let script = write_script();
|
||||
let cfg = McpServerConfig {
|
||||
name: "fake".to_string(),
|
||||
transport: McpTransport::Stdio,
|
||||
command: Some("python3".to_string()),
|
||||
args: Some(vec![script.to_string_lossy().to_string()]),
|
||||
env: None,
|
||||
url: None,
|
||||
api_key: None,
|
||||
};
|
||||
|
||||
let (notif_tx, mut notif_rx) = mpsc::unbounded_channel::<McpNotification>();
|
||||
let (log_tx, mut log_rx) = mpsc::unbounded_channel::<McpLogLine>();
|
||||
|
||||
let _server = McpServer::start(&cfg, Some(notif_tx), Some(log_tx), None)
|
||||
.await
|
||||
.expect("server should start");
|
||||
|
||||
let logs: Vec<McpLogLine> = drain(&mut log_rx, Duration::from_millis(1500)).await;
|
||||
let notes: Vec<McpNotification> = drain(&mut notif_rx, Duration::from_millis(500)).await;
|
||||
|
||||
// stderr banner captured on the log channel.
|
||||
assert!(
|
||||
logs.iter().any(|l| l.level == "stderr" && l.text.contains("startup banner on stderr")),
|
||||
"expected a [stderr] log line, got: {logs:?}",
|
||||
);
|
||||
// notifications/message captured with its MCP level + logger prefix.
|
||||
assert!(
|
||||
logs.iter().any(|l| l.level == "warning" && l.text.contains("disk almost full") && l.text.contains("test")),
|
||||
"expected a [warning] log line from notifications/message, got: {logs:?}",
|
||||
);
|
||||
// The log record was diverted away from the notification queue…
|
||||
assert!(
|
||||
!notes.iter().any(|(_, msg)| msg.get("method").and_then(Value::as_str) == Some("notifications/message")),
|
||||
"notifications/message must NOT reach the notification queue, got: {notes:?}",
|
||||
);
|
||||
// …while the business event still flowed through it.
|
||||
assert!(
|
||||
notes.iter().any(|(_, msg)| msg.get("method").and_then(Value::as_str) == Some("event/ping")),
|
||||
"expected event/ping on the notification queue, got: {notes:?}",
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&script);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! End-to-end test of `tools/list` cursor pagination against a real stdio MCP
|
||||
//! subprocess.
|
||||
//!
|
||||
//! A tiny Python "server" returns its tools across two pages: the first
|
||||
//! `tools/list` (no `cursor`) yields one tool plus a `nextCursor`; the follow-up
|
||||
//! (with that `cursor`) yields the rest and no `nextCursor`. This exercises the
|
||||
//! cursor loop in `McpServer::start`. Skipped if `python3` is absent.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::Command;
|
||||
|
||||
use mcp_client::config::{McpServerConfig, McpTransport};
|
||||
use mcp_client::server::McpServer;
|
||||
|
||||
const FAKE_SERVER: &str = r#"
|
||||
import sys, json
|
||||
|
||||
def send(obj):
|
||||
sys.stdout.write(json.dumps(obj) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def readline():
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
return None
|
||||
line = line.strip()
|
||||
return line if line else readline()
|
||||
|
||||
while True:
|
||||
raw = readline()
|
||||
if raw is None:
|
||||
break
|
||||
msg = json.loads(raw)
|
||||
mid = msg.get("id")
|
||||
method = msg.get("method")
|
||||
if method == "initialize":
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
||||
"protocolVersion": "2025-11-25", "capabilities": {},
|
||||
"serverInfo": {"name": "fake", "version": "0"}}})
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
elif method == "tools/list":
|
||||
cursor = (msg.get("params") or {}).get("cursor")
|
||||
if cursor is None:
|
||||
# Page 1: one tool + a cursor pointing at the next page.
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
||||
"tools": [{"name": "alpha", "description": "first",
|
||||
"inputSchema": {"type": "object", "properties": {}}}],
|
||||
"nextCursor": "page-2"}})
|
||||
elif cursor == "page-2":
|
||||
# Page 2: the rest, no further cursor → loop terminates.
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
||||
"tools": [{"name": "beta", "description": "second",
|
||||
"inputSchema": {"type": "object", "properties": {}}},
|
||||
{"name": "gamma", "description": "third",
|
||||
"inputSchema": {"type": "object", "properties": {}}}]}})
|
||||
else:
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {"tools": []}})
|
||||
elif mid is not None:
|
||||
send({"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": "unknown"}})
|
||||
"#;
|
||||
|
||||
fn python3_available() -> bool {
|
||||
Command::new("python3").arg("--version").output().is_ok()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_follows_next_cursor_across_pages() {
|
||||
if !python3_available() {
|
||||
eprintln!("python3 not found — skipping pagination integration test");
|
||||
return;
|
||||
}
|
||||
|
||||
let script_path =
|
||||
std::env::temp_dir().join(format!("skald_paginate_{}.py", std::process::id()));
|
||||
std::fs::File::create(&script_path)
|
||||
.unwrap()
|
||||
.write_all(FAKE_SERVER.as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let cfg = McpServerConfig {
|
||||
name: "fake".to_string(),
|
||||
transport: McpTransport::Stdio,
|
||||
command: Some("python3".to_string()),
|
||||
args: Some(vec![script_path.to_string_lossy().to_string()]),
|
||||
env: None,
|
||||
url: None,
|
||||
api_key: None,
|
||||
};
|
||||
|
||||
let server = McpServer::start(&cfg, None, None, None)
|
||||
.await
|
||||
.expect("server should start");
|
||||
|
||||
let names: Vec<&str> = server.tools().iter().map(|t| t.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["alpha", "beta", "gamma"],
|
||||
"all pages should be collected"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&script_path);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! End-to-end test of the block-and-poll Tasks client against a real stdio MCP
|
||||
//! subprocess.
|
||||
//!
|
||||
//! A tiny Python "server" exposes one tool with `execution.taskSupport: "required"`.
|
||||
//! On `tools/call` it returns a `CreateTaskResult` (a durable handle, no `content`)
|
||||
//! instead of a result; the client then polls `tasks/get` until `completed` and
|
||||
//! fetches the real answer via `tasks/result`. A second mode never completes, so the
|
||||
//! test can drop the call mid-poll and assert the client sends `tasks/cancel`.
|
||||
//! Skipped if `python3` is absent.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use mcp_client::config::{McpServerConfig, McpTransport};
|
||||
use mcp_client::server::McpServer;
|
||||
use mcp_client::McpCallResult;
|
||||
|
||||
/// argv[1] = mode ("complete" | "cancel"); argv[2] = marker file (cancel mode).
|
||||
const FAKE_SERVER: &str = r#"
|
||||
import sys, json
|
||||
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "complete"
|
||||
marker = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
get_count = 0
|
||||
|
||||
def send(obj):
|
||||
sys.stdout.write(json.dumps(obj) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def readline():
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
return None
|
||||
line = line.strip()
|
||||
return line if line else readline()
|
||||
|
||||
while True:
|
||||
raw = readline()
|
||||
if raw is None:
|
||||
break
|
||||
msg = json.loads(raw)
|
||||
mid = msg.get("id")
|
||||
method = msg.get("method")
|
||||
if method == "initialize":
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
||||
"protocolVersion": "2025-11-25", "capabilities": {},
|
||||
"serverInfo": {"name": "fake", "version": "0"}}})
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
elif method == "tools/list":
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {"tools": [
|
||||
{"name": "gen", "description": "deferred generator",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"execution": {"taskSupport": "required"}}]}})
|
||||
elif method == "tools/call":
|
||||
# Defer: return a task handle (no `content`).
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
||||
"taskId": "job-1", "status": "working", "pollInterval": 500}})
|
||||
elif method == "tasks/get":
|
||||
get_count += 1
|
||||
if mode == "complete" and get_count >= 2:
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
||||
"taskId": "job-1", "status": "completed"}})
|
||||
else:
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
||||
"taskId": "job-1", "status": "working", "pollInterval": 500}})
|
||||
elif method == "tasks/result":
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
||||
"content": [{"type": "text", "text": "the real answer"}]}})
|
||||
elif method == "tasks/cancel":
|
||||
if marker:
|
||||
with open(marker, "w") as f:
|
||||
f.write((msg.get("params") or {}).get("taskId", ""))
|
||||
if mid is not None:
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {}})
|
||||
elif mid is not None:
|
||||
send({"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": "unknown"}})
|
||||
"#;
|
||||
|
||||
fn python3_available() -> bool {
|
||||
Command::new("python3").arg("--version").output().is_ok()
|
||||
}
|
||||
|
||||
fn write_script() -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("skald_tasks_{}.py", std::process::id()));
|
||||
std::fs::File::create(&path).unwrap().write_all(FAKE_SERVER.as_bytes()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn cfg(script: &std::path::Path, mode: &str, marker: Option<&std::path::Path>) -> McpServerConfig {
|
||||
let mut args = vec![script.to_string_lossy().to_string(), mode.to_string()];
|
||||
if let Some(m) = marker {
|
||||
args.push(m.to_string_lossy().to_string());
|
||||
}
|
||||
McpServerConfig {
|
||||
name: "fake".to_string(),
|
||||
transport: McpTransport::Stdio,
|
||||
command: Some("python3".to_string()),
|
||||
args: Some(args),
|
||||
env: None,
|
||||
url: None,
|
||||
api_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_is_polled_to_completion_and_returns_real_result() {
|
||||
if !python3_available() {
|
||||
eprintln!("python3 not found — skipping tasks integration test");
|
||||
return;
|
||||
}
|
||||
let script = write_script();
|
||||
let server = McpServer::start(&cfg(&script, "complete", None), None, None, None)
|
||||
.await
|
||||
.expect("server should start");
|
||||
|
||||
// The tool opts into tasks (taskSupport: required); call_tool must add the
|
||||
// `task` field, poll to completion, and return the real result — not the handle.
|
||||
let result = server.call_tool("gen", serde_json::json!({}))
|
||||
.await
|
||||
.expect("task should complete");
|
||||
match result {
|
||||
McpCallResult::Text(t) => assert_eq!(t, "the real answer"),
|
||||
other => panic!("expected the polled Text result, got {other:?}"),
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&script);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_a_polling_call_sends_tasks_cancel() {
|
||||
if !python3_available() {
|
||||
eprintln!("python3 not found — skipping tasks cancel integration test");
|
||||
return;
|
||||
}
|
||||
let script = write_script();
|
||||
let marker = std::env::temp_dir().join(format!("skald_tasks_marker_{}", std::process::id()));
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
|
||||
let server = McpServer::start(&cfg(&script, "cancel", Some(&marker)), None, None, None)
|
||||
.await
|
||||
.expect("server should start");
|
||||
|
||||
// In "cancel" mode tasks/get never completes, so the call polls forever; time out
|
||||
// to drop the future mid-poll, which must fire tasks/cancel via the drop-guard.
|
||||
let timed = tokio::time::timeout(
|
||||
Duration::from_millis(900),
|
||||
server.call_tool("gen", serde_json::json!({})),
|
||||
).await;
|
||||
assert!(timed.is_err(), "call should still be polling (timed out), not resolved");
|
||||
|
||||
// Give the guard's spawned tasks/cancel time to reach the server.
|
||||
tokio::time::sleep(Duration::from_millis(700)).await;
|
||||
let recorded = std::fs::read_to_string(&marker).unwrap_or_default();
|
||||
assert_eq!(recorded, "job-1", "server should have received tasks/cancel for job-1");
|
||||
|
||||
let _ = std::fs::remove_file(&script);
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
}
|
||||
Reference in New Issue
Block a user