From 6e6a751db034df48857da97c6c84adb1f90c6ef8 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 1 May 2026 18:29:51 +0200 Subject: [PATCH] feat: alignment MCP translation tools --- ALIGNMENT.md | 2 +- lib/bds/mcp/tools.ex | 47 ++++++++++++++++++++++++++++++ specs/mcp.allium | 27 +++++++++++++++++ test/bds/mcp_test.exs | 67 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) diff --git a/ALIGNMENT.md b/ALIGNMENT.md index 028bf90..f650e87 100644 --- a/ALIGNMENT.md +++ b/ALIGNMENT.md @@ -19,7 +19,7 @@ Goal: align bDS2 with old bDS behavior. Use the Allium specs as the contract onl - Spec: matches old bDS cursor behavior. - Action: implement cursor parsing/templates for posts and media resources and add tests for first page, next cursor, invalid cursor, and final page. -## P0: MCP Translation Tools +## P0: MCP Translation Tools (done) - Old bDS: exposes `get_post_translations`, `get_media_translations`, and app-gated `upsert_media_translation`. - bDS2 now: domain translation functions exist, but MCP tools are missing. diff --git a/lib/bds/mcp/tools.ex b/lib/bds/mcp/tools.ex index 8cb06c7..0554234 100644 --- a/lib/bds/mcp/tools.ex +++ b/lib/bds/mcp/tools.ex @@ -27,6 +27,9 @@ defmodule BDS.MCP.Tools do tool("search_posts", true), tool("count_posts", true), tool("read_post_by_slug", true), + tool("get_post_translations", true), + tool("get_media_translations", true), + tool("upsert_media_translation", false), tool("draft_post", false), tool("propose_script", false), tool("propose_template", false), @@ -46,6 +49,9 @@ defmodule BDS.MCP.Tools do "search_posts" -> {:ok, search_posts(params)} "count_posts" -> {:ok, count_posts(params)} "read_post_by_slug" -> read_post_by_slug(params) + "get_post_translations" -> get_post_translations(params) + "get_media_translations" -> get_media_translations(params) + "upsert_media_translation" -> upsert_media_translation(params) "draft_post" -> draft_post(params) "propose_script" -> propose_script(params) "propose_template" -> propose_template(params) @@ -165,6 +171,47 @@ defmodule BDS.MCP.Tools do end end + defp get_post_translations(params) do + post_id = map_get(params, :postId, nil) + + case Posts.get_post(post_id) do + nil -> + {:error, :not_found} + + %Post{} -> + with {:ok, translations} <- Posts.list_post_translations(post_id) do + {:ok, %{"translations" => sanitize(translations)}} + end + end + end + + defp get_media_translations(params) do + media_id = map_get(params, :mediaId, nil) + + case Media.get_media(media_id) do + nil -> + {:error, :not_found} + + %MediaAsset{} -> + {:ok, %{"translations" => sanitize(Media.list_media_translations(media_id))}} + end + end + + defp upsert_media_translation(params) do + media_id = map_get(params, :mediaId, nil) + language = params |> map_get(:language, "") |> normalize_term() + + attrs = %{ + title: map_get(params, :title, nil), + alt: map_get(params, :alt, nil), + caption: map_get(params, :caption, nil) + } + + with {:ok, translation} <- Media.upsert_media_translation(media_id, language, attrs) do + {:ok, %{"translation" => sanitize(translation)}} + end + end + defp draft_post(params) do project = Queries.active_project!() diff --git a/specs/mcp.allium b/specs/mcp.allium index 753b960..7ca936d 100644 --- a/specs/mcp.allium +++ b/specs/mcp.allium @@ -83,6 +83,9 @@ surface McpAutomationSurface { McpToolInvoked("search_posts", params) McpToolInvoked("count_posts", params) McpToolInvoked("read_post_by_slug", slug, language) + McpToolInvoked("get_post_translations", post_id) + McpToolInvoked("get_media_translations", media_id) + McpToolInvoked("upsert_media_translation", params) McpToolInvoked("draft_post", params) McpToolInvoked("propose_script", params) McpToolInvoked("propose_template", params) @@ -204,6 +207,30 @@ rule ReadPostBySlug { ensures: FullPostContent(post) } +rule GetPostTranslations { + when: McpToolInvoked("get_post_translations", post_id) + -- Lists all available translations for a post. + ensures: PostTranslations(post_id) +} + +rule GetMediaTranslations { + when: McpToolInvoked("get_media_translations", media_id) + -- Lists all available translated metadata for a media item. + ensures: MediaTranslations(media_id) +} + +rule UpsertMediaTranslation { + when: McpToolInvoked("upsert_media_translation", params) + -- Creates or updates translated media metadata for a language. + ensures: media/UpsertMediaTranslationRequested( + params.media_id, + params.language, + params.title, + params.alt, + params.caption + ) +} + -- Write tools (proposal-based) rule DraftPost { diff --git a/test/bds/mcp_test.exs b/test/bds/mcp_test.exs index 4a74ef0..a53f6bc 100644 --- a/test/bds/mcp_test.exs +++ b/test/bds/mcp_test.exs @@ -29,6 +29,9 @@ defmodule BDS.MCPTest do assert "search_posts" in tool_names assert "count_posts" in tool_names assert "read_post_by_slug" in tool_names + assert "get_post_translations" in tool_names + assert "get_media_translations" in tool_names + assert "upsert_media_translation" in tool_names assert "draft_post" in tool_names assert "propose_script" in tool_names assert "propose_template" in tool_names @@ -72,6 +75,70 @@ defmodule BDS.MCPTest do assert read_result["post"]["slug"] == "travel-notes" end + test "translation tools expose post and media translations and upsert media metadata", %{ + project: project, + temp_dir: temp_dir + } do + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Translatable Post", + content: "Source body", + language: "en" + }) + + assert {:ok, _post_translation} = + BDS.Posts.upsert_post_translation(post.id, "de", %{ + title: "Ubersetzter Beitrag", + excerpt: "Kurzfassung", + content: "Ubersetzter Inhalt" + }) + + source_path = Path.join(temp_dir, "translation-media.txt") + File.write!(source_path, "media body") + + assert {:ok, media} = + BDS.Media.import_media(%{ + project_id: project.id, + source_path: source_path, + title: "Source Media", + alt: "Source Alt", + caption: "Source Caption", + language: "en" + }) + + assert {:ok, post_result} = + BDS.MCP.call_tool("get_post_translations", %{postId: post.id}) + + assert [post_translation] = post_result["translations"] + assert post_translation["language"] == "de" + assert post_translation["title"] == "Ubersetzter Beitrag" + assert post_translation["excerpt"] == "Kurzfassung" + assert post_translation["content"] == "Ubersetzter Inhalt" + assert post_translation["status"] == "draft" + + assert {:ok, upsert_result} = + BDS.MCP.call_tool("upsert_media_translation", %{ + mediaId: media.id, + language: "de", + title: "Medientitel", + alt: "Medien Alt", + caption: "Medien Beschriftung" + }) + + assert upsert_result["translation"]["language"] == "de" + assert upsert_result["translation"]["title"] == "Medientitel" + + assert {:ok, media_result} = + BDS.MCP.call_tool("get_media_translations", %{mediaId: media.id}) + + assert [media_translation] = media_result["translations"] + assert media_translation["language"] == "de" + assert media_translation["title"] == "Medientitel" + assert media_translation["alt"] == "Medien Alt" + assert media_translation["caption"] == "Medien Beschriftung" + end + test "proposal-backed write tools follow the old app lifecycle for scripts, templates, and metadata", %{ project: project,