Compare commits
3 Commits
59e5d71396
...
6e6a751db0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e6a751db0 | |||
| 661bc0037c | |||
| 5d70f1b55a |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user