All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.11.0 - 2026-10-17
Three things ship together in 0.11.0:
- P1 hardening: everything the 2026-08-24 audit classified as broken today for a consumer.
- A dual-era server. The same server modules now answer MCP
2026-07-28(stateless, per-request_meta:server/discover, multi round-trip requests,subscriptions/listen, streamed responses, cache hints,Mcp-*header validation, the tasks extension) alongside2025-11-25and2025-06-18. The era is classified per request; legacy clients see the protocol they always did, apart from the conformance fixes listed below. - An MCP client.
ConduitMcp.Clientis now a supervised, dual-era MCP client with stdio and Streamable HTTP transports, a multi-serverConduitMcp.Client.Hub, policy/approval/hook governance, Anthropic, OpenAI and Gemini adapters, and OAuth 2.1. The module name was taken from the old payload builders, which moved (see Breaking changes).
Breaking changes
Twenty-two behaviour changes, because the old behaviour was unsafe, wrong, or in the way of the new protocol. Each bullet names its one-line opt-out, or says why it has none.
- CORS is off by default.
:cors_originno longer defaults to"*", and noaccess-control-allow-*header is emitted unless you set it. Previously every response carriedaccess-control-allow-origin: *, so a page onhttps://evil.examplecould POST to the server and read the reply — theoptions _route answered the preflight 200 andACAO: *authorised the read. No DNS rebinding required. Opt out:cors_origin: "*". - An unset
:allowed_originsnow fails closed. A request carrying anOriginheader is rejected with 403 instead of logged-and-allowed. Requests without anOriginstill pass, so non-browser MCP clients are unaffected. An:allowed_originsof an unsupported shape (anything but a string,"*", a list of strings or a single%Regex{}) raisesArgumentErroratinit/1on both transports. A%Regex{}is matched unanchored, so anchor it:~r/\Ahttps:\/\/example\.com\z/. Opt out:allowed_origins: "*". - Cross-mode error codes are now consistent, and spec-correct. An unknown
tool or prompt returns
-32602with a top-level"Unknown tool: <name>"/"Unknown prompt: <name>"message in all three authoring modes; a missing resource returns-32002. Previously DSL and Endpoint mode buried the real reason inside a-32602 "Parameter validation failed"payload'sdata.errors, manual mode returned-32601 "Tool not found", and a DSL server whose resources were all static raisedFunctionClauseErroron an unknown URI and answered-32603 "Internal server error". No opt-out: this is a spec-conformance fix.-32601for a missing tool or resource is a spec deviation, and a switch restoring it would be a permanent compatibility path for non-spec behaviour. tasks/*authorization is no longer default-open, andtasks/listis bounded. A caller with no principal used to match every task; it now matches only unowned ones.tasks/listaccepts a client"limit"and clamps it to a server maximum (default 100,:tasks_list_max_limit), where it previously serialised the whole table into one response. Opt in to stricter semantics:config :conduit_mcp, :tasks_require_owner, truemakes unowned tasks inaccessible too. No opt-out restores default-open matching: it is the cross-principal read this change closes.- An OAuth token with no usable subject claim is now rejected with 401.
subis optional in a JWT and absent from many client-credentials access tokens; accepting one produced an authenticated principal that every consumer reads as anonymous — tasks created unowned and world-readable, rate limiting on the shared IP bucket. The claims consulted are configurable:auth: [strategy: :oauth, subject_claims: ["sub", "client_id", ...]](default["sub", "client_id"]). There is no switch to accept a token that carries none of them, because that principal cannot be told apart from an anonymous caller; list the claim your tokens do carry. :scopeon a DSL declaration is now validated at compile time.scope ""used to compile clean and authorize everyone, because it splits to[]andEnum.all?([], _)is true.ConduitMcp.Componentalready rejected it; both authoring modes now share one validation. No opt-out:scope ""meant "unscoped", so delete thescopeline to get the same behaviour.resources/subscribe,resources/unsubscribeandcompletion/completenow enforce the referenced resource's or prompt's:scope. Subscribing delivers a resource's change notifications and completion enumerates its argument values, so both were readable without the scope that gates reading it. No opt-out: grant the caller the scope that already gates reading the resource or prompt.- A list passed to
:cors_originnow raises atinit/1instead of raisingFunctionClauseErrorinside the pipeline on every request.:allowed_originsis the option that takes a list. No opt-out: a list never worked; pass one origin string. ConduitMcp.Transport.StreamableHTTPcreates no sessions unless:sessionis configured. Omitting:sessionnow means the same assession: false: nomcp-session-idis issued or validated. Previously everyinitialize, authenticated or not, added a row to the session table, so the default configuration let any client fill it. Opt in:session: [](ConduitMcp.Session.EtsStore) orsession: [store: MyStore]. Any other:sessionvalue (true, a map) raisesArgumentErroratinit/1instead of silently meaning "off".- A duplicate DSL tool name, prompt name or resource URI fails the build, as does a duplicate resource URI across Endpoint components (Endpoint already rejected duplicate tool and prompt names). A duplicate used to compile, and which declaration served a request depended on how the compiler ordered the generated clauses — on Elixir 1.20 / OTP 29 the last, while the scope lookup and parameter validation could follow another, so a caller holding one declaration's scope could reach the other's handler. No opt-out: delete or rename one of the declarations.
- Anonymous IPv6 clients are rate-limited per /64, not per address. The
default keys of
ConduitMcp.Plugs.RateLimitandConduitMcp.Plugs.MessageRateLimit, andConduitMcp.Principal.rate_limit_key/1, key an unauthenticated IPv6 caller on its /64 prefix ("2001:db8:1:2::/64"), and an IPv4-mapped IPv6 peer (::ffff:a.b.c.d) on its IPv4 address. A host is allocated a whole /64 and picks the low 64 bits itself, so a per-address key let one client rotate addresses to evade the limit. Hosts sharing a /64 now share a bucket; raise:limitif that matters. The same bucket is the anonymous cancellation scope. Opt out:rate_limit: [key_func: &ConduitMcp.Principal.client_ip/1](and a remote-capture:key_funcfor:message_rate_limitthat prefixes"msg:"). - An empty or non-string static
:token/:api_keyraises atinit/1.Plug.Crypto.secure_compare("", "")is true, soapi_key: ""(for exampleSystem.get_env("API_KEY", "")with the variable unset) authenticated any request that sent an empty header. No opt-out: configure a real secret, or omit the option and use:verify. ConduitMcp.Clientis the new MCP client; its payload builders moved. The notification builders now live inConduitMcp.Notificationswith the same names (progress_notification/3,log_notification/3,resource_updated_notification/1,*_list_changed_notification/0). The server-to-client request builders (create_message_request/2,elicit_request/2,list_roots_request/0) are gone: in2026-07-28these are MRTR input requests, not JSON-RPC requests, built withConduitMcp.MRTR.elicit/2,sample/2(deprecated) andlist_roots/0(deprecated). The old builders had no transport that could deliver them. No opt-out: the module name is reused by the client; rename the notification calls.ConduitMcp.Errors.resource_not_found()is removed. One code cannot be right for both eras: legacy resource-not-found is-32002, modern is-32602. ReturnConduitMcp.Protocol.Legacy.resource_not_found()fromhandle_read_resource/2;ConduitMcp.Protocol.Modernrewrites it to-32602for modern clients. No opt-out: replace the call.- Notifications and JSON-RPC responses POSTed to Streamable HTTP are
answered
202 Accepted, not 204. The Streamable HTTP spec requires 202, and strict clients treat 204 as a failure. A POSTed client response no longer gets an invalid-request error. No opt-out: spec conformance. GET /withAccept: text/event-streamreturns 405 withAllow(Streamable HTTP). Answering 200 with the info JSON told an SSE client its stream had opened. Other GETs still get the info JSON. No opt-out: spec conformance.- Insufficient OAuth scope returns HTTP 403 with
WWW-Authenticate: Bearer error="insufficient_scope", scope="…"(strategy: :oauth), instead of a-32000error at 200. This is what lets a client step up its authorization. The body is still the JSON-RPC error, which now carriesdata: {"error": "insufficient_scope", "scope": …}under every auth strategy. No opt-out: non-OAuth strategies keep 200. - The default CORS headers changed. When
:cors_originis set,access-control-allow-headersnow includesmcp-session-id,mcp-protocol-version,mcp-methodandmcp-name,access-control-expose-headers: mcp-session-id, mcp-protocol-versionis sent, and Streamable HTTP allowsDELETE. Without these a browser client could neither present nor read its session, nor send the modern headers.Mcp-Param-*names depend on your tools: list them in:cors_headers. Opt out: set:cors_headers/:cors_methods. - A legacy request with an unsupported
MCP-Protocol-Versionheader gets 400. The 2025-11-25 transports spec requires it; the header used to be ignored. A missing header is still allowed, andinitializeis exempt. No opt-out: fix the client's header. - Legacy
tasks/get,tasks/resultandtasks/cancelon a missing or foreign task answer-32602, not-32002. 2025-11-25 tasks specifies-32602for an invalid or nonexistenttaskId;-32002means resource-not-found. No opt-out: spec conformance. - DSL
completedeclarations are now served, and checked at compile time. Acompletenaming an argument itspromptorresourceblock does not declare is aCompileError. The declarations used to compile and do nothing (see Fixed), so a typo went unnoticed. No opt-out: fix or delete the declaration. image/1is spec-conformant and raises on unknown data. A URL becomes aresource_linkcontent item; base64 PNG, JPEG, GIF or WebP gets itsmimeType; anything else raisesArgumentErrorinstead of producing an image item with nomimeType, which the spec requires. Opt out:image(data, mime_type)(newimage/2).
Smaller behaviour changes worth knowing about:
notifications/cancelledis now counted byConduitMcp.Plugs.MessageRateLimit(other notifications are still exempt). It mutates server state and is reachable unauthenticated.[:conduit_mcp, :auth, :verify]telemetry reportsreason: :invalid_credentialfor a failed static-strategy verification instead of the verifier's own reason, which may embed the credential.ConduitMcp.Plugs.OAuthfailures report one atom from the set documented in its moduledoc (:expired,:alg_not_allowed,:key_unavailable, …); token-header text and key-provider error terms go only to the clamped log line. A failure no documented reason covers reports:verification_failedrather than raising.resources/read,resources/subscribeandresources/unsubscribewith a missing or non-stringuri, andcompletion/completewith a non-stringref.uri/ref.name, return-32602with the value clamped. A scoped server answered-32603and logged an error; an unscoped server passed the non-string value to your callback, which no longer receives one. Telemetry metadatauri,tool_name,prompt_nameandmethodarenilwhen the client sent a non-string, and the default telemetry logger renders them throughConduitMcp.Reflect, so a client value can neither raise and detach it nor forge a log line.ConduitMcp.Validation.validate_tool_params/3andvalidate_prompt_args/3return string-keyed error maps on every path. The non-map-input path returned atom keys.- The legacy SSE keep-alive frame changed from
": keepalive\n\n"to":\r\n", shared with the new response streams. Both are SSE comments that clients discard. ConduitMcp.Supervisorstarts four more children:ConduitMcp.Subscriptions(aRegistry),ConduitMcp.Subscriptions.Admission(serializes listen admission per principal),ConduitMcp.Client.Registry(aRegistry; no process per client) andConduitMcp.Transport.SSEStream.Owner, plus theConduitMcp.Ext.Taskstable owner and janitor when the tasks extension is enabled.- Sessions store a
"principal"key, so a modern request carrying a legacy session id can be checked against the session's principal. CustomConduitMcp.Session.Stores store whatever map they are given. [:conduit_mcp, :request, :stop]metadata gainsera,protocol_versionand trace-context keys; the existing keys are unchanged.
Security
- plug is pinned to a security floor of 1.20.3 (
~> 1.20 and >= 1.20.3).Plug.Parsersruns before authentication and always decodes the query string, so~> 1.19let a consumer resolve plug 1.19.0–1.19.2 and hand unauthenticated callers CVE-2026-54892 (quadratic decoding of nested params). 1.19.x and 1.20.0–1.20.2 also carry CVE-2026-56813 and CVE-2026-56814; 1.20.3 is the first release clear of all three. - hpax is floored at 1.0.4 (
optional: true, so no new dependency for consumers). Bandit's HTTP/2 stack depends on it, and Bandit's own requirement still admits hpax 1.0.0–1.0.3 (CVE-2026-58226, unauthenticated HPACK decoding DoS). - Bandit is pinned to a security floor of 1.13.1 (
~> 1.13 and >= 1.13.1). Bandit is a non-optional dependency and is the server both transports run on. 1.12.5 carries EEF-CVE-2026-105659 (HIGH) — an HTTP/2 blocked-send deadline that everyWINDOW_UPDATEresets, so a trickling peer pins stream processes indefinitely — and EEF-CVE-2026-105660 (MEDIUM) — an unvalidated HTTP/1 request-target authority lets the client chooseconn.host/conn.port. Releases the old~> 1.9admitted also carry CVE-2026-74836 (HIGH) — connection-window starvation pins an unbounded number of Plug processes indefinitely, and a clientRST_STREAMcannot free them because the stream is blocked inside a synchronous call; and CVE-2026-75484 (MEDIUM) — HTTP/2 header values containing CR, LF or NUL reachconn.req_headersunvalidated, which the HTTP/1 path already rejected. - mint is floored at 1.11 (
optional: true, so no new dependency for consumers). It is the HTTP stack under the client's optional:finch; 1.10.x carries EEF-CVE-2026-91043 (HIGH, HPACK-indexed cookies bypassmax_header_list_size), EEF-CVE-2026-92103 and EEF-CVE-2026-94194. expis now enforced.Joken.Config.default_claims(default_exp: 3600)only affects token generation; Joken validates by folding over the claims the token actually carries, so a token with noexpwas accepted forever.expis now mandatory andnbfis enforced when present.- Expired and wrong-issuer tokens now report themselves.
normalize_joken_error/1matched"expired"against a Joken failure that renders as"Invalid token", so expiry, wrong issuer and wrong audience all collapsed into:invalid_signature/"Token verification failed". It now dispatches on the failed claim, so a client can tell "refresh your token" from "this token is forged". - Cancellation rows are also bounded per scope
(
:cancellations_max_rows_per_scope, default 256). A global cap alone is a cross-tenant denial of service: one unauthenticated client filling the table stops every other client's cancellations from being recorded. The cancellation table is now anordered_set, so the per-scope count is a bounded range scan. The per-scope quota is the only thing that rejects a cancellation; a refusednotifications/cancelledanswers-32000(server error). Through the handler, a cancellation is recorded only for a request that is in flight in the caller's scope (MCP lets a receiver ignore unknown or finished requests), so a client holds no more rows than it has requests running, and the caps are a backstop. In-flight markers live in a second table,:conduit_mcp_in_flight, owned byConduitMcp.Cancellation.InFlightOwner; markers left by a request process killed before its cleanup ran are swept by the cancellation janitor ([:conduit_mcp, :cancellation, :cleanup]reports them asin_flight_removed). The in-flight table is a:duplicate_bagand the per-request clear only writes when a cancellation row exists, so request bookkeeping scales across schedulers instead of serialising on one ETS lock (16 processes: about 3 Mping/s, where a single ordered-set lock held them to about 0.2 M). ConduitMcp.Plugs.OriginValidation's moduledoc no longer claims to stop DNS rebinding. It does not: after a rebind the attacker's page is same-origin, and browsers send noOriginon a same-origin GET, so the header-less path applies and the allowlist never runs. What Origin validation does cover is the cross-origin case (a cross-origin POST always carriesOrigin). The control for rebinding isHostvalidation, which this library does not implement — the moduledoc now says so and points at the mitigations.- Cross-client cancellation abuse fixed (
notifications/cancelled). The cancellation table was keyed on the raw client-chosen JSON-RPC id, so{"requestId": "1"}aborted every concurrent client's request id1and a1..1000loop aborted every in-flight tool call on the node. Rows are now keyed{scope, id}, where scope is"session:<id>", else"principal:<id>", else"ip:<addr>"— namespaced, so a principal whose id equals a client IP does not share that IP's rows or quota. Thescopemetadata on[:conduit_mcp, :request, :cancelled]carries the prefixed value. For an anonymous IPv6 caller the"ip:"scope is its /64 prefix, so rotating addresses does not multiply the per-scope quota. Ids must be a string or an integer, and a string at most 256 bytes; anything else returns a-32602JSON-RPC error instead of a 500 or an unbounded row. :scopeis now enforced on resources and prompts, not only tools.use ConduitMcp.Component, type: :resource, scope: "admin:read"compiled clean and enforced nothing.handle_resource_read/4andhandle_prompt_get/4gained the authorization hook, scopes are collected from all three declaration types (URI templates included), and the DSL'sscope/1now raises at compile time outside atool/resource/promptblock. The resource scope lookup mirrorsresources/readdispatch in both modes, unscoped resources included: a URI served by a static resource, or by an earlier-declared template, is governed by that resource's scope even when it declares none, so an unscopeduser://meno longer inheritsuser://{id}'s scope. Endpoint mode builds each template's parameter keys at compile time, so every declared template dispatches and is gated, whatever atoms happen to exist at runtime.strategy: :oauthworks onConduitMcp.Transport.SSE. SSE had no:oauthbranch, so it fell through toConduitMcp.Plugs.Auth's catch-all and returned a blanket 401 "Server configuration error" plus aLogger.erroron every request, even with a valid token.- JWKS refreshes are single-flighted and rate-limited. Every cache miss was
an independent outbound fetch: at TTL lapse with 500 rps, 500 requests each
blocked a Bandit process for up to 15 s against an endpoint IdPs rate-limit.
fetch_key/2also refreshed on any unknownkid, before any signature check, so an unauthenticated caller could drive one fetch per request. Now: one fetch per URI at a time, a:refresh_cooldown(default 30 s), and a lock-age guard so a crashed holder cannot wedge refreshes. The cooldown gates both kid-triggered refreshes and refetches after:cache_ttllapses: with a cached key set, a request inside the window is served the cached keys (bounded by:stale_max_age) instead of fetching, so a failing IdP sees one fetch per cooldown, not one per request. The window also applies to a cold cache after a failed fetch: nothing is cached to serve, so requests inside it get{:error, :refresh_cooldown}instead of another fetch (the first fetch for a URI is never delayed). A request that arrives mid-refresh, whether from a lapsed TTL or an unknownkid, waits for its result. A refresh skipped by the cooldown logs at:debug, not as a failed refresh, including when the cached keys are past:stale_max_ageand it fails closed. - The JWKS 1 MB cap is enforced while streaming. It was applied to an
already-buffered, already-decompressed body, so a multi-gigabyte response
exhausted the VM before the guard ran. Responses now stream through a
size-bounded collector with
compressed: false, so a compression bomb can never be expanded in memory. The:reqrequirement is now"~> 0.6.1 or ~> 0.7"— the0.6.1security floor was previously only prose. - The SSE keep-alive loop is bounded. It matched only
{:plug_conn, :sent}with anaftertimeout, so every other message accumulated forever and was rescanned on every tick — a monotonic leak with O(n) per-tick cost over a long-lived connection. It now drains foreign messages, honours:max_connection_lifetime(default 1 h) and rejects past:max_connections(default 1 000) with 503. Each stream holds one row keyed by its pid, and rows whose process has died are swept when the cap is reached: under HTTP/2, a peer close kills Bandit's stream process before its cleanup runs, so a counter would leak one slot per disconnect. The count is O(1), and while every slot is live a rejected connect re-sweeps at most once a second.:max_connectionsmust be a positive integer; anything else raises atinit/1(a non-integer used to compare greater than any count and silently disable the cap). - Reflected client text is bounded and stripped. New
ConduitMcp.Reflectclamps length and removes control characters for every client value echoed into an error message or log line (method names,protocolVersion,taskId, cancellation reasons, auth failure reasons). A non-stringprotocolVersionnow returnsinvalid_paramsinstead of raising into "Internal server error". mix benchno longer ships to consumers.package.filespackagedlibwholesale andelixirc_pathscompiled it in:prod, soMix.Tasks.Benchwas installed in consumer projects, appeared in theirmix help, and created a straybench/output/in their repo root. It now lives indev/.- The RFC 9728 protected-resource metadata endpoint is publicly reachable. It sat behind the auth plug, so the document a client fetches after a 401 to discover the authorization server required already being authenticated. It is now served by both transports, without auth.
- The client never turns certificate checks off. The Streamable HTTP
transport refuses any
verifyother than:verify_peer(a duplicateverify:key included) and any customverify_funat client start, and the OAuth client forcesverify_peeron every metadata, registration and token request. Atransport_opts: [verify: :verify_none, ...]used to reach an untrusted server. Opt out:insecure_skip_tls_verify: trueon the transport, for local development only (it logs a warning). - No header value ever reaches a client error, a log or an LLM. A bearer
token holding a byte HTTP headers can't carry (a trailing
\nfromFile.read!/1, a control byte from an authorization server or token store) made Mint echo the wholeAuthorizationvalue intoConduitMcp.Client.Error, its message and the adapters' tool results. Tokens must now be visible ASCII: a static one raises at start, a per-request one fails withkind: :auth, reason: :invalid_token, a token endpoint's with:invalid_token_response, and any HTTP-layer{:invalid_header_value, name, value}is reduced to{:invalid_header_value, name}.:headersvalues must be visible ASCII, space or tab. Opt out: none; trim the token (String.trim/1) before returning it. server_url_policy: :public_onlypins the vetted address. The URL was checked and then Finch resolved the name again, a DNS-rebinding window. Each request (listen streams and the legacy sessionDELETEincluded) now connects to the address it vetted, with SNI, certificate verification andHostbound to the original name, through per-address pools in the client's (or yourfinch:) Finch instance. A new:resolveroption replaces DNS for the check, and:public_onlywithinsecure_skip_tls_verify: trueis refused at start. Opt out:server_url_policy: :any.- Finch telemetry carries credentials.
[:finch, …]event metadata includes the request headers (Authorization,Mcp-Session-Id);guides/client_security.mdnow warns against logging it wholesale. - Private cached results are keyed by credential. Lists marked
cacheScope: "private"(or unmarked, which covers every legacy result) are served only while the client sends the credential they were fetched with (bearer digest, OAuth{principal, issuer, resource}, or a declared:credential_id), and are dropped when it changes; public lists are shared. An:authfunction without:credential_idnever has private results served from the cache. Opt out: give the functioncredential_id:to cache for a fixed identity. - Hub restart backoff is capped at 30 s. A failed hub server is
restarted after full jitter from 1 s, doubling, capped at 30 s (was
5 minutes). Each client still gives up after 3 restarts in 5 s. Opt out:
restart_backoff: &ConduitMcp.Client.Backoff.full_jitter(&1, base: 1_000, cap: 300_000). - A hub runs at most 16 stdio servers (
max_stdio_servers), so a config or anadd_server/3caller can't fork without bound. Too many inservers:raises at start;add_server/3beyond the cap returnskind: :overloaded, reason: :max_stdio_servers. Opt out:max_stdio_servers: :infinity. - MRTR
requestStateis bound to the server module that sealed it. The keyring is application-global, so a token sealed by one endpoint opened at another endpoint in the same node for the same tool, arguments and principal. Tokens issued before the upgrade fail with-32602and clients restart the round. Opt out: none. - Modern
subscriptions/listenrunshandle_subscribe_resource/2. A modern client could subscribe to a URI the author's hook refused and learn its update timing. Refused URIs are dropped from the acknowledgment silently; without the hook, only the scope check applies. Opt out: don't definehandle_subscribe_resource/2. - Legacy sessions are bound to their principal. A legacy request or
DELETEpresenting another principal'sMcp-Session-Idgets the same 404 as an unknown id. It used that session's data, could cancel the owner's requests and could end the session. Opt out: none (sessions that recorded no principal bind nothing). - Listen streams have a mailbox bound. A client that stops reading parks
its stream in the socket send while notifications pile up.
publish/2skips a stream holding 1 000 or more messages and emits[:conduit_mcp, :subscription, :drop]. Opt out: raiseconfig :conduit_mcp, :listen_max_queue. - The
x-mcp-headerannotation cache no longer depends on the first caller. A manual server whose tool list varies per user cached whichever user called first, so header checks were skipped for tools that user couldn't see. The cache now uses the anonymous view, and other tools are read from the caller's own list each time;handle_list_tools/1must cope with a conn that has no principal. Opt out: none. - Streamed (SSE) responses send
cache-control: no-store.no-cachestill let intermediaries keep a copy of tool output. Opt out: none. - Client crash reports no longer carry
Mcp-Param-*header copies of tool arguments. The connection'sformat_status/1redacts request:headers(pending and queued), queued per-call:assigns, and a queuedset_auth/3's credentials. Opt out: none.
Fixed
- InProcess serves modern progress and log notifications. A modern
tools/callwith:on_progress, or any call afterset_log_level/2, was answered-32601overConduitMcp.Client.Transport.InProcess. It now runs to its response and delivers the tool's progress and log notifications; onlysubscriptions/listenstill answers-32601. - Postponed client control calls are bounded and dropped once their caller
gives up.
await_ready/2,subscribe/2andunsubscribe/2made before:readycount against:max_queued; one whose caller timed out or exited is dropped on replay, so asubscribe/2that returned{:error, :timeout}is never applied later. - The client connection never waits on a task it stops. Handler tasks and in-process requests are killed rather than shut down gracefully, so a task that traps exits can't stall the connection for 5 s.
- Handler capabilities are computed once, and a bad
:backoffcan't crash the connection.ConduitMcp.Client.Config.new!/1builds the client capabilities per era, soelicitation_modes/0is no longer called on every request. A:backoffthat raises or returns anything but a non-negative integer falls back toConduitMcp.Client.Backoff.full_jitter/1with a warning. - The client connection's own requests have a deadline. Legacy
resources/(un)subscribeand the dead-callertasks/canceltime out at:request_timeout; callers waiting on a subscribe are answered when the transport closes, and a notification handler process that dies is restarted with a warning. - DSL resource reads put
contentson the wire. Areadhandler answering withtext/1,json/1,image/2oraudio/2, as the README and DSL guide show, sent a tool-shaped"content"list that strict clients reject (@modelcontextprotocol/client2.3.1: "Invalid result for resources/read"). The blocks are now converted tocontentsfor the requested URI, typed with the resource'smime_type; image and audio becomeblobentries. Opt out: returnraw_resource/2or a"contents"map yourself; those pass through untouched. - A tool's own result
_metasurvives a request_meta.tools/callcopied the request's_metaover the result's, so a tool returning_meta(MCP Apps UI metadata, for one) lost it whenever the client sent aprogressToken. Legacy results still echo the request_meta, merged with the tool's keys winning; modern results carry only the tool's_metaandserverInfo. Opt out: none needed. Retry-Afterrounds up. Both rate-limit plugs floored the backend's wait to whole seconds, so a client told to wait after a 1.5 s window came back after 1 s and was denied again. Hammer 7.5's token-bucket backend reports real sub-second waits, which made this common. The header now rounds up, with a floor of 1 s.- SSE works over HTTP/2 for strict clients.
GET /ssesentconnection: keep-alive, a connection-specific header RFC 9113 §8.2.2 forbids in HTTP/2, so curl and other nghttp2-based clients rejected every stream. The header is gone; HTTP/1.1 connections are persistent by default, so nothing changes there. ConduitMcp.Plugs.MessageRateLimitno longer crashes on a non-stringmethod. A denied request whose JSON-RPCmethodwas an object raisedProtocol.UndefinedErrorin the warning log (a 500 instead of a 429), and amethodcontaining a newline forged log lines. Telemetrymethodisnilfor a non-string, as documented, and the log line goes throughConduitMcp.Reflect.notifications/cancelledwith a non-objectparamsno longer crashes the request.null, an array, a string or a number raisedBadMapError, and the transport answered a bare 500. It now returns a JSON-RPC-32602error with"id": null(HTTP 200).- A JSON array in any reflected field no longer crashes the request.
ConduitMcp.Reflect.text/2rescued onlyProtocol.UndefinedError, butto_string/1on a list raisesArgumentErrororUnicodeConversionError.notifications/cancelledpipes the client'sreasonthrough it on a path with no rescue, so"reason": [1.5]produced a bare 500 with no JSON-RPC reply on either transport. Arrays are now inspected;[1, 2]renders as"[1, 2]"rather than being coerced to two control characters and stripped to"". Plug.Router.forwardworks again with:rate_limit. Resolving plugs ininit/1put a local function capture in the router's options, andforward/2escapes those at compile time — so the Phoenix integration in the README failed to compile with "cannot escape #Function<…default_key_func>" whenever rate limiting was configured. Both rate-limit plugs now exposedefault_key_func/1publicly and capture it remotely.- The global cancellation cap no longer denies callers who are under their
own quota. It was checked first, so one client opening 40 sessions and
filling each scope's 256 rows (10 240 > the 10 000 default) refused every
other client's cancellations — reinstating the cross-tenant denial of
service the per-scope quota exists to prevent. The global cap is now a memory
backstop that evicts instead of rejecting: each reclaim frees a full batch of
max_rows / 20rows, oldest first from the largest scope, then from the next largest, so a flood of one-row scopes cannot force a table scan per insert. - SSE
:max_connectionsfails closed. An unavailable slot table was read as "no slots taken", silently disabling the cap rather than enforcing it. - A supervised ETS owner that loses its table now retries. The degrade was
terminal: the process idled forever owning nothing while the table's lifetime
silently became one request's. It also reported invalid
:ets.new/2options as an ownership race; those now raise. :key_providervalidation coversfetch_key/2. Onlyfetch_keys/1was checked, butfetch_signing_key/2dispatches on the token'skid— so a half-implemented provider passedinit/1and raisedUndefinedFunctionErroron the first token from any real JWKS-publishing authorization server.- Endpoint mode reflects unknown resource URIs safely. Two of the three "Resource not found" sites still interpolated the raw URI.
ConduitMcp.Validation.format_validation_errors/1accepts what the validators return. They propagate:tool_not_found/:prompt_not_foundas bare atoms, and the function was guardedwhen is_list(errors), so the documented{:error, errs} -> format_validation_errors(errs)raisedFunctionClauseErroron a name typo.- A struct-shaped
:current_useris namespaced by its type.%MyApp.User{id: 42}and%MyApp.ApiClient{id: 42}both derived"42", and task ownership is an exact string compare — so the service account could read and cancel the human's tasks. A verifier returning a status value (true,false,:ok) derives no id, so the principal falls back to the per-credential digest instead of every caller sharing the id"true". - A scoped resource with no
readhandler no longer skews scope enforcement. It contributed to the templated scan but not to dispatch, so an overlapping template could enforce one scope and run another's handler. - Overlapping URI templates run one handler. DSL mode evaluated every
matching template's
readhandler, side effects included, before picking the first result. Only the first matching template's handler runs now, and a handler returningnilno longer falls through to the next template (in either mode); it is reported as an internal error, like a static handler returningnil. - The session table survives.
:conduit_mcp_sessionswas created lazily by whichever request touched it first and died with that request, soSession.get/2returned{:error, :not_found}for a session id the client had just been handed. It is now owned by a supervisedConduitMcp.Session.EtsStore.Owner, capped (:sessions_max_rows, default 100 000) and swept by aConduitMcp.Session.Janitorstarted for you (disable withconfig :conduit_mcp, :session_janitor, false).initializefails closed with 503 when the store is at capacity. - One canonical authenticated principal.
ConduitMcp.Plugs.OAuthassigned a claims map to:current_user, and ownership is an exact-match comparison — so a task stamped on one request never matched on the next and the owner's own task 404'd; against static auth every client sharing a token collapsed into one owner. Both plugs now assignConduitMcp.Principal(conn.assigns[:mcp_principal]) carrying a stable scalar:id, and task ownership, per-user rate limiting and scope checks all read it.:current_useris unchanged and still yours to shape. The id formats ("sub:<v>","client_id:<v>","<Struct>:<id>","static:<digest>") are documented inConduitMcp.Principal.ConduitMcp.Principal.id/1returns only strings: a principal assigned withoutput/2whose:idis not a string is treated as anonymous instead of raising in the cancellation scope or the rate-limit key. - Per-user message rate limiting actually works. Its default key read a shape neither auth plug assigned, so two OAuth subjects behind one proxy shared a bucket. It now keys on the principal.
- A malformed
remote_ipno longer kills the request.conn.remote_ip |> :inet.ntoa() |> to_string()raisesProtocol.UndefinedErroron{:error, :einval}; both rate-limit plugs now go throughConduitMcp.Principal.client_ip/1. tasks/listfilters in the C layer. It copied the whole table into the caller's heap and filtered in Elixir, even when the filter matched nothing.:owner,:statusand:limitare now one:ets.select/3match spec. The owner is embedded as a match-spec constant, so a tuple owner (or a map containing one) lists its own tasks instead of raisingArgumentError, andConduitMcp.Tasks.list/2re-applies:limitafter its owner re-check, so a custom store that ignores:owneror:limitcannot over-return. Owners compare with=:=, the facade's exact match, so owner1does not count1.0's rows. A custom store that ignores:ownermust also ignore:limit(seeConduitMcp.Tasks.Store).- A typo'd validation option fails the build.
field(:name, :string, min_lenght: 3)used to warn and validate nothing. It now raises, with a "did you mean" suggestion. A recognised option with a badly shaped value (min: "5") is reported as an invalid value, not as a typo of itself. ConduitMcp.Protocol.server_error/0exists. The moduledoc advertised it while thedefdelegateblock omitted it, so calling it raisedUndefinedFunctionError.ConduitMcp.Protocol.methods/0cannot disagree with the router. It was a second, hand-maintained copy missing six routed methods (resources/templates/list, all fourtasks/*,notifications/cancelled). It is now derived fromConduitMcp.Handler's dispatch table.- Optional dependencies fail loudly instead of 401ing. The
if Code.ensure_loaded?guards are conditional compilation, frozen when:conduit_mcpwas built inside your_build— and Mix does not rebuild an already-built dependency when you later add one. Configuringstrategy: :oauthor a JWKSkey_providerwithout the dependency now raisesConduitMcp.OptionalDependencyErroratinit/1, naming the dependency andmix deps.compile conduit_mcp --force. - Supervision documentation now matches the actual tree (
CLAUDE.md,ConduitMcp.Server,ConduitMcp.Session.EtsStore), andConduitMcp.Applicationappears in the generated docs. mcp-protocol-versionechoes the negotiated version. Every legacy response carried a hardcoded2025-11-25, so a client that negotiated2025-06-18was told another version. The header now comes from theinitializeresult, then the session, then a supported request header.ConduitMcp.Telemetry.events/0lists every event the library emits.request.cancelled, thecancellation.*events,session.cleanupandtasks.cleanupwere emitted but missing fromevents/0, its docs, the default log handlers and PromEx, so handlers attached from the list never saw them. A test now pins the list to the events actually emitted.- DSL
completedeclarations work. Completions declared inpromptandresourceblocks were stored but never served: a latercompleteoverwrote an earlier one, a resource block'scompletewas filed under prompts, and nohandle_complete/3was generated, socompletion/completealways answered empty andcompletionswas never advertised. They now generatehandle_complete/3(at most 100 values, withtotalandhasMore; the block'sscopeapplies). - DSL and Component resources include
name, set to the URI. Every MCP revision requires it, and strict clients rejected the listing. param :x, :string, opts(DSL) andfield(Component) without a description keep their options. The options were dropped and the keyword list sent as the"description", which broketools/list.image/1returns spec-conformant content (see Breaking changes).
Added
ConduitMcp.Client.set_auth/3— replaces a running client's credentials (any:authform, pluscredential_id:), reconnects, and drops private cached results. OAuth options without:storekeep the current store.- Streamable HTTP client options
:credential_id(names whose tokens an:authfunction returns, so private results can be cached for that identity) and:resolver(replaces DNS for the:public_onlycheck). config :conduit_mcp, :listen_max_queueand the[:conduit_mcp, :subscription, :drop]telemetry event (with a PromEx counter).ConduitMcp.Principal— the canonical "who is calling", withid/1,scopes/1,client_ip/1,client_bucket/1andrate_limit_key/1.ConduitMcp.Cancellation.track/2,untrack/2,in_flight?/2andvalid_request_id?/1— the in-flight registry the handler uses to decide whether anotifications/cancelledis recorded.ConduitMcp.Reflect— the boundary helper for reflected client text.ConduitMcp.Transport.Shared— the single implementation of everything the two transports share. No function body exists in both transports any more, and each plug is resolved once ininit/1rather than on every request.ConduitMcp.OptionalDeps/ConduitMcp.OptionalDependencyError.ConduitMcp.Plugs.OriginValidationnow honours the documented%Regex{}and bare-string:allowed_originsshapes, which previously hit the catch-all and 403'd everyOrigin-bearing request.ConduitMcp.Plugs.Authgained:principal_idfor naming the principal behind a shared static credential. It requires a configured static secret (:bearer_tokenwith:token,:api_keywith:api_key);init/1raisesArgumentErrorwhenever a:verifyfunction authenticates instead —:function,:custom, or:bearer_token/:api_keyconfigured withverify:— where one fixed id would merge every user into a single principal.- Optional-dependency table in
README.mdand prerequisites blocks inguides/authentication.mdandguides/rate_limiting.md, both stating themix deps.compile conduit_mcp --forcerequirement. - A bare-consumer CI job (
.github/scripts/bare_consumer_check.sh) that builds:conduit_mcpinside a project declaring no optional dependencies — the one configuration the main suite can never cover, sinceoptional: truedeps are fetched for the defining project.publishnow gates on it. ConduitMcp.EtsOwner— the shared implementation behind all six supervised ETS table owners. A lost:ets.new/2race logs and retries every second instead of raising: an Owner that raised on restart tookConduitMcp.Supervisor— and with it the consumer's application — down after three attempts in five seconds, and a janitor tick callingensure_table/0is enough to cause it. Invalid:ets.new/2options still raise. The retry interval isEtsOwner.start_link/4's:reclaim_interval(default 1 000 ms).ConduitMcp.Session.Janitorgained:telemetry_eventand:noun. The library reuses this janitor for the cancellation table, so the cancellation sweep now emits[:conduit_mcp, :cancellation, :janitor]rather than reporting its evictions to your[:conduit_mcp, :session, :cleanup]handler once a minute.:session_janitorand:cancellation_janitoraccepttrueas the symmetric spelling offalse, and raise a message naming the key for anything else instead of aCaseClauseErrorinsideApplication.start/2.ConduitMcp.Tasks.list/1acceptslimit: :infinity— the same "unbounded" spelling:tasks_max_rowsuses. It previously returned[].ConduitMcp.Reflect.text/2also strips U+061C (ALM), U+2060 and U+FEFF — the invisible format and bidi controls outside the ranges it already covered.default_key_func/1onConduitMcp.Plugs.RateLimitandConduitMcp.Plugs.MessageRateLimitis public (@doc false) so the resolved plug options survivePlug.Router.forward/2's compile-time escape. The same constraint applies to your own callbacks in transport options (e.g.:key_func): pass a remote capture (&MyApp.RateKeys.msg/1), not an anonymous function. SeeConduitMcp.Transport.Shared.- A per-parameter
type_coercion: booleanvalidation option overrides the global:type_coercionsetting for that parameter; an object parameter's fields inherit it. - The
2026-07-28(modern) protocol era, served next to the legacy one.ConduitMcp.Eraclassifies each request (per-request_meta⇒ modern;initialize, sessions ⇒ legacy) right afterPlug.Parsers. Modern requests never create, require or look up a session (a supplied session id is still checked against its principal), echo their own version, and get modern errors and statuses:-32020header mismatch,-32021missing client capability (withdata.requiredCapabilities),-32022unsupported version, 400/404 HTTP statuses (ConduitMcp.Protocol.Modern). Results carryresultTypeand_meta["io.modelcontextprotocol/serverInfo"]. Why:2026-07-28is the new MCP revision, and clients of both eras exist. Opt out: none needed; legacy clients are served as before. server/discover(ConduitMcp.Discover) advertisessupportedVersions, capabilities (ConduitMcp.Capabilities.build/2) and instructions. It stays authenticated.:instructionsoption onuse ConduitMcp.Server,ConduitMcp.Endpointand both transports, returned byinitializeandserver/discover. Unset emits nothing.- Header validation (
ConduitMcp.Plugs.HeaderValidation, codecConduitMcp.Headers). Modern requests must agree withMcp-Method,Mcp-Name(ontools/call,resources/read,prompts/get) and anyMcp-Param-*; the version header must match_meta. It runs before:message_rate_limit, so rejected requests aren't counted, and never echoes a header value. The DSL/Componentheader:param option publishesx-mcp-header; an invalid annotation (number type, inside array items, duplicate name, not an HTTP token) is aCompileError, because conforming clients drop such tools. Manual mode validates thehandle_list_toolsschema at runtime and logs bad annotations. - Multi round-trip requests (MRTR). Tools, resources and prompts (DSL,
Component, manual) can return
{:input_required, input_requests, state}, with requests built byConduitMcp.MRTR.elicit/2(and the deprecatedsample/2,list_roots/0). Each request is checked against the client's capabilities (ConduitMcp.MRTR.missing_capabilities/2);stateis sealed intorequestStatebyConduitMcp.MRTR.State(AES-256-GCM, bound to the version, method, salient params and principal, 5-minute expiry), and the retry finds%{state:, responses:}inconn.assigns[:mcp_input]. A tampered or foreign token gets a flat-32602. Requiresconfig :conduit_mcp, :mrtr_keys, [{kid, key32}], validated at boot when set; the first key seals and the rest still open, which is how rotation works. A legacy-era request returning{:input_required, …}gets-32603. Opt out: none needed; opt-in by return value. - Streamed
tools/callresponses. A modern request with_meta.progressTokenorlogLevel, from a client acceptingtext/event-stream, may receivenotifications/progress/notifications/message(ConduitMcp.Progress.report/3,ConduitMcp.Log.log/4) before its result, with:\r\nkeep-alives (ConduitMcp.Transport.SSEStream,ConduitMcp.StreamSpec). Closing the stream cancels the request: the server records it withConduitMcp.Cancellation.cancel_request/2, keyed to the request itself so two anonymous clients behind one address cannot clear each other's cancellation. Bounded by:max_streams(JSON fallback at the cap),:max_stream_lifetimeand:stream_cancel_timeout. Opt out: none needed; without those_metakeys or the Accept type, responses stay JSON, and both functions do nothing on legacy requests. subscriptions/listen(ConduitMcp.Subscriptions,ConduitMcp.Subscriptions.Filter). A modern client opens a stream with a filter (toolsListChanged,promptsListChanged,resourcesListChanged,resourceSubscriptions,taskIds), is acknowledged with the honoured subset, and receives the matching notifications tagged with itssubscriptionId. Push them withConduitMcp.notify_tools_changed/0,notify_prompts_changed/0,notify_resources_changed/0andnotify_resource_updated/1(node-local; broadcast on your own PubSub for a cluster). Resource URIs are authorized per URI and silently dropped when unauthorized; the stream closes at tokenexpor:max_stream_lifetime;:max_listens_per_principal(4) bounds a principal's live streams — a further listen replaces that principal's oldest one (notifications/cancelled, then the closing result; telemetry reason:superseded), because a client that vanished is only noticed on the stream's next write — and:listen_idle_timeoutends idle ones; 503 at:max_streams, 406 without the event-stream Accept. Modern capabilities advertiselistChanged: true,resources.subscribe: trueandlogging(any modern tool may sendnotifications/message). NewNotifications.subscriptions_acknowledged_notification/1andcancelled_notification/2.- Cache hints. Cacheable modern results (
server/discover, the three lists, templates andresources/read) carryttlMsandcacheScope, never on interim results. Globalconfig :conduit_mcp, cache_ttl_ms:, cache_scope:(validated at boot), per declaration with the DSLcache ttl_ms:, scope:macro or the Componentcache:option (ConduitMcp.CacheHints). A list takes the minimum TTL and staysprivateif any item is private or scoped;scopepluscache_scope: :publicis a compile error. Opt out: omitcache; the global defaults apply. - Tasks extension (experimental).
ConduitMcp.Ext.Tasksimplementsio.modelcontextprotocol/tasksfor2026-07-28: atools/callreturning{:task, attrs}getsresultType: "task"when the client declared the extension (else-32021), served bytasks/get,tasks/updateandtasks/cancel(which requireMcp-Name= the task id). Tasks are owner-scoped, ids are 32-byte bearer tokens, and the store is a behaviour (Ext.Tasks.Store, defaultExt.Tasks.EtsStore). Status changes go to the owner's listen streams asnotifications/tasks(Ext.Tasks.ListenNotifier, replaceable viaExt.Tasks.Notifier). Opt out: off by default;config :conduit_mcp, :ext_tasks, trueenables it. The legacyConduitMcp.Tasksis frozen (legacy era only).
streams;ConduitMcp.Handler.dispatch/3returns `{:reply, map}{:stream, StreamSpec.t()} :noreply`, so manual-mode routers can serve modern handle_request/3keeps its contract and answers a stream-only method with-32601.HeaderValidation.invalidate/1drops the cached manual-mode tool schemas.DELETE /ends a session (Streamable HTTP, sessions on). 200 on success, 404 for an unknown id, 400 without one, 405 with sessions off.image/2takes the data and an explicit MIME type.- The MCP client:
ConduitMcp.Client. One supervised connection per server (Supervisor+ a:gen_statemconnection) that probesserver/discoverand falls back toinitialize(protocol: :auto, or pin:modern,:legacyor a version). Starting never blocks; requests made before ready wait within their own timeout; a timed-out or abandoned request is cancelled; nothing that may have executed is retried. Names may be any term (ConduitMcp.Client.Registry, no atoms). API: tools, resources, templates, prompts, completion,ping,set_log_level,subscribe/unsubscribe(both eras, kept across reconnects),subscribe_notifications,notify_roots_changed, andcall_tool(…, task: :return | :await)withget_task,await_taskandcancel_task. Errors are%ConduitMcp.Client.Error{}with a closedkindset; a tool'sisErroris{:ok, %ToolResult{is_error: true}}. Options are validated atchild_spec/1(ConduitMcp.Client.Config). Why: Elixir hosts had no way to call MCP servers. Opt out: none needed; the application starts no client. - Client cache. Tool, prompt and resource lists are cached per
connection in a
:protectedtable (list_all_*,cached_tools/2,tool/3), honouringttlMs(capped by:max_cache_ttl), refreshed onlist_changed(coalesced, at most once a second per list), with pagination caps. Tool intake drops invalid names, malformed or duplicate definitions and invalidx-mcp-headerannotations with telemetry and a warning instead of failing the listing. Opt out:list_all_tools(…, refresh: true)ormax_cache_ttl. ConduitMcp.Client.Handler: sampling, elicitation and roots in both eras (legacy server requests run in supervised tasks; modern MRTR inputs run in the caller), with safety defaults:maxTokensclamp,sampling: [max_tool_iterations: 5], an input rate limit,includeContextcoercion, idle-sampling denial, never-fetched https-only elicitation URLs, a form-schema subset check,file://roots only. A client with no handler declares no capabilities.- Client transports.
Transport.Stdioruns a local server with no shell (:spawn_executable, literal arguments), an allowlisted environment, a junk-output budget, an 8 MiB line cap, and EOF → TERM → KILL shutdown that reaps the server even if the connection is killed.Transport.StreamableHTTP(needs:finch) talks to remote servers in both eras with byte caps, no decompression, separate total and idle deadlines, no cross-origin redirects,verify_peerTLS, session renewal, the legacy GET stream and modern listen.Transport.InProcessdrives a server module directly, for tests and development. TheTransportbehaviour gains optionalvalidate_options/1, so a bad option raises atchild_spec/1.ConduitMcp.Client.URLPolicyblocks private, loopback and link-local addresses for server-originated URLs. Secrets travel asConduitMcp.Client.Secret, which never inspects, logs or encodes its value. - Governed tool calls. Once
:policy,:hooks,:toolsor:validateis set,call_tool/4runsConduitMcp.Client.Pipelinein the caller: selection (ToolFilter), pre-hooks, schema validation, policy (deny > pin > ask > allow > default; loose deny/ask, exact allow, on{server, tool}; untrusted annotations ignored;Policy.Paths; tool fingerprints pin approved definitions), approval via:on_askwith per-principal, per-fingerprint 24 h grants (Client.Grants), post/failure hooks, and optional LLM shaping (provenance, size cap, invisible-character stripping, MIME allowlist). Hooks fail closed: a raising, timed-out or contract-breaking pre-hook sends nothing, and a failing post-hook withholds the result. Optional JSON Schema validation through:jsv(Client.SchemaValidator.JSV), with size, depth and remote-$refbounds checked first. Opt out: leave them unset; post and failure hooks takeon_error: :ignore. ConduitMcp.Client.Hub: one supervised client per server, tools listed asserver__tool(Client.Naming,Client.ToolSet; colliding names exclude every collider) from each client's cache, every call routed through the governed pipeline. It refuses to start without:policy. A crash-looping server is restarted with backoff and cannot take the others down; servers can be added and removed at runtime.taint:plusPolicy.tainted/1let rules react to other servers' output in the turn. Servers sharing an authorization server share one in-memory token store. Opt out: use a singleConduitMcp.Client.- LLM adapters:
Client.Adapters.Anthropic,Client.Adapters.OpenAI(Chat and Responses; explicitstrict, with per-tool fallback and null stripping) andClient.Adapters.Gemini(experimental) convert tool lists, parse tool calls and build tool results.Adapters.execute_tool_calls/4runs a model's calls concurrently through the pipeline and answers them in order; an approver's halting deny returns{:halt, results}.ToolCall.call_idcarries the provider's id. - Client OAuth 2.1 (
ConduitMcp.Client.OAuth, needs:finch): protected-resource and authorization-server discovery, client ID metadata documents, pre-registration and the deprecated dynamic registration, authorization code with PKCE and RFC 9207isschecks (host-managedPending), refresh with single-flight rotation, 403 step-up with a scope union,client_credentials(secret orprivate_key_jwtwith:jose), a one-shotLoopbackcallback server, and aTokenStorebehaviour (default:protectedTokenStore.ETS). Every OAuth request is pinned to an addressURLPolicyvetted. Opt out: leaveauth:unset. - Telemetry:
[:conduit_mcp, :mrtr, :input_required],[:conduit_mcp, :subscription, :start | :stop], and the client's[:conduit_mcp, :client, …]events (requests, connection lifecycle, tool intake, governance, hub, adapters, OAuth, input requests, transport mailbox), all listed inConduitMcp.Telemetry.events/0with their metadata keys. Client metadata comes from an allowlist and never carries secrets, nor arguments or results unless payload capture is enabled (below); client-spawned processes setmcp_*Logger metadata.ConduitMcp.PromExgains MRTR, subscription, client and OAuth metric groups. - Opt-in payload capture in client telemetry (TL-2):
telemetry: [capture_payloads: [redact: fun, max_bytes: 4_096]](client and hub) adds:argumentsto[:conduit_mcp, :client, :tool_call, :start]and:resultto:stop, passed through the requiredredactin the caller; an over-size value becomes{:truncated, byte_size}, and a raisingredactor an unencodable return (aSecretincluded):redaction_failed.capture_payloads: truewithout:redactraises at start. Off by default: neither key is present. - Optional dependencies
:finch(~> 0.21, the client's HTTP transport and OAuth) and:jsv(~> 0.26, client schema validation), guarded byConduitMcp.OptionalDeps.finch!/0,jsv!/0,oauth_client!/0andclient_assertion!/0at init. - Server log messages reaching the client are bounded. Inbound
notifications/messageis sanitized (ConduitMcp.Reflect), level-clamped to the RFC 5424 set and rate-limited per connection (log_rate_limit: [per_second: 50, burst: 100]); drops emit[:conduit_mcp, :client, :log, :dropped]. A server could flood a host's log handler. Opt out: raiselog_rate_limit. - Client trace context. A
:traceparentrequest option (falling back toLogger.metadata()[:traceparent]) is validated with the now-publicConduitMcp.Era.traceparent/1and sent in modern_meta;tracestateandbaggageare never sent. - Test coverage expanded to 2 357 tests (2 256 tests, 61 doctests, 40
properties; up from 745 in 0.10.1), including real-socket
:integrationand child-process:stdiotests that run in the default suite. - Dependencies updated (lock only; no requirement changed): hammer 7.5.0,
joken 2.7.0, prom_ex 1.12.0, req 0.7.4, credo 1.7.19, dialyxir 1.4.8,
ex_doc 0.40.4, sobelow 0.15.0, stream_data 1.4.0, benchee 1.5.1, plus
transitive updates.
peep5.x is held back by prom_ex's requirement.
0.10.1 - 2026-08-05
Follow-up hardening from a re-review of the 0.9.4–0.9.7 changes (PRs #13–#17).
Security
- Owner-scoped
tasks/*routes (IDOR/BOLA). The experimentaltasks/get,tasks/cancel,tasks/result, andtasks/listroutes keyed purely on the client-suppliedtaskId, so any caller could read or cancel any task (andtasks/listreturned every task system-wide). They now scope to the caller's principal via a new owner-awareConduitMcp.TasksAPI. Scoping is opt-in and back-compatible: a request with no principal, or a task created without an owner, behaves exactly as before; a principal mismatch returns{:error, :not_found}without leaking the task's existence. Stamp ownership withConduitMcp.Tasks.create/3and configure principal extraction with:task_owner_fun(defaultconn.assigns[:current_user]— return a stable scalar such assub/id, since ownership is checked by exact match). - JWKS SSRF posture and stale-key window documented in the
ConduitMcp.OAuth.KeyProvider.JWKSmoduledoc:jwks_uriis trusted operator config (private/link-local addresses are still fetched, not blocked — harden egress if the URI is less-trusted), and during a JWKS outage cached keys keep validating until:stale_max_age(default 24h) before failing closed.
Added
ConduitMcp.Tasksowner-scoped API —create/3,get/2,cancel/2,list/2, andowner/1, plus the configurable:task_owner_fun. TheConduitMcp.Tasks.Store@moduledocdocuments the top-level"owner"convention; reference stores promote it into_map/1.- Named error codes
ConduitMcp.Errors.task_not_ready/0(-32004) andrequest_cancelled/0(-32800), delegated fromConduitMcp.Protocol, replacing magic literals. [:conduit_mcp, :cancellation, :cleanup]telemetry (measurement%{removed: count}) emitted byConduitMcp.Cancellation.cleanup/1, mirroring the session janitor.
Fixed
- JWKS ETS cache could crash a concurrent request. The cache table was
created by whichever request first fetched keys and destroyed when that
process exited, so a concurrent request could hit an
:etsArgumentErroron the authentication path. The table is now owned by a supervised process (ConduitMcp.OAuth.KeyProvider.JWKS.Owner, started fromConduitMcp.Application), which also lets the cache persist across requests as intended. Complements the 0.10.0 fix that tolerates losing the check-then-create race.
Changed
- Example app (
examples/oban_tasks_server/) and guide hardening — the Oban exception→telemetry bridge now marks a task"failed"on its final attempt (the previousjob.stateguard never fired, since the state is"executing"at exception time);Tasks.Store.cancel/1updates the row before cancelling the Oban job (so a failed write can't strand a"working"row); client-supplied durations are validated; the Oban dep is pinned to~> 2.22.0(raw-SQL coupling to internal tables); a logger note warns that crash metadata can leak job args; and theguides/oban_tasks.mdworker example is corrected.
0.10.0 - 2026-08-05
Security
Patched every dependency carrying a published advisory. Verified against OSV across all 47 locked packages, which now report none.
Package From To Advisories fixed plug1.19.2 1.20.3 quadratic-time decoding of nested query/body params (high, CVE-2026-54892); multipart :lengthnot charged for part headers, enabling unbounded temp-file creation (medium, CVE-2026-56814); cookie attribute injection inPlug.Conn.Cookies.encode/2(low, CVE-2026-56813)bandit1.11.1 1.12.4 quadratic CPU blow-up reassembling fragmented WebSocket messages (high, CVE-2026-65623) mint1.7.1 1.9.3 CONTINUATION/HEADERS flood (high); unbounded streamsmap growth via PUSH_PROMISE (high); request-line CRLF injection (low); Content-Length+prefix (moderate)req0.5.17 0.7.2 unbounded archive/compression extraction driven by response content-type (high); multipart header injection (moderate) The
plugandbanditadvisories are the ones that matter for a deployed server: both are remote denial-of-service reachable through the transports on any request, with no configuration required. Themintandreqones reach this library only through the optional JWKS key provider — where the decompression advisory is genuinely reachable rather than merely present, sinceConduitMcp.OAuth.KeyProvider.JWKScaps the JWKS body at 1MB but Req decompresses before that cap applies.No declared constraint changed:
~> 1.19,~> 1.9and~> 0.5all already permitted the patched releases, so onlymix.lockmoved.bandit 1.12requiresthousand_island ~> 1.5, so that moved too (1.4.3 -> 1.5.0);plug_crypto(2.1.1 -> 2.2.0),finch(0.20.0 -> 0.23.0),hpaxandcastorefollowed as transitives. The JWKS moduledoc now recommends{:req, "~> 0.6"}.JWT algorithm allow-list —
ConduitMcp.Plugs.OAuthnow validates the token headeralgagainst an allow-list (new:algorithmsoption, default: RS/ES/PS families) before key lookup, and requires the resolved signing key to match the header algorithm's family. Staticoctkeys implicitly allow their HS algorithms, so existing HMAC setups keep working. Set:algorithmsexplicitly if you rely on a non-default algorithm.JWKS fetch hardening — the JWKS key provider now requires
https(override withallow_insecure_jwks: truefor dev), no longer follows redirects, applies connect/receive timeouts, and caps responses at 1MB. A failed refresh now serves previously cached keys with a logged warning instead of failing.WWW-Authenticate header hygiene — config-sourced values (resource URI, scopes) are stripped of CR/LF/quotes before header interpolation. The SSE
endpointevent URL can now be pinned with the new:base_urloption; theHost-header fallback is sanitized.Origin-validation startup warning — transports log a warning at init when
:allowed_originsis unset (DNS-rebinding exposure for browser-reachable servers). Passallowed_origins: "*"to opt out explicitly.
Changed
- Validation
min_length/max_lengthnow count graphemes (String.length/1) instead of bytes — multi-byte UTF-8 input is no longer over-counted. Behavior change for non-ASCII parameter values. - Custom-constraint validation reports all violations at once instead of halting at the first failing parameter — clients fixing bad input no longer need one round-trip per error.
Fixed
- HS (HMAC) token verification crashed — the OAuth plug passed the oct
JWK map to
Joken.Signer.create/2, which requires the raw binary secret; staticoctkeys now work. Malformed signing keys now yield a 401 instead of crashing the request process. - ETS table-creation races in the JWKS cache and session store could crash a request under concurrent cold start; both now tolerate losing the check-then-create race (matching the tasks/cancellation stores).
- Unknown JWK key types silently fell back to RS256, masking misconfiguration with a confusing signature error; they are now rejected with a logged warning.
.credo.exsaccidentally disabled nearly the whole default check suite (enabled:replaces the suite;extra:adjusts it). The full suite now runs locally and in CI.
Deprecated
- Leaving
:allowed_originsunset logs a warning; a future major release will require an explicit Origin policy for browser-reachable transports.
Added
require_session: truesession option forConduitMcp.Transport.StreamableHTTP— rejects non-initializePOSTs without anMcp-Session-Idheader (HTTP 400), per the MCP specification.:base_urloption forConduitMcp.Transport.SSEand:algorithmsforConduitMcp.Plugs.OAuth(see Security).resources/templates/listmethod — MCP-spec-required endpoint for discovering URI-templated resources. Templated resources (URIs with{param}placeholders) now appear here instead of inresources/list. New optionalhandle_list_resource_templates/1Server callback.notifications/cancelledhandling via the newConduitMcp.Cancellationmodule. Tool authors pollCancellation.cancelled?(conn)to cooperatively abort long-running work. Handler stashes the request id inconn.assigns[:mcp_request_id]and emits[:conduit_mcp, :request, :cancelled]telemetry.- Capability advertisement for
completions,logging, andresources.subscribe— previously these features were routed but never declared oninitialize, so spec-compliant clients never used them. - Tool schema fields
title,icons,outputSchema, andexecution.taskSupport(MCP 2025-11-25). New DSL macros:title/1,icons/1,output_schema/1,task_support/1. - Tasks JSON-RPC methods —
tasks/get,tasks/cancel,tasks/result,tasks/listrouted toConduitMcp.Tasks. task/2DSL helper for returning a task id from a long-running tool invocation.ConduitMcp.Session.Janitor— opt-in GenServer that periodically prunes expired sessions from the ETS-backed store. Add to your supervision tree to bound memory growth on public-facing servers.ConduitMcp.Tasks.delete/1,cleanup/1, andTasks.Janitor— parallel cleanup for the tasks table, which previously had no eviction at all.ConduitMcp.Tasks.Storebehaviour — task storage is now pluggable, mirroringConduitMcp.Session.Store. Configure viaconfig :conduit_mcp, :tasks_store, MyApp.MyTasksStore. The default remains the in-memoryConduitMcp.Tasks.EtsStore, so existing servers keep their current behaviour without any changes. Standardtasks/*JSON-RPC routes dispatch through the configured store, so swapping in a durable backend (e.g., Oban + SQLite or Postgres) requires no handler changes.examples/async_tasks_server/— runnable example demonstrating the MCP 2025-11-25 tasks lifecycle (in-memory, ETS-backed).examples/oban_tasks_server/— runnable example demonstrating the same lifecycle backed by Oban + SQLite for durability, with theinput_requiredstate exercised via{:snooze, _}.- Object parameters actually work.
:objectparams (ConduitMcp.DSL) and:objectfields (ConduitMcp.Component.Schema) were dead code: every declaration form crashed at compile time, and the one shape that compiled was rejected at runtime by the validator. All forms now compile and validate — blockless (open) objects, block objects with declared fields, objects nested in objects, anditems :objectinside an:array. - Nested runtime validation. A declared object's nested fields are now
enforced by NimbleOptions to any depth: required fields, types, and the
custom constraints (
enum,min/max, length limits,validator). Errors name the full path (bag.inner.city). Undeclared nested keys are rejected with an actionable message instead of NimbleOptions'expected atom, got: "zzz". Nested keys are atomised only when they match a declared field name, so client input can never mint an atom. additional_properties:option for:objectparams and fields —trueenforces the declared fields and passes undeclared keys through to the handler;false(the default once fields are declared) rejects them. It also drives"additionalProperties"in the generated JSON Schema, which is now always emitted for objects so the published schema matches what the server enforces.items/1,2inConduitMcp.Component.Schema— component-mode array fields had no way to declare an item type at all, and a barefieldinside an:arrayblock silently corrupted the parent field list.itemsis now the only thing an:arrayblock accepts, at every nesting depth.- 3-arg (and 2-arg) block forms —
param :bag, :object, "desc" do ... endandfield :bag, :object do ... endused to bind to the blockless clause ([do: ...]is a keyword list), silently discarding the block; the form thefield/4docs themselves showed was broken. Both now work in both DSLs. A block on a type that has no block form raises aCompileErrornaming the file and line. - Type coercion now follows nested objects. A nested
:integerfield accepts the same"30"the top level does; previously coercion stopped at depth 0 while nested type checking did not, so the two disagreed. - The two DSL front ends now accept the same programs.
ConduitMcp.DSLsilently swallowed a barefieldinside an:arrayblock, anitemsoutside one, and afieldoutside any object block, and raised a bareFunctionClauseErrorforitems :string do ... end. All four are nowCompileErrors naming the file and line, matchingComponent.Schema. The compile-time scope plumbing both DSLs share moved into one place.
Fixed
- DSL empty-map type warning under Elixir 1.20 —
__scope_for_tool__/1in DSL-mode servers without any scoped tools used to expand toMap.get(%{}, _), which Elixir 1.20's type checker flags as always returning the default. Same fix as commit0f05a9ffor Endpoint mode: replace the single-map lookup with per-tool function clauses plus a catch-all. Servers that never declared OAuth scopes now compile cleanly under--warnings-as-errors. min:/max:/validator:were bypassable by sending a number as a string. Custom constraints are checked by this library rather than NimbleOptions, and the checks ran before type coercion — socheck_min_value/3skipped a binary value, coercion then turned it into a number, and the markers had already been stripped from the schema NimbleOptions sees. Withtype_coercion: true(the default),"5"passedmin: 18and the handler received5.validator:was worse: the function was called with the uncoerced binary, and"5" > 18istruein Erlang term order. Coercion now runs first, so every constraint sees the value the handler will see. Noteenum:now matches after coercion too —enum: [1, 2, 3]accepts"1"for an:integerfield, where it previously rejected it.
Changed
resources/listnow returns only static URIs. Templated URIs move toresources/templates/listper spec. Clients that relied on templated URIs inresources/listwere already non-spec-compliant.Session.Storebehaviour gained an optionalcleanup/1callback used bySession.Janitor. Existing stores that don't implement it continue to work; the janitor logs a warning and idles.- OAuth scope rejection on
tools/callnow returns a JSON-RPC error with the request's id (previouslynil, breaking client correlation). - Validation errors now always carry a
parameter. A type mismatch used to returnparameter: niland NimbleOptions' raw prose, while a missing required field returned the field name — so a client author had to special-case the failure kind to locate the field. Every error now names its parameter, dotted for nested fields (bag.inner.city). Type-error messages no longer carry NimbleOptions' internal(in options [:bag])suffix, because the parameter says it. - Undeclared parameters are rejected with a proper error. Previously left to
NimbleOptions, which reported no parameter name and — for a name that did not
already exist as an atom — raised out of validation entirely, so the same
mistake surfaced as either a validation error or an internal error depending
on the VM's atom table. Now always
%{"parameter" => name, "message" => "unknown parameter \"name\""}.
Removed
- Validation telemetry events
[:conduit_mcp, :validation, :started],[:conduit_mcp, :validation, :success], and[:conduit_mcp, :validation, :failed]are no longer emitted. They fired on every validated request (three per call, even with no handler attached) and were redundant with the existing[:conduit_mcp, :tool, :execute]event. Migrate attached handlers to[:conduit_mcp, :tool, :execute]whose metadata's:statusfield indicates:ok/:errorand whose payload includes validation failures. custom_constraint_markers/0onConduitMcp.Validation.SchemaConverter— replaced bystrip_markers/1, which does the stripping itself and recurses into nestedkeys:schemas. Callers that fetched the marker list to do their ownKeyword.drop/2should callstrip_markers/1instead; four modules in this library did exactly that and now share the one implementation.
0.9.7 - 2026-06-18
Fixed
tools/callerrors crashed when the request carried_meta—Handler.maybe_add_meta/2unconditionally wrote the request's_metainto["result", "_meta"], but error responses (%{"error" => ...}) have no"result"key. With a_metapresent (clients such as the Python MCP SDK send aprogressTokenon everytools/call), this raised"could not put/update key \"_meta\" on a nil value", which the handler masked as a generic"Internal server error"— so every tool that returned an error surfaced the wrong message._metais now only merged into responses that have a"result"; error responses pass through untouched.
[0.9.3] - 2026-04-18
Fixed
- Empty-map type warnings under Elixir 1.20 — endpoints with no scoped tools and/or no prompts no longer trigger
Map.get/2,3"will always return default" warnings from@before_compile-generated__scope_for_tool__/1and__convert_to_atom_keys__/2. Replaced single-map lookups with per-component function clauses (__scope_for_tool__(<<name>>) -> scopeand__key_map__(<<name>>) -> escaped_map) plus catch-all fallbacks.mix compile --warnings-as-errorsis now clean for read-only / tool-only endpoints.
[0.9.1] - 2026-03-24
Performance
- persistent_term validation config — replaced
Application.get_envwith:persistent_term.getfor O(1) lock-free config reads on every validated request (4–10% faster validation, 5–11% less memory) - Cached server capabilities — new internal
ServerMetamodule lazily caches allfunction_exported?results in persistent_term, eliminating 9 repeated BIF calls per request (2–7% faster handler dispatch) - Pre-computed clean schemas —
__validation_schema_for_tool__/1now returns{full_schema, clean_schema}tuples pre-stripped of constraint markers at compile time, eliminating per-requestKeyword.drop - Static resource URI dispatch — resources with no
{param}placeholders now generate direct pattern-match clauses (O(1)) instead of linear regex scan (O(n))
Fixed
- Atom table exhaustion —
String.to_atom/1in validation replaced withString.to_existing_atom/1to prevent atom table exhaustion from malicious parameter names
Added
ConduitMcp.Validation.update_validation_config/1— public API for updating validation config at runtime (writes both Application env and persistent_term)
[0.9.0] - 2026-03-22
Added
- MCP Apps support — first-class support for the MCP Apps extension, enabling tools to return interactive UI components rendered as sandboxed iframes in host clients
meta/1macro — attach arbitrary_metametadata to tool definitions (generic, future-proof)ui/1macro — shortcut for declaring_meta.ui.resourceUrion a toolapp/2macro — convenience that registers both a tool (with_meta.ui) and itsui://HTML resource in one declarationraw_resource/2helper — return raw content with a MIME type from resource handlers- Component mode
ui:option —use ConduitMcp.Component, type: :tool, ui: "ui://..."
- MCP Apps guide — new HexDocs guide covering DSL, Component, and app macro usage with client-side build workflow
- MCP Apps example —
examples/mcp_apps_demo/with a server health dashboard demonstrating the full tool → UI resource → iframe pattern
0.8.5 - 2026-03-22
Changed
- Removed Jason dependency — replaced with Elixir 1.18+ built-in
JSONmodule across all lib, test, and transport code (one fewer dependency)
Performance
- Pre-compiled URI template regex — resource URI matching regex is now compiled once at compile time instead of rebuilt on every request (2.4x faster resource reads in DSL mode, 1.7x in Endpoint mode)
- Single-pass constraint validation — merged 4 separate schema traversals (enum, numeric, string length, custom) into a single
Enum.reduce_whilepass (1.6x faster) - Optimized marker removal — replaced 11-iteration
Enum.reducewithKeyword.drop/2(2.2x faster) - Single config fetch — validation reads
Application.get_envonce per call instead of 3 times (1.3x faster) - O(1) schema lookup in type coercion — replaced
Enum.findper parameter with pre-builtMaplookup
Added
- Benchee benchmark suite (
mix bench) with 6 benchmark files:uri_template_bench— dynamic regex vs pre-compiled vs String.splitvalidation_bench— full pipeline, key conversion, constraint passes, marker removal, config lookupshandler_bench— method dispatch,function_exported?overhead, telemetry costjson_bench— built-in JSON encode/decode at varying payload sizesprotocol_bench— request validation and response construction baselinefull_request_bench— DSL vs Manual vs Endpoint mode comparison
mix benchtask — run all benchmarks, run specific (mix bench validation), or list (mix bench --list)- HTML benchmark reports generated in
bench/output/
0.8.0 - 2026-03-22
Added
- Endpoint + Component mode — third way to define MCP servers alongside DSL and Manual modes
ConduitMcp.Componentbehaviour for defining tools, resources, and prompts as individual modulesConduitMcp.Component.SchemaDSL (schema do field ... end) with automatic JSON Schema and NimbleOptions generationConduitMcp.Endpointaggregator withcomponentmacro, declarative rate_limit/message_rate_limit/auth config- Auto-detected capabilities from registered component types
- Compile-time validation (duplicate names, invalid modules, missing callbacks)
- Atom-keyed params in
execute/2for ergonomic pattern matching
ConduitMcp.Errorsmodule — centralized JSON-RPC 2.0 and MCP error code constantsparse_error/0,invalid_request/0,method_not_found/0,invalid_params/0,internal_error/0,server_error/0,resource_not_found/0- Replaces hardcoded magic numbers across the codebase
- Transport auto-extraction — StreamableHTTP and SSE transports auto-read endpoint config (name, version, rate_limit, auth) as fallback defaults
- Handler capability detection —
build_capabilities/1uses__capabilities__/0when available for selective capability advertisement - 6 new documentation guides — choosing_a_mode, endpoint_mode, dsl_mode, manual_mode, authentication, rate_limiting
Improved
- Test coverage expanded to 503 tests (up from 405)
- README restructured with all 3 server modes, responses reference, MCP spec coverage table
- Error codes refactored —
ConduitMcp.Protocolnow delegates toConduitMcp.Errors
0.7.0 - 2026-03-21
Added
- MCP spec 2025-11-25 support with backward compatibility for 2025-06-18
- Protocol version negotiation in
initialize(supports both versions) MCP-Protocol-Versionresponse header on all POST responsesMCP-Session-Idheader with session creation and validation
- Protocol version negotiation in
- Pluggable session store (
ConduitMcp.Session.Storebehaviour)- Default ETS store included (
ConduitMcp.Session.EtsStore) - Documentation for Redis, PostgreSQL, and Mnesia stores
- Default ETS store included (
- Cursor-based pagination via arity-2 list callbacks (backward compatible with arity-1)
- Tool annotations DSL (
readOnlyHint,destructiveHint,idempotentHint,openWorldHint) _metafield passthrough forprogressTokensupportlistChangedcapability declarations- Origin header validation for DNS rebinding prevention
- New handler methods:
completion/complete,logging/setLevel,resources/subscribe,resources/unsubscribe audio/2helper macro for audio content typeConduitMcp.Tasksmodule for long-running operation state machineConduitMcp.Clientmodule for server-to-client requests (sampling, elicitation, roots)- OAuth 2.1 authentication (
ConduitMcp.Plugs.OAuth) with JWT validation- JWKS key provider with HTTP fetching (
ConduitMcp.OAuth.KeyProvider.JWKS) - Static key provider (
ConduitMcp.OAuth.KeyProvider.Static) - Resource metadata endpoint (
ConduitMcp.OAuth.ResourceMetadata) - Tool-level scope enforcement
- JWKS key provider with HTTP fetching (
- New error code
-32002(resource not found) - CI/CD pipeline with compile, format, credo, test, dialyzer, and hex publish jobs
- Configurable initialize response (
server_name,server_versionviaconn.private)
Improved
- Test coverage expanded to 405 tests (up from 309)
- Dependencies updated: bandit 1.10.3, credo 1.7.17, ex_doc 0.40.1, telemetry 1.4.1
- Handler refactored to reduce cyclomatic complexity with extracted helper functions
Fixed
- Version mismatch where handler returned hardcoded version instead of app version
- Hardcoded protocol version in transport (now uses
ConduitMcp.Protocol.protocol_version/0) - Flaky telemetry tests caused by async race conditions
- Flaky StreamableHTTP tests caused by shared ETS state in async mode
- Elixir 1.20 compilation warnings
0.6.5 - 2026-02-07
Added
- Message-level rate limiting (
ConduitMcp.Plugs.MessageRateLimit)- Second rate limiting layer that limits MCP method calls per time window
- POST-only: GET/OPTIONS pass through automatically
- Skips JSON-RPC notifications (no
idfield) - Configurable excluded methods (e.g.,
["initialize", "ping"]) - User-aware default key function (uses
conn.assigns[:current_user]from Auth plug) "msg:"key prefix prevents Hammer counter collision with HTTP rate limiter- HTTP 429 response with
Retry-Afterheader and JSON-RPC error (code-32000) - Telemetry event:
[:conduit_mcp, :message_rate_limit, :check] - PromEx metrics:
message_rate_limit_check_total,message_rate_limit_check_duration_milliseconds - Configurable via
:message_rate_limittransport option - Works alongside existing HTTP-level rate limiting
Improved
- Test coverage expanded to 309 tests
- README updated with Rate Limiting documentation (HTTP + message-level)
- Telemetry documentation updated with message rate limit events
- PromEx plugin updated with message rate limit metrics
- Applied
mix formatto all files in the codebase
[0.5.0] - 2025-11-24
Added
Resource URI parameter extraction - Complete implementation
- Extracts parameters from URI templates (e.g.,
"user://{id}"→"user://123"→%{"id" => "123"}) - Supports multiple parameters (e.g.,
"user://{id}/posts/{post_id}") - Uses proper regex escaping with placeholder tokens
- Returns
{:ok, params}on match or:no_matchotherwise - Full implementation in
extract_uri_params/2(internal) - Resolves TODO from previous versions
- Extracts parameters from URI templates (e.g.,
PromEx plugin for Prometheus monitoring
- Optional integration via
{:prom_ex, "~> 1.11", optional: true} - Conditional compilation (only loads if PromEx available)
- 10 production-ready metrics (5 counters + 5 histograms)
- Monitors all ConduitMCP operations: requests, tools, resources, prompts, auth
- Optimized histogram buckets per operation type
- Low cardinality design with string normalization
- Comprehensive documentation with PromQL query examples
- Alert rule examples included
- Zero runtime overhead when not enabled
- Optional integration via
Improved
Test coverage expanded significantly
- 33 new tests added (21 for core features, 12 for PromEx)
- Resource URI parameter extraction: 11 new tests
- Prompt functionality: 4 new tests
- Tool functionality: 6 new tests
- PromEx plugin: 12 new tests
- Total: 229 tests, all passing
Documentation enhanced
- Added comprehensive Prometheus Metrics section to README
- 190+ lines of PromEx plugin documentation
- PromQL query cookbook with examples
- Alert rule templates
- Complete metric reference
Fixed
- Version consistency across all files (updated from 0.4.6 to 0.4.7, now 0.5.0)
- Removed repository artifacts:
- Deleted
erl_crash.dump(4.9 MB) - Deleted
conduit_mcp-0.4.0.tarandconduit_mcp-0.4.6.tar
- Deleted
- Updated test badge count (193 → 229 passing)
Breaking Changes
None - This release is fully backward compatible.
[0.4.7] - 2025-11-19
Added
raw/1helper macro for direct JSON output without MCP content wrapping- Bypasses standard MCP content structure for debugging purposes
- Returns
{:ok, data}directly instead of wrapped content array - Supports maps, strings, lists, and all data types
- Includes comprehensive documentation with MCP compatibility warnings
- Full test coverage with 3 test cases
Documentation
- Updated README.md helper functions list to include
raw/1 - Added detailed module documentation with usage examples and warnings
0.4.6 - 2025-01-16
Changed
- Streamlined README and CHANGELOG for clarity
- Focused documentation on essential features
- Reduced README by 53% (634 → 298 lines)
- Reduced CHANGELOG by 48% (190 → 99 lines)
Improved
- README now highlights DSL as primary approach
- Removed outdated migration guides
- Cleaner examples and better organization
- Added version and test badges
0.4.5 - 2025-01-16
Added
- Clean DSL for defining MCP servers
tool,prompt,resourcemacros for declarative definitions- Automatic JSON Schema generation from parameters
- Helper functions:
text(),json(),error(),system(),user(),assistant() - Support for inline functions, MFA handlers, and function captures
- Parameter features: enums, defaults, required fields, type validation
- Flexible authentication system
ConduitMcp.Plugs.Authwith 5 strategies- Bearer token, API key, custom function, MFA, database lookup
- CORS preflight bypass, configurable assign key
- Case-insensitive bearer token support
- Extended telemetry
[:conduit_mcp, :resource, :read]- Resource operations[:conduit_mcp, :prompt, :get]- Prompt operations[:conduit_mcp, :auth, :verify]- Authentication- Complete observability for all MCP operations
Changed
- Examples updated to use DSL (simple_tools_server, phoenix_mcp)
- Transport modules support
:authoption - Auth configured per-transport (no separate pipeline needed)
- Documentation streamlined to focus on DSL
Tests
- 36 DSL tests (tools, prompts, resources, helpers, schema builder)
- 26 auth plug tests (all strategies, error handling, CORS)
- 16 telemetry tests
- 193 total tests, all passing
0.4.0 - 2025-01-16
Changed (Breaking)
- Pure stateless architecture
- Removed GenServer and Agent - zero process overhead
- Server is just a module with pure functions
- No supervision tree required
- Maximum concurrency (limited only by Bandit)
- Simplified callback API
- Removed
mcp_init/1 - Changed
{:reply, result, state}→{:ok, result} - Callbacks receive
conn(Plug.Conn) as first parameter - No more state passing/returning
- Error maps use string keys
- Removed
- Handler updates
- Calls module functions directly (no GenServer.call)
- Transport layers pass Plug.Conn for request context
Performance
- Zero process overhead - pure function calls
- Full concurrent request processing
- No serialization bottleneck
0.3.0 - 2025-10-28
Added
- Comprehensive test suite (109 tests, 82% coverage)
- Test infrastructure (TestServer, TelemetryTestHelper)
- ExCoveralls integration
Changed
- Simplified and professionalized README
0.2.0 - 2025-10-09
Added
- Telemetry events (
[:conduit_mcp, :request, :stop],[:conduit_mcp, :tool, :execute]) - Configurable CORS headers
- Enhanced logging
Fixed
- SSE buffering with nginx proxies
0.1.0 - 2025-10-08
Added
- Initial release
- MCP specification 2025-06-18 implementation
ConduitMcp.Serverbehaviour- StreamableHTTP and SSE transports
- Tools, resources, and prompts support
- Basic authentication
- Phoenix integration example