diff --git a/guides/MCP.md b/guides/MCP.md index e8ccad8c..2c0fca54 100644 --- a/guides/MCP.md +++ b/guides/MCP.md @@ -4,9 +4,9 @@ You expose Corex component metadata to AI tools (Cursor, Claude Desktop, VS Code) from your running app. The MCP server is self-hosted—no external SaaS. -Built on [Tidewave Phoenix](https://github.com/tidewave-ai/tidewave_phoenix) (Apache-2.0). +Built on [Tidewave Phoenix](https://github.com/tidewave-ai/tidewave_phoenix) (Apache-2.0). Corex MCP keeps Tidewave's HTTP transport and security model but ships read-only component tools only. When upgrading Corex, compare `lib/mcp/` against the reference Tidewave version noted in `lib/mcp/server.ex`. -Do not enable MCP in production. The tools are read-only, but the endpoint still widens your attack surface. Use it only while developing locally (or in `:test` when generated apps include it for CI). +Do not enable MCP in production. The tools are read-only, but the endpoint still widens your attack surface. Use it only while developing locally (or in `:test` when generated apps include it for CI). `plug Corex.MCP` raises at boot in `:prod` unless you pass `force: true` (discouraged). ## Before you start @@ -71,10 +71,48 @@ All tools are read-only. | Tool | Purpose | | ---- | ------- | | `list_components` | All component ids (`accordion`, `date_picker`, …) | -| `get_component` | Module, slots, docs, `source_path` for one `id` | -| `installation_guide` | Install steps for new or existing projects (`scenario`: `new_project`, `existing_project`, `all`) | +| `get_component` | Module, slots, docs, repo-relative `source_path` for one `id` | +| `installation_guide` | Install steps for new or existing projects (`scenario`: `new_project`, `existing_project`, or omit for `all`) | -Call `list_components` before `get_component` when you need a valid `id`. +Call `list_components` before `get_component` when you need a valid `id`. Invalid tool arguments return an MCP error instead of being silently ignored. + +## Security + +Corex MCP follows the Tidewave dev-server security model: + +- **Loopback by default.** Only requests from localhost are accepted unless you set `allow_remote_access: true` on the plug (discouraged outside trusted networks). +- **Origin header.** `POST /corex/mcp` and `GET /corex/config` reject requests that include an `Origin` header. Clients such as Cursor typically omit it. +- **Read-only tools.** No code evaluation, SQL, or log access. Component ids are allowlisted before lookup. +- **Never production.** Mount in `:dev` / `:test` only. The plug refuses to initialize in `:prod` unless `force: true` is passed. + +Unlike Tidewave, Corex MCP does **not** modify your app's Content-Security-Policy or `X-Frame-Options` headers. Tidewave weakens CSP globally because its landing page loads an embedded browser client; Corex serves static HTML only. + +## Configuration + +```elixir +plug Corex.MCP, + allow_remote_access: false, + force: false +``` + +| Option | Default | Description | +| ------ | ------- | ----------- | +| `allow_remote_access` | `false` | Allow non-loopback clients when `true` | +| `force` | `false` | Allow mounting in `:prod` when `true` (discouraged) | + +Optional application config: + +```elixir +config :corex, mcp_root: "/path/to/project" +``` + +`mcp_root` sets the directory used to relativize `source_path` in `get_component` (defaults to `File.cwd!()`). Useful in umbrella apps or CI. + +Verbose MCP logging: + +```elixir +config :corex, debug: true +``` ## Related diff --git a/lib/mcp/component_docs.ex b/lib/mcp/component_docs.ex index 72d33c9d..15cbbc05 100644 --- a/lib/mcp/component_docs.ex +++ b/lib/mcp/component_docs.ex @@ -1,16 +1,12 @@ defmodule Corex.MCP.ComponentDocs do @moduledoc false + alias Corex.MCP + def enrich(spec, mod) do case Code.fetch_docs(mod) do {:docs_v1, line, _beam_lang, format, doc_blob, meta, _} -> - meta_map = - case meta do - %{} = m -> m - _ -> %{} - end - - source_path = resolve_source_path(meta_map, mod) + source_path = resolve_source_path(meta, mod) case moduledoc_markdown(doc_blob, format, mod) do {:ok, docs} -> @@ -42,36 +38,49 @@ defmodule Corex.MCP.ComponentDocs do defp resolve_source_path(meta, mod) do case source_path_from_meta(meta) do - p when is_binary(p) and p != "" -> p + path when is_binary(path) and path != "" -> path _ -> compile_source_path(mod) end end - defp source_path_from_meta(meta) when is_map(meta) do - meta = Map.new(meta, fn {k, v} -> {to_string(k), v} end) - normalize_source_path_string(Map.get(meta, "source_path")) + defp source_path_from_meta(%{} = meta) do + meta + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> Map.get("source_path") + |> relative_source_path() end + defp source_path_from_meta(_), do: nil + defp compile_source_path(mod) do with list when is_list(list) <- mod.module_info(:compile), src when not is_nil(src) <- Keyword.get(list, :source) do - normalize_source_path_string(src) - else - _ -> nil + relative_source_path(src) end end - defp normalize_source_path_string(p) when is_list(p), do: List.to_string(p) - defp normalize_source_path_string(p) when is_binary(p), do: p - defp normalize_source_path_string(_), do: nil + defp relative_source_path(path) when is_list(path), + do: relative_source_path(List.to_string(path)) + + defp relative_source_path(path) when is_binary(path) do + abs = Path.expand(path) + root = Path.expand(MCP.root()) + + cond do + abs == root -> "." + String.starts_with?(abs, root <> "/") -> Path.relative_to(abs, root) + true -> Path.basename(abs) + end + end + + defp relative_source_path(_), do: nil defp moduledoc_markdown(%{"en" => content}, "text/markdown", mod) when is_binary(content) do {:ok, "# #{inspect(mod)}\n\n#{content}"} end - defp moduledoc_markdown(%{"en" => content}, format, mod) - when is_binary(content) do + defp moduledoc_markdown(%{"en" => content}, format, mod) when is_binary(content) do {:ok, "# #{inspect(mod)}\n\n#{content}" <> "\n\n_(documentation format: #{inspect(format)}, not text/markdown)_\n"} diff --git a/lib/mcp/config.ex b/lib/mcp/config.ex new file mode 100644 index 00000000..851eecd6 --- /dev/null +++ b/lib/mcp/config.ex @@ -0,0 +1,22 @@ +defmodule Corex.MCP.Config do + @moduledoc false + + @enforce_keys [:allow_remote_access] + defstruct allow_remote_access: false + + @type t :: %__MODULE__{ + allow_remote_access: boolean() + } + + def build(opts) when is_list(opts) do + %__MODULE__{ + allow_remote_access: Keyword.get(opts, :allow_remote_access, false) + } + end + + def build(%__MODULE__{} = config), do: config + + def build(%{} = config) do + struct!(__MODULE__, config) + end +end diff --git a/lib/mcp/plug.ex b/lib/mcp/plug.ex index 22d992f9..32ee11ea 100644 --- a/lib/mcp/plug.ex +++ b/lib/mcp/plug.ex @@ -4,49 +4,56 @@ defmodule Corex.MCP do require Logger + alias Corex.MCP.{Config, Server} + + @doc """ + Returns the project root for MCP path relativization. + """ + def root, do: Application.get_env(:corex, :mcp_root, File.cwd!()) + @impl true def init(opts) when is_list(opts) do maybe_silence_mcp_server_logs() - - %{ - allow_remote_access: Keyword.get(opts, :allow_remote_access, false) - } + assert_not_prod!(opts) + :ok = Server.init_tools() + Config.build(opts) end - def init(%{} = opts) do - maybe_silence_mcp_server_logs() - Map.merge(%{allow_remote_access: false}, opts) + def init(config) when is_map(config), do: Config.build(config) + + @impl true + def call(%Plug.Conn{path_info: ["corex" | rest]} = conn, %Config{} = config) do + conn + |> validate!() + |> Plug.Conn.put_private(:corex_mcp_config, config) + |> Plug.forward(rest, Corex.MCP.Router, []) + |> Plug.Conn.halt() end + def call(conn, _config), do: conn + defp maybe_silence_mcp_server_logs do if Application.get_env(:corex, :debug) do :ok else - Logger.put_module_level(Corex.MCP.Server, :none) + Logger.put_module_level(Server, :none) end end - @impl true - def call(%Plug.Conn{path_info: ["corex" | rest]} = conn, config) do - conn - |> validate!() - |> Plug.Conn.put_private(:corex_mcp_config, config) - |> Plug.forward(rest, Corex.MCP.Router, []) - |> Plug.Conn.halt() - end + defp assert_not_prod!(opts) do + if Mix.env() == :prod and not Keyword.get(opts, :force, false) do + raise """ + plug Corex.MCP must not be enabled in production. - def call(conn, _opts) do - conn - |> Plug.Conn.register_before_send(fn conn -> - conn - |> maybe_rewrite_csp() - |> Plug.Conn.delete_resp_header("x-frame-options") - end) + Corex MCP is dev-only. Remove the plug from your endpoint or pass force: true \ + if you explicitly accept the security risk. + """ + end end defp validate!(conn) do if live_reload_enabled?(conn) or request_body_parsed?(conn) do - raise "plug Corex.MCP is runnning too late, after the request body has been parsed. " <> + raise "plug Corex.MCP is running too late, after the request body has been parsed. " <> "Make sure to place \"plug Corex.MCP\" before the \"if code_reloading? do\" block" end @@ -60,46 +67,4 @@ defmodule Corex.MCP do defp request_body_parsed?(conn) do not match?(%Plug.Conn.Unfetched{}, conn.body_params) end - - defp maybe_rewrite_csp(conn) do - case Plug.Conn.get_resp_header(conn, "content-security-policy") do - [csp | _] -> - csp = rewrite_csp(csp) - Plug.Conn.put_resp_header(conn, "content-security-policy", csp) - - _ -> - conn - end - end - - defp rewrite_csp(csp) do - policy_directives = String.split(csp, ";", trim: true) - - for policy_directive <- policy_directives, - policy_directive = String.trim(policy_directive), - not String.starts_with?(policy_directive, "frame-ancestors") do - rewrite_csp_directive(policy_directive) - end - |> Enum.join("; ") - end - - defp rewrite_csp_directive(policy_directive) do - case String.split(policy_directive, " ", parts: 2) do - ["script-src", directives] -> - script_src_with_unsafe_eval(directives) - - [policy, directives] -> - "#{policy} #{directives}" - - [leftover] -> - leftover - end - end - - defp script_src_with_unsafe_eval(directives) do - case :binary.match(directives, "'unsafe-eval'") do - :nomatch -> "script-src 'unsafe-eval' #{directives}" - _ -> "script-src #{directives}" - end - end end diff --git a/lib/mcp/router.ex b/lib/mcp/router.ex index 790aed5f..eb57d716 100644 --- a/lib/mcp/router.ex +++ b/lib/mcp/router.ex @@ -6,8 +6,10 @@ defmodule Corex.MCP.Router do @moduledoc false use Plug.Router + import Plug.Conn require Logger + alias Corex.Json alias Corex.MCP.Server @@ -21,6 +23,12 @@ defmodule Corex.MCP.Router do For security reasons, Corex.MCP does not accept requests with an origin header for this endpoint. """ + @json_parser Plug.Parsers.init( + parsers: [:json], + pass: [], + json_decoder: Json.encoder() + ) + plug(:match) plug(:check_remote_ip) plug(:check_origin) @@ -29,66 +37,62 @@ defmodule Corex.MCP.Router do get "/" do conn |> put_resp_content_type("text/html") - |> send_resp(200, corex_mcp_html()) + |> send_resp(200, landing_html()) |> halt() end get "/config" do conn |> put_resp_content_type("application/json") - |> send_resp(200, Json.encode_to_iodata!(config(conn.private.corex_mcp_config))) + |> send_resp(200, Json.encode_to_iodata!(client_config())) |> halt() end get "/mcp" do - Logger.metadata(corex_mcp: true) - conn + |> put_logger_metadata() |> send_resp(405, "Method Not Allowed") |> halt() end post "/mcp" do - Logger.metadata(corex_mcp: true) - - opts = - Plug.Parsers.init( - parsers: [:json], - pass: [], - json_decoder: Json.encoder() - ) - conn - |> Plug.Parsers.call(opts) + |> put_logger_metadata() + |> Plug.Parsers.call(@json_parser) |> Server.handle_http_message() |> halt() end match "/*_ignored" do - Logger.metadata(corex_mcp: true) - conn + |> put_logger_metadata() |> send_resp(404, "Not Found") |> halt() end defp check_remote_ip(conn, _opts) do + config = conn.private.corex_mcp_config + cond do - loopback_address?(conn.remote_ip) -> + local_address?(conn.remote_ip) -> conn - conn.private.corex_mcp_config.allow_remote_access -> + config.allow_remote_access -> conn true -> - deny_remote_access(conn) + Logger.warning(@remote_access_forbidden) + + conn + |> send_resp(403, @remote_access_forbidden) + |> halt() end end - defp loopback_address?({127, 0, 0, _}), do: true - defp loopback_address?({0, 0, 0, 0, 0, 0, 0, 1}), do: true - defp loopback_address?({0, 0, 0, 0, 0, 65_535, 32_512, 1}), do: true - defp loopback_address?(_), do: false + defp local_address?({127, 0, 0, _}), do: true + defp local_address?({0, 0, 0, 0, 0, 0, 0, 1}), do: true + defp local_address?({0, 0, 0, 0, 0, 65_535, 32_512, 1}), do: true + defp local_address?(_), do: false defp check_origin(conn, _opts) do case {conn.path_info, get_req_header(conn, "origin")} do @@ -99,27 +103,20 @@ defmodule Corex.MCP.Router do conn {_, _} -> - deny_origin_header(conn) - end - end - - defp deny_remote_access(conn) do - Logger.warning(@remote_access_forbidden) + Logger.warning(@origin_header_forbidden) - conn - |> send_resp(403, @remote_access_forbidden) - |> halt() + conn + |> send_resp(403, @origin_header_forbidden) + |> halt() + end end - defp deny_origin_header(conn) do - Logger.warning(@origin_header_forbidden) - + defp put_logger_metadata(conn) do + Logger.metadata(corex_mcp: true) conn - |> send_resp(403, @origin_header_forbidden) - |> halt() end - defp corex_mcp_html do + defp landing_html do """ @@ -135,18 +132,18 @@ defmodule Corex.MCP.Router do """ end - defp package_version(app) do - if vsn = Application.spec(app)[:vsn] do - List.to_string(vsn) - end - end - - defp config(plug_config) do + defp client_config do %{ name: "corex", framework_type: "phoenix", - corex_version: package_version(:corex), - allow_remote_access: plug_config.allow_remote_access + corex_version: package_version(:corex) } end + + defp package_version(app) do + case Application.spec(app, :vsn) do + nil -> nil + vsn -> List.to_string(vsn) + end + end end diff --git a/lib/mcp/server.ex b/lib/mcp/server.ex index 2da2c72c..b299deae 100644 --- a/lib/mcp/server.ex +++ b/lib/mcp/server.ex @@ -1,12 +1,13 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2025 Dashbit # See LICENSE for third-party notices. -# Modifications: Corex.MCP namespace; ETS :corex_mcp_tools; Corex.MCP.Tools.*; Corex -# serverInfo; init_tools (re)loads tool specs from raw_tools/0 on each call so metadata -# (e.g. readOnlyHint) and callbacks stay in sync with the current build. +# Upstream: tidewave v0.5.6 (deps/tidewave/lib/tidewave/mcp/server.ex) +# Modifications: Corex.MCP namespace; Corex.MCP.Tools.*; Corex serverInfo; +# tools stored in :persistent_term, initialized from plug init/1. defmodule Corex.MCP.Server do @moduledoc false + require Logger import Plug.Conn @@ -15,38 +16,30 @@ defmodule Corex.MCP.Server do @protocol_version "2025-03-26" @vsn Mix.Project.config()[:version] || "0.0.0" + @tools_key {__MODULE__, :tools_and_dispatch} - defp raw_tools do - [McpToolComponents.tools(), McpToolInstallation.tools()] - |> List.flatten() - end + @parse_error -32_600 + @method_not_found -32_601 + @invalid_params -32_602 - @doc "Loads tool specs and callbacks into `:corex_mcp_tools` ETS." + @doc "Loads tool specs and callbacks into persistent term storage." def init_tools do - tools = raw_tools() - dispatch_map = Map.new(tools, fn tool -> {tool.name, tool.callback} end) - - if :ets.whereis(:corex_mcp_tools) == :undefined do - :ets.new(:corex_mcp_tools, [ - :set, - :named_table, - :public, - read_concurrency: true - ]) - end + tools = + [McpToolComponents.tools(), McpToolInstallation.tools()] + |> Enum.flat_map(& &1) - :ets.insert(:corex_mcp_tools, {:tools, {tools, dispatch_map}}) + dispatch = Map.new(tools, &{&1.name, &1.callback}) + :persistent_term.put(@tools_key, {tools, dispatch}) :ok end - @doc "Returns the stored `{tools, dispatch}` tuple from ETS." + @doc "Returns the stored `{tools, dispatch}` tuple." def tools_and_dispatch do - [{:tools, tools}] = :ets.lookup(:corex_mcp_tools, :tools) - tools + :persistent_term.get(@tools_key) end defp tools do - {tools, _} = tools_and_dispatch() + {tools, _dispatch} = tools_and_dispatch() for tool <- tools do tool @@ -68,11 +61,9 @@ defmodule Corex.MCP.Server do _ -> {:error, %{ - code: -32_601, + code: @method_not_found, message: "Method not found", - data: %{ - name: name - } + data: %{name: name} }} end end @@ -82,9 +73,8 @@ defmodule Corex.MCP.Server do is_nil(client_version) -> {:error, "Protocol version is required"} - client_version < unquote(@protocol_version) -> - {:error, - "Unsupported protocol version. Server supports #{unquote(@protocol_version)} or later"} + client_version < @protocol_version -> + {:error, "Unsupported protocol version. Server supports #{@protocol_version} or later"} true -> :ok @@ -92,40 +82,26 @@ defmodule Corex.MCP.Server do end defp handle_ping(request_id) do - {:ok, - %{ - jsonrpc: "2.0", - id: request_id, - result: %{} - }} + jsonrpc_result(request_id, %{}) end defp handle_initialize(request_id, params) do - params = params || %{} - case validate_protocol_version(params["protocolVersion"]) do :ok -> - {:ok, + jsonrpc_result(request_id, %{ + protocolVersion: @protocol_version, + capabilities: %{tools: %{listChanged: false}}, + serverInfo: %{name: "Corex MCP", version: to_string(@vsn)}, + tools: tools() + }) + + {:error, reason} -> + {:error, %{ jsonrpc: "2.0", id: request_id, - result: %{ - protocolVersion: unquote(@protocol_version), - capabilities: %{ - tools: %{ - listChanged: false - } - }, - serverInfo: %{ - name: "Corex MCP", - version: to_string(@vsn) - }, - tools: tools() - } + error: %{code: @invalid_params, message: reason} }} - - {:error, reason} -> - {:error, reason} end end @@ -145,6 +121,10 @@ defmodule Corex.MCP.Server do result_or_error(request_id, {:ok, %{templates: []}}) end + defp jsonrpc_result(request_id, result) do + {:ok, %{jsonrpc: "2.0", id: request_id, result: result}} + end + defp result_or_error(request_id, {:ok, text, metadata}) when is_binary(text) and is_map(metadata) do result_or_error(request_id, {:ok, %{content: [%{type: "text", text: text}], _meta: metadata}}) @@ -155,12 +135,7 @@ defmodule Corex.MCP.Server do end defp result_or_error(request_id, {:ok, result}) when is_map(result) do - {:ok, - %{ - jsonrpc: "2.0", - id: request_id, - result: result - }} + jsonrpc_result(request_id, result) end defp result_or_error(request_id, {:error, :invalid_arguments}) do @@ -168,7 +143,7 @@ defmodule Corex.MCP.Server do %{ jsonrpc: "2.0", id: request_id, - error: %{code: -32_602, message: "Invalid arguments for tool"} + error: %{code: @invalid_params, message: "Invalid arguments for tool"} }} end @@ -180,12 +155,7 @@ defmodule Corex.MCP.Server do end defp result_or_error(request_id, {:error, error}) when is_map(error) do - {:error, - %{ - jsonrpc: "2.0", - id: request_id, - error: error - }} + {:error, %{jsonrpc: "2.0", id: request_id, error: error}} end defp handle_call_tool(request_id, %{"name" => name} = params, assigns) do @@ -227,88 +197,66 @@ defmodule Corex.MCP.Server do defp handle_message(%{"method" => method, "id" => id} = message, assigns) do Logger.info("Routing MCP message - Method: #{method}, ID: #{id}") Logger.debug("Full message: #{inspect(message, pretty: true)}") - route_mcp_method(method, id, message, assigns) + route_request(method, id, message, assigns) end - defp route_mcp_method("ping", id, _message, _assigns) do - Logger.debug("Handling ping request") - handle_ping(id) - end + defp route_request("ping", id, _message, _assigns), do: handle_ping(id) - defp route_mcp_method("initialize", id, message, _assigns) do - Logger.info( - "Handling initialize request with params: #{inspect(message["params"], pretty: true)}" - ) - - handle_initialize(id, message["params"]) + defp route_request("initialize", id, message, _assigns) do + handle_initialize(id, message["params"] || %{}) end - defp route_mcp_method("tools/list", id, message, _assigns) do - Logger.debug("Handling tools list request") + defp route_request("tools/list", id, message, _assigns) do handle_list_tools(id, message["params"]) end - defp route_mcp_method("tools/call", id, message, assigns) do - Logger.debug( - "Handling tool call request with params: #{inspect(message["params"], pretty: true)}" - ) - + defp route_request("tools/call", id, message, assigns) do safe_call_tool(id, message["params"], assigns) end - defp route_mcp_method("prompts/list", id, message, _assigns) do - Logger.debug("Handling prompts list request") + defp route_request("prompts/list", id, message, _assigns) do handle_list_prompts(id, message["params"]) end - defp route_mcp_method("resources/list", id, message, _assigns) do - Logger.debug("Handling resources list request") + defp route_request("resources/list", id, message, _assigns) do handle_list_resources(id, message["params"]) end - defp route_mcp_method("resources/templates/list", id, message, _assigns) do - Logger.debug("Handling templates list request") + defp route_request("resources/templates/list", id, message, _assigns) do handle_list_templates(id, message["params"]) end - defp route_mcp_method(other, id, _message, _assigns) do + defp route_request(other, id, _message, _assigns) do Logger.warning("Received unsupported method: #{other}") {:error, %{ jsonrpc: "2.0", id: id, - error: %{ - code: -32_601, - message: "Method not found", - data: %{name: other} - } + error: %{code: @method_not_found, message: "Method not found", data: %{name: other}} }} end defp validate_jsonrpc_message(%{"jsonrpc" => "2.0"} = message) do - case {Map.has_key?(message, "id"), Map.has_key?(message, "method"), - Map.has_key?(message, "result")} do - {true, true, _} -> - validate_jsonrpc_request_id(message["id"], message) + cond do + Map.has_key?(message, "id") and Map.has_key?(message, "method") -> + valid_request_id?(message["id"], message) - {false, true, _} -> + not Map.has_key?(message, "id") and Map.has_key?(message, "method") -> {:ok, message} - {true, _, true} -> + Map.has_key?(message, "id") and Map.has_key?(message, "result") -> {:ok, message} - _ -> + true -> {:error, :invalid_jsonrpc} end end defp validate_jsonrpc_message(_), do: {:error, :invalid_jsonrpc} - defp validate_jsonrpc_request_id(id, message) when is_binary(id) or is_number(id), - do: {:ok, message} - - defp validate_jsonrpc_request_id(_, _), do: {:error, :invalid_jsonrpc} + defp valid_request_id?(id, message) when is_binary(id) or is_number(id), do: {:ok, message} + defp valid_request_id?(_, _), do: {:error, :invalid_jsonrpc} defp send_json(conn, data) do conn @@ -317,52 +265,43 @@ defmodule Corex.MCP.Server do end defp send_jsonrpc_error(conn, id, code, message, data \\ nil) do - error = %{ - code: code, - message: message - } - - error = if data, do: Map.put(error, :data, data), else: error - - response = %{ - jsonrpc: "2.0", - id: id, - error: error - } + error = + %{code: code, message: message} + |> maybe_put_error_data(data) conn |> put_resp_content_type("application/json") - |> send_resp(200, Corex.Json.encode!(response)) + |> send_resp(200, Corex.Json.encode!(%{jsonrpc: "2.0", id: id, error: error})) end + defp maybe_put_error_data(error, nil), do: error + defp maybe_put_error_data(error, data), do: Map.put(error, :data, data) + @doc "Handles a JSON-RPC MCP request over HTTP." def handle_http_message(conn) do - :ok = init_tools() Logger.info("Received #{conn.method} message") params = conn.body_params conn = fetch_query_params(conn) Logger.debug("Raw params: #{inspect(params, pretty: true)}") - case validate_jsonrpc_message(params) do - {:ok, message} -> - assigns = conn.private.corex_mcp_config - - case handle_message(message, assigns) do - {:ok, nil} -> - conn |> put_status(202) |> send_json(%{status: "ok"}) - - {:ok, response} -> - Logger.debug("Sending HTTP response: #{inspect(response, pretty: true)}") - conn |> put_status(200) |> send_json(response) - - {:error, error_response} -> - Logger.warning("Error handling message: #{inspect(error_response)}") - conn |> put_status(400) |> send_json(error_response) - end - + with {:ok, message} <- validate_jsonrpc_message(params), + {:ok, response} <- handle_message(message, conn.private.corex_mcp_config) do + case response do + nil -> + conn |> put_status(202) |> send_json(%{status: "ok"}) + + response -> + Logger.debug("Sending HTTP response: #{inspect(response, pretty: true)}") + conn |> put_status(200) |> send_json(response) + end + else {:error, :invalid_jsonrpc} -> Logger.warning("Invalid JSON-RPC message format") - send_jsonrpc_error(conn, nil, -32_600, "Could not parse message") + send_jsonrpc_error(conn, nil, @parse_error, "Could not parse message") + + {:error, error_response} when is_map(error_response) -> + Logger.warning("Error handling message: #{inspect(error_response)}") + conn |> put_status(400) |> send_json(error_response) end end end diff --git a/lib/mcp/tools/components.ex b/lib/mcp/tools/components.ex index 76391d5d..04bbe292 100644 --- a/lib/mcp/tools/components.ex +++ b/lib/mcp/tools/components.ex @@ -6,6 +6,9 @@ defmodule Corex.MCP.Tools.Components do alias Corex.MCP.ComponentDocs + @max_id_length 64 + @unknown_id_message "Unknown component id. Use list_components for valid ids." + def tools do [ %{ @@ -41,27 +44,24 @@ defmodule Corex.MCP.Tools.Components do ] end - def list_components(_args) do - ids = for id <- Corex.component_ids(), do: to_string(id) - {:ok, Corex.Json.encode!(%{"components" => ids})} + def list_components(%{} = args) when map_size(args) == 0 do + ids = Enum.map(Corex.component_ids(), &to_string/1) + {:ok, Corex.Json.encode!(%{components: ids})} end - def get_component(%{"id" => id}) when is_binary(id) do - case Corex.component_module_for_mcp_id(id) do - {:ok, mod} -> - atom_id = String.to_existing_atom(id) - - case Corex.component_spec(atom_id) do - {:ok, spec} -> - payload = ComponentDocs.enrich(spec, mod) - {:ok, Corex.Json.encode!(payload)} - - :error -> - {:error, "Unknown component id. Use list_components for valid ids."} - end + def list_components(_), do: {:error, :invalid_arguments} - :error -> - {:error, "Unknown component id. Use list_components for valid ids."} + def get_component(%{"id" => id} = args) + when is_binary(id) and byte_size(id) <= @max_id_length and map_size(args) == 1 do + with {:ok, mod} <- Corex.component_module_for_mcp_id(id), + atom_id = String.to_existing_atom(id), + {:ok, spec} <- Corex.component_spec(atom_id) do + spec + |> ComponentDocs.enrich(mod) + |> Corex.Json.encode!() + |> then(&{:ok, &1}) + else + :error -> {:error, @unknown_id_message} end end diff --git a/lib/mcp/tools/installation.ex b/lib/mcp/tools/installation.ex index c019f29e..6c427ea6 100644 --- a/lib/mcp/tools/installation.ex +++ b/lib/mcp/tools/installation.ex @@ -1,6 +1,8 @@ defmodule Corex.MCP.Tools.Installation do @moduledoc false + @valid_scenarios ~W(new_project existing_project all) + def tools do [ %{ @@ -25,14 +27,12 @@ defmodule Corex.MCP.Tools.Installation do ] end - def installation_guide(args) do - scenario = - case Map.get(args || %{}, "scenario") do - s when s in ["new_project", "existing_project", "all"] -> s - nil -> "all" - _ -> "all" - end + def installation_guide(args) when args in [nil, %{}] do + encode_guide(full_guide()) + end + def installation_guide(%{"scenario" => scenario} = args) + when scenario in @valid_scenarios and map_size(args) == 1 do payload = case scenario do "new_project" -> Map.put(new_project_section(), :scenario, scenario) @@ -40,6 +40,12 @@ defmodule Corex.MCP.Tools.Installation do "all" -> full_guide() end + encode_guide(payload) + end + + def installation_guide(_), do: {:error, :invalid_arguments} + + defp encode_guide(payload) do {:ok, Corex.Json.encode!(payload)} end diff --git a/test/corex/mcp/component_docs_test.exs b/test/corex/mcp/component_docs_test.exs index c670a16b..8782e9ad 100644 --- a/test/corex/mcp/component_docs_test.exs +++ b/test/corex/mcp/component_docs_test.exs @@ -13,6 +13,7 @@ defmodule Corex.MCP.ComponentDocsTest do assert enriched.docs_note == nil assert is_binary(enriched.source_path) assert enriched.source_path =~ "heroicon.ex" + refute String.starts_with?(enriched.source_path, "/") assert is_integer(enriched.source_line) end diff --git a/test/corex/mcp/plug_test.exs b/test/corex/mcp/plug_test.exs index bc346e2b..928ee74c 100644 --- a/test/corex/mcp/plug_test.exs +++ b/test/corex/mcp/plug_test.exs @@ -1,53 +1,56 @@ defmodule Corex.MCPTest do use ExUnit.Case, async: false + alias Corex.MCP.{Config, Server} + @moduletag capture_log: true describe "init/1" do test "defaults allow_remote_access to false" do - assert %{allow_remote_access: false} = Corex.MCP.init([]) + assert %Config{allow_remote_access: false} = Corex.MCP.init([]) end test "honours allow_remote_access" do - assert %{allow_remote_access: true} = Corex.MCP.init(allow_remote_access: true) + assert %Config{allow_remote_access: true} = Corex.MCP.init(allow_remote_access: true) end test "accepts already-normalized map opts" do first = Corex.MCP.init([]) assert first == Corex.MCP.init(first) end + + test "initializes MCP tools for dispatch" do + Corex.MCP.init([]) + assert {tools, _dispatch} = Server.tools_and_dispatch() + assert tools != [] + end end describe "call/2 non-corex paths" do - test "rewrites script-src for unsafe-eval and strips frame-ancestors from CSP" do + test "does not rewrite CSP or remove x-frame-options" do opts = Corex.MCP.init([]) + csp = "default-src 'self'; script-src 'self' https://cdn.example; frame-ancestors 'none'" conn = Plug.Test.conn(:get, "/app") |> Corex.MCP.call(opts) - |> Plug.Conn.put_resp_header( - "content-security-policy", - "default-src 'self'; script-src 'self' https://cdn.example; frame-ancestors 'none'" - ) + |> Plug.Conn.put_resp_header("content-security-policy", csp) |> Plug.Conn.put_resp_header("x-frame-options", "DENY") |> Plug.Conn.send_resp(200, "ok") - [csp] = Plug.Conn.get_resp_header(conn, "content-security-policy") - assert csp =~ "script-src" - assert csp =~ "'unsafe-eval'" - refute csp =~ "frame-ancestors" - assert Plug.Conn.get_resp_header(conn, "x-frame-options") == [] + assert Plug.Conn.get_resp_header(conn, "content-security-policy") == [csp] + assert Plug.Conn.get_resp_header(conn, "x-frame-options") == ["DENY"] end - test "leaves CSP unchanged when header absent" do + test "passes through non-corex requests without validating body params" do opts = Corex.MCP.init([]) conn = - Plug.Test.conn(:get, "/") + Plug.Test.conn(:get, "/app") + |> Map.put(:body_params, %{}) |> Corex.MCP.call(opts) - |> Plug.Conn.send_resp(204, "") - assert Plug.Conn.get_resp_header(conn, "content-security-policy") == [] + refute conn.halted end end @@ -74,7 +77,7 @@ defmodule Corex.MCPTest do |> Map.put(:remote_ip, {127, 0, 0, 1}) |> Map.put(:body_params, %{}) - assert_raise RuntimeError, ~r/plug Corex.MCP is runnning too late/, fn -> + assert_raise RuntimeError, ~r/plug Corex.MCP is running too late/, fn -> Corex.MCP.call(conn, opts) end end @@ -114,7 +117,7 @@ defmodule Corex.MCPTest do decoded = Corex.Json.decode!(conn.resp_body) assert decoded["name"] == "corex" assert decoded["framework_type"] == "phoenix" - assert decoded["allow_remote_access"] == false + refute Map.has_key?(decoded, "allow_remote_access") assert is_binary(decoded["corex_version"]) end diff --git a/test/corex/mcp/server_test.exs b/test/corex/mcp/server_test.exs index ca122136..c0b41473 100644 --- a/test/corex/mcp/server_test.exs +++ b/test/corex/mcp/server_test.exs @@ -3,13 +3,14 @@ defmodule Corex.MCP.ServerTest do @moduletag capture_log: true - alias Corex.MCP.Server + alias Corex.MCP.{Config, Server} setup do unless Application.get_env(:corex, :debug) do Logger.put_module_level(Corex.MCP.Server, :none) end + :ok = Server.init_tools() :ok end @@ -17,10 +18,10 @@ defmodule Corex.MCP.ServerTest do Plug.Test.conn(:post, "/") |> Map.put(:body_params, body_map) |> Map.put(:params, body_map) - |> Plug.Conn.put_private(:corex_mcp_config, %{allow_remote_access: false}) + |> Plug.Conn.put_private(:corex_mcp_config, %Config{allow_remote_access: false}) end - test "init_tools stores tools in :corex_mcp_tools" do + test "init_tools stores tools for dispatch" do assert :ok = Server.init_tools() assert {tools, _dispatch} = Server.tools_and_dispatch() assert tools != [] @@ -30,6 +31,13 @@ defmodule Corex.MCP.ServerTest do assert "installation_guide" in names end + test "init_tools is safe to call repeatedly (code reload / concurrent plug init)" do + assert :ok = Server.init_tools() + assert :ok = Server.init_tools() + assert {tools, _} = Server.tools_and_dispatch() + assert tools != [] + end + test "handle_http_message initialize returns protocol and tools" do body = %{ "jsonrpc" => "2.0", @@ -284,4 +292,23 @@ defmodule Corex.MCP.ServerTest do decoded = Corex.Json.decode!(conn.resp_body) assert decoded["error"]["code"] == -32_601 end + + test "handle_http_message works without re-initializing tools on each request" do + ping = %{"jsonrpc" => "2.0", "id" => 20, "method" => "ping"} + + conn1 = + ping + |> post_conn() + |> Server.handle_http_message() + + assert conn1.status == 200 + + conn2 = + ping + |> Map.put("id", 21) + |> post_conn() + |> Server.handle_http_message() + + assert conn2.status == 200 + end end diff --git a/test/corex/mcp/tools/components_test.exs b/test/corex/mcp/tools/components_test.exs index b41efe02..2b68d40e 100644 --- a/test/corex/mcp/tools/components_test.exs +++ b/test/corex/mcp/tools/components_test.exs @@ -22,6 +22,20 @@ defmodule Corex.MCP.Tools.ComponentsTest do assert decoded["components"] == Enum.map(Corex.component_ids(), &to_string/1) end + test "list_components rejects non-empty arguments" do + assert {:error, :invalid_arguments} = Components.list_components(%{"extra" => "x"}) + end + + test "get_component rejects unknown keys" do + assert {:error, :invalid_arguments} = + Components.get_component(%{"id" => "accordion", "extra" => "x"}) + end + + test "get_component rejects id longer than 64 bytes" do + long_id = String.duplicate("a", 65) + assert {:error, :invalid_arguments} = Components.get_component(%{"id" => long_id}) + end + test "get_component returns spec, docs, and source metadata for a known id" do json = case Components.get_component(%{"id" => "accordion"}) do @@ -39,6 +53,7 @@ defmodule Corex.MCP.Tools.ComponentsTest do assert is_nil(decoded["docs_note"]) assert is_binary(decoded["source_path"]) assert decoded["source_path"] =~ "accordion.ex" + refute String.starts_with?(decoded["source_path"], "/") assert is_integer(decoded["source_line"]) end diff --git a/test/corex/mcp/tools/installation_test.exs b/test/corex/mcp/tools/installation_test.exs index ae45dd5b..28bab299 100644 --- a/test/corex/mcp/tools/installation_test.exs +++ b/test/corex/mcp/tools/installation_test.exs @@ -56,6 +56,16 @@ defmodule Corex.MCP.Tools.InstallationTest do assert Enum.any?(titles, &(&1 =~ "MCP")) end + test "installation_guide rejects invalid scenario" do + assert {:error, :invalid_arguments} = + Installation.installation_guide(%{"scenario" => "bogus"}) + end + + test "installation_guide rejects unknown keys" do + assert {:error, :invalid_arguments} = + Installation.installation_guide(%{"scenario" => "all", "extra" => "x"}) + end + test "tools/0 includes installation_guide" do names = for t <- Installation.tools(), do: t.name assert "installation_guide" in names