Compare commits

...

3 Commits

9 changed files with 305 additions and 27 deletions

View File

@@ -1,22 +1,25 @@
# Alignment Tasks # Alignment Tasks
Allium CLI: `/opt/homebrew/bin/allium`. Use `allium check specs/<file>.allium` only when tending a spec; no Allium command is needed for code-only alignment tasks.
Goal: align bDS2 with old bDS behavior. Use the Allium specs as the contract only where they match old bDS. If old bDS and bDS2 agree but the spec differs, tend the spec. Goal: align bDS2 with old bDS behavior. Use the Allium specs as the contract only where they match old bDS. If old bDS and bDS2 agree but the spec differs, tend the spec.
## P0: MCP Proposal Lifecycle ## P0: MCP Proposal Lifecycle (done)
- Old bDS: proposals are in-memory and removed after `accept_proposal` or `discard_proposal`. - Old bDS: proposals are in-memory and removed after `accept_proposal` or `discard_proposal`.
- bDS2 now: proposals are persisted and marked `accepted` / `discarded`. - bDS2 now: proposals are persisted and marked `accepted` / `discarded`.
- Spec: matches old bDS; accepted/discarded proposals should no longer exist. - Spec: matches old bDS; accepted/discarded proposals should no longer exist.
- Action: change bDS2 to remove accepted/discarded proposals, update tests, and remove/adjust terminal-status expectations. - Action: change bDS2 to remove accepted/discarded proposals, update tests, and remove/adjust terminal-status expectations.
- Status: done.
## P0: MCP Cursor Resources ## P0: MCP Cursor Resources (done)
- Old bDS: `bds://posts{?cursor}` and `bds://media{?cursor}` use base64url cursors, page size 50, and `nextCursor`. - Old bDS: `bds://posts{?cursor}` and `bds://media{?cursor}` use base64url cursors, page size 50, and `nextCursor`.
- bDS2 now: first-page resources exist, but cursor URI/resource-template behavior is missing. - bDS2 now: first-page resources exist, but cursor URI/resource-template behavior is missing.
- Spec: matches old bDS cursor behavior. - 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. - 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`. - 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. - bDS2 now: domain translation functions exist, but MCP tools are missing.

View File

@@ -10,12 +10,18 @@ defmodule BDS.MCP do
@typedoc "Resource descriptor returned by `list_resources/0`." @typedoc "Resource descriptor returned by `list_resources/0`."
@type resource_descriptor :: Resources.descriptor() @type resource_descriptor :: Resources.descriptor()
@typedoc "Resource template descriptor returned by `list_resource_templates/0`."
@type resource_template_descriptor :: Resources.template_descriptor()
@spec list_tools() :: [tool_descriptor()] @spec list_tools() :: [tool_descriptor()]
defdelegate list_tools(), to: Tools, as: :list defdelegate list_tools(), to: Tools, as: :list
@spec list_resources() :: [resource_descriptor()] @spec list_resources() :: [resource_descriptor()]
defdelegate list_resources(), to: Resources, as: :list defdelegate list_resources(), to: Resources, as: :list
@spec list_resource_templates() :: [resource_template_descriptor()]
defdelegate list_resource_templates(), to: Resources, as: :templates
@spec call_tool(String.t(), map()) :: {:ok, term()} | {:error, term()} @spec call_tool(String.t(), map()) :: {:ok, term()} | {:error, term()}
defdelegate call_tool(name, params), to: Tools, as: :call defdelegate call_tool(name, params), to: Tools, as: :call

View File

@@ -69,8 +69,8 @@ defmodule BDS.MCP.ProposalStore do
Enum.map(expired, &Repo.get(Proposal, &1.id)) Enum.map(expired, &Repo.get(Proposal, &1.id))
end end
def mark_accepted(id) when is_binary(id), do: mark_status(id, :accepted) def mark_accepted(id) when is_binary(id), do: remove(id)
def mark_discarded(id) when is_binary(id), do: mark_status(id, :discarded) def mark_discarded(id) when is_binary(id), do: remove(id)
defp mark_status(id, status) do defp mark_status(id, status) do
case Repo.get(Proposal, id) do case Repo.get(Proposal, id) do

View File

@@ -14,6 +14,9 @@ defmodule BDS.MCP.Resources do
@typedoc "Resource descriptor returned by `list/0`." @typedoc "Resource descriptor returned by `list/0`."
@type descriptor :: %{name: String.t(), uri: String.t()} @type descriptor :: %{name: String.t(), uri: String.t()}
@typedoc "Resource template descriptor returned by `templates/0`."
@type template_descriptor :: %{name: String.t(), uriTemplate: String.t()}
@spec list() :: [descriptor()] @spec list() :: [descriptor()]
def list do def list do
[ [
@@ -24,13 +27,21 @@ defmodule BDS.MCP.Resources do
] ]
end end
@spec templates() :: [template_descriptor()]
def templates do
[
%{name: "posts", uriTemplate: "bds://posts{?cursor}"},
%{name: "media", uriTemplate: "bds://media{?cursor}"}
]
end
@spec read(String.t()) :: {:ok, term()} | {:error, term()} @spec read(String.t()) :: {:ok, term()} | {:error, term()}
def read(uri) when is_binary(uri) do def read(uri) when is_binary(uri) do
ProposalStore.ensure_started() ProposalStore.ensure_started()
case URI.parse(uri) do case URI.parse(uri) do
%URI{scheme: "bds", host: "posts", path: nil} -> {:ok, posts_resource(0)} %URI{scheme: "bds", host: "posts", path: nil, query: query} -> posts_resource(query)
%URI{scheme: "bds", host: "media", path: nil} -> {:ok, media_resource(0)} %URI{scheme: "bds", host: "media", path: nil, query: query} -> media_resource(query)
%URI{scheme: "bds", host: "tags", path: nil} -> {:ok, tags_resource()} %URI{scheme: "bds", host: "tags", path: nil} -> {:ok, tags_resource()}
%URI{scheme: "bds", host: "categories", path: nil} -> {:ok, categories_resource()} %URI{scheme: "bds", host: "categories", path: nil} -> {:ok, categories_resource()}
%URI{scheme: "bds", host: "posts", path: "/" <> id} -> read_post_resource(id) %URI{scheme: "bds", host: "posts", path: "/" <> id} -> read_post_resource(id)
@@ -39,7 +50,13 @@ defmodule BDS.MCP.Resources do
end end
end end
defp posts_resource(offset) do defp posts_resource(query) do
with {:ok, offset} <- cursor_offset(query) do
{:ok, posts_page(offset)}
end
end
defp posts_page(offset) do
project = Queries.active_project!() project = Queries.active_project!()
page_size = Queries.page_size() page_size = Queries.page_size()
{:ok, result} = Search.search_posts(project.id, "", %{offset: offset, limit: page_size}) {:ok, result} = Search.search_posts(project.id, "", %{offset: offset, limit: page_size})
@@ -48,12 +65,18 @@ defmodule BDS.MCP.Resources do
"items" => Enum.map(result.posts, &Queries.post_summary/1), "items" => Enum.map(result.posts, &Queries.post_summary/1),
"total" => result.total, "total" => result.total,
"offset" => result.offset, "offset" => result.offset,
"limit" => result.limit, "limit" => result.limit
"has_more" => result.offset + result.limit < result.total
} }
|> maybe_put_next_cursor(result)
end end
defp media_resource(offset) do defp media_resource(query) do
with {:ok, offset} <- cursor_offset(query) do
{:ok, media_page(offset)}
end
end
defp media_page(offset) do
project = Queries.active_project!() project = Queries.active_project!()
page_size = Queries.page_size() page_size = Queries.page_size()
{:ok, result} = Search.search_media(project.id, "", %{offset: offset, limit: page_size}) {:ok, result} = Search.search_media(project.id, "", %{offset: offset, limit: page_size})
@@ -62,9 +85,45 @@ defmodule BDS.MCP.Resources do
"items" => Enum.map(result.media, &Util.sanitize/1), "items" => Enum.map(result.media, &Util.sanitize/1),
"total" => result.total, "total" => result.total,
"offset" => result.offset, "offset" => result.offset,
"limit" => result.limit, "limit" => result.limit
"has_more" => result.offset + result.limit < result.total
} }
|> maybe_put_next_cursor(result)
end
defp cursor_offset(nil), do: {:ok, 0}
defp cursor_offset(query) do
case URI.decode_query(query) do
%{"cursor" => cursor} when cursor != "" -> decode_cursor(cursor)
%{"cursor" => ""} -> {:error, :invalid_cursor}
_params -> {:ok, 0}
end
end
defp decode_cursor(cursor) do
with {:ok, decoded} <- Base.url_decode64(cursor, padding: false),
{:ok, %{"offset" => offset}} when is_integer(offset) and offset >= 0 <-
Jason.decode(decoded) do
{:ok, offset}
else
_other -> {:error, :invalid_cursor}
end
end
defp maybe_put_next_cursor(resource, result) do
next_offset = result.offset + result.limit
if next_offset < result.total do
Map.put(resource, "nextCursor", encode_cursor(next_offset))
else
resource
end
end
defp encode_cursor(offset) do
%{"offset" => offset}
|> Jason.encode!()
|> Base.url_encode64(padding: false)
end end
defp tags_resource do defp tags_resource do

View File

@@ -188,6 +188,10 @@ defmodule BDS.MCP.Server do
"resources/list" -> "resources/list" ->
{:ok, success_response(id, %{"resources" => BDS.MCP.list_resources()})} {:ok, success_response(id, %{"resources" => BDS.MCP.list_resources()})}
"resources/templates/list" ->
{:ok,
success_response(id, %{"resourceTemplates" => BDS.MCP.list_resource_templates()})}
"resources/read" -> "resources/read" ->
read_resource(id, params) read_resource(id, params)
@@ -230,6 +234,9 @@ defmodule BDS.MCP.Server do
{:error, :not_found} -> {:error, :not_found} ->
{:error, error_response(id, -32004, "Not found")} {:error, error_response(id, -32004, "Not found")}
{:error, :invalid_cursor} ->
{:error, error_response(id, -32602, "Invalid cursor")}
end end
end end

View File

@@ -76,6 +76,18 @@ defmodule BDS.MCP.Stdio do
%{"jsonrpc" => "2.0", "id" => id, "result" => %{"resources" => BDS.MCP.list_resources()}} %{"jsonrpc" => "2.0", "id" => id, "result" => %{"resources" => BDS.MCP.list_resources()}}
end end
defp handle_payload(%{
"jsonrpc" => "2.0",
"id" => id,
"method" => "resources/templates/list"
}) do
%{
"jsonrpc" => "2.0",
"id" => id,
"result" => %{"resourceTemplates" => BDS.MCP.list_resource_templates()}
}
end
defp handle_payload(%{ defp handle_payload(%{
"jsonrpc" => "2.0", "jsonrpc" => "2.0",
"id" => id, "id" => id,

View File

@@ -27,6 +27,9 @@ defmodule BDS.MCP.Tools do
tool("search_posts", true), tool("search_posts", true),
tool("count_posts", true), tool("count_posts", true),
tool("read_post_by_slug", 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("draft_post", false),
tool("propose_script", false), tool("propose_script", false),
tool("propose_template", false), tool("propose_template", false),
@@ -46,6 +49,9 @@ defmodule BDS.MCP.Tools do
"search_posts" -> {:ok, search_posts(params)} "search_posts" -> {:ok, search_posts(params)}
"count_posts" -> {:ok, count_posts(params)} "count_posts" -> {:ok, count_posts(params)}
"read_post_by_slug" -> read_post_by_slug(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) "draft_post" -> draft_post(params)
"propose_script" -> propose_script(params) "propose_script" -> propose_script(params)
"propose_template" -> propose_template(params) "propose_template" -> propose_template(params)
@@ -165,6 +171,47 @@ defmodule BDS.MCP.Tools do
end end
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 defp draft_post(params) do
project = Queries.active_project!() project = Queries.active_project!()

View File

@@ -10,8 +10,6 @@ use "./template.allium" as template
enum ProposalStatus { enum ProposalStatus {
pending pending
accepted
discarded
expired expired
} }
@@ -49,8 +47,6 @@ entity Proposal {
is_expired: expires_at <= now is_expired: expires_at <= now
transitions status { transitions status {
pending -> accepted
pending -> discarded
pending -> expired pending -> expired
} }
} }
@@ -87,6 +83,9 @@ surface McpAutomationSurface {
McpToolInvoked("search_posts", params) McpToolInvoked("search_posts", params)
McpToolInvoked("count_posts", params) McpToolInvoked("count_posts", params)
McpToolInvoked("read_post_by_slug", slug, language) 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("draft_post", params)
McpToolInvoked("propose_script", params) McpToolInvoked("propose_script", params)
McpToolInvoked("propose_template", params) McpToolInvoked("propose_template", params)
@@ -208,6 +207,30 @@ rule ReadPostBySlug {
ensures: FullPostContent(post) 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) -- Write tools (proposal-based)
rule DraftPost { rule DraftPost {
@@ -342,7 +365,6 @@ rule AcceptProposal {
media/UpdateMediaRequested(proposal.target_media, deserialize_media_changes(proposal.data)) media/UpdateMediaRequested(proposal.target_media, deserialize_media_changes(proposal.data))
if proposal.kind = propose_post_metadata: if proposal.kind = propose_post_metadata:
post/UpdatePostRequested(proposal.target_post, deserialize_post_changes(proposal.data)) post/UpdatePostRequested(proposal.target_post, deserialize_post_changes(proposal.data))
proposal.status = accepted
not exists proposal not exists proposal
} }
@@ -355,7 +377,6 @@ rule DiscardProposal {
script/DeleteScriptRequested(proposal.proposed_script) script/DeleteScriptRequested(proposal.proposed_script)
if proposal.kind = propose_template: if proposal.kind = propose_template:
template/DeleteTemplateRequested(proposal.proposed_template) template/DeleteTemplateRequested(proposal.proposed_template)
proposal.status = discarded
not exists proposal not exists proposal
} }

View File

@@ -29,6 +29,9 @@ defmodule BDS.MCPTest do
assert "search_posts" in tool_names assert "search_posts" in tool_names
assert "count_posts" in tool_names assert "count_posts" in tool_names
assert "read_post_by_slug" 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 "draft_post" in tool_names
assert "propose_script" in tool_names assert "propose_script" in tool_names
assert "propose_template" in tool_names assert "propose_template" in tool_names
@@ -72,6 +75,70 @@ defmodule BDS.MCPTest do
assert read_result["post"]["slug"] == "travel-notes" assert read_result["post"]["slug"] == "travel-notes"
end 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", test "proposal-backed write tools follow the old app lifecycle for scripts, templates, and metadata",
%{ %{
project: project, project: project,
@@ -180,7 +247,7 @@ defmodule BDS.MCPTest do
assert_raise Ecto.NoResultsError, fn -> BDS.Posts.get_post!(draft_post_id) end assert_raise Ecto.NoResultsError, fn -> BDS.Posts.get_post!(draft_post_id) end
end end
test "proposal lifecycle is persisted with pending, accepted, discarded, and expired statuses" do test "proposal lifecycle removes accepted and discarded proposals" do
assert {:ok, accepted_result} = assert {:ok, accepted_result} =
BDS.MCP.call_tool("draft_post", %{title: "Accept Me", content: "Body"}) BDS.MCP.call_tool("draft_post", %{title: "Accept Me", content: "Body"})
@@ -188,19 +255,14 @@ defmodule BDS.MCPTest do
assert ProposalStore.get(accepted_id).status == :pending assert ProposalStore.get(accepted_id).status == :pending
assert {:ok, _accepted} = BDS.MCP.call_tool("accept_proposal", %{proposalId: accepted_id}) assert {:ok, _accepted} = BDS.MCP.call_tool("accept_proposal", %{proposalId: accepted_id})
assert ProposalStore.get(accepted_id) == nil
accepted_proposal = ProposalStore.get(accepted_id)
assert accepted_proposal.status == :accepted
assert accepted_proposal.entity_id == accepted_result["post"]["id"]
assert {:ok, discarded_result} = assert {:ok, discarded_result} =
BDS.MCP.call_tool("draft_post", %{title: "Discard Me Later", content: "Body"}) BDS.MCP.call_tool("draft_post", %{title: "Discard Me Later", content: "Body"})
discarded_id = discarded_result["proposal_id"] discarded_id = discarded_result["proposal_id"]
assert {:ok, _discarded} = BDS.MCP.call_tool("discard_proposal", %{proposalId: discarded_id}) assert {:ok, _discarded} = BDS.MCP.call_tool("discard_proposal", %{proposalId: discarded_id})
assert ProposalStore.get(discarded_id) == nil
discarded_proposal = ProposalStore.get(discarded_id)
assert discarded_proposal.status == :discarded
expired = expired =
ProposalStore.create("draft_post", %{"post_id" => "expired-post"}, ProposalStore.create("draft_post", %{"post_id" => "expired-post"},
@@ -241,4 +303,65 @@ defmodule BDS.MCPTest do
assert {:ok, post_resource} = BDS.MCP.read_resource("bds://posts/#{post.id}") assert {:ok, post_resource} = BDS.MCP.read_resource("bds://posts/#{post.id}")
assert post_resource["slug"] == "resource-post" assert post_resource["slug"] == "resource-post"
end end
test "post resources use base64url cursors with 50 item pages", %{project: project} do
for index <- 1..51 do
assert {:ok, _post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Cursor Post #{String.pad_leading(to_string(index), 2, "0")}",
content: "Cursor body #{index}",
language: "en"
})
end
assert {:ok, first_page} = BDS.MCP.read_resource("bds://posts")
assert length(first_page["items"]) == 50
assert first_page["nextCursor"] =~ ~r/^[A-Za-z0-9_-]+$/
refute Map.has_key?(first_page, "has_more")
assert {:ok, final_page} =
BDS.MCP.read_resource("bds://posts?cursor=#{first_page["nextCursor"]}")
assert length(final_page["items"]) == 1
refute Map.has_key?(final_page, "nextCursor")
end
test "media resources use base64url cursors with 50 item pages", %{
project: project,
temp_dir: temp_dir
} do
for index <- 1..51 do
source_path = Path.join(temp_dir, "cursor-media-#{index}.txt")
File.write!(source_path, "media body #{index}")
assert {:ok, _media} =
BDS.Media.import_media(%{
project_id: project.id,
source_path: source_path,
title: "Cursor Media #{String.pad_leading(to_string(index), 2, "0")}"
})
end
assert {:ok, first_page} = BDS.MCP.read_resource("bds://media")
assert length(first_page["items"]) == 50
assert first_page["nextCursor"] =~ ~r/^[A-Za-z0-9_-]+$/
assert {:ok, final_page} =
BDS.MCP.read_resource("bds://media?cursor=#{first_page["nextCursor"]}")
assert length(final_page["items"]) == 1
refute Map.has_key?(final_page, "nextCursor")
end
test "cursor resources reject invalid cursors and list URI templates" do
assert {:error, :invalid_cursor} = BDS.MCP.read_resource("bds://posts?cursor=not-valid")
assert {:error, :invalid_cursor} = BDS.MCP.read_resource("bds://media?cursor=not-valid")
templates = BDS.MCP.list_resource_templates()
template_uris = Enum.map(templates, & &1.uriTemplate)
assert "bds://posts{?cursor}" in template_uris
assert "bds://media{?cursor}" in template_uris
end
end end