Compare commits

...

3 Commits

9 changed files with 305 additions and 27 deletions

View File

@@ -1,22 +1,25 @@
# 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.
## P0: MCP Proposal Lifecycle
## P0: MCP Proposal Lifecycle (done)
- Old bDS: proposals are in-memory and removed after `accept_proposal` or `discard_proposal`.
- bDS2 now: proposals are persisted and marked `accepted` / `discarded`.
- 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.
- 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`.
- bDS2 now: first-page resources exist, but cursor URI/resource-template behavior is missing.
- 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.

View File

@@ -10,12 +10,18 @@ defmodule BDS.MCP do
@typedoc "Resource descriptor returned by `list_resources/0`."
@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()]
defdelegate list_tools(), to: Tools, as: :list
@spec list_resources() :: [resource_descriptor()]
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()}
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))
end
def mark_accepted(id) when is_binary(id), do: mark_status(id, :accepted)
def mark_discarded(id) when is_binary(id), do: mark_status(id, :discarded)
def mark_accepted(id) when is_binary(id), do: remove(id)
def mark_discarded(id) when is_binary(id), do: remove(id)
defp mark_status(id, status) 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`."
@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()]
def list do
[
@@ -24,13 +27,21 @@ defmodule BDS.MCP.Resources do
]
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()}
def read(uri) when is_binary(uri) do
ProposalStore.ensure_started()
case URI.parse(uri) do
%URI{scheme: "bds", host: "posts", path: nil} -> {:ok, posts_resource(0)}
%URI{scheme: "bds", host: "media", path: nil} -> {:ok, media_resource(0)}
%URI{scheme: "bds", host: "posts", path: nil, query: query} -> posts_resource(query)
%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: "categories", path: nil} -> {:ok, categories_resource()}
%URI{scheme: "bds", host: "posts", path: "/" <> id} -> read_post_resource(id)
@@ -39,7 +50,13 @@ defmodule BDS.MCP.Resources do
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!()
page_size = Queries.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),
"total" => result.total,
"offset" => result.offset,
"limit" => result.limit,
"has_more" => result.offset + result.limit < result.total
"limit" => result.limit
}
|> maybe_put_next_cursor(result)
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!()
page_size = Queries.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),
"total" => result.total,
"offset" => result.offset,
"limit" => result.limit,
"has_more" => result.offset + result.limit < result.total
"limit" => result.limit
}
|> 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
defp tags_resource do

View File

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

View File

@@ -76,6 +76,18 @@ defmodule BDS.MCP.Stdio do
%{"jsonrpc" => "2.0", "id" => id, "result" => %{"resources" => BDS.MCP.list_resources()}}
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(%{
"jsonrpc" => "2.0",
"id" => id,

View File

@@ -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!()

View File

@@ -10,8 +10,6 @@ use "./template.allium" as template
enum ProposalStatus {
pending
accepted
discarded
expired
}
@@ -49,8 +47,6 @@ entity Proposal {
is_expired: expires_at <= now
transitions status {
pending -> accepted
pending -> discarded
pending -> expired
}
}
@@ -87,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)
@@ -208,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 {
@@ -342,7 +365,6 @@ rule AcceptProposal {
media/UpdateMediaRequested(proposal.target_media, deserialize_media_changes(proposal.data))
if proposal.kind = propose_post_metadata:
post/UpdatePostRequested(proposal.target_post, deserialize_post_changes(proposal.data))
proposal.status = accepted
not exists proposal
}
@@ -355,7 +377,6 @@ rule DiscardProposal {
script/DeleteScriptRequested(proposal.proposed_script)
if proposal.kind = propose_template:
template/DeleteTemplateRequested(proposal.proposed_template)
proposal.status = discarded
not exists proposal
}

View File

@@ -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,
@@ -180,7 +247,7 @@ defmodule BDS.MCPTest do
assert_raise Ecto.NoResultsError, fn -> BDS.Posts.get_post!(draft_post_id) 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} =
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 {:ok, _accepted} = BDS.MCP.call_tool("accept_proposal", %{proposalId: accepted_id})
accepted_proposal = ProposalStore.get(accepted_id)
assert accepted_proposal.status == :accepted
assert accepted_proposal.entity_id == accepted_result["post"]["id"]
assert ProposalStore.get(accepted_id) == nil
assert {:ok, discarded_result} =
BDS.MCP.call_tool("draft_post", %{title: "Discard Me Later", content: "Body"})
discarded_id = discarded_result["proposal_id"]
assert {:ok, _discarded} = BDS.MCP.call_tool("discard_proposal", %{proposalId: discarded_id})
discarded_proposal = ProposalStore.get(discarded_id)
assert discarded_proposal.status == :discarded
assert ProposalStore.get(discarded_id) == nil
expired =
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 post_resource["slug"] == "resource-post"
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