use std::collections::BTreeMap; use chrono::{Datelike, TimeZone as _, Utc}; use serde_json::{Map, Value, json}; use crate::db::DbConnection; use crate::db::queries::{media as media_q, media_translation, post as post_q, post_translation}; use crate::engine::{EngineError, EngineResult}; use crate::model::{Post, ProposalKind}; use super::resources::{post_detail, post_summary}; use super::{active_project, create_proposal, optional_string, required_string, string_array}; const MAX_PAGE_SIZE: usize = 50; pub fn list() -> Vec { [ tool( "check_term", "Check Term", "Check whether a term is a category, a tag, or both, with post counts.", object_schema(json!({"term": string_schema("Term to check")}), &["term"]), true, ), tool( "search_posts", "Search Posts", "Full-text and filtered post search with pagination, backlinks, and outgoing links.", post_query_schema(false), true, ), tool( "count_posts", "Count Posts", "Count filtered posts grouped by year, month, tag, category, or status.", count_schema(), true, ), tool( "read_post_by_slug", "Read Post By Slug", "Read full post content and metadata, optionally in a translated language.", object_schema( json!({ "slug": string_schema("Post slug"), "language": string_schema("Optional translation language") }), &["slug"], ), true, ), tool( "get_post_translations", "Get Post Translations", "List every translation for a post.", id_schema("postId", "Post ID"), true, ), tool( "get_media_translations", "Get Media Translations", "List every translated metadata record for a media item.", id_schema("mediaId", "Media ID"), true, ), tool( "upsert_media_translation", "Propose Media Translation", "Propose translated media metadata for explicit desktop approval.", object_schema( json!({ "mediaId": string_schema("Media ID"), "language": string_schema("Language code"), "title": string_schema("Translated title"), "alt": string_schema("Translated alt text"), "caption": string_schema("Translated caption") }), &["mediaId", "language"], ), false, ), tool( "draft_post", "Draft Post", "Propose a post. No post is created until explicit desktop approval.", object_schema( json!({ "title": string_schema("Post title"), "content": string_schema("Markdown body"), "excerpt": string_schema("Excerpt"), "tags": string_array_schema("Tags"), "categories": string_array_schema("Categories"), "author": string_schema("Author"), "language": string_schema("Language") }), &["title", "content"], ), false, ), tool( "propose_script", "Propose Script", "Validate and propose a Lua script for explicit desktop approval.", object_schema( json!({ "title": string_schema("Script title"), "kind": {"type":"string","enum":["macro","utility","transform"]}, "content": string_schema("Lua source"), "entrypoint": string_schema("Entrypoint function") }), &["title", "kind", "content"], ), false, ), tool( "propose_template", "Propose Template", "Validate and propose a Liquid template for explicit desktop approval.", object_schema( json!({ "title": string_schema("Template title"), "kind": {"type":"string","enum":["post","list","not-found","partial"]}, "content": string_schema("Liquid source") }), &["title", "kind", "content"], ), false, ), tool( "propose_media_metadata", "Propose Media Metadata", "Propose media metadata changes for explicit desktop approval.", object_schema( json!({ "mediaId": string_schema("Media ID"), "title": nullable_string_schema("Title"), "alt": nullable_string_schema("Alt text"), "caption": nullable_string_schema("Caption"), "tags": string_array_schema("Tags") }), &["mediaId"], ), false, ), tool( "propose_post_metadata", "Propose Post Metadata", "Propose post metadata changes for explicit desktop approval.", object_schema( json!({ "postId": string_schema("Post ID"), "title": string_schema("Title"), "excerpt": nullable_string_schema("Excerpt"), "tags": string_array_schema("Tags"), "categories": string_array_schema("Categories") }), &["postId"], ), false, ), ] .into_iter() .collect() } pub fn call(conn: &DbConnection, name: &str, params: Value) -> EngineResult { if !params.is_object() { return Err(EngineError::Validation( "tool arguments must be an object".into(), )); } match name { "check_term" => check_term(conn, ¶ms), "search_posts" => search_posts(conn, ¶ms), "count_posts" => count_posts(conn, ¶ms), "read_post_by_slug" => read_post_by_slug(conn, ¶ms), "get_post_translations" => get_post_translations(conn, ¶ms), "get_media_translations" => get_media_translations(conn, ¶ms), "upsert_media_translation" => propose_media_translation(conn, ¶ms), "draft_post" => propose(conn, ProposalKind::DraftPost, None, ¶ms), "propose_script" => propose_script(conn, ¶ms), "propose_template" => propose_template(conn, ¶ms), "propose_media_metadata" => propose_media_metadata(conn, ¶ms), "propose_post_metadata" => propose_post_metadata(conn, ¶ms), _ => Err(EngineError::NotFound(format!("MCP tool {name}"))), } } fn tool(name: &str, title: &str, description: &str, schema: Value, read_only: bool) -> Value { json!({ "name": name, "title": title, "description": description, "inputSchema": schema, "annotations": { "readOnlyHint": read_only, "destructiveHint": false, "openWorldHint": false } }) } fn string_schema(description: &str) -> Value { json!({"type":"string","description":description}) } fn nullable_string_schema(description: &str) -> Value { json!({"type":["string","null"],"description":description}) } fn string_array_schema(description: &str) -> Value { json!({"type":"array","items":{"type":"string"},"description":description}) } fn object_schema(properties: Value, required: &[&str]) -> Value { let mut schema = json!({ "type": "object", "properties": properties, "additionalProperties": false }); if !required.is_empty() { schema["required"] = serde_json::to_value(required).unwrap_or_default(); } schema } fn id_schema(field: &str, description: &str) -> Value { let mut properties = Map::new(); properties.insert(field.into(), string_schema(description)); object_schema(Value::Object(properties), &[field]) } fn post_query_schema(query_required: bool) -> Value { object_schema( json!({ "query": string_schema("Full-text query"), "category": string_schema("Category filter"), "tags": string_array_schema("All required tags"), "language": string_schema("Available language"), "missingTranslationLanguage": string_schema("Missing translation language"), "year": {"type":"integer"}, "month": {"type":"integer","minimum":1,"maximum":12}, "status": {"type":"string","enum":["draft","published","archived"]}, "offset": {"type":"integer","minimum":0}, "limit": {"type":"integer","minimum":1,"maximum":MAX_PAGE_SIZE} }), if query_required { &["query"] } else { &[] }, ) } fn count_schema() -> Value { let mut schema = post_query_schema(false); schema["properties"]["groupBy"] = json!({"type":"array","items":{"type":"string","enum":["year","month","tag","category","status"]},"minItems":1}); schema["required"] = json!(["groupBy"]); schema } fn check_term(conn: &DbConnection, params: &Value) -> EngineResult { let term = required_string(params, "term")?; let (project, _) = active_project(conn)?; let posts = post_q::list_posts_by_project(conn, &project.id)?; let normalized = term.to_lowercase(); let tag_count = posts .iter() .filter(|post| post.tags.iter().any(|tag| tag.to_lowercase() == normalized)) .count(); let category_count = posts .iter() .filter(|post| { post.categories .iter() .any(|category| category.to_lowercase() == normalized) }) .count(); Ok(json!({ "is_category": category_count > 0, "category_post_count": category_count, "is_tag": tag_count > 0, "tag_post_count": tag_count })) } fn filtered_posts(conn: &DbConnection, params: &Value) -> EngineResult> { let (project, _) = active_project(conn)?; let mut posts = post_q::list_posts_by_project(conn, &project.id)?; let query = optional_string(params, "query") .unwrap_or_default() .trim() .to_lowercase(); let fts_matches = if query.is_empty() { None } else { let language = optional_string(params, "language").unwrap_or("en"); Some( crate::db::fts::search_posts(conn, &query, language)? .into_iter() .collect::>(), ) }; let category = optional_string(params, "category").map(str::to_lowercase); let tags = string_array(params, "tags") .into_iter() .map(|tag| tag.to_lowercase()) .collect::>(); let language = optional_string(params, "language").map(str::to_lowercase); let missing_language = optional_string(params, "missingTranslationLanguage").map(str::to_lowercase); let status = optional_string(params, "status"); let year = integer(params, "year")?.map(|value| value as i32); let month = integer(params, "month")?.map(|value| value as u32); if month.is_some() && year.is_none() { return Err(EngineError::Validation("month requires year".into())); } posts.retain(|post| { if fts_matches .as_ref() .is_some_and(|matches| !matches.contains(&post.id)) { return false; } if category.as_ref().is_some_and(|wanted| { !post .categories .iter() .any(|value| value.to_lowercase() == *wanted) }) { return false; } if tags.iter().any(|wanted| { !post .tags .iter() .any(|value| value.to_lowercase() == *wanted) }) { return false; } if status.is_some_and(|wanted| post.status.as_str() != wanted) { return false; } if let Some(wanted) = &language { let canonical = post .language .as_deref() .is_some_and(|value| value.eq_ignore_ascii_case(wanted)); let translated = post_translation::get_post_translation_by_post_and_language(conn, &post.id, wanted) .is_ok(); if !canonical && !translated { return false; } } if let Some(wanted) = &missing_language { let canonical = post .language .as_deref() .is_some_and(|value| value.eq_ignore_ascii_case(wanted)); let translated = post_translation::get_post_translation_by_post_and_language(conn, &post.id, wanted) .is_ok(); if canonical || translated { return false; } } if let Some(wanted_year) = year { let Some(date) = Utc.timestamp_millis_opt(post.created_at).single() else { return false; }; if date.year() != wanted_year || month.is_some_and(|wanted| date.month() != wanted) { return false; } } true }); Ok(posts) } fn search_posts(conn: &DbConnection, params: &Value) -> EngineResult { let posts = filtered_posts(conn, params)?; let total = posts.len(); let offset = unsigned(params, "offset")?.unwrap_or(0); let limit = unsigned(params, "limit")?.unwrap_or(MAX_PAGE_SIZE); if limit == 0 || limit > MAX_PAGE_SIZE { return Err(EngineError::Validation(format!( "limit must be between 1 and {MAX_PAGE_SIZE}" ))); } let posts = posts .into_iter() .skip(offset) .take(limit) .map(|post| post_summary(conn, &post)) .collect::>>()?; Ok(json!({ "total": total, "offset": offset, "limit": limit, "hasMore": offset.saturating_add(limit) < total, "posts": posts })) } fn count_posts(conn: &DbConnection, params: &Value) -> EngineResult { let group_by = string_array(params, "groupBy"); if group_by.is_empty() || group_by.iter().any(|dimension| { !["year", "month", "tag", "category", "status"].contains(&dimension.as_str()) }) { return Err(EngineError::Validation("invalid groupBy".into())); } let posts = filtered_posts(conn, params)?; let total = posts.len(); let mut counts = BTreeMap::, usize)>::new(); for post in posts { for row in group_rows(&post, &group_by) { let key = serde_json::to_string(&row)?; counts .entry(key) .and_modify(|(_, count)| *count += 1) .or_insert((row, 1)); } } let groups = counts .into_values() .map(|(mut row, count)| { row.insert("count".into(), json!(count)); Value::Object(row) }) .collect::>(); Ok(json!({"groups": groups, "totalPosts": total})) } fn group_rows(post: &Post, dimensions: &[String]) -> Vec> { let Some((dimension, rest)) = dimensions.split_first() else { return vec![Map::new()]; }; let values = match dimension.as_str() { "year" => Utc .timestamp_millis_opt(post.created_at) .single() .map(|date| vec![json!(date.year())]) .unwrap_or_else(|| vec![Value::Null]), "month" => Utc .timestamp_millis_opt(post.created_at) .single() .map(|date| vec![json!(date.month())]) .unwrap_or_else(|| vec![Value::Null]), "tag" => values_or_null(&post.tags), "category" => values_or_null(&post.categories), "status" => vec![json!(post.status.as_str())], _ => vec![Value::Null], }; values .into_iter() .flat_map(|value| { group_rows(post, rest).into_iter().map({ let dimension = dimension.clone(); move |mut row| { row.insert(dimension.clone(), value.clone()); row } }) }) .collect() } fn values_or_null(values: &[String]) -> Vec { if values.is_empty() { vec![Value::Null] } else { values.iter().map(|value| json!(value)).collect() } } fn read_post_by_slug(conn: &DbConnection, params: &Value) -> EngineResult { let slug = required_string(params, "slug")?; let (project, data_dir) = active_project(conn)?; let post = post_q::get_post_by_project_and_slug(conn, &project.id, slug) .map_err(|_| EngineError::NotFound(format!("post slug {slug}")))?; let Some(language) = optional_string(params, "language") else { return Ok(json!({"post": post_detail(conn, &data_dir, &post)?})); }; if post .language .as_deref() .is_some_and(|canonical| canonical.eq_ignore_ascii_case(language)) { return Ok(json!({"post": post_detail(conn, &data_dir, &post)?})); } let translation = post_translation::get_post_translation_by_post_and_language(conn, &post.id, language) .map_err(|_| EngineError::NotFound(format!("post translation {language}")))?; let mut detail = post_detail(conn, &data_dir, &post)?; detail["title"] = json!(translation.title); detail["excerpt"] = json!(translation.excerpt); detail["content"] = json!(translation_content(&data_dir, &translation)); detail["language"] = json!(translation.language); detail["canonicalLanguage"] = json!(post.language); Ok(json!({"post": detail})) } fn translation_content( data_dir: &std::path::Path, translation: &crate::model::PostTranslation, ) -> String { if let Some(content) = &translation.content { return content.clone(); } std::fs::read_to_string(data_dir.join(&translation.file_path)) .ok() .and_then(|source| { crate::util::frontmatter::read_translation_file(&source) .ok() .map(|(_, body)| body) }) .unwrap_or_default() } fn get_post_translations(conn: &DbConnection, params: &Value) -> EngineResult { let id = required_string(params, "postId")?; let (project, data_dir) = active_project(conn)?; let post = post_q::get_post_by_id(conn, id) .map_err(|_| EngineError::NotFound(format!("post {id}")))?; if post.project_id != project.id { return Err(EngineError::NotFound(format!("post {id}"))); } let translations = post_translation::list_post_translations_by_post(conn, id)? .into_iter() .map(|translation| { let mut value = serde_json::to_value(&translation)?; value["content"] = json!(translation_content(&data_dir, &translation)); Ok(value) }) .collect::>>()?; Ok(json!({"translations": translations})) } fn get_media_translations(conn: &DbConnection, params: &Value) -> EngineResult { let id = required_string(params, "mediaId")?; let (project, _) = active_project(conn)?; let media = media_q::get_media_by_id(conn, id) .map_err(|_| EngineError::NotFound(format!("media {id}")))?; if media.project_id != project.id { return Err(EngineError::NotFound(format!("media {id}"))); } Ok(json!({ "translations": media_translation::list_media_translations_by_media(conn, id)? })) } fn propose_media_translation(conn: &DbConnection, params: &Value) -> EngineResult { let id = required_string(params, "mediaId")?; required_string(params, "language")?; let (project, _) = active_project(conn)?; let media = media_q::get_media_by_id(conn, id) .map_err(|_| EngineError::NotFound(format!("media {id}")))?; if media.project_id != project.id { return Err(EngineError::NotFound(format!("media {id}"))); } propose( conn, ProposalKind::ProposeMediaTranslation, Some(id), params, ) } fn propose_script(conn: &DbConnection, params: &Value) -> EngineResult { required_string(params, "title")?; let content = required_string(params, "content")?; required_string(params, "kind")? .parse::() .map_err(EngineError::Validation)?; crate::engine::script::validate_script_syntax(content).map_err(EngineError::Validation)?; propose(conn, ProposalKind::ProposeScript, None, params) } fn propose_template(conn: &DbConnection, params: &Value) -> EngineResult { required_string(params, "title")?; let content = required_string(params, "content")?; required_string(params, "kind")? .parse::() .map_err(EngineError::Validation)?; crate::engine::template::validate_template(content).map_err(EngineError::Validation)?; propose(conn, ProposalKind::ProposeTemplate, None, params) } fn propose_media_metadata(conn: &DbConnection, params: &Value) -> EngineResult { let id = required_string(params, "mediaId")?; let (project, _) = active_project(conn)?; let media = media_q::get_media_by_id(conn, id) .map_err(|_| EngineError::NotFound(format!("media {id}")))?; if media.project_id != project.id { return Err(EngineError::NotFound(format!("media {id}"))); } propose(conn, ProposalKind::ProposeMediaMetadata, Some(id), params) } fn propose_post_metadata(conn: &DbConnection, params: &Value) -> EngineResult { let id = required_string(params, "postId")?; let (project, _) = active_project(conn)?; let post = post_q::get_post_by_id(conn, id) .map_err(|_| EngineError::NotFound(format!("post {id}")))?; if post.project_id != project.id { return Err(EngineError::NotFound(format!("post {id}"))); } propose(conn, ProposalKind::ProposePostMetadata, Some(id), params) } fn propose( conn: &DbConnection, kind: ProposalKind, entity_id: Option<&str>, params: &Value, ) -> EngineResult { if kind == ProposalKind::DraftPost { required_string(params, "title")?; required_string(params, "content")?; } let (project, _) = active_project(conn)?; let proposal = create_proposal(conn, kind, &project.id, entity_id, params)?; Ok(json!({ "proposalId": proposal.id, "status": proposal.status, "expiresAt": proposal.expires_at, "message": "Pending explicit approval in RuDS Settings" })) } fn integer(value: &Value, key: &str) -> EngineResult> { match value.get(key) { None => Ok(None), Some(value) => value .as_i64() .map(Some) .ok_or_else(|| EngineError::Validation(format!("{key} must be an integer"))), } } fn unsigned(value: &Value, key: &str) -> EngineResult> { integer(value, key)?.map_or(Ok(None), |value| { usize::try_from(value) .map(Some) .map_err(|_| EngineError::Validation(format!("{key} cannot be negative"))) }) }