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),
})
.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 = query.description.clone().unwrap_or_else(|| format!("Query: {display_name}"));
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) -> Tool {
let description = mutation
.description
.clone()
.unwrap_or_else(|| format!("Mutation: {display_name}"));
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
}
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(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" }),
}
}