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-ui-server/src/anonymous/mcp.rs

//! **Gunnar's MCP connector, through the console** (TASKS-13): the default
//! build serves none of its doors; an `mcp` build serves its metadata and
//! answers `/mcp` without a token with the RFC 9728 login pointer, past the
//! console's session gate, and keeps the person's list behind a session.

use super::{anon, console, send, signed_in, FakeGunnar};

/// The connector's doors.
const DOORS: &[&str] = &[
    "/.well-known/oauth-protected-resource",
    "/.well-known/oauth-protected-resource/mcp",
    "/.well-known/oauth-authorization-server",
    "/oauth/authorize.js",
];

/// ★ **The default build serves no `/mcp`**: no metadata, no OAuth doors, no
/// login pointer, and the person's list does not exist.
#[cfg(not(feature = "mcp"))]
#[tokio::test]
async fn the_default_build_serves_no_mcp() {
    let (app, keys, _feed) = console(FakeGunnar::new(), true).await;
    for path in DOORS {
        let (status, body) = anon(&app, path).await;
        assert!(
            !body.contains("authorization_servers")
                && !body.contains("registration_endpoint")
                && !body.contains("authorize/approve"),
            "{path} was served by the default build: {status} {body}"
        );
    }
    let (status, body) = send(&app, http::Method::POST, "/mcp", None).await;
    assert_ne!(status, http::StatusCode::OK, "{body}");
    assert!(!body.contains("resource_metadata"), "{body}");
    let (status, body) = send(&app, http::Method::POST, "/oauth/register", None).await;
    assert!(
        !body.contains("client_id") && status != http::StatusCode::CREATED,
        "{status} {body}"
    );
    let (_status, body) = signed_in(
        &app,
        &keys,
        "kim",
        crate::role::Role::Viewer,
        "/api/account/mcp",
    )
    .await;
    assert!(!body.contains("connections"), "{body}");
    // ★ Owner 2026-10-26: no switch either. `/api/mcp` answers as a path never routed.
    for method in [http::Method::GET, http::Method::PUT] {
        let (status, body) = send(&app, method.clone(), "/api/mcp", Some("break-glass")).await;
        let (never, never_body) = send(&app, method.clone(), "/api/no-such-door", Some("break-glass")).await;
        assert_eq!((status, body.contains("\"on\"")), (never, false), "{method} /api/mcp exists in the default build: {status} {body} (a path never routed: {never} {never_body})");
    }
}

#[cfg(feature = "mcp")]
mod served {
    use super::*;

    /// `PUT /api/mcp {"on": on}` as the session `cookie`.
    fn put(keys: &crate::auth::Webauthn, cookie: &str, on: bool) -> http::Request<axum::body::Body> {
        http::Request::put("/api/mcp")
            .header(http::header::COOKIE, format!("{}={cookie}", crate::auth::SESSION_COOKIE))
            .header(http::header::CONTENT_TYPE, "application/json")
            .header(http::header::ORIGIN, keys.origin())
            .body(axum::body::Body::from(format!("{{\"on\":{on}}}")))
            .unwrap()
    }

    /// The connector starts OFF (owner 2026-10-26: the administrator's switch); an
    /// administrator turns it on.
    async fn switch_on(app: &axum::Router, keys: &std::sync::Arc<crate::auth::Webauthn>) {
        use tower::ServiceExt;
        let admin = keys.make_session("root", crate::role::Role::Admin, true);
        assert_eq!(app.clone().oneshot(put(keys, &admin, true)).await.unwrap().status(), http::StatusCode::OK);
    }

