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