Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions guides/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
47 changes: 28 additions & 19 deletions lib/mcp/component_docs.ex
Original file line number Diff line number Diff line change
@@ -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} ->
Expand Down Expand Up @@ -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"}
Expand Down
22 changes: 22 additions & 0 deletions lib/mcp/config.ex
Original file line number Diff line number Diff line change
@@ -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
97 changes: 31 additions & 66 deletions lib/mcp/plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Loading
Loading