use std::{borrow::Cow, sync::Arc};
use fraiseql_core::schema::{
ArgumentDefinition, CompiledSchema, FieldType, MutationDefinition, QueryDefinition,
};
use rmcp::model::{JsonObject, Tool, ToolAnnotations};
use super::McpConfig;
#[derive(Debug, Clone, Copy)]
pub enum ExposedOperation<'a> {
Query(&'a QueryDefinition),
Mutation(&'a MutationDefinition),
}
impl ExposedOperation<'_> {
#[must_use]
pub const fn return_type(&self) -> &str {
match self {
Self::Query(q) => q.return_type.as_str(),
Self::Mutation(m) => m.return_type.as_str(),
}
}
#[must_use]
pub const fn is_mutation(&self) -> bool {
matches!(self, Self::Mutation(_))
}
#[must_use]
pub fn arguments(&self, schema: &CompiledSchema) -> Vec<ArgumentDefinition> {
match self {
Self::Query(q) => q.graphql_arguments(schema),
Self::Mutation(m) => m.arguments.clone(),
}
}
}
#[must_use]
pub fn exposed_operations<'a>(
schema: &'a CompiledSchema,
config: &McpConfig,
) -> Vec<(String, ExposedOperation<'a>)> {
let mut exposed = Vec::new();
for query in &schema.queries {
let display = schema.display_name(&query.name);
if should_include(&display, config) {
exposed.push((display, ExposedOperation::Query(query)));
}
}
if config.read_only {
let contradicts: Vec<&String> = config
.include
.iter()
.filter(|name| schema.mutations.iter().any(|m| &schema.display_name(&m.name) == *name))
.collect();
if !contradicts.is_empty() {
tracing::warn!(
mutations = ?contradicts,
"[mcp] read_only = true overrides `include` listing these mutations — they are \
NOT exposed as tools (read_only wins, fail-closed)"
);
}
} else {
for mutation in &schema.mutations {
let display = schema.display_name(&mutation.name);
if should_include(&display, config) {
exposed.push((display, ExposedOperation::Mutation(mutation)));
}
}
}
exposed
}
#[must_use]
pub fn resolve_tool<'a>(
tool_name: &str,
schema: &'a CompiledSchema,
config: &McpConfig,
) -> Option<ExposedOperation<'a>> {
exposed_operations(schema, config)
.into_iter()
.find(|(name, _)| name == tool_name)
.map(|(_, op)| op)
}
#[must_use]
pub fn schema_to_tools(schema: &CompiledSchema, config: &McpConfig) -> Vec<Tool> {
exposed_operations(schema, config)
.into_iter()
.map(|(display, op)| match op {
ExposedOperation::Query(q) => query_to_tool(q, &display, schema),
ExposedOperation::Mutation(m) => mutation_to_tool(m, &display, schema),
})
.collect()
}
#[must_use]
pub fn should_include(name: &str, config: &McpConfig) -> bool {
if !config.include.is_empty() && !config.include.iter().any(|i| i == name) {
return false;
}
if config.exclude.iter().any(|e| e == name) {
return false;
}
true
}
fn query_to_tool(query: &QueryDefinition, display_name: &str, schema: &CompiledSchema) -> Tool {
let description = describe_result(
query.description.clone().unwrap_or_else(|| format!("Query: {display_name}")),
&query.return_type,
schema,
);
let mut tool = Tool::new(
Cow::Owned(display_name.to_string()),
Cow::Owned(description),
Arc::new(arguments_to_json_schema(&ExposedOperation::Query(query).arguments(schema))),
);
tool.annotations = Some(ToolAnnotations::new().read_only(true).open_world(false));
tool
}
fn mutation_to_tool(
mutation: &MutationDefinition,
display_name: &str,
schema: &CompiledSchema,
) -> Tool {
let description = describe_result(
mutation
.description
.clone()
.unwrap_or_else(|| format!("Mutation: {display_name}")),
&mutation.return_type,
schema,
);
let mut tool = Tool::new(
Cow::Owned(display_name.to_string()),
Cow::Owned(description),
Arc::new(arguments_to_json_schema(&mutation.arguments)),
);
tool.annotations = Some(
ToolAnnotations::new()
.read_only(false)
.destructive(true)
.idempotent(false)
.open_world(false),
);
tool
}
fn describe_result(description: String, return_type: &str, schema: &CompiledSchema) -> String {
let types: Vec<&str> = schema.unions.iter().find(|u| u.name == return_type).map_or_else(
|| {
let implementors: Vec<&str> = schema
.types
.iter()
.filter(|t| t.implements.iter().any(|i| i == return_type))
.map(|t| t.name.as_str())
.collect();
if implementors.is_empty() {
vec![return_type]
} else {
implementors
}
},
|u| u.member_types.iter().map(String::as_str).collect(),
);
let gated = schema
.types
.iter()
.filter(|t| types.contains(&t.name.as_str()))
.flat_map(|t| &t.fields)
.any(|f| f.requires_scope.is_some() || f.authorize);
if gated {
format!(
"{description} Some fields of the result are returned only to callers allowed to \
read them."
)
} else {
description
}
}
pub(crate) fn arguments_to_json_schema(arguments: &[ArgumentDefinition]) -> JsonObject {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for arg in arguments {
let schema = field_type_to_json_schema(&arg.arg_type);
let mut prop = serde_json::Map::new();
if let serde_json::Value::Object(map) = schema {
prop.extend(map);
}
if let Some(ref desc) = arg.description {
prop.insert("description".to_string(), serde_json::Value::String(desc.clone()));
}
properties.insert(arg.name.clone(), serde_json::Value::Object(prop));
if !arg.nullable && arg.default_value.is_none() {
required.push(serde_json::Value::String(arg.name.clone()));
}
}
let mut schema = serde_json::Map::new();
schema.insert("type".to_string(), serde_json::Value::String("object".to_string()));
schema.insert("properties".to_string(), serde_json::Value::Object(properties));
if !required.is_empty() {
schema.insert("required".to_string(), serde_json::Value::Array(required));
}
schema
}
pub(crate) fn field_type_to_json_schema(field_type: &FieldType) -> serde_json::Value {
match field_type {
FieldType::Int => serde_json::json!({ "type": "integer" }),
FieldType::Float => serde_json::json!({ "type": "number" }),
FieldType::Boolean => serde_json::json!({ "type": "boolean" }),
FieldType::Json => serde_json::json!({ "type": "object" }),
FieldType::Vector => serde_json::json!({ "type": "array", "items": { "type": "number" } }),
FieldType::List { item: inner, .. } => {
serde_json::json!({ "type": "array", "items": field_type_to_json_schema(inner) })
},
FieldType::String
| FieldType::Id
| FieldType::Uuid
| FieldType::Decimal
| FieldType::DateTime
| FieldType::Date
| FieldType::Time
| FieldType::Scalar(_)
| FieldType::Object(_)
| FieldType::Enum(_)
| FieldType::Input(_)
| FieldType::Interface(_)
| FieldType::Union(_)
| _ => serde_json::json!({ "type": "string" }),
}
}