crates/gunnar-mcp/tests/tools_and_limits.rs
//! **The allowlist, the write grant, the address rule, the private-repo
//! refusal and the rate limit**, through `/mcp` as an agent sees them.
mod common;
use common::*;
use http::StatusCode;
use serde_json::json;
#[tokio::test]
async fn an_unknown_tool_is_refused_by_name_and_nothing_is_called() {
let h = Harness::new();
let c = h.connect("cred-kim", "", false).await;
for name in ["fetch_url", "shell", "http_proxy", "run_command"] {
let r = h
.tool(&c.access, name, json!({ "url": "https://example.invalid" }))
.await;
assert_eq!(r.status, StatusCode::OK);
let v = r.json();
assert_eq!(v["error"]["code"], -32602, "{v}");
assert!(
v["error"]["message"]
.as_str()
.unwrap()
.starts_with(&format!("Unknown tool: {name}.")),
"{v}"
);
}
let seen = h.fake.0.lock().unwrap();
assert!(seen.calls.is_empty(), "an unknown tool reached the backend");
assert!(seen.audits.iter().any(|a| a.event == "mcp.call"
&& a.tool == "fetch_url"
&& a.outcome.contains("allowlist")));
}
#[tokio::test]
async fn tools_list_is_the_fixed_read_list_without_the_write_grant() {
let h = Harness::new();
let c = h
.connect("cred-kim", "gunnar:read gunnar:write", false)
.await;
assert_eq!(c.scope, "gunnar:read", "write was asked for but not ticked");
let listed = h.rpc(&c.access, "tools/list", json!({})).await.json();
let names: Vec<&str> = listed["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
assert_eq!(names.len(), 11, "{names:?}");
assert!(!names.contains(&"comment_on_issue"));
let w = h
.tool(
&c.access,
"comment_on_issue",
json!({ "namespace": "kim", "repo": "secret", "number": 1, "body": "hi" }),
)
.await
.json();
assert_eq!(w["error"]["code"], -32602);
assert!(
w["error"]["message"].as_str().unwrap().contains("write"),
"{w}"
);
assert!(h.fake.0.lock().unwrap().calls.is_empty());
}
#[tokio::test]
async fn the_write_grant_is_separate_and_explicit() {
let h = Harness::new();
// Ticked, but never asked for: still read.
let unasked = h.connect("cred-kim", "gunnar:read", true).await;
assert_eq!(unasked.scope, "gunnar:read");
// Asked for AND ticked: write.
let c = h
.connect("cred-kim", "gunnar:read gunnar:write", true)
.await;
assert_eq!(c.scope, "gunnar:read gunnar:write");
let r = h
.tool(
&c.access,
"comment_on_issue",
json!({ "namespace": "kim", "repo": "secret", "number": 1, "body": "hi" }),
)
.await;
let (text, err) = result_text(&r);
assert!(!err && text.contains("Commented on issue #1"), "{text}");
}
#[tokio::test]
async fn no_tool_accepts_an_address() {
let h = Harness::new();
let c = h.connect("cred-kim", "", false).await;
let listed = h.rpc(&c.access, "tools/list", json!({})).await.json();
for t in listed["result"]["tools"].as_array().unwrap() {
assert_eq!(t["inputSchema"]["additionalProperties"], false);
for key in t["inputSchema"]["properties"].as_object().unwrap().keys() {
for word in [
"url", "uri", "host", "address", "endpoint", "remote", "server",
] {
assert!(!key.contains(word), "{} takes {key}", t["name"]);
}
}
}
for (tool, args) in [
(
"list_repositories",
json!({ "url": "https://example.invalid" }),
),
(
"list_repositories",
json!({ "query": "https://example.invalid/x" }),
),
(
"search_code",
json!({ "namespace": "pub", "repo": "open", "pattern": "ssh://git@example.invalid/x" }),
),
(
"list_commits",
json!({ "namespace": "pub", "repo": "open", "host": "example.invalid" }),
),
] {
let (text, err) = result_text(&h.tool(&c.access, tool, args.clone()).await);
assert!(err, "{tool} {args}: {text}");
}
assert!(
h.fake.0.lock().unwrap().calls.is_empty(),
"an address reached the backend"
);
}
#[tokio::test]
async fn a_private_repository_is_never_visible_to_somebody_who_cannot_see_it() {
let h = Harness::new();
let eve = h.connect("cred-eve", "", false).await;
let (list, _) = result_text(&h.tool(&eve.access, "list_repositories", json!({})).await);
assert!(
list.contains("pub/open") && !list.contains("secret"),
"{list}"
);
// A private one and one that does not exist answer in ONE voice.
let (hidden, err) = result_text(
&h.tool(
&eve.access,
"list_issues",
json!({ "namespace": "kim", "repo": "secret" }),
)
.await,
);
let (missing, _) = result_text(
&h.tool(
&eve.access,
"list_issues",
json!({ "namespace": "kim", "repo": "nothing" }),
)
.await,
);
assert!(err);
assert_eq!(hidden, missing);
assert_eq!(hidden, gunnar_mcp::HIDDEN_WORDS);
// And the person who owns it sees it.
let kim = h.connect("cred-kim", "", false).await;
let (own, err) = result_text(
&h.tool(
&kim.access,
"get_issue",
json!({ "namespace": "kim", "repo": "secret", "number": 3 }),
)
.await,
);
assert!(!err && own.contains("#3 [open] A bug"), "{own}");
assert!(
!own.contains("@mock.invalid"),
"no e-mail address in an answer: {own}"
);
}
#[tokio::test]
async fn calls_beyond_the_budget_get_429_with_retry_after() {
let mut config = gunnar_mcp::Config::new(ISSUER);
config.calls_burst = 3;
config.calls_per_second = 0.2;
let h = Harness::with(config);
let c = h.connect("cred-kim", "", false).await;
for _ in 0..3 {
assert_eq!(
h.tool(&c.access, "list_repositories", json!({}))
.await
.status,
StatusCode::OK
);
}
let over = h.tool(&c.access, "list_repositories", json!({})).await;
assert_eq!(over.status, StatusCode::TOO_MANY_REQUESTS);
let wait: u64 = over.headers["retry-after"]
.to_str()
.unwrap()
.parse()
.unwrap();
assert!((1..=5).contains(&wait), "{wait}");
// Another person's connection has its own budget.
let eve = h.connect("cred-eve", "", false).await;
assert_eq!(
h.tool(&eve.access, "list_repositories", json!({}))
.await
.status,
StatusCode::OK
);
}
#[tokio::test]
async fn the_oauth_doors_have_a_per_address_budget() {
let mut config = gunnar_mcp::Config::new(ISSUER);
config.registrations_per_hour = 2;
let h = Harness::with(config);
let body =
json!({ "redirect_uris": ["http://localhost:9/cb"], "token_endpoint_auth_method": "none" });
assert_eq!(h.register(&body).await.status, StatusCode::CREATED);
assert_eq!(h.register(&body).await.status, StatusCode::CREATED);
let over = h.register(&body).await;
assert_eq!(over.status, StatusCode::TOO_MANY_REQUESTS);
assert!(over.headers.contains_key("retry-after"));
}
#[tokio::test]
async fn the_transport_rules() {
let h = Harness::new();
let c = h.connect("cred-kim", "", false).await;
// No event stream.
assert_eq!(h.get("/mcp").await.status, StatusCode::METHOD_NOT_ALLOWED);
// A notification is accepted with 202 and no body.
let note = h
.send(
http::Request::post("/mcp")
.header("authorization", format!("Bearer {}", c.access))
.header("content-type", "application/json")
.body(axum::body::Body::from(
json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }).to_string(),
))
.unwrap(),
)
.await;
assert_eq!(note.status, StatusCode::ACCEPTED);
// An unknown protocol version header is refused; a known one is fine.
let bad = h
.send(
http::Request::post("/mcp")
.header("authorization", format!("Bearer {}", c.access))
.header("mcp-protocol-version", "1999-01-01")
.body(axum::body::Body::from(
json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }).to_string(),
))
.unwrap(),
)
.await;
assert_eq!(bad.status, StatusCode::BAD_REQUEST);
// A foreign browser origin is refused.
let foreign = h
.send(
http::Request::post("/mcp")
.header("authorization", format!("Bearer {}", c.access))
.header("origin", "https://evil.mock.invalid")
.body(axum::body::Body::from(
json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }).to_string(),
))
.unwrap(),
)
.await;
assert_eq!(foreign.status, StatusCode::FORBIDDEN);
// A token in the query string is not a token.
let query = h.get(&format!("/mcp?access_token={}", c.access)).await;
assert_ne!(query.status, StatusCode::OK);
// An unknown method.
let m = h
.rpc(
&c.access,
"resources/read",
json!({ "uri": "file:///etc/passwd" }),
)
.await
.json();
assert_eq!(m["error"]["code"], -32601);
}