feat: mcp server first take
This commit is contained in:
76
test/bds/mcp_agent_config_test.exs
Normal file
76
test/bds/mcp_agent_config_test.exs
Normal file
@@ -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
|
||||
91
test/bds/mcp_server_test.exs
Normal file
91
test/bds/mcp_server_test.exs
Normal file
@@ -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
|
||||
197
test/bds/mcp_test.exs
Normal file
197
test/bds/mcp_test.exs
Normal file
@@ -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: "<article>{{ post.title }}</article>"
|
||||
})
|
||||
|
||||
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
|
||||
70
test/bds/scripting/api_test.exs
Normal file
70
test/bds/scripting/api_test.exs
Normal file
@@ -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 '<strong>' .. meta.name .. '</strong>'",
|
||||
"end"
|
||||
]
|
||||
|> Enum.join("\n")
|
||||
|
||||
assert {:ok, "<strong>Scripting API</strong>"} =
|
||||
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
|
||||
Reference in New Issue
Block a user