feat: alignment with mcp ressource cursor paging
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user