diff --git a/ALIGNMENT.md b/ALIGNMENT.md index f650e87..7b84843 100644 --- a/ALIGNMENT.md +++ b/ALIGNMENT.md @@ -26,7 +26,7 @@ Goal: align bDS2 with old bDS behavior. Use the Allium specs as the contract onl - Spec: missing these tools. - 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`. - bDS2 now: only posts, media, tags, and categories are exposed. diff --git a/lib/bds/mcp/resources.ex b/lib/bds/mcp/resources.ex index 6de7d81..563a5fb 100644 --- a/lib/bds/mcp/resources.ex +++ b/lib/bds/mcp/resources.ex @@ -1,12 +1,16 @@ defmodule BDS.MCP.Resources do @moduledoc false + import Ecto.Query + alias BDS.MCP.ProposalStore alias BDS.MCP.Queries alias BDS.MCP.Util alias BDS.Media.Media, as: MediaAsset alias BDS.Metadata + alias BDS.Posts.PostMedia alias BDS.Posts.Post + alias BDS.Projects alias BDS.Repo alias BDS.Search alias BDS.Tags @@ -23,7 +27,8 @@ defmodule BDS.MCP.Resources do %{name: "posts", uri: "bds://posts"}, %{name: "media", uri: "bds://media"}, %{name: "tags", uri: "bds://tags"}, - %{name: "categories", uri: "bds://categories"} + %{name: "categories", uri: "bds://categories"}, + %{name: "stats", uri: "bds://stats"} ] end @@ -31,7 +36,9 @@ defmodule BDS.MCP.Resources do def templates do [ %{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 @@ -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: "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) - %URI{scheme: "bds", host: "media", path: "/" <> id} -> read_media_resource(id) + %URI{scheme: "bds", host: "stats", path: nil} -> {:ok, stats_resource()} + %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} end end @@ -155,6 +163,40 @@ defmodule BDS.MCP.Resources do } 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 case Repo.get(Post, id) do %Post{} = post -> {:ok, Queries.post_detail(post)} @@ -168,4 +210,47 @@ defmodule BDS.MCP.Resources do nil -> {:error, :not_found} 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 diff --git a/lib/bds/mcp/server.ex b/lib/bds/mcp/server.ex index 10066b3..1cb5da0 100644 --- a/lib/bds/mcp/server.ex +++ b/lib/bds/mcp/server.ex @@ -189,8 +189,7 @@ defmodule BDS.MCP.Server do {:ok, success_response(id, %{"resources" => BDS.MCP.list_resources()})} "resources/templates/list" -> - {:ok, - success_response(id, %{"resourceTemplates" => BDS.MCP.list_resource_templates()})} + {:ok, success_response(id, %{"resourceTemplates" => BDS.MCP.list_resource_templates()})} "resources/read" -> read_resource(id, params) @@ -227,9 +226,7 @@ defmodule BDS.MCP.Server do {:ok, result} -> {:ok, success_response(id, %{ - "contents" => [ - %{"uri" => uri, "mimeType" => "application/json", "text" => Jason.encode!(result)} - ] + "contents" => [resource_content(uri, result)] })} {: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 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 error_response(id, code, message) do diff --git a/specs/mcp.allium b/specs/mcp.allium index 7ca936d..a51f022 100644 --- a/specs/mcp.allium +++ b/specs/mcp.allium @@ -167,6 +167,46 @@ surface CategoriesResource { -- 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 rule CheckTerm { diff --git a/test/bds/mcp_server_test.exs b/test/bds/mcp_server_test.exs index 49f11f4..fe0a401 100644 --- a/test/bds/mcp_server_test.exs +++ b/test/bds/mcp_server_test.exs @@ -13,7 +13,7 @@ defmodule BDS.MCPServerTest do {:ok, project} = BDS.Projects.create_project(%{name: "MCP Server", data_path: temp_dir}) {:ok, _active} = BDS.Projects.set_active_project(project.id) - %{project: project} + %{project: project, temp_dir: temp_dir} end 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() 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 diff --git a/test/bds/mcp_test.exs b/test/bds/mcp_test.exs index a53f6bc..774a62e 100644 --- a/test/bds/mcp_test.exs +++ b/test/bds/mcp_test.exs @@ -304,6 +304,66 @@ defmodule BDS.MCPTest do assert post_resource["slug"] == "resource-post" 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 for index <- 1..51 do assert {:ok, _post} =