feat: alignment with mcp ressource cursor paging

This commit is contained in:
2026-05-01 18:26:57 +02:00
parent 5d70f1b55a
commit 661bc0037c
6 changed files with 154 additions and 9 deletions

View File

@@ -12,7 +12,7 @@ Goal: align bDS2 with old bDS behavior. Use the Allium specs as the contract onl
- 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. - 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.

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

@@ -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

@@ -236,4 +236,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