diff --git a/lib/bds/ai/chat.ex b/lib/bds/ai/chat.ex index a9080a4..96e1807 100644 --- a/lib/bds/ai/chat.ex +++ b/lib/bds/ai/chat.ex @@ -515,11 +515,30 @@ defmodule BDS.AI.Chat do base = get_setting("ai.system_prompt") || @default_system_prompt case project_stats_summary(project_id) do - nil -> base - summary -> base <> "\n\nCurrent blog statistics:\n" <> summary + nil -> + base + + summary -> + base <> "\n\nCurrent blog statistics:\n" <> summary <> "\n\n" <> blog_tool_guidance() end end + defp blog_tool_guidance do + Enum.join( + [ + "Available blog data tools:", + "- Use blog_stats for aggregate counts of posts, media, tags, and categories.", + "- Use search_posts for full-text blog search and filtered post lookup by category, tag, language, year, month, or status.", + "- Use read_post_by_slug to read full post content and metadata when a slug is known.", + "- Use list_posts when asked for post titles, slugs, URLs, statuses, backlinks, or recent/top/latest post lists. This is allowed project data access.", + "- Use list_media when asked for media titles, filenames, MIME types, or recent media lists. This is allowed project data access.", + "- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.", + "If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data." + ], + "\n" + ) + end + defp project_stats_summary(nil), do: nil defp project_stats_summary(project_id) do diff --git a/lib/bds/ai/chat_tools.ex b/lib/bds/ai/chat_tools.ex index 7a97bc3..563567b 100644 --- a/lib/bds/ai/chat_tools.ex +++ b/lib/bds/ai/chat_tools.ex @@ -5,9 +5,11 @@ defmodule BDS.AI.ChatTools do alias BDS.AI.Chat alias BDS.Media.Media + alias BDS.MCP.Queries alias BDS.Posts.Post alias BDS.Projects.Project alias BDS.Repo + alias BDS.Search @spec execute(String.t(), map(), String.t() | nil) :: map() def execute("blog_stats", _arguments, project_id) do @@ -23,20 +25,79 @@ defmodule BDS.AI.ChatTools do } end - def execute("list_posts", arguments, project_id) do - limit = normalize_limit(arguments["limit"]) + def execute("check_term", arguments, project_id) do + project_id = project_id || active_project_id() + term = normalize_term(arguments["term"]) - Repo.all( - from(post in Post, - where: post.project_id == ^project_id, - order_by: [desc: post.updated_at], - limit: ^limit, - select: %{id: post.id, title: post.title, slug: post.slug, status: post.status} - ) - ) + posts = Repo.all(from post in Post, where: post.project_id == ^project_id) + + tag_post_count = + Enum.count(posts, fn post -> + Enum.any?(post.tags || [], &(normalize_term(&1) == term)) + end) + + category_post_count = + Enum.count(posts, fn post -> + Enum.any?(post.categories || [], &(normalize_term(&1) == term)) + end) + + %{ + is_category: category_post_count > 0, + category_post_count: category_post_count, + is_tag: tag_post_count > 0, + tag_post_count: tag_post_count + } + end + + def execute("search_posts", arguments, project_id) do + project_id = project_id || active_project_id() + filters = search_filters(arguments) + + {:ok, result} = Search.search_posts(project_id, arguments["query"] || "", filters) + + %{ + posts: 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 + } + end + + def execute("read_post_by_slug", arguments, project_id) do + project_id = project_id || active_project_id() + + case Repo.get_by(Post, project_id: project_id, slug: arguments["slug"]) do + %Post{} = post -> %{post: Queries.post_detail(post)} + nil -> %{error: "not_found"} + end + end + + def execute("list_posts", arguments, project_id) do + project_id = project_id || active_project_id() + limit = normalize_limit(arguments["limit"]) + offset = normalize_offset(arguments["offset"]) + filters = search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset}) + + {:ok, result} = Search.search_posts(project_id, "", filters) + + %{ + posts: + Enum.map(result.posts, fn post -> + post + |> Queries.post_summary() + |> Map.put("url", "/posts/#{post.slug}") + |> Map.put("updated_at", post.updated_at) + end), + total: result.total, + offset: result.offset, + limit: result.limit, + has_more: result.offset + result.limit < result.total + } end def execute("list_media", arguments, project_id) do + project_id = project_id || active_project_id() limit = normalize_limit(arguments["limit"]) Repo.all( @@ -48,12 +109,46 @@ defmodule BDS.AI.ChatTools do id: media.id, title: media.title, mime_type: media.mime_type, - filename: media.filename + filename: media.filename, + updated_at: media.updated_at } ) ) end + def execute("list_tags", _arguments, project_id) do + project_id = project_id || active_project_id() + + %{ + tags: counted_terms(project_id, :tags), + count: length(counted_terms(project_id, :tags)) + } + end + + def execute("list_categories", _arguments, project_id) do + project_id = project_id || active_project_id() + + %{ + categories: counted_terms(project_id, :categories), + count: length(counted_terms(project_id, :categories)) + } + end + + def execute("count_posts", arguments, project_id) do + project_id = project_id || active_project_id() + group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1) + {:ok, result} = Search.search_posts(project_id, "", search_filters(arguments)) + + groups = + result.posts + |> Enum.flat_map(&Queries.group_rows(&1, group_by)) + |> Enum.group_by(& &1, fn _row -> 1 end) + |> Enum.map(fn {row, counts} -> Map.put(row, "count", length(counts)) end) + |> Enum.sort_by(&Map.to_list/1) + + %{groups: groups, total_posts: result.total} + end + def execute("render_table", arguments, _project_id) do %{ type: "table", @@ -142,19 +237,88 @@ defmodule BDS.AI.ChatTools do "properties" => %{} }) }, + %{ + name: "check_term", + spec: + tool_spec( + "check_term", + "Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or list_posts when unsure whether a term is a category or tag.", + %{ + "type" => "object", + "properties" => %{"term" => %{"type" => "string"}}, + "required" => ["term"] + } + ) + }, + %{ + name: "search_posts", + spec: + tool_spec( + "search_posts", + "Search blog posts using full-text search. Can filter by category, tags, language, missing translation language, year, month, or status. Returns paginated concrete post data with titles, slugs, tags, categories, backlinks, and links_to.", + post_search_schema(true) + ) + }, + %{ + name: "read_post_by_slug", + spec: + tool_spec( + "read_post_by_slug", + "Read full content and metadata of a specific blog post by slug. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.", + %{ + "type" => "object", + "properties" => %{"slug" => %{"type" => "string"}}, + "required" => ["slug"] + } + ) + }, %{ name: "list_posts", spec: - tool_spec("list_posts", "List recent posts in the active project", limit_schema()) + tool_spec( + "list_posts", + "List blog posts with optional filtering by status, category, tags, language, year, or month. Returns paginated concrete post data with titles, slugs, URLs, statuses, tags, categories, backlinks, and links_to. Use for recent, latest, top, or title-list requests.", + post_search_schema(false) + ) }, %{ name: "list_media", spec: tool_spec( "list_media", - "List recent media items in the active project", + "List concrete media data in the active project, including titles, filenames, MIME types, and update times.", limit_schema() ) + }, + %{ + name: "list_tags", + spec: + tool_spec( + "list_tags", + "List all tags used across blog posts with post counts.", + %{ + "type" => "object", + "properties" => %{} + } + ) + }, + %{ + name: "list_categories", + spec: + tool_spec( + "list_categories", + "List all categories used across blog posts with post counts.", + %{"type" => "object", "properties" => %{}} + ) + }, + %{ + name: "count_posts", + spec: + tool_spec( + "count_posts", + "Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.", + count_posts_schema() + ) } ] else @@ -245,6 +409,47 @@ defmodule BDS.AI.ChatTools do } end + defp post_search_schema(require_query) do + schema = %{ + "type" => "object", + "properties" => %{ + "query" => %{"type" => "string"}, + "status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]}, + "category" => %{"type" => "string"}, + "tags" => %{"type" => "array", "items" => %{"type" => "string"}}, + "language" => %{"type" => "string"}, + "missingTranslationLanguage" => %{"type" => "string"}, + "year" => %{"type" => "integer"}, + "month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12}, + "limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50}, + "offset" => %{"type" => "integer", "minimum" => 0} + } + } + + if require_query, do: Map.put(schema, "required", ["query"]), else: schema + end + + defp count_posts_schema do + %{ + "type" => "object", + "properties" => %{ + "groupBy" => %{ + "type" => "array", + "items" => %{ + "type" => "string", + "enum" => ["year", "month", "tag", "category", "status"] + } + }, + "year" => %{"type" => "integer"}, + "month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12}, + "status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]}, + "category" => %{"type" => "string"}, + "tags" => %{"type" => "array", "items" => %{"type" => "string"}} + }, + "required" => ["groupBy"] + } + end + defp render_table_schema do %{ "type" => "object", @@ -334,6 +539,41 @@ defmodule BDS.AI.ChatTools do defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value defp normalize_limit(_value), do: 10 + defp normalize_offset(value) when is_integer(value) and value >= 0, do: value + defp normalize_offset(_value), do: 0 + + defp search_filters(arguments) do + %{} + |> maybe_put(:category, arguments["category"]) + |> maybe_put(:tags, arguments["tags"]) + |> maybe_put(:language, arguments["language"]) + |> maybe_put(:missing_translation_language, arguments["missingTranslationLanguage"]) + |> maybe_put(:year, arguments["year"]) + |> maybe_put(:month, arguments["month"]) + |> maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"])) + |> Map.put(:offset, normalize_offset(arguments["offset"])) + |> Map.put(:limit, normalize_limit(arguments["limit"])) + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, _key, ""), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp counted_terms(project_id, field) do + Repo.all( + from post in Post, where: post.project_id == ^project_id, select: field(post, ^field) + ) + |> List.flatten() + |> Enum.reject(&blank?/1) + |> Enum.frequencies() + |> Enum.map(fn {term, count} -> %{name: term, count: count} end) + |> Enum.sort_by(&String.downcase(to_string(&1.name))) + end + + defp blank?(value), do: is_nil(value) or String.trim(to_string(value)) == "" + + defp normalize_term(value), do: value |> to_string() |> String.downcase() + defp active_project_id do Repo.one(from(project in Project, where: project.is_active == true, select: project.id)) end diff --git a/lib/bds/mcp/tools.ex b/lib/bds/mcp/tools.ex index 0554234..7e77cbf 100644 --- a/lib/bds/mcp/tools.ex +++ b/lib/bds/mcp/tools.ex @@ -18,7 +18,13 @@ defmodule BDS.MCP.Tools do @proposal_ttl_app_ms 30 * 60 * 1000 @typedoc "Tool descriptor returned by `list/0`." - @type descriptor :: %{name: String.t(), annotations: map()} + @type descriptor :: %{ + name: String.t(), + title: String.t(), + description: String.t(), + inputSchema: map(), + annotations: map() + } @spec list() :: [descriptor()] def list do @@ -75,12 +81,269 @@ defmodule BDS.MCP.Tools do end defp tool(name, read_only) do + metadata = tool_metadata(name) + %{ name: name, - annotations: %{"readOnlyHint" => read_only, "destructiveHint" => false} + title: metadata.title, + description: metadata.description, + inputSchema: metadata.input_schema, + annotations: %{ + "readOnlyHint" => read_only, + "destructiveHint" => false, + "openWorldHint" => false + } } end + defp tool_metadata("check_term") do + %{ + title: "Check Term", + description: + "Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or count_posts when unsure whether a term is a category or tag.", + input_schema: object_schema(%{"term" => string_schema("The term to look up")}, ["term"]) + } + end + + defp tool_metadata("search_posts") do + %{ + title: "Search Posts", + description: + "Search blog posts by query, category, tags, language, translation coverage, date, or status. Returns a paginated envelope with total, offset, limit, hasMore, and posts. Each post includes title, slug, tags, categories, backlinks, and linksTo. When hasMore is true, increase offset by limit. Use check_term first if unsure whether a term is a category or tag.", + input_schema: post_query_schema(false) + } + end + + defp tool_metadata("count_posts") do + %{ + title: "Count Posts", + description: + "Count posts grouped by year, month, tag, category, or status. Returns aggregated counts without full post data, useful for analytics, distributions, and heat maps. Example: groupBy=[\"month\",\"tag\"] with year=2004.", + input_schema: + object_schema( + Map.merge(group_filter_properties(), %{ + "groupBy" => %{ + "type" => "array", + "items" => enum_schema(["year", "month", "tag", "category", "status"]), + "description" => "Dimensions to group by; one to three dimensions is usually best" + } + }), + ["groupBy"] + ) + } + end + + defp tool_metadata("read_post_by_slug") do + %{ + title: "Read Post By Slug", + description: + "Read full content and metadata for a specific blog post by slug. Includes title, excerpt, content, status, tags, categories, backlinks, linksTo, and available languages. Optionally request a translation by language.", + input_schema: + object_schema( + %{ + "slug" => string_schema("The slug of the post to read"), + "language" => string_schema("Optional language code for a translation") + }, + ["slug"] + ) + } + end + + defp tool_metadata("get_post_translations") do + %{ + title: "Get Post Translations", + description: + "List all translations available for a blog post, including language, title, excerpt, content, and status.", + input_schema: object_schema(%{"postId" => string_schema("The post ID")}, ["postId"]) + } + end + + defp tool_metadata("get_media_translations") do + %{ + title: "Get Media Translations", + description: + "List all available translations for media metadata, including language, title, alt text, and captions.", + input_schema: object_schema(%{"mediaId" => string_schema("The media ID")}, ["mediaId"]) + } + end + + defp tool_metadata("upsert_media_translation") do + %{ + title: "Upsert Media Translation", + description: "Create or update translated media metadata for a specific language.", + input_schema: + object_schema( + %{ + "mediaId" => string_schema("The media ID"), + "language" => string_schema("Language code to update"), + "title" => string_schema("Translated title"), + "alt" => string_schema("Translated alt text"), + "caption" => string_schema("Translated caption") + }, + ["mediaId", "language"] + ) + } + end + + defp tool_metadata("draft_post") do + %{ + title: "Draft Post", + description: "Create a new draft blog post for review before publishing.", + input_schema: + object_schema( + %{ + "title" => string_schema("Post title"), + "content" => string_schema("Post content in Markdown"), + "excerpt" => string_schema("Short excerpt or summary"), + "tags" => string_array_schema("Tags for the post"), + "categories" => string_array_schema("Categories for the post"), + "author" => string_schema("Post author name") + }, + ["title", "content"] + ) + } + end + + defp tool_metadata("propose_script") do + %{ + title: "Propose Script", + description: "Propose a new Python script, macro, utility, or transform for review.", + input_schema: + object_schema( + %{ + "title" => string_schema("Script title"), + "kind" => enum_schema(["macro", "utility", "transform"]), + "content" => string_schema("Python source code"), + "entrypoint" => string_schema("Entry point function name") + }, + ["title", "kind", "content"] + ) + } + end + + defp tool_metadata("propose_template") do + %{ + title: "Propose Template", + description: "Propose a new Liquid template for review.", + input_schema: + object_schema( + %{ + "title" => string_schema("Template title"), + "kind" => enum_schema(["post", "list", "not-found", "partial"]), + "content" => string_schema("Liquid template content") + }, + ["title", "kind", "content"] + ) + } + end + + defp tool_metadata("propose_media_metadata") do + %{ + title: "Propose Media Metadata", + description: + "Propose changes to media metadata such as title, alt text, caption, and tags.", + input_schema: + object_schema( + %{ + "mediaId" => string_schema("The media ID"), + "title" => string_schema("New title"), + "alt" => string_schema("New alt text"), + "caption" => string_schema("New caption"), + "tags" => string_array_schema("New tags") + }, + ["mediaId"] + ) + } + end + + defp tool_metadata("propose_post_metadata") do + %{ + title: "Propose Post Metadata", + description: + "Propose changes to post metadata such as title, excerpt, tags, and categories.", + input_schema: + object_schema( + %{ + "postId" => string_schema("The post ID"), + "title" => string_schema("New title"), + "excerpt" => string_schema("New excerpt"), + "tags" => string_array_schema("New tags"), + "categories" => string_array_schema("New categories") + }, + ["postId"] + ) + } + end + + defp tool_metadata("accept_proposal") do + %{ + title: "Accept Proposal", + description: "Accept a pending proposal and apply or publish its changes.", + input_schema: + object_schema(%{"proposalId" => string_schema("The proposal ID")}, ["proposalId"]) + } + end + + defp tool_metadata("discard_proposal") do + %{ + title: "Discard Proposal", + description: "Discard a pending proposal and remove any temporary draft artifacts.", + input_schema: + object_schema(%{"proposalId" => string_schema("The proposal ID")}, ["proposalId"]) + } + end + + defp post_query_schema(query_required) do + required = if query_required, do: ["query"], else: [] + + object_schema( + Map.merge(group_filter_properties(), %{ + "query" => string_schema("Full-text search query"), + "language" => string_schema("Require posts available in this language"), + "missingTranslationLanguage" => + string_schema("Require posts missing this translation language"), + "offset" => %{"type" => "integer", "minimum" => 0, "description" => "Pagination offset"}, + "limit" => %{ + "type" => "integer", + "minimum" => 1, + "maximum" => 50, + "description" => "Maximum results to return" + } + }), + required + ) + end + + defp group_filter_properties do + %{ + "year" => %{"type" => "integer", "description" => "Filter to posts in this year"}, + "month" => %{ + "type" => "integer", + "minimum" => 1, + "maximum" => 12, + "description" => "Filter to posts in this month; requires year" + }, + "status" => enum_schema(["draft", "published", "archived"]), + "category" => string_schema("Filter by category"), + "tags" => string_array_schema("Filter by tags; all must match") + } + end + + defp object_schema(properties, required) do + %{"type" => "object", "properties" => properties} + |> maybe_schema_required(required) + end + + defp maybe_schema_required(schema, []), do: schema + defp maybe_schema_required(schema, required), do: Map.put(schema, "required", required) + + defp string_schema(description), do: %{"type" => "string", "description" => description} + + defp string_array_schema(description), + do: %{"type" => "array", "items" => %{"type" => "string"}, "description" => description} + + defp enum_schema(values), do: %{"type" => "string", "enum" => values} + defp check_term(%{"term" => term}), do: check_term(%{term: term}) defp check_term(%{term: term}) do diff --git a/test/bds/ai_test.exs b/test/bds/ai_test.exs index 5fd2065..b170cc2 100644 --- a/test/bds/ai_test.exs +++ b/test/bds/ai_test.exs @@ -203,7 +203,9 @@ defmodule BDS.AITest do url: "https://api.example.test/v1", api_key: "top-secret", model: "gpt-4o-mini" - }, secret_backend: FakeSecretBackend) + }, + secret_backend: FakeSecretBackend + ) assert endpoint.kind == :online assert endpoint.url == "https://api.example.test/v1" @@ -316,7 +318,9 @@ defmodule BDS.AITest do url: "https://api.example.test/v1", api_key: "online-secret", model: "gpt-4o-mini" - }, secret_backend: FakeSecretBackend) + }, + secret_backend: FakeSecretBackend + ) assert {:ok, _endpoint} = BDS.AI.put_endpoint( @@ -325,7 +329,9 @@ defmodule BDS.AITest do url: "http://localhost:11434/v1", api_key: nil, model: "llama-default" - }, secret_backend: FakeSecretBackend) + }, + secret_backend: FakeSecretBackend + ) assert :ok = BDS.AI.set_airplane_mode(true) assert :ok = BDS.AI.put_model_preference(:airplane_title, "llama3.1") @@ -354,7 +360,9 @@ defmodule BDS.AITest do url: "https://api.example.test/v1", api_key: "online-secret", model: "gpt-4o-mini" - }, secret_backend: FakeSecretBackend) + }, + secret_backend: FakeSecretBackend + ) assert :ok = BDS.AI.set_airplane_mode(false) assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini") @@ -389,7 +397,9 @@ defmodule BDS.AITest do url: "https://api.example.test/v1", api_key: "online-secret", model: "gpt-4o-mini" - }, secret_backend: FakeSecretBackend) + }, + secret_backend: FakeSecretBackend + ) assert :ok = BDS.AI.set_airplane_mode(false) assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini") @@ -421,7 +431,9 @@ defmodule BDS.AITest do url: "http://localhost:11434/v1", api_key: nil, model: "llama-default" - }, secret_backend: FakeSecretBackend) + }, + secret_backend: FakeSecretBackend + ) assert :ok = BDS.AI.set_airplane_mode(true) assert :ok = BDS.AI.put_model_preference(:airplane_image_analysis, "llama3.2") @@ -434,7 +446,11 @@ defmodule BDS.AITest do alt: nil, caption: nil, image_url: "file:///tmp/test.png" - }, runtime: FakeRuntime, test_pid: self(), secret_backend: FakeSecretBackend) + }, + runtime: FakeRuntime, + test_pid: self(), + secret_backend: FakeSecretBackend + ) assert :ok = BDS.AI.put_model_capabilities("llama3.2", %{ @@ -450,7 +466,11 @@ defmodule BDS.AITest do alt: nil, caption: nil, image_url: "file:///tmp/test.png" - }, runtime: FakeRuntime, test_pid: self(), secret_backend: FakeSecretBackend) + }, + runtime: FakeRuntime, + test_pid: self(), + secret_backend: FakeSecretBackend + ) assert analysis.alt == "Orange sunset over calm water" @@ -471,7 +491,9 @@ defmodule BDS.AITest do url: "https://api.example.test/v1", api_key: "online-secret", model: "gpt-4o-mini" - }, secret_backend: FakeSecretBackend) + }, + secret_backend: FakeSecretBackend + ) assert :ok = BDS.AI.set_airplane_mode(false) assert {:ok, conversation} = BDS.AI.start_chat(%{model: "gpt-4o-mini"}) @@ -506,12 +528,51 @@ defmodule BDS.AITest do assert Enum.any?(first_request.messages, fn message -> message["role"] == "system" and String.contains?(message["content"], "Posts: 1") and - String.contains?(message["content"], "Media: 1") + String.contains?(message["content"], "Media: 1") and + String.contains?(message["content"], "Available blog data tools") and + String.contains?(message["content"], "list_posts") and + String.contains?(message["content"], "list_media") end) + tool_descriptions = + first_request.tools + |> Map.new(fn tool -> + {get_in(tool, ["function", "name"]), get_in(tool, ["function", "description"])} + end) + + assert tool_descriptions["blog_stats"] =~ "aggregate" + assert tool_descriptions["list_posts"] =~ "titles" + assert tool_descriptions["list_posts"] =~ "URLs" + assert tool_descriptions["list_media"] =~ "filenames" + assert Enum.any?(second_request.messages, fn message -> message["role"] == "tool" end) end + test "non-stat chat tools expose concrete project data" do + {:ok, project} = create_project_fixture("Concrete Tools") + :ok = seed_project_content(project.id) + + [post] = + Repo.all( + from post in Post, + where: post.project_id == ^project.id, + select: post + ) + + assert %{posts: [listed_post], total: 1} = + BDS.AI.ChatTools.execute("list_posts", %{"limit" => 5}, project.id) + + assert listed_post["title"] == post.title + assert listed_post["slug"] == post.slug + assert listed_post["url"] == "/posts/#{post.slug}" + assert listed_post["updated_at"] == post.updated_at + + assert [listed_media] = BDS.AI.ChatTools.execute("list_media", %{"limit" => 5}, project.id) + assert listed_media.filename == "image.png" + assert listed_media.mime_type == "image/png" + assert listed_media.updated_at + end + test "cancel_chat aborts an in-flight chat turn" do assert {:ok, _endpoint} = BDS.AI.put_endpoint( @@ -520,7 +581,9 @@ defmodule BDS.AITest do url: "https://api.example.test/v1", api_key: "online-secret", model: "gpt-4o-mini" - }, secret_backend: FakeSecretBackend) + }, + secret_backend: FakeSecretBackend + ) assert {:ok, conversation} = BDS.AI.start_chat(%{model: "gpt-4o-mini"}) diff --git a/test/bds/mcp_test.exs b/test/bds/mcp_test.exs index 774a62e..704acdf 100644 --- a/test/bds/mcp_test.exs +++ b/test/bds/mcp_test.exs @@ -21,9 +21,8 @@ defmodule BDS.MCPTest do end test "list_tools follows the old app tool surface for implemented backend features" do - tool_names = - BDS.MCP.list_tools() - |> Enum.map(& &1.name) + tools = BDS.MCP.list_tools() + tool_names = Enum.map(tools, & &1.name) assert "check_term" in tool_names assert "search_posts" in tool_names @@ -39,6 +38,20 @@ defmodule BDS.MCPTest do assert "propose_post_metadata" in tool_names assert "accept_proposal" in tool_names assert "discard_proposal" in tool_names + + search_posts = Enum.find(tools, &(&1.name == "search_posts")) + assert search_posts.title == "Search Posts" + assert search_posts.description =~ "paginated envelope" + assert search_posts.description =~ "backlinks" + assert get_in(search_posts.inputSchema, ["properties", "query", "description"]) =~ "Full-text" + assert get_in(search_posts.inputSchema, ["properties", "tags", "items", "type"]) == "string" + assert search_posts.annotations["readOnlyHint"] == true + assert search_posts.annotations["openWorldHint"] == false + + draft_post = Enum.find(tools, &(&1.name == "draft_post")) + assert draft_post.description =~ "draft blog post" + assert draft_post.inputSchema["required"] == ["title", "content"] + assert draft_post.annotations["readOnlyHint"] == false end test "check_term, search_posts, count_posts, and read_post_by_slug expose current blog data", %{