Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a554c20
fix(dispatcher): make logging/setLevel a library builtin
akriaueno Jun 6, 2026
d5e8ec6
feat(lifecycle): add enforce_initialized gating option
akriaueno Jun 6, 2026
a30f115
feat(dispatcher): add tool_errors option for tool-result errors
akriaueno Jun 6, 2026
8499d5e
feat(server): validate tool names and reject duplicates at compile time
akriaueno Jun 6, 2026
0cb8e27
feat(transport): add sse_buffer_limit option
akriaueno Jun 6, 2026
cd93951
docs: scope the resumable SSE claim to the GET stream
akriaueno Jun 6, 2026
326b5c2
docs: clarify :sse_buffer_limit default is nil (CodeRabbit)
akriaueno Jun 6, 2026
3c7a282
fix(dispatcher): harden logging/setLevel and restrict pre-init to ping
akriaueno Jun 6, 2026
0d30892
test(transport): verify sse_buffer_limit reaches the session
akriaueno Jun 6, 2026
2cd9a5b
fix(lifecycle): commit notifications/initialized and log level synchr…
akriaueno Jun 6, 2026
93eaa95
fix(dispatcher): accept string-keyed logging capability
akriaueno Jun 6, 2026
453320a
docs: scope duplicate-tool-name rejection to DSL-declared names
akriaueno Jun 6, 2026
a0c393d
fix(session): fall back to default replay buffer when buffer_limit is…
akriaueno Jun 6, 2026
83600e5
feat(dispatcher): default tool_errors to :result for spec compliance
akriaueno Jun 6, 2026
389ee33
fix(server): anchor tool-name validation with \A...\z
akriaueno Jun 6, 2026
dabe2f9
fix(dispatcher): clean error if the session dies during logging/setLevel
akriaueno Jun 6, 2026
dddae9b
test(transport): cover tool_errors, logging/setLevel and notification…
akriaueno Jun 6, 2026
3116120
docs: tool-name regex, enforce_initialized strict mode, GET-only SSE …
akriaueno Jun 6, 2026
ec45e88
docs: align transport docs with the tool_errors :result default
akriaueno Jun 6, 2026
3e6bfac
test(transport): cover tool_errors :json_rpc path and init validation
akriaueno Jun 6, 2026
17961a7
fix(transport): handle a session that vanishes during notifications/i…
akriaueno Jun 6, 2026
e427c8c
feat(dispatcher): surface input-schema validation failures as tool er…
akriaueno Jun 6, 2026
19ff1cc
fix(dispatcher): treat non-object tools/call arguments as a protocol …
akriaueno Jun 6, 2026
c112e25
feat!: enforce MCP spec compliance by default
akriaueno Jun 6, 2026
b166276
docs: document spec-compliance-by-default and bump to 0.3.0
akriaueno Jun 6, 2026
cb7fa32
fix(dispatcher): keep a raised Urchin.Error a JSON-RPC error in tools…
akriaueno Jun 6, 2026
0d8d0dc
docs: scope argument validation to DSL tools and clarify the tool-nam…
akriaueno Jun 6, 2026
4b8b607
fix(server): stop enforcing a tool-name pattern (match the spec)
akriaueno Jun 8, 2026
df0482a
refactor(session): set the initialized flag only via mark_initialized/1
akriaueno Jun 8, 2026
b640c1c
docs: note that MCP tool-name recommendations still apply
akriaueno Jun 8, 2026
ac3bae9
test(dispatcher): mark logging/setLevel tests as the initialized path
akriaueno Jun 8, 2026
82e7768
docs: clarify that only non-Urchin.Error raises become isError results
akriaueno Jun 8, 2026
4a887c5
fix(dispatcher): gate logging/setLevel before initialization
akriaueno Jun 8, 2026
ff4e7b1
docs: scope duplicate detection to literal names and fix the pre-init…
akriaueno Jun 8, 2026
615e736
test(transport): cover DELETE with an unsupported protocol version
akriaueno Jun 8, 2026
4440526
feat(dispatcher): validate completion/complete params and result shape
akriaueno Jun 8, 2026
a21eb3f
feat(tool): require object input and output schemas
akriaueno Jun 8, 2026
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
64 changes: 60 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

