diff --git a/CODESMELL.md b/CODESMELL.md index 5293f3b..f9cef8d 100644 --- a/CODESMELL.md +++ b/CODESMELL.md @@ -2,19 +2,17 @@ Living document. Each section lists **status**, then **open work in priority order**, then a brief notes block. Completed work is listed compactly at the bottom (`## Changelog`). -Last refreshed: 2026-05-08. +Last refreshed: 2026-05-09. --- ## 1. God Modules -**Status:** in progress. Five originally-flagged god modules are reduced to coordinator size; eight new files (mostly LiveView editors) crossed the 800-line threshold and are now the active queue. +**Status:** in progress. The originally-flagged god modules (now including `BDS.MCP`) are all reduced to coordinator size; the open queue is empty. ### Open queue (priority order) -| # | Module | Current lines | Target | Strategy | -|---|---|---|---|---| -| 9 | `BDS.MCP` | 677 | ≤ 350 | Split tools / resources / proposals / serialization clusters. (Carried over from original priority list.) | +_None._ All modules previously on the queue have been split; refresh the queue if a new module crosses the 800-line threshold. **Established pattern:** extract cohesive helper clusters into submodules under `lib//.ex`; `import BDS.X.Y, only: [...]` from the main module so internal call sites are unchanged; `defdelegate` for any helper still needed through the public namespace; verify with `mix compile --warnings-as-errors`, `mix dialyzer --format short`, and full `mix test` after each extraction. @@ -33,6 +31,7 @@ Last refreshed: 2026-05-08. - `BDS.Desktop.ShellLive.PostEditor` 963 → 506 (47 %) - `BDS.Desktop.ShellLive.SettingsEditor` 872 → 226 (74 %) - `BDS.Desktop.ShellLive.ChatEditor` 972 → 576 (41 %) +- `BDS.MCP` 677 → 27 (96 %) --- @@ -166,6 +165,11 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search` ## Changelog +### 2026-05-09 + +- **God modules**: + - `BDS.MCP` 677 → 27 (96 %). Submodules under `lib/bds/mcp/`: `Util` (36, sanitize/1 + normalize_term/1 + maybe_put/3 + map_get/3), `Queries` (201, page_size/0 + active_project!/0 + post_summary/1 + post_detail/1 + translated_post_detail/2 + search_filters/1 + parse_status/1 + group_rows/2 + private group_values/2 (5 clauses) + available_languages/2 + linked_posts/2 + private load_linked_post/1 + post_body/1 + translation_body/1 + tag_post_count/2 + category_post_count/2), `Tools` (394, list/0 + call/2 + validate_template/1 + private check_term/1 + search_posts/1 + count_posts/1 + read_post_by_slug/1 + draft_post/1 + propose_script/1 + propose_template/1 + propose_media_metadata/1 + propose_post_metadata/1 + accept_proposal/1 + discard_proposal/1 + script_validation/1 + parse_script_kind/1 + parse_template_kind/1 + tool/2), `Resources` (112, list/0 + read/1 + private posts_resource/1 + media_resource/1 + tags_resource/0 + categories_resource/0 + read_post_resource/1 + read_media_resource/1). Coordinator `BDS.MCP` keeps the 5 public entrypoints (`list_tools/0`, `list_resources/0`, `call_tool/2`, `read_resource/1`, `validate_template/1`) as `defdelegate` to `Tools`/`Resources` and re-exports the `tool_descriptor`/`resource_descriptor` types as aliases of `Tools.descriptor`/`Resources.descriptor`. Cross-submodule deps are linear and acyclic: `Tools` → `Queries` + `Util` + `ProposalStore`; `Resources` → `Queries` + `Util` + `ProposalStore`; `Queries` → `Util`; `Util` is a leaf. Existing siblings (`ProposalStore`, `Proposal`, `AgentConfig`, `Server`, `Stdio`) untouched. The pre-existing `BDS.MCP.Server`/`Stdio` callers (`call_tool` / `list_tools` / `list_resources` / `read_resource`) keep using the coordinator unchanged. Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (Total errors: 0), `mix test` (342 tests, 0 failures, 4 skipped). + ### 2026-05-08 - **God modules**: diff --git a/lib/bds/mcp.ex b/lib/bds/mcp.ex index 71eb5f4..f23f38b 100644 --- a/lib/bds/mcp.ex +++ b/lib/bds/mcp.ex @@ -1,677 +1,27 @@ defmodule BDS.MCP do @moduledoc false - import Ecto.Query - - alias BDS.Media - alias BDS.Media.Media, as: MediaAsset - alias BDS.Metadata - alias BDS.MCP.ProposalStore - alias BDS.PostLinks - alias BDS.Posts - alias BDS.Posts.Post - alias BDS.Posts.Translation, as: PostTranslation - alias BDS.Projects - alias BDS.Repo - alias BDS.Scripts - alias BDS.Search - alias BDS.Tags - alias BDS.Templates - - @page_size 50 - @proposal_ttl_app_ms 30 * 60 * 1000 + alias BDS.MCP.Resources + alias BDS.MCP.Tools @typedoc "Tool descriptor returned by `list_tools/0`." - @type tool_descriptor :: %{name: String.t(), annotations: map()} + @type tool_descriptor :: Tools.descriptor() @typedoc "Resource descriptor returned by `list_resources/0`." - @type resource_descriptor :: %{name: String.t(), uri: String.t()} + @type resource_descriptor :: Resources.descriptor() @spec list_tools() :: [tool_descriptor()] - def list_tools do - [ - tool("check_term", true), - tool("search_posts", true), - tool("count_posts", true), - tool("read_post_by_slug", true), - tool("draft_post", false), - tool("propose_script", false), - tool("propose_template", false), - tool("propose_media_metadata", false), - tool("propose_post_metadata", false), - tool("accept_proposal", false), - tool("discard_proposal", false) - ] - end + defdelegate list_tools(), to: Tools, as: :list @spec list_resources() :: [resource_descriptor()] - def list_resources do - [ - %{name: "posts", uri: "bds://posts"}, - %{name: "media", uri: "bds://media"}, - %{name: "tags", uri: "bds://tags"}, - %{name: "categories", uri: "bds://categories"} - ] - end + defdelegate list_resources(), to: Resources, as: :list @spec call_tool(String.t(), map()) :: {:ok, term()} | {:error, term()} - def call_tool(name, params) when is_binary(name) and is_map(params) do - ProposalStore.ensure_started() - - case name do - "check_term" -> {:ok, check_term(params)} - "search_posts" -> {:ok, search_posts(params)} - "count_posts" -> {:ok, count_posts(params)} - "read_post_by_slug" -> read_post_by_slug(params) - "draft_post" -> draft_post(params) - "propose_script" -> propose_script(params) - "propose_template" -> propose_template(params) - "propose_media_metadata" -> propose_media_metadata(params) - "propose_post_metadata" -> propose_post_metadata(params) - "accept_proposal" -> accept_proposal(params) - "discard_proposal" -> discard_proposal(params) - _other -> {:error, :unknown_tool} - end - end + defdelegate call_tool(name, params), to: Tools, as: :call @spec read_resource(String.t()) :: {:ok, term()} | {:error, term()} - def read_resource(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: "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) - _other -> {:error, :not_found} - end - end + defdelegate read_resource(uri), to: Resources, as: :read @spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}} - def validate_template(source) when is_binary(source) do - case Liquex.parse(source) do - {:ok, _ast} -> {:ok, %{valid: true, errors: []}} - {:error, reason, line} -> {:ok, %{valid: false, errors: ["#{inspect(reason)} at line #{line}"]}} - end - end - - defp tool(name, read_only) do - %{ - name: name, - annotations: %{"readOnlyHint" => read_only, "destructiveHint" => false} - } - end - - defp active_project! do - case Enum.find(Projects.list_projects(), & &1.is_active) do - nil -> raise "no active project" - project -> project - end - end - - defp check_term(%{"term" => term}), do: check_term(%{term: term}) - - defp check_term(%{term: term}) do - project = active_project!() - normalized = normalize_term(term) - - 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) == normalized)) - end) - - category_post_count = - Enum.count(posts, fn post -> - Enum.any?(post.categories || [], &(normalize_term(&1) == normalized)) - 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 - - defp search_posts(params) do - project = active_project!() - filters = search_filters(params) - query = map_get(params, :query, "") - {:ok, result} = Search.search_posts(project.id, query, filters) - - posts = Enum.map(result.posts, &post_summary/1) - - %{ - "posts" => posts, - "total" => result.total, - "offset" => result.offset, - "limit" => result.limit, - "has_more" => result.offset + result.limit < result.total - } - end - - defp count_posts(params) do - project = active_project!() - group_by = map_get(params, :groupBy, []) |> Enum.map(&to_string/1) - filters = search_filters(params) - {:ok, result} = Search.search_posts(project.id, "", filters) - - groups = - result.posts - |> Enum.flat_map(&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 - - defp read_post_by_slug(%{"slug" => slug} = params), do: read_post_by_slug(Map.put_new(params, :slug, slug)) - - defp read_post_by_slug(%{slug: slug} = params) do - project = active_project!() - - case Repo.get_by(Post, project_id: project.id, slug: slug) do - nil -> {:error, :not_found} - %Post{} = post -> - payload = - case map_get(params, :language) do - nil -> post_detail(post) - "" -> post_detail(post) - language -> - if normalize_term(language) == normalize_term(post.language) do - post_detail(post) - else - translated_post_detail(post, language) - end - end - - {:ok, %{"post" => payload}} - end - end - - defp draft_post(params) do - project = active_project!() - - attrs = %{ - project_id: project.id, - title: map_get(params, :title, ""), - content: map_get(params, :content, ""), - excerpt: map_get(params, :excerpt), - tags: map_get(params, :tags, []), - categories: map_get(params, :categories, []), - author: map_get(params, :author) - } - - with {:ok, post} <- Posts.create_post(attrs) do - proposal = - ProposalStore.create("draft_post", %{"post_id" => post.id}, - entity_id: post.id, - ttl_ms: @proposal_ttl_app_ms - ) - - {:ok, %{"proposal_id" => proposal.id, "post" => sanitize(post)}} - end - end - - defp propose_script(params) do - project = active_project!() - content = map_get(params, :content, "") - - with validation <- script_validation(content), - {:ok, script} <- - Scripts.create_script(%{ - project_id: project.id, - title: map_get(params, :title, ""), - kind: parse_script_kind(map_get(params, :kind)), - content: content, - entrypoint: map_get(params, :entrypoint) - }) do - proposal = - ProposalStore.create("propose_script", %{"script_id" => script.id}, - entity_id: script.id, - ttl_ms: @proposal_ttl_app_ms - ) - - {:ok, - %{ - "proposal_id" => proposal.id, - "script" => sanitize(script), - "preview" => %{ - "title" => script.title, - "kind" => Atom.to_string(script.kind), - "content_length" => String.length(content), - "syntax_valid" => validation.valid, - "syntax_errors" => validation.errors - } - }} - end - end - - defp propose_template(params) do - project = active_project!() - content = map_get(params, :content, "") - {:ok, validation} = validate_template(content) - - with {:ok, template} <- - Templates.create_template(%{ - project_id: project.id, - title: map_get(params, :title, ""), - kind: parse_template_kind(map_get(params, :kind)), - content: content - }) do - proposal = - ProposalStore.create("propose_template", %{"template_id" => template.id}, - entity_id: template.id, - ttl_ms: @proposal_ttl_app_ms - ) - - {:ok, - %{ - "proposal_id" => proposal.id, - "template" => sanitize(template), - "preview" => %{ - "title" => template.title, - "kind" => Atom.to_string(template.kind), - "content_length" => String.length(content), - "syntax_valid" => validation.valid, - "syntax_errors" => validation.errors - } - }} - end - end - - defp propose_media_metadata(params) do - media_id = map_get(params, :mediaId) - - case Repo.get(MediaAsset, media_id) do - nil -> {:error, :not_found} - %MediaAsset{} = media -> - changes = - %{} - |> maybe_put("title", map_get(params, :title)) - |> maybe_put("alt", map_get(params, :alt)) - |> maybe_put("caption", map_get(params, :caption)) - |> maybe_put("tags", map_get(params, :tags)) - - proposal = - ProposalStore.create( - "propose_media_metadata", - %{"media_id" => media_id, "changes" => changes}, - entity_id: media_id, - ttl_ms: @proposal_ttl_app_ms - ) - - {:ok, %{"proposal_id" => proposal.id, "current" => sanitize(media), "proposed" => changes}} - end - end - - defp propose_post_metadata(params) do - post_id = map_get(params, :postId) - - case Repo.get(Post, post_id) do - nil -> {:error, :not_found} - %Post{} = post -> - changes = - %{} - |> maybe_put("title", map_get(params, :title)) - |> maybe_put("excerpt", map_get(params, :excerpt)) - |> maybe_put("tags", map_get(params, :tags)) - |> maybe_put("categories", map_get(params, :categories)) - - proposal = - ProposalStore.create( - "propose_post_metadata", - %{"post_id" => post_id, "changes" => changes}, - entity_id: post_id, - ttl_ms: @proposal_ttl_app_ms - ) - - {:ok, %{"proposal_id" => proposal.id, "current" => sanitize(post), "proposed" => changes}} - end - end - - defp accept_proposal(params) do - proposal_id = map_get(params, :proposalId) - - case ProposalStore.get(proposal_id) do - nil -> {:error, :not_found} - proposal -> - result = - case proposal.kind do - "draft_post" -> - proposal.data["post_id"] |> Posts.publish_post() - - "propose_script" -> - proposal.data["script_id"] |> Scripts.publish_script() - - "propose_template" -> - proposal.data["template_id"] |> Templates.publish_template() - - "propose_media_metadata" -> - Media.update_media(proposal.data["media_id"], proposal.data["changes"] || %{}) - - "propose_post_metadata" -> - Posts.update_post(proposal.data["post_id"], proposal.data["changes"] || %{}) - - _other -> - {:error, :unsupported_proposal} - end - - case result do - {:ok, value} -> - _ = ProposalStore.mark_accepted(proposal_id) - {:ok, %{"success" => true, "message" => "accepted", "result" => sanitize(value)}} - - {:error, reason} -> - {:error, reason} - end - end - end - - defp discard_proposal(params) do - proposal_id = map_get(params, :proposalId) - - case ProposalStore.get(proposal_id) do - nil -> {:error, :not_found} - proposal -> - result = - case proposal.kind do - "draft_post" -> Posts.delete_post(proposal.data["post_id"]) - "propose_script" -> Scripts.delete_script(proposal.data["script_id"]) - "propose_template" -> Templates.delete_template(proposal.data["template_id"]) - _other -> {:ok, :discarded} - end - - case result do - {:ok, _value} -> - _ = ProposalStore.mark_discarded(proposal_id) - {:ok, %{"success" => true, "message" => "discarded"}} - - {:error, reason} -> - {:error, reason} - end - end - end - - defp posts_resource(offset) do - project = active_project!() - {:ok, result} = Search.search_posts(project.id, "", %{offset: offset, limit: @page_size}) - - %{ - "items" => Enum.map(result.posts, &post_summary/1), - "total" => result.total, - "offset" => result.offset, - "limit" => result.limit, - "has_more" => result.offset + result.limit < result.total - } - end - - defp media_resource(offset) do - project = active_project!() - {:ok, result} = Search.search_media(project.id, "", %{offset: offset, limit: @page_size}) - - %{ - "items" => Enum.map(result.media, &sanitize/1), - "total" => result.total, - "offset" => result.offset, - "limit" => result.limit, - "has_more" => result.offset + result.limit < result.total - } - end - - defp tags_resource do - project = active_project!() - tags = Tags.list_tags(project.id) - - %{ - "items" => - Enum.map(tags, fn tag -> - %{ - "id" => tag.id, - "name" => tag.name, - "color" => tag.color, - "post_count" => tag_post_count(project.id, tag.name) - } - end) - } - end - - defp categories_resource do - project = active_project!() - {:ok, metadata} = Metadata.get_project_metadata(project.id) - - %{ - "items" => - Enum.map(metadata.categories, fn category -> - %{"name" => category, "post_count" => category_post_count(project.id, category)} - end) - } - end - - defp read_post_resource(id) do - case Repo.get(Post, id) do - %Post{} = post -> {:ok, post_detail(post)} - nil -> {:error, :not_found} - end - end - - defp read_media_resource(id) do - case Repo.get(MediaAsset, id) do - %MediaAsset{} = media -> {:ok, sanitize(media)} - nil -> {:error, :not_found} - end - end - - defp post_detail(%Post{} = post) do - post - |> sanitize() - |> Map.put("content", post_body(post)) - |> Map.put("backlinks", linked_posts(post.id, :incoming)) - |> Map.put("links_to", linked_posts(post.id, :outgoing)) - |> Map.put("available_languages", available_languages(post.id, post.language)) - end - - defp translated_post_detail(%Post{} = post, language) do - normalized_language = normalize_term(language) - - case Repo.get_by(PostTranslation, translation_for: post.id, language: normalized_language) do - nil -> post_detail(post) - %PostTranslation{} = translation -> - post_detail(post) - |> Map.put("title", translation.title) - |> Map.put("excerpt", translation.excerpt) - |> Map.put("content", translation_body(translation)) - |> Map.put("language", translation.language) - |> Map.put("canonical_language", post.language) - end - end - - defp linked_posts(post_id, :incoming) do - PostLinks.list_incoming_links(post_id) - |> Enum.map(&load_linked_post(&1.source_post_id)) - |> Enum.reject(&is_nil/1) - end - - defp linked_posts(post_id, :outgoing) do - PostLinks.list_outgoing_links(post_id) - |> Enum.map(&load_linked_post(&1.target_post_id)) - |> Enum.reject(&is_nil/1) - end - - defp load_linked_post(post_id) do - case Repo.get(Post, post_id) do - %Post{} = post -> %{"id" => post.id, "title" => post.title, "slug" => post.slug} - nil -> nil - end - end - - defp post_summary(%Post{} = post) do - %{ - "id" => post.id, - "title" => post.title, - "slug" => post.slug, - "status" => Atom.to_string(post.status), - "tags" => post.tags || [], - "categories" => post.categories || [], - "created_at" => post.created_at, - "backlinks" => linked_posts(post.id, :incoming), - "links_to" => linked_posts(post.id, :outgoing) - } - end - - defp available_languages(post_id, canonical_language) do - languages = - Repo.all( - from translation in PostTranslation, - where: translation.translation_for == ^post_id, - select: translation.language - ) - - ([canonical_language] ++ languages) - |> Enum.reject(&(&1 in [nil, ""])) - |> Enum.uniq() - end - - defp post_body(%Post{content: content}) when is_binary(content), do: content - - defp post_body(%Post{} = post) do - project = Projects.get_project!(post.project_id) - full_path = Path.join(Projects.project_data_dir(project), post.file_path || "") - - case File.read(full_path) do - {:ok, contents} -> - case String.split(contents, "\n---\n", parts: 2) do - [_frontmatter, body] -> String.trim_trailing(body, "\n") - _parts -> contents - end - - {:error, _reason} -> "" - end - end - - defp translation_body(%PostTranslation{content: content}) when is_binary(content), do: content - - defp translation_body(%PostTranslation{} = translation) do - project = Projects.get_project!(translation.project_id) - full_path = Path.join(Projects.project_data_dir(project), translation.file_path || "") - - case File.read(full_path) do - {:ok, contents} -> - case String.split(contents, "\n---\n", parts: 2) do - [_frontmatter, body] -> String.trim_trailing(body, "\n") - _parts -> contents - end - - {:error, _reason} -> "" - end - end - - defp tag_post_count(project_id, tag_name) do - Repo.all(from post in Post, where: post.project_id == ^project_id) - |> Enum.count(&(tag_name in (&1.tags || []))) - end - - defp category_post_count(project_id, category) do - Repo.all(from post in Post, where: post.project_id == ^project_id) - |> Enum.count(&(category in (&1.categories || []))) - end - - defp group_rows(_post, []), do: [%{}] - - defp group_rows(post, [dimension | rest]) do - values = group_values(post, dimension) - - for value <- values, - tail <- group_rows(post, rest) do - Map.put(tail, dimension, value) - end - end - - defp group_values(post, "year") do - [BDS.Persistence.from_unix_ms!(post.created_at).year] - end - - defp group_values(post, "month") do - [BDS.Persistence.from_unix_ms!(post.created_at).month] - end - - defp group_values(post, "tag") do - if Enum.empty?(post.tags || []), do: [nil], else: post.tags - end - - defp group_values(post, "category") do - if Enum.empty?(post.categories || []), do: [nil], else: post.categories - end - - defp group_values(post, "status"), do: [Atom.to_string(post.status)] - - defp search_filters(params) do - %{} - |> maybe_put(:category, map_get(params, :category)) - |> maybe_put(:tags, map_get(params, :tags)) - |> maybe_put(:language, map_get(params, :language)) - |> maybe_put(:missing_translation_language, map_get(params, :missingTranslationLanguage)) - |> maybe_put(:year, map_get(params, :year)) - |> maybe_put(:month, map_get(params, :month)) - |> maybe_put(:status, parse_status(map_get(params, :status))) - |> Map.put(:offset, map_get(params, :offset, 0)) - |> Map.put(:limit, map_get(params, :limit, @page_size)) - end - - defp parse_status(nil), do: nil - defp parse_status(status) when is_atom(status), do: status - defp parse_status(status) when is_binary(status), do: String.to_existing_atom(status) - - defp script_validation(content) do - case BDS.Scripting.validate(content) do - :ok -> %{valid: true, errors: []} - {:error, reason} -> %{valid: false, errors: [inspect(reason)]} - end - end - - defp parse_script_kind(value) when is_atom(value), do: value - defp parse_script_kind("macro"), do: :macro - defp parse_script_kind("utility"), do: :utility - defp parse_script_kind("transform"), do: :transform - - defp parse_template_kind(value) when is_atom(value), do: value - defp parse_template_kind("post"), do: :post - defp parse_template_kind("list"), do: :list - defp parse_template_kind("not-found"), do: :not_found - defp parse_template_kind("not_found"), do: :not_found - defp parse_template_kind("partial"), do: :partial - - defp sanitize(%_struct{} = struct) do - struct - |> Map.from_struct() - |> Map.drop([:__meta__, :post, :project, :media]) - |> sanitize() - end - - defp sanitize(map) when is_map(map) do - Map.new(map, fn {key, value} -> {to_string(key), sanitize(value)} end) - end - - defp sanitize(list) when is_list(list), do: Enum.map(list, &sanitize/1) - defp sanitize(value) when is_atom(value), do: Atom.to_string(value) - defp sanitize(value), do: value - - defp normalize_term(nil), do: "" - defp normalize_term(value), do: value |> to_string() |> String.downcase() - - defp maybe_put(map, _key, nil), do: map - defp maybe_put(map, key, value), do: Map.put(map, key, value) - - defp map_get(map, key, default \\ nil) do - cond do - Map.has_key?(map, key) -> Map.get(map, key) - Map.has_key?(map, Atom.to_string(key)) -> Map.get(map, Atom.to_string(key)) - true -> default - end - end + defdelegate validate_template(source), to: Tools end diff --git a/lib/bds/mcp/queries.ex b/lib/bds/mcp/queries.ex new file mode 100644 index 0000000..bd4384f --- /dev/null +++ b/lib/bds/mcp/queries.ex @@ -0,0 +1,201 @@ +defmodule BDS.MCP.Queries do + @moduledoc false + + import Ecto.Query + + alias BDS.MCP.Util + alias BDS.PostLinks + alias BDS.Posts.Post + alias BDS.Posts.Translation, as: PostTranslation + alias BDS.Projects + alias BDS.Repo + + @page_size 50 + + @spec page_size() :: pos_integer() + def page_size, do: @page_size + + @spec active_project!() :: Projects.Project.t() + def active_project! do + case Enum.find(Projects.list_projects(), & &1.is_active) do + nil -> raise "no active project" + project -> project + end + end + + @spec post_summary(Post.t()) :: map() + def post_summary(%Post{} = post) do + %{ + "id" => post.id, + "title" => post.title, + "slug" => post.slug, + "status" => Atom.to_string(post.status), + "tags" => post.tags || [], + "categories" => post.categories || [], + "created_at" => post.created_at, + "backlinks" => linked_posts(post.id, :incoming), + "links_to" => linked_posts(post.id, :outgoing) + } + end + + @spec post_detail(Post.t()) :: map() + def post_detail(%Post{} = post) do + post + |> Util.sanitize() + |> Map.put("content", post_body(post)) + |> Map.put("backlinks", linked_posts(post.id, :incoming)) + |> Map.put("links_to", linked_posts(post.id, :outgoing)) + |> Map.put("available_languages", available_languages(post.id, post.language)) + end + + @spec translated_post_detail(Post.t(), String.t()) :: map() + def translated_post_detail(%Post{} = post, language) do + normalized_language = Util.normalize_term(language) + + case Repo.get_by(PostTranslation, translation_for: post.id, language: normalized_language) do + nil -> + post_detail(post) + + %PostTranslation{} = translation -> + post_detail(post) + |> Map.put("title", translation.title) + |> Map.put("excerpt", translation.excerpt) + |> Map.put("content", translation_body(translation)) + |> Map.put("language", translation.language) + |> Map.put("canonical_language", post.language) + end + end + + @spec search_filters(map()) :: map() + def search_filters(params) do + %{} + |> Util.maybe_put(:category, Util.map_get(params, :category)) + |> Util.maybe_put(:tags, Util.map_get(params, :tags)) + |> Util.maybe_put(:language, Util.map_get(params, :language)) + |> Util.maybe_put(:missing_translation_language, Util.map_get(params, :missingTranslationLanguage)) + |> Util.maybe_put(:year, Util.map_get(params, :year)) + |> Util.maybe_put(:month, Util.map_get(params, :month)) + |> Util.maybe_put(:status, parse_status(Util.map_get(params, :status))) + |> Map.put(:offset, Util.map_get(params, :offset, 0)) + |> Map.put(:limit, Util.map_get(params, :limit, @page_size)) + end + + @spec parse_status(term()) :: atom() | nil + def parse_status(nil), do: nil + def parse_status(status) when is_atom(status), do: status + def parse_status(status) when is_binary(status), do: String.to_existing_atom(status) + + @spec group_rows(Post.t(), [String.t()]) :: [map()] + def group_rows(_post, []), do: [%{}] + + def group_rows(post, [dimension | rest]) do + values = group_values(post, dimension) + + for value <- values, + tail <- group_rows(post, rest) do + Map.put(tail, dimension, value) + end + end + + defp group_values(post, "year") do + [BDS.Persistence.from_unix_ms!(post.created_at).year] + end + + defp group_values(post, "month") do + [BDS.Persistence.from_unix_ms!(post.created_at).month] + end + + defp group_values(post, "tag") do + if Enum.empty?(post.tags || []), do: [nil], else: post.tags + end + + defp group_values(post, "category") do + if Enum.empty?(post.categories || []), do: [nil], else: post.categories + end + + defp group_values(post, "status"), do: [Atom.to_string(post.status)] + + @spec available_languages(String.t(), String.t() | nil) :: [String.t()] + def available_languages(post_id, canonical_language) do + languages = + Repo.all( + from translation in PostTranslation, + where: translation.translation_for == ^post_id, + select: translation.language + ) + + ([canonical_language] ++ languages) + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.uniq() + end + + @spec linked_posts(String.t(), :incoming | :outgoing) :: [map()] + def linked_posts(post_id, :incoming) do + PostLinks.list_incoming_links(post_id) + |> Enum.map(&load_linked_post(&1.source_post_id)) + |> Enum.reject(&is_nil/1) + end + + def linked_posts(post_id, :outgoing) do + PostLinks.list_outgoing_links(post_id) + |> Enum.map(&load_linked_post(&1.target_post_id)) + |> Enum.reject(&is_nil/1) + end + + defp load_linked_post(post_id) do + case Repo.get(Post, post_id) do + %Post{} = post -> %{"id" => post.id, "title" => post.title, "slug" => post.slug} + nil -> nil + end + end + + @spec post_body(Post.t()) :: String.t() + def post_body(%Post{content: content}) when is_binary(content), do: content + + def post_body(%Post{} = post) do + project = Projects.get_project!(post.project_id) + full_path = Path.join(Projects.project_data_dir(project), post.file_path || "") + + case File.read(full_path) do + {:ok, contents} -> + case String.split(contents, "\n---\n", parts: 2) do + [_frontmatter, body] -> String.trim_trailing(body, "\n") + _parts -> contents + end + + {:error, _reason} -> + "" + end + end + + @spec translation_body(PostTranslation.t()) :: String.t() + def translation_body(%PostTranslation{content: content}) when is_binary(content), do: content + + def translation_body(%PostTranslation{} = translation) do + project = Projects.get_project!(translation.project_id) + full_path = Path.join(Projects.project_data_dir(project), translation.file_path || "") + + case File.read(full_path) do + {:ok, contents} -> + case String.split(contents, "\n---\n", parts: 2) do + [_frontmatter, body] -> String.trim_trailing(body, "\n") + _parts -> contents + end + + {:error, _reason} -> + "" + end + end + + @spec tag_post_count(String.t(), String.t()) :: non_neg_integer() + def tag_post_count(project_id, tag_name) do + Repo.all(from post in Post, where: post.project_id == ^project_id) + |> Enum.count(&(tag_name in (&1.tags || []))) + end + + @spec category_post_count(String.t(), String.t()) :: non_neg_integer() + def category_post_count(project_id, category) do + Repo.all(from post in Post, where: post.project_id == ^project_id) + |> Enum.count(&(category in (&1.categories || []))) + end +end diff --git a/lib/bds/mcp/resources.ex b/lib/bds/mcp/resources.ex new file mode 100644 index 0000000..8f5ec69 --- /dev/null +++ b/lib/bds/mcp/resources.ex @@ -0,0 +1,112 @@ +defmodule BDS.MCP.Resources do + @moduledoc false + + alias BDS.MCP.ProposalStore + alias BDS.MCP.Queries + alias BDS.MCP.Util + alias BDS.Media.Media, as: MediaAsset + alias BDS.Metadata + alias BDS.Posts.Post + alias BDS.Repo + alias BDS.Search + alias BDS.Tags + + @typedoc "Resource descriptor returned by `list/0`." + @type descriptor :: %{name: String.t(), uri: String.t()} + + @spec list() :: [descriptor()] + def list do + [ + %{name: "posts", uri: "bds://posts"}, + %{name: "media", uri: "bds://media"}, + %{name: "tags", uri: "bds://tags"}, + %{name: "categories", uri: "bds://categories"} + ] + 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: "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) + _other -> {:error, :not_found} + end + end + + defp posts_resource(offset) do + project = Queries.active_project!() + page_size = Queries.page_size() + {:ok, result} = Search.search_posts(project.id, "", %{offset: offset, limit: page_size}) + + %{ + "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 + } + end + + defp media_resource(offset) do + project = Queries.active_project!() + page_size = Queries.page_size() + {:ok, result} = Search.search_media(project.id, "", %{offset: offset, limit: page_size}) + + %{ + "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 + } + end + + defp tags_resource do + project = Queries.active_project!() + tags = Tags.list_tags(project.id) + + %{ + "items" => + Enum.map(tags, fn tag -> + %{ + "id" => tag.id, + "name" => tag.name, + "color" => tag.color, + "post_count" => Queries.tag_post_count(project.id, tag.name) + } + end) + } + end + + defp categories_resource do + project = Queries.active_project!() + {:ok, metadata} = Metadata.get_project_metadata(project.id) + + %{ + "items" => + Enum.map(metadata.categories, fn category -> + %{"name" => category, "post_count" => Queries.category_post_count(project.id, category)} + end) + } + end + + defp read_post_resource(id) do + case Repo.get(Post, id) do + %Post{} = post -> {:ok, Queries.post_detail(post)} + nil -> {:error, :not_found} + end + end + + defp read_media_resource(id) do + case Repo.get(MediaAsset, id) do + %MediaAsset{} = media -> {:ok, Util.sanitize(media)} + nil -> {:error, :not_found} + end + end +end diff --git a/lib/bds/mcp/tools.ex b/lib/bds/mcp/tools.ex new file mode 100644 index 0000000..aebbf7b --- /dev/null +++ b/lib/bds/mcp/tools.ex @@ -0,0 +1,395 @@ +defmodule BDS.MCP.Tools do + @moduledoc false + + import Ecto.Query + import BDS.MCP.Util, only: [maybe_put: 3, map_get: 3, sanitize: 1, normalize_term: 1] + + alias BDS.MCP.ProposalStore + alias BDS.MCP.Queries + alias BDS.Media + alias BDS.Media.Media, as: MediaAsset + alias BDS.Posts + alias BDS.Posts.Post + alias BDS.Repo + alias BDS.Scripts + alias BDS.Search + alias BDS.Templates + + @proposal_ttl_app_ms 30 * 60 * 1000 + + @typedoc "Tool descriptor returned by `list/0`." + @type descriptor :: %{name: String.t(), annotations: map()} + + @spec list() :: [descriptor()] + def list do + [ + tool("check_term", true), + tool("search_posts", true), + tool("count_posts", true), + tool("read_post_by_slug", true), + tool("draft_post", false), + tool("propose_script", false), + tool("propose_template", false), + tool("propose_media_metadata", false), + tool("propose_post_metadata", false), + tool("accept_proposal", false), + tool("discard_proposal", false) + ] + end + + @spec call(String.t(), map()) :: {:ok, term()} | {:error, term()} + def call(name, params) when is_binary(name) and is_map(params) do + ProposalStore.ensure_started() + + case name do + "check_term" -> {:ok, check_term(params)} + "search_posts" -> {:ok, search_posts(params)} + "count_posts" -> {:ok, count_posts(params)} + "read_post_by_slug" -> read_post_by_slug(params) + "draft_post" -> draft_post(params) + "propose_script" -> propose_script(params) + "propose_template" -> propose_template(params) + "propose_media_metadata" -> propose_media_metadata(params) + "propose_post_metadata" -> propose_post_metadata(params) + "accept_proposal" -> accept_proposal(params) + "discard_proposal" -> discard_proposal(params) + _other -> {:error, :unknown_tool} + end + end + + @spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}} + def validate_template(source) when is_binary(source) do + case Liquex.parse(source) do + {:ok, _ast} -> {:ok, %{valid: true, errors: []}} + {:error, reason, line} -> {:ok, %{valid: false, errors: ["#{inspect(reason)} at line #{line}"]}} + end + end + + defp tool(name, read_only) do + %{ + name: name, + annotations: %{"readOnlyHint" => read_only, "destructiveHint" => false} + } + end + + defp check_term(%{"term" => term}), do: check_term(%{term: term}) + + defp check_term(%{term: term}) do + project = Queries.active_project!() + normalized = normalize_term(term) + + 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) == normalized)) + end) + + category_post_count = + Enum.count(posts, fn post -> + Enum.any?(post.categories || [], &(normalize_term(&1) == normalized)) + 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 + + defp search_posts(params) do + project = Queries.active_project!() + filters = Queries.search_filters(params) + query = map_get(params, :query, "") + {:ok, result} = Search.search_posts(project.id, query, filters) + + posts = Enum.map(result.posts, &Queries.post_summary/1) + + %{ + "posts" => posts, + "total" => result.total, + "offset" => result.offset, + "limit" => result.limit, + "has_more" => result.offset + result.limit < result.total + } + end + + defp count_posts(params) do + project = Queries.active_project!() + group_by = map_get(params, :groupBy, []) |> Enum.map(&to_string/1) + filters = Queries.search_filters(params) + {:ok, result} = Search.search_posts(project.id, "", filters) + + 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 + + defp read_post_by_slug(%{"slug" => slug} = params), + do: read_post_by_slug(Map.put_new(params, :slug, slug)) + + defp read_post_by_slug(%{slug: slug} = params) do + project = Queries.active_project!() + + case Repo.get_by(Post, project_id: project.id, slug: slug) do + nil -> + {:error, :not_found} + + %Post{} = post -> + payload = + case map_get(params, :language, nil) do + nil -> + Queries.post_detail(post) + + "" -> + Queries.post_detail(post) + + language -> + if normalize_term(language) == normalize_term(post.language) do + Queries.post_detail(post) + else + Queries.translated_post_detail(post, language) + end + end + + {:ok, %{"post" => payload}} + end + end + + defp draft_post(params) do + project = Queries.active_project!() + + attrs = %{ + project_id: project.id, + title: map_get(params, :title, ""), + content: map_get(params, :content, ""), + excerpt: map_get(params, :excerpt, nil), + tags: map_get(params, :tags, []), + categories: map_get(params, :categories, []), + author: map_get(params, :author, nil) + } + + with {:ok, post} <- Posts.create_post(attrs) do + proposal = + ProposalStore.create("draft_post", %{"post_id" => post.id}, + entity_id: post.id, + ttl_ms: @proposal_ttl_app_ms + ) + + {:ok, %{"proposal_id" => proposal.id, "post" => sanitize(post)}} + end + end + + defp propose_script(params) do + project = Queries.active_project!() + content = map_get(params, :content, "") + + with validation <- script_validation(content), + {:ok, script} <- + Scripts.create_script(%{ + project_id: project.id, + title: map_get(params, :title, ""), + kind: parse_script_kind(map_get(params, :kind, nil)), + content: content, + entrypoint: map_get(params, :entrypoint, nil) + }) do + proposal = + ProposalStore.create("propose_script", %{"script_id" => script.id}, + entity_id: script.id, + ttl_ms: @proposal_ttl_app_ms + ) + + {:ok, + %{ + "proposal_id" => proposal.id, + "script" => sanitize(script), + "preview" => %{ + "title" => script.title, + "kind" => Atom.to_string(script.kind), + "content_length" => String.length(content), + "syntax_valid" => validation.valid, + "syntax_errors" => validation.errors + } + }} + end + end + + defp propose_template(params) do + project = Queries.active_project!() + content = map_get(params, :content, "") + {:ok, validation} = validate_template(content) + + with {:ok, template} <- + Templates.create_template(%{ + project_id: project.id, + title: map_get(params, :title, ""), + kind: parse_template_kind(map_get(params, :kind, nil)), + content: content + }) do + proposal = + ProposalStore.create("propose_template", %{"template_id" => template.id}, + entity_id: template.id, + ttl_ms: @proposal_ttl_app_ms + ) + + {:ok, + %{ + "proposal_id" => proposal.id, + "template" => sanitize(template), + "preview" => %{ + "title" => template.title, + "kind" => Atom.to_string(template.kind), + "content_length" => String.length(content), + "syntax_valid" => validation.valid, + "syntax_errors" => validation.errors + } + }} + end + end + + defp propose_media_metadata(params) do + media_id = map_get(params, :mediaId, nil) + + case Repo.get(MediaAsset, media_id) do + nil -> + {:error, :not_found} + + %MediaAsset{} = media -> + changes = + %{} + |> maybe_put("title", map_get(params, :title, nil)) + |> maybe_put("alt", map_get(params, :alt, nil)) + |> maybe_put("caption", map_get(params, :caption, nil)) + |> maybe_put("tags", map_get(params, :tags, nil)) + + proposal = + ProposalStore.create( + "propose_media_metadata", + %{"media_id" => media_id, "changes" => changes}, + entity_id: media_id, + ttl_ms: @proposal_ttl_app_ms + ) + + {:ok, %{"proposal_id" => proposal.id, "current" => sanitize(media), "proposed" => changes}} + end + end + + defp propose_post_metadata(params) do + post_id = map_get(params, :postId, nil) + + case Repo.get(Post, post_id) do + nil -> + {:error, :not_found} + + %Post{} = post -> + changes = + %{} + |> maybe_put("title", map_get(params, :title, nil)) + |> maybe_put("excerpt", map_get(params, :excerpt, nil)) + |> maybe_put("tags", map_get(params, :tags, nil)) + |> maybe_put("categories", map_get(params, :categories, nil)) + + proposal = + ProposalStore.create( + "propose_post_metadata", + %{"post_id" => post_id, "changes" => changes}, + entity_id: post_id, + ttl_ms: @proposal_ttl_app_ms + ) + + {:ok, %{"proposal_id" => proposal.id, "current" => sanitize(post), "proposed" => changes}} + end + end + + defp accept_proposal(params) do + proposal_id = map_get(params, :proposalId, nil) + + case ProposalStore.get(proposal_id) do + nil -> + {:error, :not_found} + + proposal -> + result = + case proposal.kind do + "draft_post" -> + proposal.data["post_id"] |> Posts.publish_post() + + "propose_script" -> + proposal.data["script_id"] |> Scripts.publish_script() + + "propose_template" -> + proposal.data["template_id"] |> Templates.publish_template() + + "propose_media_metadata" -> + Media.update_media(proposal.data["media_id"], proposal.data["changes"] || %{}) + + "propose_post_metadata" -> + Posts.update_post(proposal.data["post_id"], proposal.data["changes"] || %{}) + + _other -> + {:error, :unsupported_proposal} + end + + case result do + {:ok, value} -> + _ = ProposalStore.mark_accepted(proposal_id) + {:ok, %{"success" => true, "message" => "accepted", "result" => sanitize(value)}} + + {:error, reason} -> + {:error, reason} + end + end + end + + defp discard_proposal(params) do + proposal_id = map_get(params, :proposalId, nil) + + case ProposalStore.get(proposal_id) do + nil -> + {:error, :not_found} + + proposal -> + result = + case proposal.kind do + "draft_post" -> Posts.delete_post(proposal.data["post_id"]) + "propose_script" -> Scripts.delete_script(proposal.data["script_id"]) + "propose_template" -> Templates.delete_template(proposal.data["template_id"]) + _other -> {:ok, :discarded} + end + + case result do + {:ok, _value} -> + _ = ProposalStore.mark_discarded(proposal_id) + {:ok, %{"success" => true, "message" => "discarded"}} + + {:error, reason} -> + {:error, reason} + end + end + end + + defp script_validation(content) do + case BDS.Scripting.validate(content) do + :ok -> %{valid: true, errors: []} + {:error, reason} -> %{valid: false, errors: [inspect(reason)]} + end + end + + defp parse_script_kind(value) when is_atom(value), do: value + defp parse_script_kind("macro"), do: :macro + defp parse_script_kind("utility"), do: :utility + defp parse_script_kind("transform"), do: :transform + + defp parse_template_kind(value) when is_atom(value), do: value + defp parse_template_kind("post"), do: :post + defp parse_template_kind("list"), do: :list + defp parse_template_kind("not-found"), do: :not_found + defp parse_template_kind("not_found"), do: :not_found + defp parse_template_kind("partial"), do: :partial +end diff --git a/lib/bds/mcp/util.ex b/lib/bds/mcp/util.ex new file mode 100644 index 0000000..c2b8a59 --- /dev/null +++ b/lib/bds/mcp/util.ex @@ -0,0 +1,36 @@ +defmodule BDS.MCP.Util do + @moduledoc false + + @spec sanitize(term()) :: term() + def sanitize(%_struct{} = struct) do + struct + |> Map.from_struct() + |> Map.drop([:__meta__, :post, :project, :media]) + |> sanitize() + end + + def sanitize(map) when is_map(map) do + Map.new(map, fn {key, value} -> {to_string(key), sanitize(value)} end) + end + + def sanitize(list) when is_list(list), do: Enum.map(list, &sanitize/1) + def sanitize(value) when is_atom(value), do: Atom.to_string(value) + def sanitize(value), do: value + + @spec normalize_term(term()) :: String.t() + def normalize_term(nil), do: "" + def normalize_term(value), do: value |> to_string() |> String.downcase() + + @spec maybe_put(map(), term(), term()) :: map() + def maybe_put(map, _key, nil), do: map + def maybe_put(map, key, value), do: Map.put(map, key, value) + + @spec map_get(map(), atom(), term()) :: term() + def map_get(map, key, default \\ nil) do + cond do + Map.has_key?(map, key) -> Map.get(map, key) + Map.has_key?(map, Atom.to_string(key)) -> Map.get(map, Atom.to_string(key)) + true -> default + end + end +end