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
}