This release makes the server enforce the MCP specification by default. Several behaviors
that were previously absent or lenient are now always on; see Changed for the breaking
details and how to adapt.

### Added

- Session lifecycle limits: `:max_sessions` (reject new sessions with `503` past a cap —
Expand All @@ -19,19 +23,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Declarative tool scopes: `tool "name", scopes: ["files:write"], ...` enforces the scopes
against `ctx.auth` before the handler runs, failing closed when the request carries no
authorization.
- `:validate_arguments` transport option (default `false`) validates `tools/call` arguments
against each DSL tool's `input_schema` and rejects a mismatch with `invalid_params` before
the handler runs. `Urchin.Schema` implements the supported (minimal) JSON Schema subset.
- `:expose_internal_errors` transport option (default `false`). Unexpected exceptions and
malformed handler returns are now logged in full but return a generic message to the
client; enable the option to surface the detail in development. Deliberate `Urchin.Error`
values and `{:error, message}` returns still pass through unchanged.
values and `{:error, message}` returns are never redacted — their `message`/`data` reach the
client unchanged.
- Capability guards: `Urchin.Context.create_message/3`, `elicit/3` and `list_roots/2`
return an error without contacting the client when it did not advertise the matching
`sampling`/`elicitation`/`roots` capability.
- `415 Unsupported Media Type` for POST requests whose `Content-Type` is not
`application/json`.
- `SECURITY.md` with a threat model, deployment checklist and vulnerability reporting.
- `:sse_buffer_limit` transport option (default `nil`, preserving the session's internal
default of `100`) forwarding the per-session GET-stream replay buffer size to the session;
previously only configurable on `Urchin.Session` directly.

### Changed

The following are now enforced by default, with no opt-out, for MCP spec compliance. They are
breaking relative to `0.2.0`.

- A DSL tool's `tools/call` arguments are validated against its `input_schema` before the
handler runs; a mismatch is returned as a `CallToolResult` with `isError: true` so the model
can self-correct. A tool that declares no `input_schema` now defaults to an object that
accepts no properties (`additionalProperties: false`), so unexpected arguments are rejected —
declare an explicit `input_schema` to accept arbitrary fields. A non-object `arguments` value
is a malformed request and remains a JSON-RPC `invalid_params` error. Servers that implement
`call_tool/3` by hand validate their own arguments. `Urchin.Schema` implements the supported
(minimal) JSON Schema subset.
- Operation requests received before the client sends `notifications/initialized` are rejected
with `invalid_request`; only `ping` is allowed before initialization (the lifecycle's
pings-and-logging exception is for the server's own requests, not the client's
`logging/setLevel`). Clients must complete the lifecycle handshake before issuing other
requests.
- A `tools/call` handler's `{:error, message}` (string) is returned as a `CallToolResult` with
`isError: true` so the model can self-correct. A protocol error returned as
`{:error, %Urchin.Error{}}` is always a JSON-RPC error. (Previously a string handler error
became a JSON-RPC internal error.)
- Duplicate literal tool names within a server are rejected at compile time (a silently shadowed
duplicate was previously accepted, with the last declaration winning); non-literal names (a
variable or expression) cannot be compared statically and are not checked. Urchin enforces no
tool-name pattern (the MCP schema imposes none); servers should still follow the MCP naming
recommendations.
- `initialize` requires `protocolVersion` (string), `capabilities` (object) and `clientInfo`
(with a string `name` and `version`); a missing or mistyped field is an `invalid_params`
error rather than a silently-defaulted value. The server's `serverInfo` must likewise carry a
string `name` and `version`.
- The `MCP-Protocol-Version` header is validated on `DELETE`, matching `POST` and `GET`.
- `completion/complete` request params are validated (`ref` as a `ref/prompt`/`ref/resource`
union, `argument.name`/`value` as strings, `context.arguments` values as strings) and return
`invalid_params` when malformed. Results are capped at 100 values — a handler returning more is
truncated to the top 100 (already ranked by relevance) with `hasMore` set — and a
non-conforming result shape (non-string `values`, etc.) is an internal error.
- A tool's `input_schema` and `output_schema` must be JSON Schema objects whose root `type` is
`"object"` (per the MCP tools spec); the DSL rejects a non-conforming schema at compile time.
- `logging/setLevel` is now a library builtin: when the server advertises the `logging`
capability (via `use Urchin.Server, logging: true`) it succeeds and applies the level to the
session even without a `set_log_level/2` callback. The level is validated against the MCP log
levels (`invalid_params` otherwise), an exported `set_log_level/2` still runs as a hook, and
the session level is updated only after the hook succeeds. Servers that do not advertise
`logging` return `method_not_found`.

### Fixed

- README no longer claims unqualified "resumable SSE streams"; resumption is scoped to the
GET stream, matching the implementation.

## [0.2.0] - 2026-06-05

Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ specification over the **Streamable HTTP** transport.
- Mount as a `Plug` into Phoenix/Plug pipelines, or run standalone with Bandit.
- Tools, resources, resource templates, prompts, completion and logging.
- Server-initiated requests over SSE: sampling, elicitation and roots.
- Progress notifications, cancellation, pagination and resumable SSE streams.
- Progress notifications, cancellation, pagination and a resumable GET SSE stream.
- Optional OAuth 2.1 authorization: RFC 9728 discovery and pluggable token validation.

> This library implements the server side only. The stdio transport is intentionally
Expand Down Expand Up @@ -287,7 +287,10 @@ tool "delete", description: "Delete a file" do
if Urchin.Auth.Claims.has_scope?(Urchin.Context.auth(ctx), "files:write") do
{:ok, [Urchin.Content.text("deleted")]}
else
{:error, "files:write scope required"}
# Return an Urchin.Error so the denial is a JSON-RPC `invalid_request`, matching the
# declarative `scopes:` path. A bare string `{:error, "..."}` would instead surface as a
# `CallToolResult` with `isError: true`.
{:error, Urchin.Error.invalid_request("files:write scope required")}
end
end
```
Expand Down Expand Up @@ -344,14 +347,22 @@ Passed to `Urchin.Transport.StreamableHTTP`, `Urchin.Endpoint` or `Urchin.start_
| `:request_timeout` | `60_000` | per-request handler timeout (ms) |
| `:validate_protocol_version` | `true` | validate the `MCP-Protocol-Version` header |
| `:expose_internal_errors` | `false` | return raised-exception messages to the client (dev only); exceptions are always logged |
| `:validate_arguments` | `false` | validate `tools/call` arguments against each tool's `input_schema` (see `Urchin.Schema`) |
| `:sse_buffer_limit` | `nil` | max recent GET-stream (general SSE) events kept per session for resumption replay (`nil` keeps the session default of `100`) |
| `:max_sessions` | `nil` | reject new sessions with `503` past this many, atomically and before the server's `init/1` runs (`nil` = unlimited) |
| `:session_idle_timeout` | `nil` | terminate a session after this many ms without client activity; a session serving a request is not reaped (`nil` = never) |
| `:session_max_lifetime` | `nil` | terminate a session this many ms after creation regardless of activity; set above your longest tool run (`nil` = never) |
| `:auth` | `nil` | an `Urchin.Auth` (or keyword options) to require OAuth 2.1 bearer tokens; `nil` disables authorization |

`Urchin.Endpoint`/`Urchin.start_link/2` additionally accept `:port`, `:ip`, `:scheme` and `:path`.

Some MCP behaviors are enforced unconditionally and have no option: a DSL tool's `tools/call`
arguments are validated against its `input_schema` (a mismatch is an `isError` `CallToolResult`; a
tool with no schema accepts no properties — servers that implement `call_tool/3` by hand validate
their own arguments); operation requests before `notifications/initialized` are rejected (`ping`
excepted); a `tools/call` handler's `{:error, binary}` is returned as an `isError`
`CallToolResult`; duplicate literal tool names are rejected at compile time; and
`completion/complete` results are capped at 100 values.

## Specification coverage

| Area | Methods |
Expand All @@ -370,7 +381,8 @@ The transport implements: a single endpoint serving POST/GET/DELETE, the
JSON-vs-SSE response decision, `202 Accepted` for notifications and responses,
`Origin` validation, `MCP-Session-Id` management, the `MCP-Protocol-Version` header,
SSE priming events, per-stream event ids, and `Last-Event-ID` resumption of the GET
stream.
stream. Urchin currently replays the GET general stream only; POST request streams are
not replayed (the spec permits, but does not require, replaying either).

### Not included

Expand Down
16 changes: 9 additions & 7 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,21 @@ and does not protect against, and what you must add before exposing a server pub
- **Error redaction.** Unexpected exceptions and malformed handler returns are logged in
full and replaced with a generic message before reaching clients (`:expose_internal_errors`,
default `false`, opts into the detail for development). Deliberate errors — `Urchin.Error`
values and `{:error, message}` returns — pass through unchanged, so keep secrets and
internals out of their `message`/`data`.
values and `{:error, message}` returns — are not redacted; their `message`/`data` reach the
client unchanged, so keep secrets and internals out of them. For `tools/call`, a string
`{:error, message}` is surfaced as a `CallToolResult` with `isError: true` rather than a
JSON-RPC error.
- **Capability-gated server-initiated requests.** `sampling/createMessage`,
`elicitation/create` and `roots/list` are only sent when the client advertised the
capability.
- **Declarative per-tool scopes.** `tool "name", scopes: [...]` enforces scopes against
`ctx.auth` before the handler runs, failing closed when the request carries no
authorization (only meaningful when `ctx.auth` is populated, typically by `:auth` or an
upstream `Urchin.Auth.Plug`).
- **Opt-in argument validation.** `:validate_arguments` checks `tools/call` arguments
against each tool's `input_schema`. It is a minimal subset of JSON Schema (see
`Urchin.Schema`), so unsupported keywords and `output_schema` are still your handler's
responsibility.
- **Argument validation.** A DSL tool's `tools/call` arguments are validated against its
`input_schema` before the handler runs (a hand-written `call_tool/3` validates its own
arguments). It is a minimal subset of JSON Schema (see `Urchin.Schema`), so unsupported
keywords and `output_schema` are still your handler's responsibility.
- **Bounded request bodies** (`@max_body`, ~8 MB) and a per-request handler timeout.
- **Session lifecycle limits** (opt-in): `:max_sessions`, `:session_idle_timeout` and
`:session_max_lifetime`. Without them a session persists until the client sends `DELETE`,
Expand All @@ -49,7 +51,7 @@ Urchin does **not** yet provide these; supply them in your deployment:
4. **Per-tool authorization beyond scopes.** Declarative `scopes:` covers scope checks;
add app-specific authorization (ownership, tenancy, row-level access) in handlers via
`ctx.auth`.
5. **Full input validation.** Enable `:validate_arguments` for structural checks, but
5. **Full input validation.** Structural checks against `input_schema` run automatically, but
validate unsupported JSON Schema keywords, business rules and `output_schema` in your
handler — `Urchin.Schema` is a minimal subset.

Expand Down
8 changes: 6 additions & 2 deletions lib/urchin/context.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ defmodule Urchin.Context do
assigns: %{},
min_log_level: "debug",
expose_internal_errors: false,
validate_arguments: false,
initialized: false,
cancelled_ref: nil
]

Expand All @@ -53,12 +53,16 @@ defmodule Urchin.Context do
assigns: map(),
min_log_level: String.t(),
expose_internal_errors: boolean(),
validate_arguments: boolean(),
initialized: boolean(),
cancelled_ref: reference() | nil
}

@default_request_timeout 30_000

@doc "Returns the valid MCP log levels, in increasing severity order."
@spec log_levels() :: [String.t()]
def log_levels, do: @log_levels

@doc "Returns the user state established by `c:Urchin.Server.init/1`."
@spec state(t()) :: term()
def state(%__MODULE__{state: state}), do: state
Expand Down
Loading
Loading