Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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);
}