#![cfg(feature = "http")]
#![expect(
clippy::indexing_slicing,
reason = "serde_json::Value::index is infallible"
)]
use reqwest::Client;
use serde_json::{Value, json};
use std::net::SocketAddr;
use stygian_plugin::config::Config;
use stygian_plugin::http::{AppState, build_router};
use tokio::net::TcpListener;
type TestResult = Result<(), Box<dyn std::error::Error>>;
async fn start_test_server()
-> Result<(String, tokio::task::JoinHandle<()>), Box<dyn std::error::Error>> {
let config = Config::testing();
let state = AppState::new(config)?;
let app = build_router(state);
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr: SocketAddr = listener.local_addr()?;
let base_url = format!("http://127.0.0.1:{}", addr.port());
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
Ok((base_url, handle))
}
#[tokio::test]
async fn test_health_returns_ok() -> TestResult {
let (base, _handle) = start_test_server().await?;
let client = Client::new();
let resp = client.get(format!("{base}/health")).send().await?;
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await?;
assert_eq!(body["status"], "ok");
assert_eq!(body["service"], "stygian-plugin-mcp");
Ok(())
}
#[tokio::test]
async fn test_cors_headers_present() -> TestResult {
let (base, _handle) = start_test_server().await?;
let client = Client::new();
let resp = client
.request(reqwest::Method::OPTIONS, format!("{base}/mcp/tools/list"))
.header(
"Origin",
"chrome-extension://abcdefghijklmnopqrstuvwxyz123456",
)
.header("Access-Control-Request-Method", "GET")
.send()
.await?;
let allow_origin = resp
.headers()
.get("access-control-allow-origin")
.map_or("", |v| v.to_str().unwrap_or(""));
assert!(
allow_origin == "*" || allow_origin.contains("chrome-extension"),
"expected CORS allow-origin, got: {allow_origin}"
);
Ok(())
}
#[tokio::test]
async fn test_tools_list_via_get() -> TestResult {
let (base, _handle) = start_test_server().await?;
let client = Client::new();
let resp = client.get(format!("{base}/mcp/tools/list")).send().await?;
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await?;
assert_eq!(body["jsonrpc"], "2.0");
assert!(body["result"]["tools"].is_array(), "expected tools array");
let tools = body["result"]["tools"]
.as_array()
.ok_or("expected tools to be an array")?;
assert!(
!tools.is_empty(),
"expected at least one tool in the registry"
);
let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
for expected in &[
"plugin_create_template",
"plugin_list_templates",
"plugin_get_template",
"plugin_delete_template",
"plugin_apply_template",
] {
assert!(
names.contains(expected),
"missing tool: {expected}; available: {names:?}"
);
}
Ok(())
}
#[tokio::test]
async fn test_tools_call_list_templates_jsonrpc_envelope() -> TestResult {
let (base, _handle) = start_test_server().await?;
let client = Client::new();
let req_body = json!({
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": {
"name": "plugin_list_templates",
"arguments": {}
}
});
let resp = client
.post(format!("{base}/mcp/tools/call"))
.json(&req_body)
.send()
.await?;
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await?;
assert_eq!(body["jsonrpc"], "2.0");
assert_eq!(body["id"], 42);
assert!(body.get("error").is_none(), "unexpected error: {body}");
assert!(body["result"].is_object() || body["result"].is_array());
Ok(())
}
#[tokio::test]
async fn test_tools_call_bare_envelope() -> TestResult {
let (base, _handle) = start_test_server().await?;
let client = Client::new();
let payload = json!({
"name": "plugin_list_templates",
"arguments": {}
});
let resp = client
.post(format!("{base}/mcp/tools/call"))
.json(&payload)
.send()
.await?;
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await?;
assert_eq!(body["jsonrpc"], "2.0");
Ok(())
}
#[tokio::test]
async fn test_tools_call_unknown_tool_returns_error() -> TestResult {
let (base, _handle) = start_test_server().await?;
let client = Client::new();
let req_body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "does_not_exist",
"arguments": {}
}
});
let resp = client
.post(format!("{base}/mcp/tools/call"))
.json(&req_body)
.send()
.await?;
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await?;
assert_eq!(body["jsonrpc"], "2.0");
let is_error = body["result"]["isError"].as_bool().unwrap_or(false);
let has_rpc_error = body.get("error").is_some();
assert!(
is_error || has_rpc_error,
"expected isError=true or JSON-RPC error for unknown tool, got: {body}"
);
Ok(())
}
#[tokio::test]
async fn test_tools_call_missing_name_returns_400() -> TestResult {
let (base, _handle) = start_test_server().await?;
let client = Client::new();
let req_body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"arguments": {}
}
});
let resp = client
.post(format!("{base}/mcp/tools/call"))
.json(&req_body)
.send()
.await?;
assert_eq!(resp.status(), 400);
let body: Value = resp.json().await?;
assert_eq!(body["error"]["code"], -32602);
Ok(())
}
#[tokio::test]
async fn test_mcp_dispatch_initialize() -> TestResult {
let (base, _handle) = start_test_server().await?;
let client = Client::new();
let req_body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"clientInfo": { "name": "test-client", "version": "0.0.1" },
"capabilities": {}
}
});
let resp = client
.post(format!("{base}/mcp"))
.json(&req_body)
.send()
.await?;
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await?;
assert_eq!(body["jsonrpc"], "2.0");
assert_eq!(body["id"], 1);
assert!(body.get("error").is_none(), "unexpected error: {body}");
assert!(body["result"]["serverInfo"].is_object());
Ok(())
}
#[tokio::test]
async fn test_mcp_dispatch_tools_list() -> TestResult {
let (base, _handle) = start_test_server().await?;
let client = Client::new();
let req_body = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
});
let resp = client
.post(format!("{base}/mcp"))
.json(&req_body)
.send()
.await?;
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await?;
assert_eq!(body["jsonrpc"], "2.0");
assert!(body["result"]["tools"].is_array());
Ok(())
}
#[tokio::test]
async fn test_mcp_dispatch_notification_returns_204() -> TestResult {
let (base, _handle) = start_test_server().await?;
let client = Client::new();
let notification = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": {}
});
let resp = client
.post(format!("{base}/mcp"))
.json(¬ification)
.send()
.await?;
assert_eq!(resp.status(), 204);
Ok(())
}
#[tokio::test]
async fn test_mcp_dispatch_parse_error() -> TestResult {
let (base, _handle) = start_test_server().await?;
let client = Client::new();
let junk = json!({ "not": "jsonrpc", "at": "all" });
let resp = client
.post(format!("{base}/mcp"))
.json(&junk)
.send()
.await?;
assert!(
resp.status() == 200 || resp.status() == 204,
"unexpected status: {}",
resp.status()
);
Ok(())
}
#[tokio::test]
async fn test_jsonrpc_method_not_found() -> TestResult {
let (base, _handle) = start_test_server().await?;
let client = Client::new();
let req_body = json!({
"jsonrpc": "2.0",
"id": 99,
"method": "bananas/eat",
"params": {}
});
let resp = client
.post(format!("{base}/mcp"))
.json(&req_body)
.send()
.await?;
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await?;
assert_eq!(body["id"], 99);
let code = body["error"]["code"].as_i64();
assert!(
code.is_some(),
"expected error.code in response, got: {body}"
);
Ok(())
}