Menu
Public repository / 6a72ff276d26a76936569c968c80f8b033efafa6
AkurAI-IDP / src/routes/mcp.rs
Latest change 4c52a17908f6d86f1ecbf58597cde622003c6431 - mcp: admin MCP server at POST /mcp for managing the IdP by Ólafur Búi Ólafsson
//! Admin MCP server: lets AkurAI agents manage and maintain the IdP.
//!
//! `POST /mcp` speaks MCP (JSON-RPC 2.0, streamable HTTP, JSON responses).
//! It is guarded by the same IDP_ADMIN_TOKEN bearer as `/admin`, and every
//! tool is a thin mapping onto an admin route, so validation and audit
//! logging stay in one place.
use axum::{
Json, Router,
body::Body,
http::{Method, Request, StatusCode, header},
response::{IntoResponse, Response},
routing::post,
};
use serde_json::{Value, json};
use tower::ServiceExt;
const PROTOCOL_VERSION: &str = "2025-06-18";
pub fn router(admin: Router) -> Router {
Router::new().route(
"/mcp",
post(move |body: Json<Value>| handle(admin.clone(), body.0)),
)
}
struct Tool {
name: &'static str,
description: &'static str,
/// Required string arguments; extra arguments are passed through.
required: &'static [&'static str],
properties: fn() -> Value,
destructive: bool,
read_only: bool,
}
fn no_props() -> Value {
json!({})
}
fn tenant_filter() -> Value {
json!({"tenant_id": {"type": "string", "description": "Only this organization"}})
}
fn id_prop() -> Value {
json!({"id": {"type": "string"}})
}
fn audit_props() -> Value {
json!({
"tenant_id": {"type": "string"}, "user_id": {"type": "string"},
"event": {"type": "string", "description": "e.g. USER_LOGIN, TOKEN_ISSUED"},
"since": {"type": "string", "description": "Unix seconds"},
"limit": {"type": "integer", "minimum": 1, "maximum": 1000}
})
}
fn client_create_props() -> Value {
json!({
"name": {"type": "string"}, "tenant_id": {"type": "string"},
"redirect_uris": {"type": "array", "items": {"type": "string"}},
"grant_types": {"type": "array", "items": {"type": "string"}},
"scopes": {"type": "array", "items": {"type": "string"}},
"first_party": {"type": "boolean"},
"token_endpoint_auth_method": {"type": "string", "enum": ["client_secret_basic", "client_secret_post", "none"]},
"platform_wide": {"type": "boolean"},
"audience": {"type": "string"}
})
}
fn user_create_props() -> Value {
json!({"email": {"type": "string"}, "password": {"type": "string"}, "tenant_id": {"type": "string"}})
}
fn user_update_props() -> Value {
json!({
"id": {"type": "string"}, "email": {"type": "string"},
"email_verified": {"type": "boolean"}, "mfa_enabled": {"type": "boolean"},
"force_password_change": {"type": "boolean"}
})
}
fn password_props() -> Value {
json!({"id": {"type": "string"}, "password": {"type": "string"}})
}
fn group_create_props() -> Value {
json!({"name": {"type": "string"}, "tenant_id": {"type": "string"}, "description": {"type": "string"}})
}
fn member_props() -> Value {
json!({"id": {"type": "string", "description": "Group id"}, "user_id": {"type": "string"}})
}
fn tenant_create_props() -> Value {
json!({"name": {"type": "string"}, "slug": {"type": "string"}, "domain": {"type": "string"}})
}
fn tenant_update_props() -> Value {
json!({"id": {"type": "string"}, "name": {"type": "string"}, "slug": {"type": "string"}, "domain": {"type": "string"}})
}
const fn tool(
name: &'static str,
description: &'static str,
required: &'static [&'static str],
properties: fn() -> Value,
) -> Tool {
Tool {
name,
description,
required,
properties,
destructive: false,
read_only: false,
}
}
const fn reader(
name: &'static str,
description: &'static str,
required: &'static [&'static str],
properties: fn() -> Value,
) -> Tool {
Tool {
read_only: true,
..tool(name, description, required, properties)
}
}
const fn destroyer(
name: &'static str,
description: &'static str,
required: &'static [&'static str],
properties: fn() -> Value,
) -> Tool {
Tool {
destructive: true,
..tool(name, description, required, properties)
}
}
const TOOLS: &[Tool] = &[
reader("idp_health", "IdP health and version.", &[], no_props),
reader(
"audit_query",
"Search the audit log, newest first.",
&[],
audit_props,
),
reader(
"clients_list",
"List OAuth clients (no secrets).",
&[],
tenant_filter,
),
tool(
"client_create",
"Register an OAuth client. Confidential clients return a one-time secret; token_endpoint_auth_method=none creates a public PKCE client.",
&["name", "tenant_id"],
client_create_props,
),
destroyer(
"client_rotate_secret",
"Replace a confidential client's secret; the old one stops working.",
&["id"],
id_prop,
),
destroyer(
"client_delete",
"Delete an OAuth client and its tokens.",
&["id"],
id_prop,
),
reader("users_list", "List users.", &[], tenant_filter),
reader("user_get", "Get one user.", &["id"], id_prop),
tool(
"user_create",
"Create a user.",
&["email", "password", "tenant_id"],
user_create_props,
),
tool(
"user_update",
"Update a user's email or flags.",
&["id"],
user_update_props,
),
destroyer(
"user_reset_password",
"Set a new password for a user.",
&["id", "password"],
password_props,
),
tool("user_lock", "Lock a user out.", &["id"], id_prop),
tool(
"user_unlock",
"Unlock a user and clear failed attempts.",
&["id"],
id_prop,
),
destroyer("user_delete", "Delete a user.", &["id"], id_prop),
reader("groups_list", "List groups.", &[], tenant_filter),
tool(
"group_create",
"Create a group.",
&["name", "tenant_id"],
group_create_props,
),
tool(
"group_add_member",
"Add a user to a group.",
&["id", "user_id"],
member_props,
),
tool(
"group_remove_member",
"Remove a user from a group.",
&["id", "user_id"],
member_props,
),
destroyer("group_delete", "Delete a group.", &["id"], id_prop),
reader("tenants_list", "List organizations.", &[], no_props),
tool(
"tenant_create",
"Create an organization.",
&["name", "slug"],
tenant_create_props,
),
tool(
"tenant_update",
"Rename an organization or change its slug/domain.",
&["id"],
tenant_update_props,
),
destroyer(
"tenant_delete",
"Delete an organization and everything in it.",
&["id"],
id_prop,
),
];
fn tool_descriptors() -> Value {
Value::Array(
TOOLS
.iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"inputSchema": {
"type": "object",
"properties": (t.properties)(),
"required": t.required,
},
"annotations": {
"readOnlyHint": t.read_only,
"destructiveHint": t.destructive,
},
})
})
.collect(),
)
}
/// An admin-API call: method, path under `/admin` (or `/health`), JSON body.
#[derive(Debug, PartialEq)]
struct Call {
method: Method,
path: String,
body: Option<Value>,
}
fn arg<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
args.get(key)
.and_then(Value::as_str)
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| format!("missing required argument: {key}"))
}
fn segment(value: &str) -> String {
form_urlencoded::byte_serialize(value.as_bytes()).collect()
}
fn query(args: &Value, keys: &[&str]) -> String {
let mut out = form_urlencoded::Serializer::new(String::new());
for key in keys {
match args.get(*key) {
Some(Value::String(v)) if !v.is_empty() => {
out.append_pair(key, v);
}
Some(Value::Number(n)) => {
out.append_pair(key, &n.to_string());
}
_ => {}
}
}
let q = out.finish();
if q.is_empty() { q } else { format!("?{q}") }
}
/// The body of a call: the arguments minus the path id.
fn body_without_id(args: &Value) -> Value {
let mut body = args.clone();
if let Some(map) = body.as_object_mut() {
map.remove("id");
}
body
}
fn call_for(name: &str, args: &Value) -> Result<Call, String> {
let tool = TOOLS
.iter()
.find(|t| t.name == name)
.ok_or_else(|| format!("unknown tool: {name}"))?;
for key in tool.required {
arg(args, key)?;
}
let id = || arg(args, "id").map(segment);
let call = |method, path: String, body| Call { method, path, body };
Ok(match name {
"idp_health" => call(Method::GET, "/health".into(), None),
"audit_query" => call(
Method::GET,
format!(
"/admin/audit{}",
query(args, &["tenant_id", "user_id", "event", "since", "limit"])
),
None,
),
"clients_list" => call(
Method::GET,
format!("/admin/clients{}", query(args, &["tenant_id"])),
None,
),
"client_create" => call(Method::POST, "/admin/clients".into(), Some(args.clone())),
"client_rotate_secret" => call(
Method::POST,
format!("/admin/clients/{}/rotate-secret", id()?),
None,
),
"client_delete" => call(Method::DELETE, format!("/admin/clients/{}", id()?), None),
"users_list" => call(
Method::GET,
format!("/admin/users{}", query(args, &["tenant_id"])),
None,
),
"user_get" => call(Method::GET, format!("/admin/users/{}", id()?), None),
"user_create" => call(Method::POST, "/admin/users".into(), Some(args.clone())),
"user_update" => call(
Method::PATCH,
format!("/admin/users/{}", id()?),
Some(body_without_id(args)),
),
"user_reset_password" => call(
Method::POST,
format!("/admin/users/{}/reset-password", id()?),
Some(body_without_id(args)),
),
"user_lock" => call(Method::POST, format!("/admin/users/{}/lock", id()?), None),
"user_unlock" => call(Method::POST, format!("/admin/users/{}/unlock", id()?), None),
"user_delete" => call(Method::DELETE, format!("/admin/users/{}", id()?), None),
"groups_list" => call(
Method::GET,
format!("/admin/groups{}", query(args, &["tenant_id"])),
None,
),
"group_create" => call(Method::POST, "/admin/groups".into(), Some(args.clone())),
"group_add_member" => call(
Method::POST,
format!("/admin/groups/{}/members", id()?),
Some(json!({"user_id": arg(args, "user_id")?})),
),
"group_remove_member" => call(
Method::DELETE,
format!(
"/admin/groups/{}/members/{}",
id()?,
segment(arg(args, "user_id")?)
),
None,
),
"group_delete" => call(Method::DELETE, format!("/admin/groups/{}", id()?), None),
"tenants_list" => call(Method::GET, "/admin/tenants".into(), None),
"tenant_create" => call(Method::POST, "/admin/tenants".into(), Some(args.clone())),
"tenant_update" => call(
Method::PATCH,
format!("/admin/tenants/{}", id()?),
Some(body_without_id(args)),
),
"tenant_delete" => call(Method::DELETE, format!("/admin/tenants/{}", id()?), None),
_ => return Err(format!("unknown tool: {name}")),
})
}
async fn run(app: Router, call: Call) -> Value {
let mut request = Request::builder().method(call.method).uri(&call.path);
let body = match call.body {
Some(body) => {
request = request.header(header::CONTENT_TYPE, "application/json");
Body::from(body.to_string())
}
None => Body::empty(),
};
let response = match app
.oneshot(request.body(body).expect("valid request"))
.await
{
Ok(response) => response,
Err(never) => match never {},
};
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), 1 << 20)
.await
.unwrap_or_default();
let text = String::from_utf8_lossy(&bytes).into_owned();
json!({
"content": [{"type": "text", "text": if text.is_empty() { status.to_string() } else { text }}],
"isError": !status.is_success(),
})
}
fn rpc_result(id: Value, result: Value) -> Response {
Json(json!({"jsonrpc": "2.0", "id": id, "result": result})).into_response()
}
fn rpc_error(id: Value, code: i64, message: &str) -> Response {
Json(json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}))
.into_response()
}
async fn handle(admin: Router, request: Value) -> Response {
let id = request.get("id").cloned().unwrap_or(Value::Null);
let method = request.get("method").and_then(Value::as_str).unwrap_or("");
// Notifications carry no id and get no body.
if request.get("id").is_none() {
return StatusCode::ACCEPTED.into_response();
}
match method {
"initialize" => rpc_result(
id,
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {"listChanged": false}},
"serverInfo": {"name": "akurai-idp", "version": env!("CARGO_PKG_VERSION")},
"instructions": "Manage AkurAI ID: OAuth clients, users, groups, organizations and the audit log. Destructive tools are marked; confirm before using them.",
}),
),
"ping" => rpc_result(id, json!({})),
"tools/list" => rpc_result(id, json!({"tools": tool_descriptors()})),
"tools/call" => {
let params = request.get("params").cloned().unwrap_or(Value::Null);
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
match call_for(name, &args) {
Ok(call) => {
let app = Router::new()
.nest("/admin", admin)
.route("/health", axum::routing::get(crate::health));
rpc_result(id, run(app, call).await)
}
Err(message) => rpc_result(
id,
json!({"content": [{"type": "text", "text": message}], "isError": true}),
),
}
}
_ => rpc_error(id, -32601, "method not found"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_tool_maps_to_an_admin_call() {
let args = json!({
"id": "a b", "user_id": "u/1", "name": "n", "tenant_id": "t",
"email": "e@x.is", "password": "longpassword", "slug": "s"
});
for tool in TOOLS {
let call = call_for(tool.name, &args).expect(tool.name);
assert!(
call.path.starts_with("/admin/") || call.path == "/health",
"{}",
tool.name
);
}
}
#[test]
fn path_ids_are_encoded_and_removed_from_bodies() {
let call = call_for("user_update", &json!({"id": "a/b", "email_verified": true})).unwrap();
assert_eq!(call.method, Method::PATCH);
assert_eq!(call.path, "/admin/users/a%2Fb");
assert_eq!(call.body, Some(json!({"email_verified": true})));
let call = call_for("group_remove_member", &json!({"id": "g", "user_id": "u 1"})).unwrap();
assert_eq!(call.path, "/admin/groups/g/members/u+1");
}
#[test]
fn filters_become_query_strings() {
let call = call_for("audit_query", &json!({"event": "USER_LOGIN", "limit": 5})).unwrap();
assert_eq!(call.path, "/admin/audit?event=USER_LOGIN&limit=5");
assert_eq!(
call_for("users_list", &json!({})).unwrap().path,
"/admin/users"
);
}
#[test]
fn missing_arguments_and_unknown_tools_are_rejected() {
assert_eq!(
call_for("user_delete", &json!({})).unwrap_err(),
"missing required argument: id"
);
assert!(call_for("drop_database", &json!({})).is_err());
}
#[test]
fn destructive_tools_are_annotated() {
let tools = tool_descriptors();
let find = |name: &str| {
tools
.as_array()
.unwrap()
.iter()
.find(|t| t["name"] == name)
.cloned()
.unwrap()
};
assert_eq!(
find("tenant_delete")["annotations"]["destructiveHint"],
true
);
assert_eq!(find("users_list")["annotations"]["readOnlyHint"], true);
assert_eq!(
find("user_create")["inputSchema"]["required"],
json!(["email", "password", "tenant_id"])
);
}
#[tokio::test]
async fn speaks_json_rpc() {
let reply = handle(
Router::new(),
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"}),
)
.await;
let bytes = axum::body::to_bytes(reply.into_body(), 1 << 16)
.await
.unwrap();
let reply: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(reply["result"]["serverInfo"]["name"], "akurai-idp");
let note = handle(
Router::new(),
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
)
.await;
assert_eq!(note.status(), StatusCode::ACCEPTED);
let reply = handle(
Router::new(),
json!({"jsonrpc": "2.0", "id": 2, "method": "nope"}),
)
.await;
let bytes = axum::body::to_bytes(reply.into_body(), 1 << 16)
.await
.unwrap();
let reply: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(reply["error"]["code"], -32601);
}
}