AkurAI Build
Menu

Public repository / 1f023d15b3f586c38eb60898e38dc03c4e77bed2

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