diff --git a/ALIGNMENT.md b/ALIGNMENT.md index 8c0cf3c..028bf90 100644 --- a/ALIGNMENT.md +++ b/ALIGNMENT.md @@ -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. - 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. diff --git a/lib/bds/mcp.ex b/lib/bds/mcp.ex index f23f38b..274c829 100644 --- a/lib/bds/mcp.ex +++ b/lib/bds/mcp.ex @@ -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 diff --git a/lib/bds/mcp/resources.ex b/lib/bds/mcp/resources.ex index 8f5ec69..6de7d81 100644 --- a/lib/bds/mcp/resources.ex +++ b/lib/bds/mcp/resources.ex @@ -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 diff --git a/lib/bds/mcp/server.ex b/lib/bds/mcp/server.ex index 33a976a..10066b3 100644 --- a/lib/bds/mcp/server.ex +++ b/lib/bds/mcp/server.ex @@ -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 diff --git a/lib/bds/mcp/stdio.ex b/lib/bds/mcp/stdio.ex index 91434b8..5d84aa6 100644 --- a/lib/bds/mcp/stdio.ex +++ b/lib/bds/mcp/stdio.ex @@ -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, diff --git a/test/bds/mcp_test.exs b/test/bds/mcp_test.exs index 86e4e5d..4a74ef0 100644 --- a/test/bds/mcp_test.exs +++ b/test/bds/mcp_test.exs @@ -236,4 +236,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