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