feat: mcp server first take

This commit is contained in:
2026-04-24 11:12:31 +02:00
parent f857e739f6
commit 213b3fc652
14 changed files with 1814 additions and 7 deletions

View 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

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