# `ExMCP.HttpPlug.Core`
[🔗](https://github.com/azmaveth/ex_mcp/blob/v1.0.0/lib/ex_mcp/http_plug/core.ex#L1)

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.

# `allowed_hosts`

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

# `origin_context`

```elixir
@type origin_context() :: %{
  optional(:origin) =&gt; String.t() | nil,
  optional(:scheme) =&gt; String.t(),
  optional(:host) =&gt; String.t(),
  optional(:port) =&gt; non_neg_integer() | nil
}
```

# `cors_response_origin`

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

# `host_allowed?`

```elixir
@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`

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

# `normalize_host`

```elixir
@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`

```elixir
@spec oauth_guard_disabled_error() :: map()
```

# `origin_allowed?`

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

# `parse_json`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
