diff --git a/bin/bds-mcp b/bin/bds-mcp new file mode 100755 index 0000000..16c5f4d --- /dev/null +++ b/bin/bds-mcp @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." +exec mix bds.mcp "$@" \ No newline at end of file diff --git a/lib/bds/mcp.ex b/lib/bds/mcp.ex new file mode 100644 index 0000000..3f4826f --- /dev/null +++ b/lib/bds/mcp.ex @@ -0,0 +1,659 @@ +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 + + 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 + + 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 + + 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 + + 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 + + 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}, 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}, 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}, 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}, + 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}, + 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"], atomize_keys(proposal.data["changes"])) + + "propose_post_metadata" -> + Posts.update_post(proposal.data["post_id"], atomize_keys(proposal.data["changes"])) + + _other -> + {:error, :unsupported_proposal} + end + + case result do + {:ok, value} -> + :ok = ProposalStore.remove(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} -> + :ok = ProposalStore.remove(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 atomize_keys(map) when is_map(map) do + Map.new(map, fn {key, value} -> {String.to_atom(key), value} end) + end + + 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 +end diff --git a/lib/bds/mcp/agent_config.ex b/lib/bds/mcp/agent_config.ex new file mode 100644 index 0000000..ba36c20 --- /dev/null +++ b/lib/bds/mcp/agent_config.ex @@ -0,0 +1,54 @@ +defmodule BDS.MCP.AgentConfig do + @moduledoc false + + @server_name "bDS" + + def add_to_config(agent, opts \\ []) when is_atom(agent) and is_list(opts) do + home_dir = Keyword.get(opts, :home_dir, System.user_home!()) + config_path = config_path(agent, home_dir) + command = Keyword.get(opts, :command, default_command(opts)) + args = Keyword.get(opts, :args, default_args(opts)) + + File.mkdir_p!(Path.dirname(config_path)) + + config = read_config(config_path) + updated = merge_config(agent, config, command, args) + File.write!(config_path, Jason.encode!(updated, pretty: true)) + + {:ok, %{config_path: config_path, server_name: @server_name}} + end + + def config_path(:claude_code, home_dir), do: Path.join(home_dir, ".claude.json") + def config_path(:github_copilot, home_dir), do: Path.join([home_dir, "Library", "Application Support", "Code", "User", "mcp.json"]) + + defp default_command(opts) do + Keyword.get(opts, :script_path, repo_script_path()) + end + + defp default_args(_opts), do: [] + + defp repo_script_path do + Path.expand("../../../bin/bds-mcp", __DIR__) + end + + defp read_config(path) do + if File.exists?(path) do + path + |> File.read!() + |> Jason.decode!() + else + %{} + end + end + + defp merge_config(:github_copilot, config, command, args) do + servers = Map.get(config, "servers", %{}) + + Map.put(config, "servers", Map.put(servers, @server_name, %{"type" => "stdio", "command" => command, "args" => args})) + end + + defp merge_config(:claude_code, config, command, args) do + servers = Map.get(config, "mcpServers", %{}) + Map.put(config, "mcpServers", Map.put(servers, @server_name, %{"command" => command, "args" => args})) + end +end diff --git a/lib/bds/mcp/proposal_store.ex b/lib/bds/mcp/proposal_store.ex new file mode 100644 index 0000000..dd0d991 --- /dev/null +++ b/lib/bds/mcp/proposal_store.ex @@ -0,0 +1,78 @@ +defmodule BDS.MCP.ProposalStore do + @moduledoc false + + use Agent + + alias BDS.Persistence + + @default_ttl_ms 30 * 60 * 1000 + + def ensure_started do + case Process.whereis(__MODULE__) do + nil -> + case Agent.start_link(fn -> %{} end, name: __MODULE__) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + {:error, reason} -> {:error, reason} + end + + _pid -> + :ok + end + end + + def create(kind, data, opts \\ []) when is_binary(kind) and is_map(data) do + :ok = ensure_started() + cleanup_expired(opts) + + proposal = %{ + id: Ecto.UUID.generate(), + kind: kind, + data: data, + created_at: Persistence.now_ms(), + expires_at: Persistence.now_ms() + Keyword.get(opts, :ttl_ms, @default_ttl_ms) + } + + Agent.update(__MODULE__, &Map.put(&1, proposal.id, proposal)) + proposal + end + + def get(id) when is_binary(id) do + :ok = ensure_started() + cleanup_expired([]) + Agent.get(__MODULE__, &Map.get(&1, id)) + end + + def remove(id) when is_binary(id) do + :ok = ensure_started() + Agent.update(__MODULE__, &Map.delete(&1, id)) + :ok + end + + def list do + :ok = ensure_started() + cleanup_expired([]) + + Agent.get(__MODULE__, fn proposals -> + proposals + |> Map.values() + |> Enum.sort_by(& &1.created_at) + end) + end + + def cleanup_expired(opts) do + :ok = ensure_started() + now = Persistence.now_ms() + on_expire = Keyword.get(opts, :on_expire) + + Agent.get_and_update(__MODULE__, fn proposals -> + {expired, active} = Enum.split_with(proposals, fn {_id, proposal} -> proposal.expires_at <= now end) + + Enum.each(expired, fn {_id, proposal} -> + if is_function(on_expire, 1), do: on_expire.(proposal) + end) + + {Enum.map(expired, &elem(&1, 1)), Map.new(active)} + end) + end +end diff --git a/lib/bds/mcp/server.ex b/lib/bds/mcp/server.ex new file mode 100644 index 0000000..ac6223b --- /dev/null +++ b/lib/bds/mcp/server.ex @@ -0,0 +1,316 @@ +defmodule BDS.MCP.Server do + @moduledoc false + + use GenServer + + @host "127.0.0.1" + @server_name "Blogging Desktop Server" + + def start(port \\ 0) when is_integer(port) and port >= 0 do + pid = ensure_started() + GenServer.call(pid, {:start_server, port, self()}, 5_000) + end + + def stop do + pid = ensure_started() + GenServer.call(pid, :stop_server, 5_000) + end + + def current do + pid = ensure_started() + GenServer.call(pid, :current, 5_000) + end + + def start_link(_opts \\ []) do + GenServer.start_link(__MODULE__, %{current: nil}, name: __MODULE__) + end + + @impl true + def init(state), do: {:ok, state} + + @impl true + def handle_call({:start_server, port, owner_pid}, _from, state) do + state = stop_current_server(state) + maybe_allow_repo(owner_pid) + + {:ok, listener} = + :gen_tcp.listen(port, [ + :binary, + packet: :raw, + active: false, + reuseaddr: true, + ip: {127, 0, 0, 1} + ]) + + {:ok, actual_port} = :inet.port(listener) + acceptor_pid = spawn_link(fn -> accept_loop(listener) end) + + current = %{ + host: @host, + port: actual_port, + listener: listener, + acceptor_pid: acceptor_pid, + is_running: true + } + + {:reply, {:ok, public_server(current)}, %{state | current: current}} + end + + def handle_call(:stop_server, _from, state) do + {:reply, :ok, stop_current_server(state)} + end + + def handle_call(:current, _from, state) do + {:reply, state.current && public_server(state.current), state} + end + + def handle_call({:http_request, request}, _from, state) do + response = handle_mcp_request(request) + {:reply, response, state} + end + + defp ensure_started do + case Process.whereis(__MODULE__) do + nil -> + {:ok, pid} = start_link([]) + pid + + pid -> + pid + end + end + + defp accept_loop(listener) do + case :gen_tcp.accept(listener) do + {:ok, socket} -> + spawn(fn -> serve_client(socket) end) + accept_loop(listener) + + {:error, :closed} -> + :ok + + {:error, _reason} -> + :ok + end + end + + defp serve_client(socket) do + response = + case :gen_tcp.recv(socket, 0, 5_000) do + {:ok, request} -> + request + |> parse_http_request() + |> dispatch_http_request() + + {:error, _reason} -> + http_error_response(400) + end + + :gen_tcp.send(socket, response) + :gen_tcp.close(socket) + end + + defp parse_http_request(request) do + with [header_blob, body] <- String.split(request, "\r\n\r\n", parts: 2), + [request_line | header_lines] <- String.split(header_blob, "\r\n", trim: true), + [method, target, _version] <- String.split(request_line, " ", parts: 3) do + headers = + Enum.reduce(header_lines, %{}, fn line, acc -> + case String.split(line, ":", parts: 2) do + [name, value] -> Map.put(acc, String.downcase(name), String.trim(value)) + _other -> acc + end + end) + + {:ok, %{method: method, target: target, headers: headers, body: body}} + else + _other -> {:error, :bad_request} + end + end + + defp dispatch_http_request({:error, :bad_request}), do: http_error_response(400) + + defp dispatch_http_request({:ok, %{method: "OPTIONS"}}) do + http_response(204, "", "text/plain", %{}) + end + + defp dispatch_http_request({:ok, %{method: "POST", target: target} = request}) do + case URI.parse(target) do + %URI{path: "/mcp"} -> + case GenServer.call(__MODULE__, {:http_request, request}, 5_000) do + {:ok, status, body} -> http_response(status, Jason.encode!(body), "application/json", request.headers) + {:error, status, body} -> http_response(status, body, "text/plain", request.headers) + end + + _other -> + http_error_response(404, request.headers) + end + end + + defp dispatch_http_request({:ok, request}), do: http_error_response(404, request.headers) + + defp handle_mcp_request(%{headers: headers} = request) do + with :ok <- ensure_local_origin(headers), + {:ok, payload} <- Jason.decode(request.body), + {:ok, response} <- route_rpc(payload) do + {:ok, 200, response} + else + {:error, :forbidden_origin} -> {:error, 403, "Forbidden"} + {:error, :invalid_json} -> {:error, 400, "Bad Request"} + {:error, response} when is_map(response) -> {:ok, 200, response} + end + end + + defp route_rpc(%{"jsonrpc" => "2.0", "id" => id, "method" => method} = payload) do + params = Map.get(payload, "params", %{}) + + case method do + "initialize" -> + {:ok, + success_response(id, %{ + "protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"), + "capabilities" => %{"tools" => %{}, "resources" => %{}}, + "serverInfo" => %{"name" => @server_name, "version" => Application.spec(:bds, :vsn) |> to_string()} + })} + + "tools/list" -> + {:ok, success_response(id, %{"tools" => BDS.MCP.list_tools()})} + + "tools/call" -> + call_tool(id, params) + + "resources/list" -> + {:ok, success_response(id, %{"resources" => BDS.MCP.list_resources()})} + + "resources/read" -> + read_resource(id, params) + + _other -> + {:error, error_response(id, -32601, "Method not found")} + end + end + + defp route_rpc(_payload), do: {:error, :invalid_json} + + defp call_tool(id, %{"name" => name} = params) do + arguments = Map.get(params, "arguments", %{}) + + case BDS.MCP.call_tool(name, arguments) do + {:ok, result} -> {:ok, success_response(id, %{"content" => [%{"type" => "json", "json" => result}]})} + {:error, :unknown_tool} -> {:error, error_response(id, -32601, "Unknown tool")} + {:error, :not_found} -> {:error, error_response(id, -32004, "Not found")} + {:error, reason} -> {:error, error_response(id, -32000, inspect(reason))} + end + end + + defp call_tool(id, _params), do: {:error, error_response(id, -32602, "Invalid params")} + + defp read_resource(id, %{"uri" => uri}) do + case BDS.MCP.read_resource(uri) do + {:ok, result} -> + {:ok, + success_response(id, %{ + "contents" => [ + %{"uri" => uri, "mimeType" => "application/json", "text" => Jason.encode!(result)} + ] + })} + + {:error, :not_found} -> + {:error, error_response(id, -32004, "Not found")} + + {:error, reason} -> + {:error, error_response(id, -32000, inspect(reason))} + end + end + + defp read_resource(id, _params), do: {:error, error_response(id, -32602, "Invalid params")} + + defp success_response(id, result), do: %{"jsonrpc" => "2.0", "id" => id, "result" => result} + + defp error_response(id, code, message) do + %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => code, "message" => message}} + end + + defp ensure_local_origin(headers) do + case Map.get(headers, "origin") do + nil -> :ok + origin -> if local_origin?(origin), do: :ok, else: {:error, :forbidden_origin} + end + end + + defp local_origin?(origin) do + case URI.parse(origin) do + %URI{host: host} when host in ["localhost", "127.0.0.1"] -> true + _other -> false + end + end + + defp cors_headers(headers) do + allow_origin = Map.get(headers, "origin", "*") + + [ + {"access-control-allow-origin", allow_origin}, + {"access-control-allow-methods", "POST, OPTIONS"}, + {"access-control-allow-headers", "content-type, accept, origin"}, + {"access-control-max-age", "86400"} + ] + end + + defp http_response(status, body, content_type, headers) do + reason = + case status do + 200 -> "OK" + 204 -> "No Content" + 400 -> "Bad Request" + 403 -> "Forbidden" + 404 -> "Not Found" + _other -> "Internal Server Error" + end + + header_lines = + [ + {"content-type", content_type <> "; charset=utf-8"}, + {"content-length", Integer.to_string(byte_size(body))}, + {"connection", "close"} + | cors_headers(headers) + ] + |> Enum.map(fn {name, value} -> [name, ": ", value, "\r\n"] end) + + [ + "HTTP/1.1 ", + Integer.to_string(status), + " ", + reason, + "\r\n", + header_lines, + "\r\n", + body + ] + |> IO.iodata_to_binary() + end + + defp http_error_response(status, headers \\ %{}), do: http_response(status, reason_body(status), "text/plain", headers) + + defp reason_body(400), do: "Bad Request" + defp reason_body(403), do: "Forbidden" + defp reason_body(404), do: "Not Found" + defp reason_body(_status), do: "Internal Server Error" + + defp maybe_allow_repo(owner_pid) do + try do + Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, owner_pid, self()) + rescue + _error -> :ok + end + end + + defp stop_current_server(%{current: %{listener: listener, acceptor_pid: acceptor_pid}} = state) do + _ = :gen_tcp.close(listener) + if is_pid(acceptor_pid), do: Process.exit(acceptor_pid, :normal) + %{state | current: nil} + end + + defp stop_current_server(state), do: state + + defp public_server(server), do: Map.take(server, [:host, :port, :is_running]) +end diff --git a/lib/bds/mcp/stdio.ex b/lib/bds/mcp/stdio.ex new file mode 100644 index 0000000..5a385d3 --- /dev/null +++ b/lib/bds/mcp/stdio.ex @@ -0,0 +1,61 @@ +defmodule BDS.MCP.Stdio do + @moduledoc false + + def main do + IO.binstream(:stdio, :line) + |> Enum.each(fn line -> + line = String.trim(line) + + if line != "" do + response = + case Jason.decode(line) do + {:ok, payload} -> handle_payload(payload) + {:error, _reason} -> %{"jsonrpc" => "2.0", "id" => nil, "error" => %{"code" => -32700, "message" => "Parse error"}} + end + + IO.write(Jason.encode!(response) <> "\n") + end + end) + end + + defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "initialize", "params" => params}) do + %{ + "jsonrpc" => "2.0", + "id" => id, + "result" => %{ + "protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"), + "capabilities" => %{"tools" => %{}, "resources" => %{}}, + "serverInfo" => %{"name" => "Blogging Desktop Server", "version" => Application.spec(:bds, :vsn) |> to_string()} + } + } + end + + defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "tools/list"}) do + %{"jsonrpc" => "2.0", "id" => id, "result" => %{"tools" => BDS.MCP.list_tools()}} + end + + defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "tools/call", "params" => %{"name" => name} = params}) do + case BDS.MCP.call_tool(name, Map.get(params, "arguments", %{})) do + {:ok, result} -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{"content" => [%{"type" => "json", "json" => result}]}} + {:error, reason} -> %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32000, "message" => inspect(reason)}} + end + end + + defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "resources/list"}) do + %{"jsonrpc" => "2.0", "id" => id, "result" => %{"resources" => BDS.MCP.list_resources()}} + end + + defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "resources/read", "params" => %{"uri" => uri}}) do + case BDS.MCP.read_resource(uri) do + {:ok, result} -> + %{"jsonrpc" => "2.0", "id" => id, "result" => %{"contents" => [%{"uri" => uri, "mimeType" => "application/json", "text" => Jason.encode!(result)}]}} + + {:error, reason} -> + %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32000, "message" => inspect(reason)}} + end + end + + defp handle_payload(%{"jsonrpc" => "2.0", "id" => id}) do + %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32601, "message" => "Method not found"}} + end +end diff --git a/lib/bds/scripting.ex b/lib/bds/scripting.ex index 5adf45b..f82915d 100644 --- a/lib/bds/scripting.ex +++ b/lib/bds/scripting.ex @@ -3,6 +3,7 @@ defmodule BDS.Scripting do Facade for the configured user-script runtime. """ + alias BDS.Scripting.Capabilities alias BDS.Scripting.Runtime @type job_status :: :queued | :running | :completed | :failed | :cancelled @@ -35,6 +36,28 @@ defmodule BDS.Scripting do runtime().execute(source, entrypoint, args, opts) end + @spec execute_project_script(String.t(), String.t(), String.t(), [term()], [Runtime.execution_option()]) :: + {:ok, term()} | {:error, term()} + def execute_project_script(project_id, source, entrypoint, args \\ [], opts \\ []) + when is_binary(project_id) and is_binary(source) and is_binary(entrypoint) and + is_list(args) and is_list(opts) do + capabilities = Capabilities.for_project(project_id) + execute(source, entrypoint, args, Keyword.put(opts, :capabilities, capabilities)) + end + + @spec execute_macro(String.t(), String.t(), [term()], keyword()) :: {:ok, String.t()} | {:error, term()} + def execute_macro(project_id, source, args, opts \\ []) + when is_binary(project_id) and is_binary(source) and is_list(args) and is_list(opts) do + config = Application.fetch_env!(:bds, :scripting) + timeout = Keyword.get(opts, :timeout, Keyword.fetch!(config, :timeout)) + + case execute_project_script(project_id, source, "render", args, Keyword.put(opts, :timeout, timeout)) do + {:ok, nil} -> {:ok, ""} + {:ok, value} -> {:ok, to_string(value)} + {:error, _reason} -> {:ok, ""} + end + end + @spec start_job(String.t(), String.t(), [term()], [Runtime.execution_option()]) :: {:ok, job_snapshot()} | {:error, term()} def start_job(source, entrypoint, args \\ [], opts \\ []) diff --git a/lib/bds/scripting/capabilities.ex b/lib/bds/scripting/capabilities.ex new file mode 100644 index 0000000..173abac --- /dev/null +++ b/lib/bds/scripting/capabilities.ex @@ -0,0 +1,110 @@ +defmodule BDS.Scripting.Capabilities do + @moduledoc false + + import Ecto.Query + + alias BDS.Metadata + alias BDS.PostLinks + alias BDS.Posts.Post + alias BDS.Repo + alias BDS.Tags + + def for_project(project_id) when is_binary(project_id) do + metadata = preload_metadata(project_id) + posts = preload_posts(project_id) + posts_by_id = Map.new(posts, &{&1["id"], &1}) + posts_by_slug = Map.new(posts, &{&1["slug"], &1}) + tags = preload_tags(project_id) + + %{ + meta: %{ + get_project_metadata: unary(fn -> metadata end) + }, + posts: %{ + get: unary(fn post_id -> Map.get(posts_by_id, post_id) end), + get_by_slug: unary(fn slug -> Map.get(posts_by_slug, slug) end) + }, + tags: %{ + get_all: unary(fn -> tags end) + } + } + end + + defp preload_metadata(project_id) do + {:ok, metadata} = Metadata.get_project_metadata(project_id) + sanitize(metadata) + end + + defp preload_posts(project_id) do + Repo.all(from(post in Post, where: post.project_id == ^project_id)) + |> Enum.map(&post_payload/1) + end + + defp preload_tags(project_id) do + project_id + |> Tags.list_tags() + |> Enum.map(&sanitize/1) + end + + defp unary(callback) when is_function(callback, 0) do + fn args, state -> + _decoded_args = :luerl.decode_list(args, state) + :luerl.encode_list([callback.()], state) + end + end + + defp unary(callback) when is_function(callback, 1) do + fn args, state -> + decoded_args = :luerl.decode_list(args, state) + + value = + case decoded_args do + [first | _rest] -> callback.(sanitize(first)) + [] -> callback.(nil) + end + + :luerl.encode_list([value], state) + end + end + + defp post_payload(%Post{} = post) do + post + |> sanitize() + |> Map.put("backlinks", linked_posts(post.id, :incoming)) + |> Map.put("links_to", linked_posts(post.id, :outgoing)) + 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 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 +end diff --git a/lib/bds/scripting/lua.ex b/lib/bds/scripting/lua.ex index a1796ef..5d158f8 100644 --- a/lib/bds/scripting/lua.ex +++ b/lib/bds/scripting/lua.ex @@ -27,8 +27,8 @@ defmodule BDS.Scripting.Lua do when is_binary(source) and is_binary(entrypoint) and is_list(args) and is_list(opts) do with {:ok, state} <- initial_state(opts), {:ok, state} <- put_args(state, args), - {:ok, result, _state} <- run_entrypoint(source, entrypoint, state, opts) do - {:ok, unwrap_result(result)} + {:ok, result, next_state} <- run_entrypoint(source, entrypoint, state, opts) do + {:ok, decode_result(result, next_state)} end end @@ -72,12 +72,10 @@ defmodule BDS.Scripting.Lua do defp install_capabilities(state, capabilities) when capabilities in [%{}, []], do: {:ok, state} defp install_capabilities(state, capabilities) when is_map(capabilities) do - Enum.reduce_while(capabilities, {:ok, state}, fn {name, function}, {:ok, current_state} -> - path = ["bds", to_string(name)] - - case :luerl.set_table_keys_dec(path, function, current_state) do + Enum.reduce_while(capabilities, {:ok, state}, fn {name, value}, {:ok, current_state} -> + case install_capability(["bds", to_string(name)], value, current_state) do {:ok, next_state} -> {:cont, {:ok, next_state}} - error -> {:halt, {:error, {:capability_install_failed, path, error}}} + {:error, reason} -> {:halt, {:error, reason}} end end) end @@ -85,6 +83,28 @@ defmodule BDS.Scripting.Lua do defp install_capabilities(_state, capabilities), do: {:error, {:invalid_capabilities, capabilities}} + defp install_capability(path, value, state) when is_map(value) do + with {:ok, seeded_state} <- set_capability(path, %{}, state) do + Enum.reduce_while(value, {:ok, seeded_state}, fn {name, nested_value}, {:ok, current_state} -> + case install_capability(path ++ [to_string(name)], nested_value, current_state) do + {:ok, next_state} -> {:cont, {:ok, next_state}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + end + + defp install_capability(path, value, state) do + set_capability(path, value, state) + end + + defp set_capability(path, value, state) do + case :luerl.set_table_keys_dec(path, value, state) do + {:ok, next_state} -> {:ok, next_state} + error -> {:error, {:capability_install_failed, path, error}} + end + end + defp normalize_progress_payload(payload) when is_list(payload) do if Enum.all?(payload, &match?({key, _value} when is_binary(key) or is_atom(key), &1)) do Map.new(payload, fn {key, value} -> {to_string(key), value} end) @@ -149,6 +169,28 @@ defmodule BDS.Scripting.Lua do ] end + defp decode_result(values, state) when is_list(values) do + values + |> Enum.map(&decode_result(&1, state)) + |> unwrap_result() + end + + defp decode_result(value, state) do + value + |> :luerl.decode(state) + |> normalize_decoded_value() + end + + defp normalize_decoded_value(values) when is_list(values) do + if Enum.all?(values, &match?({key, _value} when is_binary(key) or is_atom(key), &1)) do + Map.new(values, fn {key, value} -> {to_string(key), value} end) + else + Enum.map(values, &normalize_decoded_value/1) + end + end + + defp normalize_decoded_value(value), do: value + defp unwrap_result(values) when is_list(values) do case values do [] -> nil diff --git a/lib/mix/tasks/bds.mcp.ex b/lib/mix/tasks/bds.mcp.ex new file mode 100644 index 0000000..2a8a382 --- /dev/null +++ b/lib/mix/tasks/bds.mcp.ex @@ -0,0 +1,25 @@ +defmodule Mix.Tasks.Bds.Mcp do + @moduledoc false + + use Mix.Task + + @shortdoc "Runs the bDS MCP server over stdio or localhost HTTP" + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.start") + + case args do + ["--http", port] -> + {:ok, _server} = BDS.MCP.Server.start(String.to_integer(port)) + Process.sleep(:infinity) + + ["--http"] -> + {:ok, _server} = BDS.MCP.Server.start(0) + Process.sleep(:infinity) + + _other -> + BDS.MCP.Stdio.main() + end + end +end diff --git a/test/bds/mcp_agent_config_test.exs b/test/bds/mcp_agent_config_test.exs new file mode 100644 index 0000000..ad3364a --- /dev/null +++ b/test/bds/mcp_agent_config_test.exs @@ -0,0 +1,76 @@ +defmodule BDS.MCPAgentConfigTest do + use ExUnit.Case, async: false + + alias BDS.MCP.AgentConfig + + setup do + home_dir = Path.join(System.tmp_dir!(), "bds-mcp-home-#{System.unique_integer([:positive])}") + File.mkdir_p!(home_dir) + on_exit(fn -> File.rm_rf(home_dir) end) + %{home_dir: home_dir} + end + + test "github copilot config uses VS Code mcp.json servers format with stdio entry", %{home_dir: home_dir} do + install_root = Path.join(home_dir, "bDS2.app/Contents/Resources") + executable_path = Path.join(install_root, "mcp/bin/bds-mcp") + + assert {:ok, result} = + AgentConfig.add_to_config(:github_copilot, + home_dir: home_dir, + install_root: install_root, + platform: :macos + ) + + assert File.exists?(result.config_path) + written = Jason.decode!(File.read!(result.config_path)) + + assert written["servers"]["bDS"] == %{ + "type" => "stdio", + "command" => executable_path, + "args" => [] + } + end + + test "claude code config uses mcpServers format and preserves other entries", %{home_dir: home_dir} do + config_path = Path.join(home_dir, ".claude.json") + install_root = Path.join(home_dir, "dist") + + File.write!( + config_path, + Jason.encode!(%{"mcpServers" => %{"other" => %{"command" => "python"}}, "theme" => "dark"}) + ) + + assert {:ok, result} = + AgentConfig.add_to_config(:claude_code, + home_dir: home_dir, + install_root: install_root, + platform: :linux + ) + + written = Jason.decode!(File.read!(result.config_path)) + assert written["theme"] == "dark" + assert written["mcpServers"]["other"] == %{"command" => "python"} + assert written["mcpServers"]["bDS"] == %{"command" => Path.join(install_root, "mcp/bin/bds-mcp"), "args" => []} + end + + test "packaged executable path resolves inside the distributable payload" do + assert AgentConfig.packaged_executable_path("/Applications/bDS2.app/Contents/Resources", :macos) == + "/Applications/bDS2.app/Contents/Resources/mcp/bin/bds-mcp" + + assert AgentConfig.packaged_executable_path("C:/Program Files/bDS2/resources", :windows) == + "C:/Program Files/bDS2/resources/mcp/bin/bds-mcp.bat" + + assert AgentConfig.packaged_executable_path("/opt/bds2", :linux) == + "/opt/bds2/mcp/bin/bds-mcp" + end + + test "release config exposes a dedicated distributable mcp release" do + project = BDS.MixProject.project() + releases = Keyword.fetch!(project, :releases) + mcp_release = Keyword.fetch!(releases, :bds_mcp) + + assert Keyword.fetch!(project, :default_release) == :bds + assert Keyword.fetch!(mcp_release, :include_executables_for) == [:unix, :windows] + assert Path.join(Keyword.fetch!(mcp_release, :path), "bin") =~ "rel/bds_mcp" + end +end diff --git a/test/bds/mcp_server_test.exs b/test/bds/mcp_server_test.exs new file mode 100644 index 0000000..6448e69 --- /dev/null +++ b/test/bds/mcp_server_test.exs @@ -0,0 +1,91 @@ +defmodule BDS.MCPServerTest do + use ExUnit.Case, async: false + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + temp_dir = Path.join(System.tmp_dir!(), "bds-mcp-server-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = BDS.Projects.create_project(%{name: "MCP Server", data_path: temp_dir}) + {:ok, _active} = BDS.Projects.set_active_project(project.id) + + %{project: project} + end + + test "HTTP MCP server binds localhost, answers initialize, and exposes tool capabilities" do + :inets.start() + + assert {:ok, server} = BDS.MCP.Server.start(0) + assert server.host == "127.0.0.1" + assert server.port > 0 + + initialize_body = + Jason.encode!(%{ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: %{ + protocolVersion: "2025-03-26", + capabilities: %{}, + clientInfo: %{name: "test-client", version: "1.0.0"} + } + }) + + assert {:ok, {{_version, 200, _reason}, headers, body}} = + :httpc.request( + :post, + {to_charlist("http://127.0.0.1:#{server.port}/mcp"), + [{~c"content-type", ~c"application/json"}], ~c"application/json", initialize_body}, + [], + body_format: :binary + ) + + assert Enum.any?(headers, fn {name, value} -> + String.downcase(to_string(name)) == "access-control-allow-methods" and + to_string(value) =~ "POST" + end) + + decoded = Jason.decode!(body) + assert decoded["result"]["serverInfo"]["name"] == "Blogging Desktop Server" + assert decoded["result"]["capabilities"]["tools"] == %{} + + assert :ok = BDS.MCP.Server.stop() + end + + test "HTTP MCP server rejects non-local origins and can list tools" do + :inets.start() + assert {:ok, server} = BDS.MCP.Server.start(0) + + initialize_body = + Jason.encode!(%{jsonrpc: "2.0", id: 1, method: "initialize", params: %{}}) + + assert {:ok, {{_version, 403, _reason}, _headers, _body}} = + :httpc.request( + :post, + {to_charlist("http://127.0.0.1:#{server.port}/mcp"), + [{~c"content-type", ~c"application/json"}, {~c"origin", ~c"https://evil.example"}], + ~c"application/json", initialize_body}, + [], + body_format: :binary + ) + + tools_body = Jason.encode!(%{jsonrpc: "2.0", id: 2, method: "tools/list", params: %{}}) + + assert {:ok, {{_version, 200, _reason}, _headers, body}} = + :httpc.request( + :post, + {to_charlist("http://127.0.0.1:#{server.port}/mcp"), + [{~c"content-type", ~c"application/json"}], ~c"application/json", tools_body}, + [], + body_format: :binary + ) + + decoded = Jason.decode!(body) + tool_names = Enum.map(decoded["result"]["tools"], & &1["name"]) + assert "check_term" in tool_names + assert "draft_post" in tool_names + + assert :ok = BDS.MCP.Server.stop() + end +end diff --git a/test/bds/mcp_test.exs b/test/bds/mcp_test.exs new file mode 100644 index 0000000..2929b6f --- /dev/null +++ b/test/bds/mcp_test.exs @@ -0,0 +1,197 @@ +defmodule BDS.MCPTest do + use ExUnit.Case, async: false + + alias BDS.Media.Media + alias BDS.Repo + alias BDS.Scripts.Script + alias BDS.Templates.Template + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + + temp_dir = Path.join(System.tmp_dir!(), "bds-mcp-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = BDS.Projects.create_project(%{name: "MCP", data_path: temp_dir}) + {:ok, _active} = BDS.Projects.set_active_project(project.id) + + %{project: project, temp_dir: temp_dir} + 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) + + assert "check_term" in tool_names + assert "search_posts" in tool_names + assert "count_posts" in tool_names + assert "read_post_by_slug" in tool_names + assert "draft_post" in tool_names + assert "propose_script" in tool_names + assert "propose_template" in tool_names + assert "propose_media_metadata" in tool_names + assert "propose_post_metadata" in tool_names + assert "accept_proposal" in tool_names + assert "discard_proposal" in tool_names + end + + test "check_term, search_posts, count_posts, and read_post_by_slug expose current blog data", %{ + project: project + } do + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Travel Notes", + content: "Travel through Berlin", + language: "en", + tags: ["travel"], + categories: ["article"] + }) + + assert {:ok, _published} = BDS.Posts.publish_post(post.id) + assert {:ok, _tags} = BDS.Tags.sync_tags_from_posts(project.id) + + assert {:ok, term_result} = BDS.MCP.call_tool("check_term", %{term: "travel"}) + assert term_result["is_tag"] == true + assert term_result["tag_post_count"] == 1 + assert term_result["is_category"] == false + + assert {:ok, search_result} = BDS.MCP.call_tool("search_posts", %{query: "Berlin"}) + assert search_result["total"] == 1 + assert [%{"slug" => "travel-notes"}] = search_result["posts"] + + assert {:ok, count_result} = BDS.MCP.call_tool("count_posts", %{groupBy: ["tag"]}) + assert count_result["total_posts"] == 1 + assert Enum.any?(count_result["groups"], &(&1["tag"] == "travel" and &1["count"] == 1)) + + assert {:ok, read_result} = BDS.MCP.call_tool("read_post_by_slug", %{slug: "travel-notes"}) + assert read_result["post"]["title"] == "Travel Notes" + assert read_result["post"]["slug"] == "travel-notes" + end + + test "proposal-backed write tools follow the old app lifecycle for scripts, templates, and metadata", %{ + project: project, + temp_dir: temp_dir + } do + source_path = Path.join(temp_dir, "image.txt") + File.write!(source_path, "image body") + + assert {:ok, media} = + BDS.Media.import_media(%{project_id: project.id, source_path: source_path, title: "Old"}) + + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Meta Post", + content: "Body", + language: "en" + }) + + assert {:ok, draft_result} = + BDS.MCP.call_tool("draft_post", %{ + title: "Draft From MCP", + content: "Draft body", + tags: ["mcp"], + categories: ["article"] + }) + + draft_proposal_id = draft_result["proposal_id"] + draft_post_id = draft_result["post"]["id"] + assert {:ok, _accepted} = BDS.MCP.call_tool("accept_proposal", %{proposalId: draft_proposal_id}) + assert BDS.Posts.get_post!(draft_post_id).status == :published + + assert {:ok, script_result} = + BDS.MCP.call_tool("propose_script", %{ + title: "Example Script", + kind: "utility", + content: "function main() return 'ok' end" + }) + + script_id = script_result["script"]["id"] + assert {:ok, _accepted_script} = + BDS.MCP.call_tool("accept_proposal", %{proposalId: script_result["proposal_id"]}) + + assert Repo.get!(Script, script_id).status == :published + + assert {:ok, template_result} = + BDS.MCP.call_tool("propose_template", %{ + title: "Example Template", + kind: "post", + content: "
{{ post.title }}
" + }) + + template_id = template_result["template"]["id"] + assert {:ok, _accepted_template} = + BDS.MCP.call_tool("accept_proposal", %{proposalId: template_result["proposal_id"]}) + + assert Repo.get!(Template, template_id).status == :published + + assert {:ok, media_proposal} = + BDS.MCP.call_tool("propose_media_metadata", %{ + mediaId: media.id, + title: "New Title", + alt: "Alt Text" + }) + + assert {:ok, _accepted_media} = + BDS.MCP.call_tool("accept_proposal", %{proposalId: media_proposal["proposal_id"]}) + updated_media = Repo.get!(Media, media.id) + assert updated_media.title == "New Title" + assert updated_media.alt == "Alt Text" + + assert {:ok, post_proposal} = + BDS.MCP.call_tool("propose_post_metadata", %{ + postId: post.id, + title: "Updated Title", + excerpt: "Short excerpt" + }) + + assert {:ok, _accepted_post} = + BDS.MCP.call_tool("accept_proposal", %{proposalId: post_proposal["proposal_id"]}) + + updated_post = BDS.Posts.get_post!(post.id) + assert updated_post.title == "Updated Title" + assert updated_post.excerpt == "Short excerpt" + end + + test "discard_proposal removes draft-backed entities" do + assert {:ok, draft_result} = + BDS.MCP.call_tool("draft_post", %{title: "Discard Me", content: "Body"}) + + draft_post_id = draft_result["post"]["id"] + + assert {:ok, _discarded} = + BDS.MCP.call_tool("discard_proposal", %{proposalId: draft_result["proposal_id"]}) + + assert_raise Ecto.NoResultsError, fn -> BDS.Posts.get_post!(draft_post_id) end + end + + test "resource listing and reads follow old app naming for implemented resources", %{project: project} do + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Resource Post", + content: "Resource body", + language: "en", + tags: ["resources"], + categories: ["article"] + }) + + assert {:ok, _published} = BDS.Posts.publish_post(post.id) + assert {:ok, _tags} = BDS.Tags.sync_tags_from_posts(project.id) + + resource_uris = BDS.MCP.list_resources() |> Enum.map(& &1.uri) + assert "bds://posts" in resource_uris + assert "bds://media" in resource_uris + assert "bds://tags" in resource_uris + assert "bds://categories" in resource_uris + + assert {:ok, posts_resource} = BDS.MCP.read_resource("bds://posts") + assert posts_resource["total"] == 1 + + assert {:ok, post_resource} = BDS.MCP.read_resource("bds://posts/#{post.id}") + assert post_resource["slug"] == "resource-post" + end +end diff --git a/test/bds/scripting/api_test.exs b/test/bds/scripting/api_test.exs new file mode 100644 index 0000000..3aa244c --- /dev/null +++ b/test/bds/scripting/api_test.exs @@ -0,0 +1,70 @@ +defmodule BDS.Scripting.ApiTest do + use ExUnit.Case, async: false + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + + temp_dir = + Path.join(System.tmp_dir!(), "bds-scripting-api-#{System.unique_integer([:positive])}") + + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = BDS.Projects.create_project(%{name: "Scripting API", data_path: temp_dir}) + + %{project: project} + end + + test "project capabilities expose current backend data through explicit bds namespaces", %{ + project: project + } do + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Capability Post", + content: "Body", + language: "en", + tags: ["elixir"], + categories: ["article"] + }) + + assert {:ok, _published_post} = BDS.Posts.publish_post(post.id) + assert {:ok, _tags} = BDS.Tags.sync_tags_from_posts(project.id) + + source = [ + "function main()", + " local meta = bds.meta.get_project_metadata()", + " local fetched = bds.posts.get_by_slug('capability-post')", + " local tags = bds.tags.get_all()", + " return {", + " project_name = meta.name,", + " post_title = fetched.title,", + " tag_count = #tags", + " }", + "end" + ] + |> Enum.join("\n") + + assert {:ok, %{"project_name" => "Scripting API", "post_title" => "Capability Post", "tag_count" => 1}} = + BDS.Scripting.execute_project_script(project.id, source, "main") + end + + test "macro execution uses explicit project capabilities and degrades failures to empty output", %{ + project: project + } do + source = [ + "function render()", + " local meta = bds.meta.get_project_metadata()", + " return '' .. meta.name .. ''", + "end" + ] + |> Enum.join("\n") + + assert {:ok, "Scripting API"} = + BDS.Scripting.execute_macro(project.id, source, []) + + bad_source = "function render() error('boom') end" + + assert {:ok, ""} = BDS.Scripting.execute_macro(project.id, bad_source, []) + end +end