feat: alignment on missing MCP ressources
This commit is contained in:
@@ -26,7 +26,7 @@ Goal: align bDS2 with old bDS behavior. Use the Allium specs as the contract onl
|
|||||||
- Spec: missing these tools.
|
- Spec: missing these tools.
|
||||||
- Action: add the tools to `specs/mcp.allium`, implement them in MCP, and test tool listing and call behavior.
|
- Action: add the tools to `specs/mcp.allium`, implement them in MCP, and test tool listing and call behavior.
|
||||||
|
|
||||||
## P1: Missing MCP Resources
|
## P1: Missing MCP Resources (done)
|
||||||
|
|
||||||
- Old bDS: also exposes `bds://stats`, `bds://posts/{id}/media`, and `bds://media/{id}/image`.
|
- Old bDS: also exposes `bds://stats`, `bds://posts/{id}/media`, and `bds://media/{id}/image`.
|
||||||
- bDS2 now: only posts, media, tags, and categories are exposed.
|
- bDS2 now: only posts, media, tags, and categories are exposed.
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
defmodule BDS.MCP.Resources do
|
defmodule BDS.MCP.Resources do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.MCP.ProposalStore
|
alias BDS.MCP.ProposalStore
|
||||||
alias BDS.MCP.Queries
|
alias BDS.MCP.Queries
|
||||||
alias BDS.MCP.Util
|
alias BDS.MCP.Util
|
||||||
alias BDS.Media.Media, as: MediaAsset
|
alias BDS.Media.Media, as: MediaAsset
|
||||||
alias BDS.Metadata
|
alias BDS.Metadata
|
||||||
|
alias BDS.Posts.PostMedia
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.Projects
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.Search
|
alias BDS.Search
|
||||||
alias BDS.Tags
|
alias BDS.Tags
|
||||||
@@ -23,7 +27,8 @@ defmodule BDS.MCP.Resources do
|
|||||||
%{name: "posts", uri: "bds://posts"},
|
%{name: "posts", uri: "bds://posts"},
|
||||||
%{name: "media", uri: "bds://media"},
|
%{name: "media", uri: "bds://media"},
|
||||||
%{name: "tags", uri: "bds://tags"},
|
%{name: "tags", uri: "bds://tags"},
|
||||||
%{name: "categories", uri: "bds://categories"}
|
%{name: "categories", uri: "bds://categories"},
|
||||||
|
%{name: "stats", uri: "bds://stats"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -31,7 +36,9 @@ defmodule BDS.MCP.Resources do
|
|||||||
def templates do
|
def templates do
|
||||||
[
|
[
|
||||||
%{name: "posts", uriTemplate: "bds://posts{?cursor}"},
|
%{name: "posts", uriTemplate: "bds://posts{?cursor}"},
|
||||||
%{name: "media", uriTemplate: "bds://media{?cursor}"}
|
%{name: "media", uriTemplate: "bds://media{?cursor}"},
|
||||||
|
%{name: "post media", uriTemplate: "bds://posts/{id}/media"},
|
||||||
|
%{name: "media image", uriTemplate: "bds://media/{id}/image"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -44,8 +51,9 @@ defmodule BDS.MCP.Resources do
|
|||||||
%URI{scheme: "bds", host: "media", path: nil, query: query} -> media_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: "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: "stats", path: nil} -> {:ok, stats_resource()}
|
||||||
%URI{scheme: "bds", host: "media", path: "/" <> id} -> read_media_resource(id)
|
%URI{scheme: "bds", host: "posts", path: path} -> read_posts_path(path)
|
||||||
|
%URI{scheme: "bds", host: "media", path: path} -> read_media_path(path)
|
||||||
_other -> {:error, :not_found}
|
_other -> {:error, :not_found}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -155,6 +163,40 @@ defmodule BDS.MCP.Resources do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp stats_resource do
|
||||||
|
project = Queries.active_project!()
|
||||||
|
{:ok, posts} = Search.search_posts(project.id, "", %{offset: 0, limit: 1})
|
||||||
|
{:ok, media} = Search.search_media(project.id, "", %{offset: 0, limit: 1})
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project.id)
|
||||||
|
|
||||||
|
%{
|
||||||
|
"posts" => posts.total,
|
||||||
|
"media" => media.total,
|
||||||
|
"tags" => length(Tags.list_tags(project.id)),
|
||||||
|
"categories" => length(metadata.categories)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp read_posts_path("/" <> path) do
|
||||||
|
case String.split(path, "/", trim: true) do
|
||||||
|
[id] -> read_post_resource(id)
|
||||||
|
[id, "media"] -> read_post_media_resource(id)
|
||||||
|
_parts -> {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp read_posts_path(_path), do: {:error, :not_found}
|
||||||
|
|
||||||
|
defp read_media_path("/" <> path) do
|
||||||
|
case String.split(path, "/", trim: true) do
|
||||||
|
[id] -> read_media_resource(id)
|
||||||
|
[id, "image"] -> read_media_image_resource(id)
|
||||||
|
_parts -> {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp read_media_path(_path), do: {:error, :not_found}
|
||||||
|
|
||||||
defp read_post_resource(id) do
|
defp read_post_resource(id) do
|
||||||
case Repo.get(Post, id) do
|
case Repo.get(Post, id) do
|
||||||
%Post{} = post -> {:ok, Queries.post_detail(post)}
|
%Post{} = post -> {:ok, Queries.post_detail(post)}
|
||||||
@@ -168,4 +210,47 @@ defmodule BDS.MCP.Resources do
|
|||||||
nil -> {:error, :not_found}
|
nil -> {:error, :not_found}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp read_post_media_resource(post_id) do
|
||||||
|
case Repo.get(Post, post_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
%Post{} ->
|
||||||
|
items =
|
||||||
|
Repo.all(
|
||||||
|
from media in MediaAsset,
|
||||||
|
join: post_media in PostMedia,
|
||||||
|
on: post_media.media_id == media.id,
|
||||||
|
where: post_media.post_id == ^post_id,
|
||||||
|
order_by: [asc: post_media.sort_order, asc: media.updated_at],
|
||||||
|
select: media
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, %{"items" => Enum.map(items, &Util.sanitize/1)}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp read_media_image_resource(id) do
|
||||||
|
case Repo.get(MediaAsset, id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
%MediaAsset{} = media ->
|
||||||
|
project = Projects.get_project!(media.project_id)
|
||||||
|
full_path = Path.join(Projects.project_data_dir(project), media.file_path || "")
|
||||||
|
|
||||||
|
case File.read(full_path) do
|
||||||
|
{:ok, bytes} ->
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
"mimeType" => media.mime_type || "application/octet-stream",
|
||||||
|
"blob" => Base.encode64(bytes)
|
||||||
|
}}
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -189,8 +189,7 @@ defmodule BDS.MCP.Server do
|
|||||||
{:ok, success_response(id, %{"resources" => BDS.MCP.list_resources()})}
|
{:ok, success_response(id, %{"resources" => BDS.MCP.list_resources()})}
|
||||||
|
|
||||||
"resources/templates/list" ->
|
"resources/templates/list" ->
|
||||||
{:ok,
|
{:ok, success_response(id, %{"resourceTemplates" => BDS.MCP.list_resource_templates()})}
|
||||||
success_response(id, %{"resourceTemplates" => BDS.MCP.list_resource_templates()})}
|
|
||||||
|
|
||||||
"resources/read" ->
|
"resources/read" ->
|
||||||
read_resource(id, params)
|
read_resource(id, params)
|
||||||
@@ -227,9 +226,7 @@ defmodule BDS.MCP.Server do
|
|||||||
{:ok, result} ->
|
{:ok, result} ->
|
||||||
{:ok,
|
{:ok,
|
||||||
success_response(id, %{
|
success_response(id, %{
|
||||||
"contents" => [
|
"contents" => [resource_content(uri, result)]
|
||||||
%{"uri" => uri, "mimeType" => "application/json", "text" => Jason.encode!(result)}
|
|
||||||
]
|
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{:error, :not_found} ->
|
{:error, :not_found} ->
|
||||||
@@ -242,6 +239,15 @@ defmodule BDS.MCP.Server do
|
|||||||
|
|
||||||
defp read_resource(id, _params), do: {:error, error_response(id, -32602, "Invalid params")}
|
defp read_resource(id, _params), do: {:error, error_response(id, -32602, "Invalid params")}
|
||||||
|
|
||||||
|
defp resource_content(uri, %{"blob" => blob, "mimeType" => mime_type})
|
||||||
|
when is_binary(blob) and is_binary(mime_type) do
|
||||||
|
%{"uri" => uri, "mimeType" => mime_type, "blob" => blob}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resource_content(uri, result) do
|
||||||
|
%{"uri" => uri, "mimeType" => "application/json", "text" => Jason.encode!(result)}
|
||||||
|
end
|
||||||
|
|
||||||
defp success_response(id, result), do: %{"jsonrpc" => "2.0", "id" => id, "result" => result}
|
defp success_response(id, result), do: %{"jsonrpc" => "2.0", "id" => id, "result" => result}
|
||||||
|
|
||||||
defp error_response(id, code, message) do
|
defp error_response(id, code, message) do
|
||||||
|
|||||||
@@ -167,6 +167,46 @@ surface CategoriesResource {
|
|||||||
-- bds://categories
|
-- bds://categories
|
||||||
}
|
}
|
||||||
|
|
||||||
|
surface StatsResource {
|
||||||
|
facing viewer: McpClient
|
||||||
|
context blog: Blog
|
||||||
|
exposes:
|
||||||
|
blog.post_count
|
||||||
|
blog.media_count
|
||||||
|
blog.tag_count
|
||||||
|
blog.category_count
|
||||||
|
@guidance
|
||||||
|
-- bds://stats
|
||||||
|
}
|
||||||
|
|
||||||
|
surface PostMediaResource {
|
||||||
|
facing viewer: McpClient
|
||||||
|
context post: post/Post
|
||||||
|
exposes:
|
||||||
|
for m in post.media:
|
||||||
|
m.id
|
||||||
|
m.filename
|
||||||
|
m.title
|
||||||
|
m.alt
|
||||||
|
m.caption
|
||||||
|
m.tags
|
||||||
|
@guidance
|
||||||
|
-- bds://posts/{id}/media
|
||||||
|
-- Unknown post ids return not_found.
|
||||||
|
}
|
||||||
|
|
||||||
|
surface MediaImageResource {
|
||||||
|
facing viewer: McpClient
|
||||||
|
context media_item: media/Media
|
||||||
|
exposes:
|
||||||
|
media_item.mime_type
|
||||||
|
media_item.file_bytes
|
||||||
|
@guidance
|
||||||
|
-- bds://media/{id}/image
|
||||||
|
-- Returns blob content using the media MIME type.
|
||||||
|
-- Unknown media ids or missing files return not_found.
|
||||||
|
}
|
||||||
|
|
||||||
-- Read-only tools
|
-- Read-only tools
|
||||||
|
|
||||||
rule CheckTerm {
|
rule CheckTerm {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ defmodule BDS.MCPServerTest do
|
|||||||
{:ok, project} = BDS.Projects.create_project(%{name: "MCP Server", data_path: temp_dir})
|
{:ok, project} = BDS.Projects.create_project(%{name: "MCP Server", data_path: temp_dir})
|
||||||
{:ok, _active} = BDS.Projects.set_active_project(project.id)
|
{:ok, _active} = BDS.Projects.set_active_project(project.id)
|
||||||
|
|
||||||
%{project: project}
|
%{project: project, temp_dir: temp_dir}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "HTTP MCP server binds localhost, answers initialize, and exposes tool capabilities" do
|
test "HTTP MCP server binds localhost, answers initialize, and exposes tool capabilities" do
|
||||||
@@ -94,4 +94,50 @@ defmodule BDS.MCPServerTest do
|
|||||||
|
|
||||||
assert :ok = BDS.MCP.Server.stop()
|
assert :ok = BDS.MCP.Server.stop()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "HTTP MCP server returns media image resources as blob contents", %{
|
||||||
|
project: project,
|
||||||
|
temp_dir: temp_dir
|
||||||
|
} do
|
||||||
|
:inets.start()
|
||||||
|
|
||||||
|
source_path = Path.join(temp_dir, "server-image.png")
|
||||||
|
image_bytes = <<137, 80, 78, 71, 13, 10, 26, 10, "server-bytes">>
|
||||||
|
File.write!(source_path, image_bytes)
|
||||||
|
|
||||||
|
assert {:ok, media} =
|
||||||
|
BDS.Media.import_media(%{
|
||||||
|
project_id: project.id,
|
||||||
|
source_path: source_path,
|
||||||
|
title: "Server Image"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, server} = BDS.MCP.Server.start(0)
|
||||||
|
|
||||||
|
read_body =
|
||||||
|
Jason.encode!(%{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 3,
|
||||||
|
method: "resources/read",
|
||||||
|
params: %{uri: "bds://media/#{media.id}/image"}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, {{_version, 200, _reason}, _headers, body}} =
|
||||||
|
:httpc.request(
|
||||||
|
:post,
|
||||||
|
{to_charlist("http://127.0.0.1:#{server.port}/mcp"),
|
||||||
|
[{~c"content-type", ~c"application/json"}], ~c"application/json", read_body},
|
||||||
|
[],
|
||||||
|
body_format: :binary
|
||||||
|
)
|
||||||
|
|
||||||
|
decoded = Jason.decode!(body)
|
||||||
|
assert [content] = decoded["result"]["contents"]
|
||||||
|
assert content["uri"] == "bds://media/#{media.id}/image"
|
||||||
|
assert content["mimeType"] == "image/png"
|
||||||
|
assert Base.decode64!(content["blob"]) == image_bytes
|
||||||
|
refute Map.has_key?(content, "text")
|
||||||
|
|
||||||
|
assert :ok = BDS.MCP.Server.stop()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -304,6 +304,66 @@ defmodule BDS.MCPTest do
|
|||||||
assert post_resource["slug"] == "resource-post"
|
assert post_resource["slug"] == "resource-post"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "missing old app MCP resources expose stats, post media, and media image blobs", %{
|
||||||
|
project: project,
|
||||||
|
temp_dir: temp_dir
|
||||||
|
} do
|
||||||
|
assert {:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Media Resource Post",
|
||||||
|
content: "Body",
|
||||||
|
language: "en",
|
||||||
|
tags: ["resources"],
|
||||||
|
categories: ["article"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _published} = BDS.Posts.publish_post(post.id)
|
||||||
|
assert {:ok, _tags} = BDS.Tags.sync_tags_from_posts(project.id)
|
||||||
|
|
||||||
|
source_path = Path.join(temp_dir, "resource-image.png")
|
||||||
|
image_bytes = <<137, 80, 78, 71, 13, 10, 26, 10, "not-a-real-png-but-served-as-bytes">>
|
||||||
|
File.write!(source_path, image_bytes)
|
||||||
|
|
||||||
|
assert {:ok, media} =
|
||||||
|
BDS.Media.import_media(%{
|
||||||
|
project_id: project.id,
|
||||||
|
source_path: source_path,
|
||||||
|
title: "Resource Image",
|
||||||
|
alt: "Resource alt"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, :linked} = BDS.Media.link_media_to_post(media.id, post.id)
|
||||||
|
|
||||||
|
resource_uris = BDS.MCP.list_resources() |> Enum.map(& &1.uri)
|
||||||
|
assert "bds://stats" in resource_uris
|
||||||
|
|
||||||
|
template_uris = BDS.MCP.list_resource_templates() |> Enum.map(& &1.uriTemplate)
|
||||||
|
assert "bds://posts/{id}/media" in template_uris
|
||||||
|
assert "bds://media/{id}/image" in template_uris
|
||||||
|
|
||||||
|
assert {:ok, stats} = BDS.MCP.read_resource("bds://stats")
|
||||||
|
assert stats["posts"] == 1
|
||||||
|
assert stats["media"] == 1
|
||||||
|
assert stats["tags"] == 1
|
||||||
|
assert {:ok, categories} = BDS.MCP.read_resource("bds://categories")
|
||||||
|
assert stats["categories"] == length(categories["items"])
|
||||||
|
|
||||||
|
assert {:ok, post_media} = BDS.MCP.read_resource("bds://posts/#{post.id}/media")
|
||||||
|
|
||||||
|
assert [%{"id" => media_id, "title" => "Resource Image", "alt" => "Resource alt"}] =
|
||||||
|
post_media["items"]
|
||||||
|
|
||||||
|
assert media_id == media.id
|
||||||
|
|
||||||
|
assert {:ok, image_resource} = BDS.MCP.read_resource("bds://media/#{media.id}/image")
|
||||||
|
assert image_resource["mimeType"] == "image/png"
|
||||||
|
assert Base.decode64!(image_resource["blob"]) == image_bytes
|
||||||
|
|
||||||
|
assert {:error, :not_found} = BDS.MCP.read_resource("bds://posts/missing/media")
|
||||||
|
assert {:error, :not_found} = BDS.MCP.read_resource("bds://media/missing/image")
|
||||||
|
end
|
||||||
|
|
||||||
test "post resources use base64url cursors with 50 item pages", %{project: project} do
|
test "post resources use base64url cursors with 50 item pages", %{project: project} do
|
||||||
for index <- 1..51 do
|
for index <- 1..51 do
|
||||||
assert {:ok, _post} =
|
assert {:ok, _post} =
|
||||||
|
|||||||
Reference in New Issue
Block a user