use std::future::Future;
use std::pin::Pin;
use serde_json::{json, Value};
use crate::sql::Pool;
use super::auth::McpAgent;
use super::types::{codes, JsonRpcError};
#[doc(hidden)]
pub type JsonValue = Value;
pub struct McpContext {
pub pool: Pool,
pub agent: McpAgent,
pub progress: super::progress::ProgressReporter,
pub cancel: super::progress::CancelToken,
}
#[derive(Debug)]
pub struct McpError {
pub code: i64,
pub message: String,
}
impl McpError {
#[must_use]
pub fn new(code: i64, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
#[must_use]
pub fn invalid_params(message: impl Into<String>) -> Self {
Self::new(codes::INVALID_PARAMS, message)
}
#[must_use]
pub fn internal(message: impl Into<String>) -> Self {
Self::new(codes::INTERNAL_ERROR, message)
}
fn into_jsonrpc(self) -> JsonRpcError {
JsonRpcError::new(self.code, self.message)
}
}
impl std::fmt::Display for McpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for McpError {}
impl From<crate::sql::ExecError> for McpError {
fn from(e: crate::sql::ExecError) -> Self {
Self::internal(e.to_string())
}
}
pub type McpToolFuture = Pin<Box<dyn Future<Output = Result<Value, McpError>> + Send + 'static>>;
pub type McpToolHandler = fn(McpContext, Value) -> McpToolFuture;
pub struct McpTool {
pub name: &'static str,
pub description: &'static str,
pub input_schema: fn() -> Value,
pub handler: McpToolHandler,
}
inventory::collect!(McpTool);
#[doc(hidden)]
pub fn __schema_of<T: crate::openapi::OpenApiSchema>() -> Value {
serde_json::to_value(T::openapi_schema()).unwrap_or_else(|_| json!({ "type": "object" }))
}
#[doc(hidden)]
pub fn __deserialize_args<T: serde::de::DeserializeOwned>(raw: Value) -> Result<T, McpError> {
serde_json::from_value(raw)
.map_err(|e| McpError::invalid_params(format!("invalid arguments: {e}")))
}
#[must_use]
pub(crate) fn find_tool(name: &str) -> Option<&'static McpTool> {
inventory::iter::<McpTool>
.into_iter()
.find(|t| t.name == name)
}
#[must_use]
pub fn list_tools(agent: &McpAgent) -> Value {
let tools: Vec<Value> = inventory::iter::<McpTool>
.into_iter()
.filter(|t| agent.tools.iter().any(|n| n == t.name))
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"inputSchema": (t.input_schema)(),
})
})
.collect();
json!({ "tools": tools })
}
pub async fn call_tool(ctx: McpContext, params: Value) -> Result<Value, JsonRpcError> {
call_tool_with(ctx, params, None).await
}
pub(crate) async fn call_tool_with(
mut ctx: McpContext,
params: Value,
request_id: Option<&str>,
) -> Result<Value, JsonRpcError> {
let name = params
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| JsonRpcError::invalid_params("tools/call requires a string `name`"))?
.to_owned();
let args = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
let tool = find_tool(&name)
.ok_or_else(|| JsonRpcError::new(codes::TOOL_NOT_FOUND, format!("unknown tool: {name}")))?;
if !ctx.agent.tools.iter().any(|n| n == &name) {
return Err(JsonRpcError::new(
codes::TOOL_FORBIDDEN,
format!("tool `{name}` is not authorized for this agent"),
));
}
let pool = ctx.pool.clone();
let tenant = ctx.agent.tenant.clone();
let agent_id = ctx.agent.agent_id;
ctx.progress = super::progress::ProgressReporter::for_agent(
super::progress::progress_token(¶ms),
tenant.clone(),
agent_id,
);
let mut _cancel_guard = None;
if let Some(id) = request_id {
let guard = super::progress::CancelGuard::register(&tenant, agent_id, id);
ctx.cancel = guard.token();
_cancel_guard = Some(guard);
}
let outcome = catch_unwind((tool.handler)(ctx, args.clone())).await;
audit_tool_call(&pool, agent_id, &name, &args).await;
match outcome {
Ok(Ok(value)) => Ok(call_tool_result(value)),
Ok(Err(e)) if is_protocol_error(e.code) => Err(e.into_jsonrpc()),
Ok(Err(e)) => Ok(call_tool_error_result(&e.message)),
Err(_panic) => {
tracing::error!(tool = %name, agent_id, "mcp tool handler panicked");
Err(JsonRpcError::new(
codes::INTERNAL_ERROR,
"tool handler panicked",
))
}
}
}
async fn catch_unwind<F: Future>(fut: F) -> std::thread::Result<F::Output> {
use std::task::Poll;
let mut fut = Box::pin(fut);
std::future::poll_fn(move |cx| {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fut.as_mut().poll(cx))) {
Ok(Poll::Pending) => Poll::Pending,
Ok(Poll::Ready(v)) => Poll::Ready(Ok(v)),
Err(panic) => Poll::Ready(Err(panic)),
}
})
.await
}
fn call_tool_result(value: Value) -> Value {
let text = match &value {
Value::String(s) => s.clone(),
other => serde_json::to_string(other).unwrap_or_default(),
};
json!({
"content": [{ "type": "text", "text": text }],
"structuredContent": value,
"isError": false,
})
}
fn call_tool_error_result(message: &str) -> Value {
json!({
"content": [{ "type": "text", "text": message }],
"isError": true,
})
}
fn is_protocol_error(code: i64) -> bool {
matches!(
code,
codes::PARSE_ERROR
| codes::INVALID_REQUEST
| codes::METHOD_NOT_FOUND
| codes::INVALID_PARAMS
| codes::TOOL_NOT_FOUND
| codes::TOOL_FORBIDDEN
)
}
const SENSITIVE_ARG_KEYS: &[&str] = &[
"password",
"passwd",
"token",
"secret",
"api_key",
"apikey",
"access_token",
"refresh_token",
"signature",
"auth",
"authorization",
"client_secret",
"private_key",
];
fn redact_json(value: &Value) -> Value {
fn is_sensitive(key: &str) -> bool {
let k = key.to_ascii_lowercase();
SENSITIVE_ARG_KEYS.iter().any(|s| *s == k)
}
match value {
Value::Object(map) => Value::Object(
map.iter()
.map(|(k, v)| {
if is_sensitive(k) {
(k.clone(), Value::String("[redacted]".into()))
} else {
(k.clone(), redact_json(v))
}
})
.collect(),
),
Value::Array(items) => Value::Array(items.iter().map(redact_json).collect()),
other => other.clone(),
}
}
async fn audit_tool_call(pool: &Pool, agent_id: i64, tool: &str, args: &Value) {
let entry = crate::audit::PendingEntry {
entity_table: "rustango_agents",
entity_pk: agent_id.to_string(),
operation: crate::audit::AuditOp::Action,
source: crate::audit::AuditSource::Custom(format!("mcp:agent:{agent_id}")),
changes: json!({ "tool": tool, "arguments": redact_json(args) }),
};
if let Err(e) = crate::audit::emit_one_pool(pool, &entry).await {
tracing::debug!(error = %e, tool, "mcp tools/call audit not recorded");
}
}
#[macro_export]
macro_rules! register_mcp_tool {
($name:expr, $description:expr, $input:ty, $handler:expr $(,)?) => {
$crate::inventory::submit! {
$crate::mcp::McpTool {
name: $name,
description: $description,
input_schema: {
fn __rustango_mcp_input_schema() -> $crate::mcp::JsonValue {
$crate::mcp::__schema_of::<$input>()
}
__rustango_mcp_input_schema
},
handler: {
fn __rustango_mcp_tool_handler(
ctx: $crate::mcp::McpContext,
raw: $crate::mcp::JsonValue,
) -> $crate::mcp::McpToolFuture {
::std::boxed::Box::pin(async move {
let input = $crate::mcp::__deserialize_args::<$input>(raw)?;
($handler)(ctx, input).await
})
}
__rustango_mcp_tool_handler
},
}
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redact_json_scrubs_sensitive_keys_recursively() {
let args = json!({
"username": "alice",
"password": "hunter2",
"API_KEY": "sk-123",
"token_count": 42, "nested": { "client_secret": "shh", "ok": true },
"list": [ { "secret": "s" }, { "keep": "v" } ],
});
let red = redact_json(&args);
assert_eq!(red["username"], "alice");
assert_eq!(red["password"], "[redacted]");
assert_eq!(red["API_KEY"], "[redacted]"); assert_eq!(red["token_count"], 42); assert_eq!(red["nested"]["client_secret"], "[redacted]");
assert_eq!(red["nested"]["ok"], true);
assert_eq!(red["list"][0]["secret"], "[redacted]");
assert_eq!(red["list"][1]["keep"], "v");
}
}