feat: mcp server first take
This commit is contained in:
659
lib/bds/mcp.ex
Normal file
659
lib/bds/mcp.ex
Normal file
@@ -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
|
||||
54
lib/bds/mcp/agent_config.ex
Normal file
54
lib/bds/mcp/agent_config.ex
Normal file
@@ -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
|
||||
78
lib/bds/mcp/proposal_store.ex
Normal file
78
lib/bds/mcp/proposal_store.ex
Normal file
@@ -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
|
||||
316
lib/bds/mcp/server.ex
Normal file
316
lib/bds/mcp/server.ex
Normal file
@@ -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
|
||||
61
lib/bds/mcp/stdio.ex
Normal file
61
lib/bds/mcp/stdio.ex
Normal file
@@ -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
|
||||
@@ -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 \\ [])
|
||||
|
||||
110
lib/bds/scripting/capabilities.ex
Normal file
110
lib/bds/scripting/capabilities.ex
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user