chore: refactored mcp.ex

This commit is contained in:
2026-05-01 15:33:50 +02:00
parent fbc1cba52e
commit 62e44150b3
6 changed files with 762 additions and 664 deletions

View File

@@ -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

201
lib/bds/mcp/queries.ex Normal file
View File

@@ -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

112
lib/bds/mcp/resources.ex Normal file
View File

@@ -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

395
lib/bds/mcp/tools.ex Normal file
View File

@@ -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

36
lib/bds/mcp/util.ex Normal file
View File

@@ -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