use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::extractors::Tenant;
use crate::tenancy::jwt_lifecycle::{JwtIssueError, JwtLifecycle};
use super::router::McpState;
use super::transport::handle_message;
pub const CLAIM_KIND: &str = "kind";
pub const KIND_AGENT: &str = "agent";
pub const CLAIM_TENANT: &str = "tenant";
pub const CLAIM_SKILLS: &str = "skills";
pub const CLAIM_TOOLS: &str = "tools";
#[derive(Debug, Clone)]
pub struct McpAgent {
pub agent_id: i64,
pub tenant: String,
pub skills: Vec<String>,
pub tools: Vec<String>,
pub jti: String,
}
pub fn issue_agent_token(
jwt: &JwtLifecycle,
agent_id: i64,
tenant: &str,
skills: &[String],
tools: &[String],
) -> Result<String, JwtIssueError> {
let mut custom = serde_json::Map::new();
custom.insert(CLAIM_KIND.into(), json!(KIND_AGENT));
custom.insert(CLAIM_TENANT.into(), json!(tenant));
custom.insert(CLAIM_SKILLS.into(), json!(skills));
custom.insert(CLAIM_TOOLS.into(), json!(tools));
jwt.issue_access_with(agent_id, custom)
}
#[must_use]
pub fn verify_agent_token(
jwt: &JwtLifecycle,
token: &str,
expected_tenant: &str,
) -> Option<McpAgent> {
let claims = jwt.verify_access(token)?;
if claims.get_custom::<String>(CLAIM_KIND).as_deref() != Some(KIND_AGENT) {
return None;
}
let tenant = claims.get_custom::<String>(CLAIM_TENANT)?;
if tenant != expected_tenant {
return None;
}
Some(McpAgent {
agent_id: claims.sub,
tenant,
skills: claims
.get_custom::<Vec<String>>(CLAIM_SKILLS)
.unwrap_or_default(),
tools: claims
.get_custom::<Vec<String>>(CLAIM_TOOLS)
.unwrap_or_default(),
jti: claims.jti,
})
}
#[derive(Debug, Deserialize)]
pub(crate) struct AgentTokenInput {
pub name: String,
pub secret: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct AgentTokenOutput {
pub access_token: String,
pub token_type: &'static str,
pub expires_in: i64,
}
pub(crate) struct MintedToken {
pub token: String,
pub expires_in: i64,
pub scope: String,
}
pub(crate) enum MintError {
Unauthorized,
Internal,
}
pub(crate) async fn mint_agent_jwt(
jwt: &JwtLifecycle,
pool: &crate::sql::Pool,
slug: &str,
name: &str,
secret: &str,
) -> Result<MintedToken, MintError> {
let agent = match crate::tenancy::authenticate_agent_pool(pool, name, secret).await {
Ok(Some(a)) => a,
Ok(None) => return Err(MintError::Unauthorized),
Err(e) => {
tracing::warn!(error = %e, "mcp agent authentication failed");
return Err(MintError::Internal);
}
};
let agent_id = agent.id.get().copied().unwrap_or_default();
let (skills, tools) = crate::tenancy::resolve_agent_grants_pool(pool, agent_id)
.await
.map_err(|e| {
tracing::warn!(error = %e, "mcp grant resolution failed");
MintError::Internal
})?;
let token = issue_agent_token(jwt, agent_id, slug, &skills, &tools).map_err(|e| {
tracing::error!(error = %e, "mcp token issuance failed");
MintError::Internal
})?;
Ok(MintedToken {
token,
expires_in: jwt.access_ttl_secs,
scope: skills.join(" "),
})
}
pub(crate) async fn agent_token(
t: Tenant,
State(state): State<McpState>,
Json(input): Json<AgentTokenInput>,
) -> Response {
let Some(jwt) = state.jwt.as_ref() else {
return (StatusCode::INTERNAL_SERVER_ERROR, "mcp auth not configured").into_response();
};
match mint_agent_jwt(jwt, t.pool(), &t.org.slug, &input.name, &input.secret).await {
Ok(m) => Json(AgentTokenOutput {
access_token: m.token,
token_type: "Bearer",
expires_in: m.expires_in,
})
.into_response(),
Err(MintError::Unauthorized) => {
(StatusCode::UNAUTHORIZED, "invalid agent credentials").into_response()
}
Err(MintError::Internal) => {
(StatusCode::INTERNAL_SERVER_ERROR, "token issuance failed").into_response()
}
}
}
pub(crate) async fn post_authed(
t: Tenant,
State(state): State<McpState>,
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
headers: HeaderMap,
body: Bytes,
) -> Response {
let Some(jwt) = state.jwt.as_ref() else {
return (StatusCode::INTERNAL_SERVER_ERROR, "mcp auth not configured").into_response();
};
let Some(token) = bearer(&headers) else {
return unauthorized(&headers, &uri);
};
let Some(agent) = verify_agent_token(jwt, token, &t.org.slug) else {
return unauthorized(&headers, &uri);
};
let ctx = super::tools::McpContext {
pool: t.pool().clone(),
agent,
progress: super::progress::ProgressReporter::disabled(),
cancel: super::progress::CancelToken::never(),
};
handle_message(&state, &body, Some(ctx)).await
}
pub(crate) fn bearer(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)?
.to_str()
.ok()?
.strip_prefix("Bearer ")
.map(str::trim)
}
pub(crate) fn origin(headers: &HeaderMap) -> String {
let host = headers
.get(header::HOST)
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost");
let scheme = headers
.get("x-forwarded-proto")
.and_then(|h| h.to_str().ok())
.map(str::to_owned)
.unwrap_or_else(|| {
if host.starts_with("localhost") || host.starts_with("127.") {
"http".into()
} else {
"https".into()
}
});
format!("{scheme}://{host}")
}
pub(crate) fn mount_base(
headers: &HeaderMap,
original: &axum::http::Uri,
strip_suffix: &str,
) -> String {
let path = original.path();
let prefix = path
.strip_suffix(strip_suffix)
.unwrap_or(path)
.trim_end_matches('/');
format!("{}{}", origin(headers), prefix)
}
pub(crate) fn unauthorized(headers: &HeaderMap, original: &axum::http::Uri) -> Response {
let base = mount_base(headers, original, "");
let challenge =
format!(r#"Bearer resource_metadata="{base}/.well-known/oauth-protected-resource""#);
(
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, challenge)],
"missing or invalid agent token",
)
.into_response()
}
pub(crate) fn default_jwt() -> Arc<JwtLifecycle> {
Arc::new(JwtLifecycle::new(jwt_secret()))
}
pub(crate) fn jwt_secret() -> Vec<u8> {
std::env::var("RUSTANGO_SESSION_SECRET")
.ok()
.map(String::into_bytes)
.filter(|s| s.len() >= 32)
.unwrap_or_else(|| {
tracing::warn!(
"RUSTANGO_SESSION_SECRET unset or <32 bytes; MCP agent tokens \
are signed with an ephemeral per-process key (tokens won't \
survive a restart and won't verify across instances)"
);
use rand::RngCore;
let mut key = vec![0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut key);
key
})
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Uri;
fn headers(host: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(header::HOST, host.parse().unwrap());
h
}
#[test]
fn mount_base_tracks_the_nest_prefix() {
let h = headers("app.example");
let uri: Uri = "/mcp".parse().unwrap();
assert_eq!(mount_base(&h, &uri, ""), "https://app.example/mcp");
let uri: Uri = "/api/mcp/.well-known/oauth-protected-resource"
.parse()
.unwrap();
assert_eq!(
mount_base(&h, &uri, "/.well-known/oauth-protected-resource"),
"https://app.example/api/mcp"
);
let uri: Uri = "/.well-known/oauth-protected-resource".parse().unwrap();
assert_eq!(
mount_base(&h, &uri, "/.well-known/oauth-protected-resource"),
"https://app.example"
);
}
#[test]
fn unauthorized_challenge_points_at_the_mount_prefix() {
let h = headers("app.example");
let uri: Uri = "/mcp".parse().unwrap();
let resp = unauthorized(&h, &uri);
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let challenge = resp
.headers()
.get(header::WWW_AUTHENTICATE)
.unwrap()
.to_str()
.unwrap();
assert_eq!(
challenge,
r#"Bearer resource_metadata="https://app.example/mcp/.well-known/oauth-protected-resource""#
);
}
#[test]
fn localhost_origin_uses_http() {
let h = headers("localhost:8080");
let uri: Uri = "/mcp".parse().unwrap();
assert_eq!(mount_base(&h, &uri, ""), "http://localhost:8080/mcp");
}
}