    /// ★ **Off until an administrator switches it on**: every connector door answers as a
    /// path the console never routed, for a stranger and for a signed-in person alike; a
    /// viewer cannot switch it; an administrator can, the position is stored as gunnar's
    /// generic setting `console.mcp`, and the doors open and close with it.
    #[tokio::test]
    async fn only_an_administrator_switches_the_connector_and_off_it_answers_as_the_default_build() {
        use tower::ServiceExt;
        let fake = FakeGunnar::new();
        let (app, keys, _feed) = console(fake.clone(), true).await;
        let as_default = |app: axum::Router| async move {
            for path in DOORS.iter().chain(["/mcp", "/oauth/register"].iter()) {
                let (status, body) = send(&app, http::Method::POST, path, None).await;
                let (never, never_body) = send(&app, http::Method::POST, "/no-such-door", None).await;
                assert_eq!((status, body.as_str()), (never, never_body.as_str()), "{path} is served with the switch off");
            }
        };
        as_default(app.clone()).await;
        let (status, body) = signed_in(&app, &keys, "kim", crate::role::Role::Viewer, "/api/account/mcp").await;
        assert!(status == http::StatusCode::NOT_FOUND && !body.contains("connections"), "{status} {body}");

        let viewer = keys.make_session("vera", crate::role::Role::Viewer, true);
        assert_eq!(app.clone().oneshot(put(&keys, &viewer, true)).await.unwrap().status(), http::StatusCode::FORBIDDEN);
        assert_eq!(fake.setting("console.mcp"), None, "a refused flip stores nothing");
        as_default(app.clone()).await;

        switch_on(&app, &keys).await;
        assert_eq!(fake.setting("console.mcp").as_deref(), Some(&b"on"[..]), "stored as gunnar's generic setting");
        let (_, state) = signed_in(&app, &keys, "root", crate::role::Role::Admin, "/api/mcp").await;
        assert!(state.contains("\"on\":true"), "{state}");
        let (status, _) = anon(&app, "/.well-known/oauth-protected-resource").await;
        assert_eq!(status, http::StatusCode::OK, "switched on, the doors are served");

        let admin = keys.make_session("root", crate::role::Role::Admin, true);
        assert_eq!(app.clone().oneshot(put(&keys, &admin, false)).await.unwrap().status(), http::StatusCode::OK);
        assert_eq!(fake.setting("console.mcp").as_deref(), Some(&b"off"[..]));
        as_default(app.clone()).await;
    }

    /// ★ The stored position outlives the console: a console started over a gunnar that
    /// holds `console.mcp = on` serves the connector without being switched again.
    #[tokio::test]
    async fn a_console_takes_the_stored_switch_at_start() {
        // The flip counter is the process's, by name: a flip of `console.mcp` in a test running
        // beside this one marks this console's first read as older than it, and that read is
        // dropped. The next read comes after RECHECK (10 s); read again soon instead, as
        // public_site's twin of this test does.
        crate::console_setting::recheck_every(std::time::Duration::from_millis(100));
        let fake = FakeGunnar::new();
        fake.settings.lock().unwrap().insert("console.mcp".into(), b"on".to_vec());
        let (app, _keys, _feed) = console(fake, true).await;
        for _ in 0..80 {
            if anon(&app, "/.well-known/oauth-protected-resource").await.0 == http::StatusCode::OK {
                return;
            }
            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
        }
        panic!("the console never took the stored switch");
    }

    #[tokio::test]
    async fn the_metadata_is_served_to_a_stranger_and_mcp_points_at_the_login() {
        let (app, keys, _feed) = console(FakeGunnar::new(), true).await;
        switch_on(&app, &keys).await;
        for path in DOORS {
            let (status, body) = anon(&app, path).await;
            assert_eq!(status, http::StatusCode::OK, "{path}: {body}");
        }
        let (_, prm) = anon(&app, "/.well-known/oauth-protected-resource/mcp").await;
        let prm: serde_json::Value = serde_json::from_str(&prm).unwrap();
        assert!(prm["resource"].as_str().unwrap().ends_with("/mcp"), "{prm}");
        use tower::ServiceExt;
        let r = app
            .clone()
            .oneshot(
                http::Request::post("/mcp")
                    .body(axum::body::Body::from("{}"))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(r.status(), http::StatusCode::UNAUTHORIZED);
        let challenge = r.headers()[http::header::WWW_AUTHENTICATE]
            .to_str()
            .unwrap()
            .to_owned();
        assert!(challenge.contains("resource_metadata="), "{challenge}");
    }

    #[tokio::test]
    async fn the_persons_list_needs_their_session() {
        let (app, keys, _feed) = console(FakeGunnar::new(), true).await;
        switch_on(&app, &keys).await;
        let (status, _) = anon(&app, "/api/account/mcp").await;
        assert!(
            status == http::StatusCode::UNAUTHORIZED || status.is_redirection(),
            "{status}"
        );
        let (status, body) = signed_in(
            &app,
            &keys,
            "kim",
            crate::role::Role::Viewer,
            "/api/account/mcp",
        )
        .await;
        assert_eq!(status, http::StatusCode::OK, "{body}");
        assert_eq!(body, r#"{"connections":[]}"#);
        let (status, _) = send(
            &app,
            http::Method::POST,
            "/api/account/mcp/1/end",
            Some("break-glass"),
        )
        .await;
        assert_ne!(
            status,
            http::StatusCode::OK,
            "the break-glass token is nobody's person"
        );
    }
}