use std::fmt::Write as _;
use fraiseql_core::schema::{CompiledSchema, McpConfig};
use rmcp::model::{
Annotated, Prompt, PromptArgument, PromptMessage, PromptMessageRole, RawResource,
RawResourceTemplate, Resource, ResourceTemplate,
};
use super::tools::{ExposedOperation, exposed_operations};
const QUERY_URI_PREFIX: &str = "fraiseql://query/";
const RESOURCE_MIME: &str = "application/json";
#[must_use]
pub fn schema_to_resources(schema: &CompiledSchema, config: &McpConfig) -> Vec<Resource> {
exposed_operations(schema, config)
.into_iter()
.filter_map(|(display, op)| match op {
ExposedOperation::Query(q) => Some((display, q)),
ExposedOperation::Mutation(_) => None,
})
.map(|(display, q)| {
Annotated::new(
RawResource {
uri: format!("{QUERY_URI_PREFIX}{display}"),
name: display.clone(),
title: Some(display.clone()),
description: Some(q.description.clone().unwrap_or_else(|| {
format!("Rows of {} returned by the '{display}' query", q.return_type)
})),
mime_type: Some(RESOURCE_MIME.to_string()),
size: None,
icons: None,
meta: None,
},
None,
)
})
.collect()
}
#[must_use]
pub fn schema_to_resource_templates(
schema: &CompiledSchema,
config: &McpConfig,
) -> Vec<ResourceTemplate> {
exposed_operations(schema, config)
.into_iter()
.filter_map(|(display, op)| match op {
ExposedOperation::Query(q) => Some((display, q)),
ExposedOperation::Mutation(_) => None,
})
.filter(|(_, q)| returns_a_vector_type(schema, &q.return_type))
.map(|(display, q)| {
Annotated::new(
RawResourceTemplate {
uri_template: format!("{QUERY_URI_PREFIX}{display}{{?nearest,limit}}"),
name: format!("{display} similarity-search"),
title: Some(format!("{display} (similarity search)")),
description: Some(format!(
"Nearest-neighbour search over {}'s vector field. Pass `nearest` (the \
query embedding) and an optional `limit` as tool arguments; the same \
operation is callable as the '{display}' tool.",
q.return_type
)),
mime_type: Some(RESOURCE_MIME.to_string()),
icons: None,
},
None,
)
})
.collect()
}
fn returns_a_vector_type(schema: &CompiledSchema, type_name: &str) -> bool {
schema.types.iter().filter(|t| t.name == type_name).any(|t| {
t.fields.iter().any(|f| {
matches!(
f.field_type,
fraiseql_core::schema::FieldType::Vector
| fraiseql_core::schema::FieldType::HalfVector
| fraiseql_core::schema::FieldType::SparseVector
| fraiseql_core::schema::FieldType::BitVector
)
})
})
}
#[must_use]
pub fn query_name_from_uri(uri: &str) -> Option<&str> {
let name = uri.strip_prefix(QUERY_URI_PREFIX)?;
if name.is_empty() || name.contains('/') || name.contains('?') || name.contains('#') {
return None;
}
Some(name)
}
#[must_use]
pub fn schema_to_prompts(schema: &CompiledSchema, config: &McpConfig) -> Vec<Prompt> {
exposed_operations(schema, config)
.into_iter()
.map(|(display, op)| {
let arguments: Vec<PromptArgument> = op
.arguments(schema)
.into_iter()
.map(|arg| {
let mut a = PromptArgument::new(arg.name.clone()).with_required(!arg.nullable);
if let Some(ref d) = arg.description {
a = a.with_description(d.clone());
}
a
})
.collect();
Prompt::new(
display.clone(),
Some(prompt_description(&display, op)),
if arguments.is_empty() {
None
} else {
Some(arguments)
},
)
.with_title(display)
})
.collect()
}
fn prompt_description(display: &str, op: ExposedOperation<'_>) -> String {
let authored = match op {
ExposedOperation::Query(q) => q.description.clone(),
ExposedOperation::Mutation(m) => m.description.clone(),
};
authored.unwrap_or_else(|| {
if op.is_mutation() {
format!("Perform the '{display}' operation on {}", op.return_type())
} else {
format!("Read {} using the '{display}' query", op.return_type())
}
})
}
#[must_use]
pub fn render_prompt(
name: &str,
arguments: Option<&serde_json::Map<String, serde_json::Value>>,
schema: &CompiledSchema,
config: &McpConfig,
) -> Option<(String, Vec<PromptMessage>)> {
let op = super::tools::resolve_tool(name, schema, config)?;
let description = prompt_description(name, op);
let mut text = format!("{description}\n\nCall the '{name}' tool");
if let Some(args) = arguments.filter(|a| !a.is_empty()) {
text.push_str(" with:\n");
let mut keys: Vec<&String> = args.keys().collect();
keys.sort();
for key in keys {
let _ = writeln!(text, " {key} = {}", args[key]);
}
} else {
text.push('.');
}
Some((description, vec![PromptMessage::new_text(PromptMessageRole::User, text)]))
}