AkurAI Build
Menu

Public repository / 228b5716ba0b7bcc87cce261ea6df891133a35bb

AkurAI-CRM / crates/crm-mcp/src/server.rs

Latest change 46f8d3437352b9043c8406756a07d2c52c892d19 - Gate MCP merge tool on IdP role; send app.js 401s through login by Ólafur Búi Ólafsson

use crate::oauth::{ResourceConfig, TokenVerifier, GROUP_DENIED};
use crate::tools::{Caller, ToolRegistry};
use akurai_http::{Method, Request, Response};
use akurai_storage::BTree;
use std::sync::{Arc, Mutex};

const CREDENTIAL_REQUIRED: &str = r#"{"jsonrpc":"2.0","error":{"code":-32001,"message":"authenticated MCP credential required"}}"#;
const INVALID_CREDENTIAL: &str =
    r#"{"jsonrpc":"2.0","error":{"code":-32001,"message":"invalid MCP credential"}}"#;
const UNAUTHORIZED_TENANT: &str = r#"{"jsonrpc":"2.0","error":{"code":-32003,"message":"tenant is not authorized for credential"}}"#;
const FORBIDDEN_GROUP: &str = r#"{"jsonrpc":"2.0","error":{"code":-32003,"message":"your account is not a member of a group allowed in AkurAI-CRM"}}"#;
const FORBIDDEN_ORIGIN: &str = r#"{"jsonrpc":"2.0","error":{"code":-32003,"message":"cross-origin MCP requests are not allowed"}}"#;

pub struct McpConfig {
    pub db_path: String,
}

/// A shared service credential bound to one tenant (the pre-OAuth contract).
#[derive(Clone)]
pub struct StaticCredential {
    pub token: String,
    pub tenant: String,
}

impl StaticCredential {
    /// `AKURAI_CRM_MCP_TOKEN` + `AKURAI_CRM_MCP_TENANT_ID` (legacy names
    /// `CRM_MCP_TOKEN` / `CRM_MCP_TENANT_ID` are still read). Both or neither.
    pub fn from_env() -> Result<Option<Self>, String> {
        let read = |name: &str, legacy: &str| {
            std::env::var(name)
                .or_else(|_| std::env::var(legacy))
                .unwrap_or_default()
        };
        let token = read("AKURAI_CRM_MCP_TOKEN", "CRM_MCP_TOKEN");
        let tenant = read("AKURAI_CRM_MCP_TENANT_ID", "CRM_MCP_TENANT_ID");
        match (token.is_empty(), tenant.is_empty()) {
            (true, true) => Ok(None),
            (false, false) => Ok(Some(Self { token, tenant })),
            _ => {
                Err("AKURAI_CRM_MCP_TOKEN and AKURAI_CRM_MCP_TENANT_ID must be set together".into())
            }
        }
    }
}

enum Denial {
    Missing,
    Invalid,
    Tenant,
    Group,
}

pub struct McpServer {
    tools: Arc<ToolRegistry>,
    static_credential: Option<StaticCredential>,
    verifier: Option<TokenVerifier>,
    resource: ResourceConfig,
}

impl McpServer {
    /// Production wiring: static credential from env plus IdP access tokens.
    pub fn new(config: McpConfig) -> Result<Self, String> {
        Self::with_auth(
            config,
            StaticCredential::from_env()?,
            Some(TokenVerifier::from_env()?),
        )
    }

    pub fn with_auth(
        config: McpConfig,
        static_credential: Option<StaticCredential>,
        verifier: Option<TokenVerifier>,
    ) -> Result<Self, String> {
        let btree = BTree::open(&config.db_path)
            .map_err(|e| format!("failed to open MCP database: {e}"))?;
        let tools = ToolRegistry::new(Arc::new(Mutex::new(btree)));
        let resource = verifier
            .as_ref()
            .map(|v| v.config().clone())
            .unwrap_or_else(ResourceConfig::from_env);
        Ok(Self {
            tools: Arc::new(tools),
            static_credential,
            verifier,
            resource,
        })
    }

    pub fn resource(&self) -> &ResourceConfig {
        &self.resource
    }

    /// Local callers must authenticate through `http_handler`; unauthenticated
    /// dispatch is intentionally refused.
    pub fn handle_request(&self, _body: &str) -> String {
        CREDENTIAL_REQUIRED.into()
    }

    /// Resolve who a bearer credential acts as. An explicit `X-Tenant-Id`
    /// header must match its tenant. The static credential is a trusted
    /// service and keeps admin tools; IdP tokens need an owner/admin role.
    fn authorize(&self, credential: &str, tenant_header: Option<&str>) -> Result<Caller, Denial> {
        if credential.is_empty() {
            return Err(Denial::Missing);
        }
        let caller = if let Some(stat) = self
            .static_credential
            .as_ref()
            .filter(|s| constant_time_eq(credential.as_bytes(), s.token.as_bytes()))
        {
            Caller {
                tenant: stat.tenant.clone(),
                admin: true,
            }
        } else {
            let verifier = self.verifier.as_ref().ok_or(Denial::Invalid)?;
            let grant = verifier.verify(credential).map_err(|error| match error {
                GROUP_DENIED => Denial::Group,
                _ => Denial::Invalid,
            })?;
            Caller {
                tenant: grant.tenant,
                admin: grant.role.is_admin(),
            }
        };
        match tenant_header {
            Some(requested) if requested != caller.tenant => Err(Denial::Tenant),
            _ => Ok(caller),
        }
    }

    /// Build the MCP endpoint handler for mounting into an existing HTTP server.
    pub fn http_handler(self: Arc<Self>) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
        Box::new(move |req| {
            if req.path != "/mcp" {
                return Response::new(404).with_text("Not found");
            }
            if req.method != Method::Post {
                return Response::new(405).with_text("Method not allowed");
            }
            if !self.resource.origin_allowed(req.header("Origin")) {
                return json(403, FORBIDDEN_ORIGIN);
            }
            let credential = req
                .header("Authorization")
                .and_then(|value| value.strip_prefix("Bearer "))
                .map(str::trim)
                .unwrap_or("");
            let tenant_header = req
                .header("X-Tenant-Id")
                .or_else(|| req.header("X-Tenant-ID"))
                .map(str::trim)
                .filter(|t| !t.is_empty());
            match self.authorize(credential, tenant_header) {
                Ok(caller) => json(
                    200,
                    &self
                        .tools
                        .handle_request_authenticated(req.body_str().as_ref(), &caller),
                ),
                Err(Denial::Missing) => json(401, CREDENTIAL_REQUIRED)
                    .with_header("WWW-Authenticate", &self.resource.challenge(false)),
                Err(Denial::Invalid) => json(401, INVALID_CREDENTIAL)
                    .with_header("WWW-Authenticate", &self.resource.challenge(true)),
                Err(Denial::Tenant) => json(403, UNAUTHORIZED_TENANT),
                Err(Denial::Group) => json(403, FORBIDDEN_GROUP),
            }
        })
    }

    /// `GET /.well-known/oauth-protected-resource[/mcp]` (RFC 9728).
    pub fn metadata_handler(self: Arc<Self>) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
        Box::new(move |_req| json(200, &self.resource.metadata_json()))
    }
}

fn json(status: u16, body: &str) -> Response {
    Response::new(status).with_body("application/json", body.as_bytes().to_vec())
}

fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
    let mut difference = left.len() ^ right.len();
    for index in 0..left.len().max(right.len()) {
        difference |= usize::from(
            left.get(index).copied().unwrap_or(0) ^ right.get(index).copied().unwrap_or(0),
        );
    }
    difference == 0
}