use fraiseql_core::{
runtime::Executor,
schema::{CompiledSchema, FieldType, McpConfig},
security::SecurityContext,
};
use rmcp::model::{CallToolResult, ContentBlock};
use crate::config::error_sanitization::ErrorSanitizer;
pub struct McpCallContext<'a> {
pub schema: &'a CompiledSchema,
pub executor: &'a Executor,
pub config: &'a McpConfig,
pub security_context: Option<&'a SecurityContext>,
pub error_sanitizer: &'a ErrorSanitizer,
}
pub async fn call_tool(
tool_name: &str,
arguments: Option<&serde_json::Map<String, serde_json::Value>>,
ctx: &McpCallContext<'_>,
) -> CallToolResult {
let operation = match build_operation(tool_name, arguments, ctx.schema, ctx.config) {
Ok(op) => op,
Err(e) => return error_result(&e),
};
let variables = serde_json::Value::Object(operation.variables);
let exec_result = if let Some(security) = ctx.security_context {
ctx.executor
.execute_with_security(&operation.document, Some(&variables), security)
.await
} else {
if ctx.config.require_auth || ctx.schema.has_rls_configured() {
return error_result(
"Authentication required: this MCP server enforces row-level security \
or requires authentication, but the request carried no validated \
security context. Provide a Bearer token over the HTTP transport, or \
disable require_auth and RLS for unauthenticated use.",
);
}
ctx.executor.execute(&operation.document, Some(&variables)).await
};
match exec_result {
Ok(result) => {
let result_text = result.to_string();
CallToolResult::success(vec![ContentBlock::text(result_text)])
},
Err(e) => error_result(&super::handler::sanitize(ctx.error_sanitizer, &e)),
}
}
pub(crate) struct McpOperation {
pub(crate) document: String,
pub(crate) variables: serde_json::Map<String, serde_json::Value>,
}
pub(crate) fn build_operation(
tool_name: &str,
arguments: Option<&serde_json::Map<String, serde_json::Value>>,
schema: &CompiledSchema,
config: &McpConfig,
) -> Result<McpOperation, String> {
let operation = super::tools::resolve_tool(tool_name, schema, config)
.ok_or_else(|| format!("Unknown tool: {tool_name}"))?;
let declared = operation.arguments(schema);
let supplied = arguments.filter(|args| !args.is_empty());
let mut variable_defs = Vec::new();
let mut call_args = Vec::new();
let mut variables = serde_json::Map::new();
if let Some(args) = supplied {
for (name, value) in args {
let Some(arg_def) = declared.iter().find(|a| &a.name == name) else {
return Err(format!(
"Unknown argument '{name}' for tool '{tool_name}'. Accepted arguments: {}.",
accepted_argument_list(&declared)
));
};
if !is_valid_graphql_name(name) {
return Err(format!(
"Invalid argument name: '{name}'. Only [_A-Za-z][_0-9A-Za-z]* is allowed."
));
}
variable_defs.push(format!("${name}: {}", graphql_type_name(&arg_def.arg_type)));
call_args.push(format!("{name}: ${name}"));
variables.insert(name.clone(), value.clone());
}
}
let var_defs_str = if variable_defs.is_empty() {
String::new()
} else {
format!("({})", variable_defs.join(", "))
};
let args_str = if call_args.is_empty() {
String::new()
} else {
format!("({})", call_args.join(", "))
};
let fields = scalar_fields_for_type(operation.return_type(), schema);
let fields_str = if fields.is_empty() {
String::new()
} else {
format!(" {{ {} }}", fields.join(" "))
};
let op_type = if operation.is_mutation() {
"mutation"
} else {
"query"
};
Ok(McpOperation {
document: format!("{op_type} {var_defs_str} {{ {tool_name}{args_str}{fields_str} }}"),
variables,
})
}
fn accepted_argument_list(declared: &[fraiseql_core::schema::ArgumentDefinition]) -> String {
if declared.is_empty() {
"none".to_string()
} else {
declared.iter().map(|a| a.name.as_str()).collect::<Vec<_>>().join(", ")
}
}
fn graphql_type_name(field_type: &FieldType) -> String {
match field_type {
FieldType::Int => "Int".to_string(),
FieldType::Float => "Float".to_string(),
FieldType::Boolean => "Boolean".to_string(),
FieldType::Id => "ID".to_string(),
FieldType::DateTime => "DateTime".to_string(),
FieldType::Date => "Date".to_string(),
FieldType::Time => "Time".to_string(),
FieldType::Uuid => "UUID".to_string(),
FieldType::Decimal => "Decimal".to_string(),
FieldType::Json | FieldType::Vector => "JSON".to_string(),
FieldType::Scalar(name)
| FieldType::Object(name)
| FieldType::Enum(name)
| FieldType::Input(name)
| FieldType::Interface(name)
| FieldType::Union(name) => name.clone(),
FieldType::List(inner) => format!("[{}]", graphql_type_name(inner)),
_ => "String".to_string(),
}
}
pub(crate) fn is_valid_graphql_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
},
_ => false,
}
}
#[must_use]
pub fn scalar_fields_for_type(type_name: &str, schema: &CompiledSchema) -> Vec<String> {
let Some(type_def) = schema.types.iter().find(|t| t.name == type_name) else {
return vec![];
};
type_def
.fields
.iter()
.filter(|f| is_scalar_field_type(&f.field_type))
.map(|f| f.name.to_string())
.collect()
}
pub(crate) fn is_scalar_field_type(field_type: &FieldType) -> bool {
match field_type {
FieldType::String
| FieldType::Int
| FieldType::Float
| FieldType::Boolean
| FieldType::Id
| FieldType::DateTime
| FieldType::Date
| FieldType::Time
| FieldType::Json
| FieldType::Uuid
| FieldType::Decimal
| FieldType::Vector
| FieldType::BitVector
| FieldType::Scalar(_)
| FieldType::Enum(_) => true,
FieldType::List(inner) => is_scalar_field_type(inner),
FieldType::Object(_)
| FieldType::Input(_)
| FieldType::Interface(_)
| FieldType::Union(_)
| _ => false,
}
}
pub(super) fn error_result(message: &str) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(message.to_string())])
}