ExMCP.HttpPlug.Core (ex_mcp v1.0.0-rc.8)

Copy Markdown View Source

Pure request/response decisions for ExMCP.HttpPlug.

The Plug module owns side effects such as reading request bodies, writing responses, ETS/session management, and SSE processes. This module keeps the reusable protocol, origin, and host decisions as data transformations.

Origin validation

origin_allowed?/2 implements a strict allow-list:

  • Requests without an Origin header are allowed. Non-browser clients (CLIs, SDKs, server-to-server callers) do not send the header, and requiring it would break them. DNS rebinding protection for those callers comes from Host validation (host_allowed?/2).
  • Requests with an Origin header are only allowed when the origin is listed in :allowed_origins (or :allowed_origins is :any). There is deliberately no "same origin as the request Host" fallback: in a DNS rebinding attack the Host header is attacker-controlled, so comparing the Origin against it would always pass.

Host validation

host_allowed?/2 compares the request Host header against an allow-list (:any disables the check). Ports are ignored and bracketed IPv6 literals such as "[::1]:8080" match both "[::1]" and "::1" entries.

Summary

Types

allowed_hosts()

@type allowed_hosts() :: :any | [String.t()]

origin_context()

@type origin_context() :: %{
  optional(:origin) => String.t() | nil,
  optional(:scheme) => String.t(),
  optional(:host) => String.t(),
  optional(:port) => non_neg_integer() | nil
}

Functions

cors_response_origin(context, opts)

@spec cors_response_origin(origin_context(), map()) :: String.t() | nil

host_allowed?(host, allowed_hosts)

@spec host_allowed?(String.t() | nil, allowed_hosts()) :: boolean()

Checks a request Host header value against an allow-list.

Returns true when allowed_hosts is :any. Otherwise the host must be present and, after normalization via normalize_host/1, match one of the allow-list entries (compared case-insensitively, ignoring ports and IPv6 brackets).

json_rpc_error(code, message, id \\ nil, data \\ nil)

@spec json_rpc_error(integer(), String.t(), any(), map() | nil) :: map()

normalize_host(host)

@spec normalize_host(String.t()) :: String.t()

Normalizes a Host header value: trims, downcases, and strips the port.

Bracketed IPv6 hosts keep their brackets ("[::1]:8080" becomes "[::1]"). Non-bracketed values only lose a port suffix when they contain a single :, so a raw IPv6 literal such as "::1" is preserved as-is.

oauth_guard_disabled_error()

@spec oauth_guard_disabled_error() :: map()

origin_allowed?(context, opts)

@spec origin_allowed?(origin_context(), map()) :: boolean()

parse_json(body)

@spec parse_json(binary()) ::
  {:ok, map()} | {:error, :parse_error | :invalid_json_rpc_envelope}