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/templates/list" -> {:ok, success_response(id, %{"resourceTemplates" => BDS.MCP.list_resource_templates()})} "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, :invalid_cursor} -> {:error, error_response(id, -32602, "Invalid cursor")} 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(404), do: "Not Found" 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