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.
max_requestslisten option (default 10000): requests in flight per listener, streams included; past it a request is answered 503 withretry-after: 1before its body is read.barrel_mcp_http_listener:in_flight/1reports the count.max_body_byteslisten option (default 16 MiB); a body past it is answered 413 instead of being dropped to an empty body.body_timeout_mslisten option (default 60000); a body that does not arrive in time is answered 408.- Tests for TLS, ALPN and HTTP/2 on the built-in listener, with a
chain minted by
public_key:pkix_test_data/1: both protocols on one port, the standalone stream and a disconnect over HTTP/2, both caps, a reset mid-body, a plain-TCP client on a TLS port, a port or a name already taken. - The Python interop drives both SDK generations over TLS and HTTP/2
(
--http2 --cacerton the client scripts; the scripts fail if any response arrived over HTTP/1.1), and each script dumps every task's stack and exits if it is still running after 40 s, so a hang is a traceback rather than a silent timeout.
- The built-in listener hands each accepted socket to
h1:serve_socket/2orh2:serve_socket/2after the TLS handshake and ALPN, instead of running its own connection loops overh1_connectionandh2_connection. The wire library owns framing, pipelining order and the per-request process; the listener keeps the acceptors, the caps and a per-request translator that feeds the engine and forwards resets asmcp_disconnect. - Depends on
h10.9.1: a chunked request body split between the CR and LF of a chunk-size line was answered 400.
- HTTP/2 requests lost their body: the listener's h2 loop never received the DATA frames, so every POST was answered 400 and the answer itself failed on a connection already gone.
- The standalone SSE stream and the 2024-11-05 stream could not be
opened over HTTP/2: their headers carried
connection: keep-alive, which HTTP/2 forbids, h2 refused the response, and the engine looped on a stream the client never saw. The header is gone, the h2 responder strips connection-specific headers, and a refusedstream_startends the request.
- Depends on
h10.9,h20.12 andhackney4.7.4. Both wire libraries gainedserve_socket/2for an embedder that accepts and negotiates ALPN itself; the listener still dispatches throughh1_connectionandh2_connectiondirectly. - The docs reference private functions without an arity so
rebar3 ex_docbuilds without warnings.
MCP 2026-07-28 support. That revision is a stateless rewrite: no
initialize handshake, no session id, no server-initiated requests.
It keeps 2025-11-25 and earlier valid as the "legacy" era, and this
release serves both on one endpoint, decided per request rather than
per deployment.
Nothing to do. No legacy code path was removed or rewritten, no option gates the new era, and the default client behaviour still reaches a handshake-era server. Read on only if one of these applies to you:
- Your server calls back into a client through
sampling_create_message/3,elicit_create/3orroots_list/1,2. Those need a session to send the request down, and a modern connection has none, solist_sessions_with_*comes back empty against a client that probed into the modern era. Either pin that client to<<"2025-11-25">>, or port the handler to{input_required, _, _}, which works in both eras. This is the one change that can bite a host that touched nothing: the clientprotocol_versiondefault is nowauto. - You pinned
protocol_versionon a client. Pinning still works and<<"2025-11-25">>reproduces 2.3.0 exactly.autoprobesserver/discoverand falls back to the handshake. - You run more than one node and want multi round-trip requests.
Set
request_state_key. Without it each node signs with its own ephemeral key, so a retry landing elsewhere is rejected. A warning is logged at start. - You read
application:get_env(barrel_mcp, protocol_version). It was never used by the library and is gone. - You subscribe over stdio.
barrel_mcp_client:subscribe/2returns{error, {unsupported, <<"subscriptions/listen">>}}on a modern stdio connection, which has no second channel to hold a stream open. Legacy stdio is unaffected.
Embedders driving barrel_mcp_http_engine:handle/6 from their own HTTP
stack need no change: mode => stream already declares the transport can
hold a response open, which is what subscriptions/listen requires.
barrel_mcp_auth_bearerrequiresaudience;init/1returns{error, {missing_option, audience}}without it. A server must only accept tokens issued for itself.audience => anyopts out for averifierthat checks the recipient itself and logs a warning. A presentexpornbfthat is not an integer now fails verification.barrel_mcp_http_engine:init_auth/1returns{ok, AuthConfig}or{error, {auth_provider, Module, Reason}}; a provider that refuses its options failsstart_http_stream/1andstart_http/1at start, with no listener bound, instead of surfacing per request. An embedder matches on{ok, _}.- Every OAuth authorization-server URL the client uses, configured or
discovered, must be
https; anything else is refused with{error, {insecure_url, Url}}before a request is sent. Theallow_insecure_oauth => trueoption on the{oauth, ...}configs and on the discovery and grant helpers lifts the check for a plaintext test server. It is noncompliant and documented as such.
-
guides/server-internals.md(supervision tree, process model, request lifecycle per verb, tool-call modes, session fields, tables) andguides/glossary.md. The protocol-versions guide lists every era branch site, the authentication guide has a Principals section, and the ten largest modules open with a section index, their process model and their state record. -
Tools declare
task_support => forbidden | optional | required(long_running => truestill meansoptional), listed asexecution.taskSupportin the modern era. Arequiredtool refuses a client that did not declareio.modelcontextprotocol/taskswith-32021. In the modern era a task-supporting tool answers synchronously when it finishes, or asks its MRTR question, withintask_inline_ms(default 100); only past that window is a task created and itsCreateTaskResultreturned, with the worker already running (SEP-2663 "Task Creation"). A task'serroris the JSON-RPC error object. The legacy era still gets a task at once. -
The OAuth client handle runs the authorization-code flow itself. An
{oauth, #{redirect_uri, authorize, ...}}config with noaccess_tokenanswers a 401 by discovering the protected resource and its authorization server, choosing the client identity (pre-registered, CIMD or dynamic registration), calling the host'sauthorizefun with the authorization URL, validating the callback (redirect URI,state, RFC 9207iss) and exchanging the code. A 403insufficient_scopesteps up with the union of scopes (SEP-2350). Scope selection follows the specification (challenge, then PRMscopes_supported, then omitted),offline_accessis added only when the authorization server lists it (SEP-2207), and a change of authorization server drops the stored client and tokens (SEP-2352). 2025-03-26 servers get the origin-based discovery and the/authorize/token/registerfallbacks of that revision. An optionalstore => {Module, Arg}(barrel_mcp_client_auth_store) persists the client and tokens. -
The non-interactive grants discover their endpoints from the 401:
{oauth_client_credentials, ...}and{oauth_enterprise, ...}no longer needtoken_endpoint,as_token_endpoint,audienceorresourcein the config, and{oauth_jwt_bearer, #{client_id, assertion}}(RFC 7523, SEP-1933) is new. Client credentials acceptprivate_key => {Pem, Alg}forprivate_key_jwtassertions, signed by the newbarrel_mcp_jwt(ES256, RS256, HS256). -
dpop => truebinds tokens with DPoP (RFC 9449, SEP-1932): a P-256 proof key per handle, a proof on every token request and every MCP request (ath,htu,htm,jti), theDPoPauthorization scheme once the server issues a bound token, and both nonce challenges (use_dpop_noncefrom the authorization server and the resource server) answered with a fresh proof. -
barrel_mcp_client_authhas three optional callbacks,challenge/2,settled/1andrequest_headers/3. The HTTP transport hands a 401, or a 403insufficient_scope, to a worker running the handle'schallenge/2and keeps serving; refused requests wait for that one flow and are reissued when it returns, three rounds at most per request. -
The HTTP client honours the SSE
retry:field on reconnect and resumes a response stream the server closed before the response with a GET carryingLast-Event-ID(SEP-1699). -
The official conformance runner's client mode runs against our client:
test/barrel_mcp_conformance_client.erlunder four CT cases (--requirementsat 2026-07-28 and 2025-11-25,--suite allat 2025-06-18 and 2025-03-26). -
Serve and speak every revision from
2024-11-05to2026-07-28. A request is modern when itsparams._metacarriesio.modelcontextprotocol/protocolVersion. New guide:guides/protocol-versions.md. -
server/discover, answered in both eras so it doubles as the stdio probe target. -
subscriptions/listen: a long-lived POST stream replacing the GET SSE stream andresources/subscribefor modern clients.barrel_mcp:notify_list_changed/1andnotify_resource_updated/2keep their signatures and fan out to both eras. -
Multi round-trip requests. A tool returns
{input_required, Requests, State}instead of blocking on a server-to-client call; read the answers withbarrel_mcp:input/2andrequest_state/1, and checkclient_supports/2before asking.Stateis sealed with HMAC-SHA256 and bound to the principal, method and salient params (barrel_mcp_request_state). -
Request metadata headers
Mcp-Method,Mcp-NameandMcp-Param-{Name}, with=?base64?...?=for values that are not header-safe. Tools opt arguments in withx-mcp-headerin theirinputSchema, validated at registration. -
Tasks extension
io.modelcontextprotocol/tasks:resultType: "task",tasks/getpolling,tasks/update. A modern client that did not declare it gets a synchronous run rather than a task id it could not poll. -
Freshness hints (
ttlMs,cacheScope) on cacheable results. -
Resource and prompt handlers may be arity 2. The second argument is the request context, so
prompts/getandresources/readcan return{input_required, _, _}like a tool. -
Error codes
-32020HeaderMismatch,-32021MissingRequiredClientCapability,-32022UnsupportedProtocolVersion.advertise_versions(modernby default, orall) decides what-32022offers a client to retry with. -
Client:
protocol_version => auto(the new default) probes and falls back;probe_timeoutandmax_input_roundsjoin the connect spec.subscribe/2andunsubscribe/2keep their signatures oversubscriptions/listen. -
barrel_mcp_versionfor comparing revisions. Nothing else in the library orders version binaries; neither should your code. -
OAuth:
validate_callback/2(RFC 9207issandstate),registration_strategy/2,client_id_metadata_document/1andcheck_issuer_binding/2. Dynamic registration now always sendsapplication_type. -
Interop tests against the reference Python SDK v2 in both directions: the discovery probe, result stamping and freshness hints, multi round-trip requests on all three verbs and in both directions,
subscriptions/listen,x-mcp-headermirroring, and the capability and retry-round refusals.
-
barrel_mcp_client_auth_oauth:discover_authorization_server/1,2follows the 2026-07-28 discovery rules: path-aware well-known URLs in the specification's order, fall-through on a URL that yields no metadata document, and a terminal error on the first document found when itsissuerdiffers from the issuer queried, when it does not advertiseS256PKCE, or when an endpoint is nothttps. -
barrel_mcp_clientsis no longer a process. The supervision tree is the registry: onebarrel_mcp_client_shellsupervisor perServerId, holding the client with its own restart budget from the spec'srestart => #{intensity, period}(5 in 60 s by default). A crashed client keeps itsServerIdacross the restart; while a restart is still failing,whereis_client/1answersundefined,start_client/2answers{error, {restarting, Id}}andstop_client/1clears it. A client that exhausts its budget, or leaves normally, frees its id and no other client is affected. -
A legacy (2024-11-05 through 2025-11-25)
tools/callover Streamable HTTP is answered as an SSE stream whenever the client'sAcceptliststext/event-stream, which the transport already requires it to. The result is the stream's final event. This is what the reference server does by default, and every conforming client already parses either shape. A client that sends the requiredAcceptbut only ever parses a JSON body will need to read the stream. In return, a client that only POSTs can now be askedelicitation/create,sampling/createMessageandroots/listmid-request, and progress and log notifications ride the call's own response instead of needing a standalone GET stream. -
barrel_mcp_tasksis keyed by task id, with the owner as a field. A legacy task belongs to its session; a modern one has no session, so it belongs to the authenticated principal. -
The
protocol_versionapplication env is removed. It was never read.
- A Streamable HTTP session belongs to the principal that initialized
it. A POST, GET, DELETE or replay naming another principal's session
id gets the unknown-session 404, and a response to a server-initiated
request is delivered only from the session that carried it
(
barrel_mcp_session:deliver_response/3takes the session id; the arity-2 form is gone). The 2024-11-05 pair already did this; the Streamable transport did not. barrel_mcp_auth_customfails aauthenticate/2result of any shape the contract does not name instead of admitting it as subjectunknown, and no longer pretends to keep the returned state.- An HTTP acceptor that dies is replaced; the listener used to run one
short for the rest of its life.
barrel_mcp_http_listener:acceptors/1lists the live pool. - The HTTP client closes the hackney connection when it drops a response for exceeding the size cap, and when the server refuses the standalone event stream, instead of leaving it streaming or pooled.
- A request handler that loses its connection mid-response (listener shutdown, or the connection statem already gone) is no longer logged as a handler crash.
- The
registeredlist in the application resource names every locally registered process. - The legacy HTTP+SSE client resolves the
endpointevent against the stream's URL and refuses one on another origin (scheme, host or port) with{mcp_closed, _, {cross_origin, Url}}, sending nothing there. The POST carries the session'sAuthorizationheader, so an absolute endpoint from the server used to hand that credential to whatever origin the stream named. The reference client refuses it the same way. - Deleting a session now drops the rows it owns in the subscription, in-flight and pending tables. They were keyed by the session id and nothing else expired them, so a session that subscribed to a resource and then dropped left its row behind for the life of the node. The periodic sweep and the explicit delete had also diverged: only the latter forgot the session's elicitations. A caller blocked on a server-to-client request is now failed when its session goes rather than left to sit out its timeout, and pending rows whose caller died before its own timeout are reclaimed by the sweep.
- stdio bounds what it writes and how many subscriptions it serves. The
writer blocks when the peer stops draining its pipe and its mailbox
was not a bound, so a subscription firing against a stalled reader
grew it without limit; past
stdio_max_outbound_notifications(default 256) a notification is now dropped, as on the inbound side. A subscription holds no worker slot, sostdio_max_workersnever bounded them either;stdio_max_subscriptions(default 32) does. - stdio now serves the legacy server-to-client surface. The transport
advertised
resources.subscribeandlistChangedbut had no session to hang them on, soresources/subscribefailed with-32602and nonotifications/*/list_changedever arrived. The one channel is now one session, attached once a handshake revision is negotiated; a modern connection is unaffected and keeps usingsubscriptions/listen.
Bugs found while building this, all present in 2.3.0:
subscriptions/listencrashed stdio and the plain HTTP transport. Both were handed a stream handle they cannot serve and passed it to the JSON encoder; on stdio that took the server down, and reaching it needed no authentication.- The client abandoned a modern server on
-32022, falling back to a handshake that server does not have and discarding the revisions it offered. It now retries once with one of them. - HTTP listeners were not supervised: a crashed acceptor pool stayed down, and stopping the application left the port held.
- Orphaned stream handlers. A monitored-but-unlinked handler outlived its owner.
stdioignoredlong_running, driving every tool synchronously and blocking up to 60 seconds. The decision moved into the protocol, so both transports honour it.- The client treated any 4xx as a dead connection, discarding a JSON-RPC error body it could have surfaced.
- A long-running tool whose worker died without reporting left its task
workingforever, with the collector blocked on a message that never came. The sweep did not evict it, since a task that legitimately runs for hours looks the same. -32002was used for prompt handler crashes. It has meant "resource not found" since2024-11-05; crashes are now-32603.resources/readandprompts/getnot-found also returned-32601._metawas emitted besideresulton the JSON-RPC envelope. The schema declares it a field ofResult, where a conforming client looks for it.
Deprecated by the specification, still served for legacy clients, and not scheduled for removal:
- Sampling, elicitation and roots as server-to-client requests
(
barrel_mcp:sampling_create_message/3,elicit_create/3,roots_list/1,2). Modern servers use{input_required, _, _}. - Logging (
barrel_mcp:notify_log/3,4,logging/setLevel). Modern clients opt in per request through_meta. - Dynamic Client Registration
(
barrel_mcp_client_auth_oauth:register_client/2,3), in favour of Client ID Metadata Documents.
- Tool handlers can now see which tool they are serving: the handler
Ctxcarriestool_name, so a singleModule:Functionpair can back several registered tools (the shape an MCP gateway needs).
barrel_mcp_registry:run/3now honors the arity-2 handlersreg_tool/4accepts instead of always callingM:F(Args), mirroring the wire path.
- Recover the MCP client reconnect when a transient child spec lingers
after a clean exit:
start_childnow drops the dead spec on{error, already_present}and retries, so a host can redial a server that has come back up.
- Bump dependencies:
h10.7.0 -> 0.7.1,h20.10.2 -> 0.11.0,hackney4.4.2 -> 4.7.2 (HTTP/2 large-body flow-control fix),quic1.6.5 -> 1.7.1,webtransport0.4.1 -> 0.4.3.
- Bump dependencies:
h10.6.2 -> 0.7.0,h20.9.0 -> 0.10.2,hackney4.3.0 -> 4.4.2. - Pin runtime dependencies with patch-relative (
~>) version constraints so they float to the latest patch within their minor line.
- Bump dependencies to latest:
h10.6.1 -> 0.6.2,hackney4.2.3 -> 4.3.0. - Bump tooling:
erlfmt1.7.0 -> 1.8.0,rebar3_lint4.1.1 -> 5.0.4 (elvis_core 5.x), and migrate the elvis config to the new format.
- Bump
h10.6.0 -> 0.6.1.
Dependency refresh and tooling.
- Update dependencies to latest:
h10.2.3 -> 0.6.0,h20.6.1 -> 0.9.0,hackney4.0.3 -> 4.2.3. - Drop cowboy as a test dependency. The OAuth suites now run against a
small mock built on the project's own
h1server; the only test dep left ismeck. - Adopt erlfmt and elvis (rebar3_lint); commit
rebar.lockso CI keys its build cache on the lock hash. CI runs format, lint, xref, and dialyzer as distinct jobs.
barrel_mcp_client_httptracked the hackney async SSE stream handle as areference(), but it is a connection pid in hackney 4.x. The field type and theis_referenceguard are corrected; the guard never matched, so a duplicate SSE stream could be opened.
Threads the authenticated principal into tool handlers.
- Arity-2 tool handlers (
Mod:Fun(Args, Ctx)) now receive the auth provider'sauthenticate/2result inCtxunderauth_info, so owner-scoped tools can identify the caller. Arity-1 handlers (Mod:Fun(Args)) are unchanged. Withbarrel_mcp_auth_nonethe value is the anonymous principal; on paths with no auth provider (stdio) it isundefined. barrel_mcp_protocol:drive_async_plan/3, which threadsauth_infointo the synchronous tool-call path.drive_async_plan/2is retained and delegates withauth_infoset toundefined.
Erlang/OTP 29 support. No public API changes.
- Support OTP 29; CI now runs on OTP 28 and 29 (dropped 27) with rebar3 3.27.
- Replaced the deprecated bare
catchoperator withtry ... catchthroughout, as OTP 29 deprecatescatch .... - Updated dependencies:
erlang_h10.2.3,h20.6.1,hackney4.0.3; test depsmeck1.2.0 andcowboy2.15.0.
A security release from a release-time review of the HTTP transport and the auth providers. No public API changes.
- Authentication is enforced on every Streamable HTTP verb.
Previously only POST ran the configured auth provider; GET (open
SSE stream) and DELETE (terminate session) were gated by the
Mcp-Session-Idalone. A caller holding a leaked session id could read a session's server-to-client SSE traffic (including replayed buffered events) or terminate sessions without presenting a credential. Both verbs now run the same auth gate as POST, before any session lookup. Withbarrel_mcp_auth_none(the default) behaviour is unchanged. - Resource, prompt and completion handler crashes no longer leak
exception terms to the client. The 2.0.1 change that returns a
generic
Internal tool errorcovered onlytools/call. The synchronousresources/read,prompts/getandcompletion/completepaths still serialised the caughtClass:Reason(which can carry internal paths, argument values or secret-bearing terms) into the JSON-RPC error. They now log the class, reason, stack, request id and handler name vialogger:errorand return a generic message. - The built-in listener caps concurrent connections. Because
idle_timeoutisinfinity(so long-lived SSE GETs are never reaped), a connection lived until the peer closed it, so a flood of connections or slow/idle keep-alive clients could exhaust file descriptors and memory. Each listener now bounds established connections (default 16384, override with themax_connectionsstart option) and drops connections past the cap. h1's default 60srequest_timeoutalready bounds slow request headers. - Basic-auth unknown-user timing matches the configured mode.
When
hash_passwordswasfalsethe unknown-user path still ran the slow PBKDF2 stand-in while the configured-user path ran a fast SHA-256 compare, so response timing could reveal whether a username existed. The stand-in now does the same work as the active comparison mode.
- Client reports its real version. The default
client_infosent ininitializewas pinned at2.0.0; it now matches the library version. The README dependency example also pinned the stalev1.3.0tag.
Follow-up hardening from a review of the 2.0.0 transport.
_authno longer leaks into inbound responses. The Streamable HTTP transport tagged the authenticated principal (_auth) on every decoded message before splitting requests from responses, so a client-posted JSON-RPC response (answering a serversampling/elicitationrequest) carried_authinto the delivered map. It is now attached only on the request dispatch path, matching 1.x behaviour. Server-internal only; no data was sent to clients.- Accept loop no longer spins on persistent errors.
barrel_mcp_http_listenernow backs off briefly on a non-closedaccept error, so a system error such as file-descriptor exhaustion (emfile) throttles the acceptor instead of burning CPU.
A dependency-restructuring release. The HTTP server transport is rebuilt on the h1 and h2 libraries and Cowboy is removed from the library, so barrel_mcp can be embedded next to web frameworks (such as Livery) that bring their own HTTP stack without dragging Cowboy into the runtime. The protocol core and the public start/stop API are unchanged.
- No Cowboy in the runtime. The
barrel_mcpapplication'sapplicationslist is now[kernel, stdlib, crypto, h1, h2, hackney](was[..., cowboy, hackney]). The built-in HTTP server (barrel_mcp:start_http/1,barrel_mcp:start_http_stream/1) runs onh1/h2: a cleartext bind speaks HTTP/1.1, and a TLS bind serves HTTP/1.1 and HTTP/2 on the same port via ALPN. Start/stop options and the protocol-core entry points are unchanged. Hosts that relied onbarrel_mcptransitively starting Cowboy must drop that assumption and add the apps they need to their own release. - Dependencies. Added
h1(hex packageerlang_h1) 0.2.2 andh20.6.0; removedcowboy. The MCP HTTP client still useshackney, bumped to 4.0.0. Cowboy is now a test-only dependency (the OAuth DCR and EMA suites mock an authorization server with it).
barrel_mcp_http_engine: a transport-neutral implementation of the Streamable HTTP and simple HTTP protocol logic (routing, sessions, CORS, Origin validation, authentication, the OAuth protected-resource-metadata endpoint, async tool calls). It drives response I/O through a smallRespondermap of closures, so the built-inh1/h2server and external adapters (for example a Livery handler) can both reuse it.barrel_mcp_http_listener: the built-in single-porth1/h2server (cleartext h1, TLS h1+h2 via ALPN). A listenerstopnow tears down its in-flight connection processes.
barrel_mcp_prm_handler: the/.well-known/oauth-protected-resourceroute is now served directly bybarrel_mcp_http_engine.
1.3.0 - 2026-05-10
A feature release that completes the OAuth surface vs MCP 2025-11-25 and modelcontextprotocol/ext-auth: Enterprise-Managed Authorization (EMA) for SSO-driven hosts, Dynamic Client Registration (RFC 7591) including the section-3 protected variant, plus four security follow-ups from review (scope fail-closed, no exception leakage, capped client buffers, no redirect-following on discovery).
- Scope checks now fail closed. When
required_scopesis configured but a custom auth provider returns anAuthInfomap without ascopeskey (or with a non-list value), the request is rejected with{error, insufficient_scope}. Previously these requests were admitted because the catch-allcheck_scopes/2clause returned{ok, AuthInfo}. Behaviour is unchanged forbarrel_mcp_auth_bearer(which always emits a list). - Tool crash details no longer leak to clients. When a tool handler raises,
barrel_mcp_registrylogs the class, reason, stack, request id, module and function vialogger:error. The wire-level error is now a generic<<"Internal tool error">>from bothbarrel_mcp_protocolandbarrel_mcp_http_stream; the previousio_lib:format("~p", [Reason])could disclose module/file/function names and exception terms. - Streamable-HTTP client buffers are capped.
barrel_mcp_client_httpnow bounds in-flight response buffers (16 MiB) and SSE event buffers (4 MiB). On overrun the client emits{mcp_closed, Pid, {response_too_large, Bytes}}and drops the request from tracking; a malicious or compromised MCP server can no longer drive unbounded memory growth in the host. - OAuth discovery no longer follows redirects.
barrel_mcp_client_auth_oauth:discover_protected_resource/1anddiscover_authorization_server/1previously passed{follow_redirect, true}, which let an untrusted MCP server redirect discovery into an SSRF-style probe. The flag is nowfalse; non-2xx surfaces as{error, {http_error, Status}}.
- New connect-spec entry
auth => {oauth_enterprise, Config}chains an IdP-issued ID Token (or SAML assertion) through RFC 8693 token-exchange (at the IdP) and RFC 7523 jwt-bearer (at the AS) into a short-lived MCP access token. Required Config keys:idp_token_endpoint,as_token_endpoint,client_id,subject_token,subject_token_type,audience,resource. Optionalclient_secret/client_assertion,scopes. Implements the second half ofmodelcontextprotocol/ext-authfor SSO-driven MCP hosts. - New public exchangers
barrel_mcp_client_auth_oauth:token_exchange/2andjwt_bearer/2for hosts that want to drive each step directly. - The handle re-walks the chain on every 401 (no
refresh_tokeninvolved). When the IdP returnsinvalid_grantthe library surfaces the typed{error, subject_token_expired}so the host can re-acquire from its IdP without parsing JSON.
- New public
barrel_mcp_client_auth_oauth:register_client/2. Posts the supplied client metadata to the AS'sregistration_endpointand returns the response unchanged:client_id, optionalclient_secret,client_id_issued_at,client_secret_expires_at, plus any echoed metadata. Hosts feed the returned credentials into a subsequent{oauth, ...}/{oauth_client_credentials, ...}connect spec. - Stays a standalone exchanger: auto-wiring would need persistent storage of issued credentials, which is host policy. Documented in the OAuth section of the auth guide.
- New
register_client/3accepts anOptsmap.initial_access_token(RFC 7591 section 3) attachesAuthorization: Bearer ...so protected registration endpoints work.
- The MCP spec defines
_metaas the extensibility hook on every JSON-RPC envelope. Previously only_meta.progressTokenwas read ontools/call; everything else dropped on the floor. Now:- Inbound: tool handler
Ctxcarries the full inbound_metamap under themetakey.progress_tokenstays for back-compat. The async plan emitted bybarrel_mcp_protocol:handle/1fortools/callcarriesmetaso transports without their own_metaextraction (stdio, legacy HTTP) get it viabarrel_mcp_protocol:drive_async_plan/2. - Outbound: new return shapes on tool handlers:
{result_meta, Result, MetaMap},{structured_meta, Data, Content, MetaMap},{tool_error, Content, MetaMap}: surface_metaon the response. The existing tuple shapes are unchanged. - Envelope helpers: new
barrel_mcp_protocol:success_response/3anderror_response/4accept an optional_metamap. Empty map omits the field. Used by every transport's tool-outcome path so the wire shape is consistent.
- Inbound: tool handler
- 8 new eunit cases cover the envelope helpers,
drive_async_planfor the new result/structured/error meta variants, and an end-to-end_metaround-trip through a tool that echoes Ctx-supplied_metaback through the response.
- New
resource_metadatastart option onbarrel_mcp:start_http_stream/1andbarrel_mcp:start_http/1. When set, the server registers a cowboy route at/.well-known/oauth-protected-resourcethat returns the configured RFC 9728 PRM document as JSON, and threads the absolute PRM URL into the auth provider's challenge. - Wire change.
barrel_mcp_auth_bearer's 401 challenge now emitsBearer realm="...", resource_metadata="<URL>"(RFC 9728 / MCP auth sub-spec) instead of the previous non-conformantresource="..."parameter (which conflated the RFC 8707 audience claim with the metadata URL). MCP clients can now auto-discover a barrel_mcp deployment's authorization server end-to-end by parsingWWW-Authenticateand followingresource_metadata. - New
barrel_mcp_prm_handlercowboy handler. Two new helpers exported frombarrel_mcp_http_stream:normalize_resource_metadata/1,inject_resource_metadata_url/2(legacybarrel_mcp_httpreuses them). - New CT cases
prm_endpoint_serves_metadataandbearel_challenge_includes_resource_metadatainbarrel_mcp_http_stream_security_SUITE.
- New client emitter for
notifications/roots/list_changed. Hosts that mutate their roots afterinitialize(user opened a new workspace, granted access to a new directory, …) call this so the server picks up the change without polling. The server may follow up withroots/listagainst the host's handler. - The server-side dispatch hook (
application:set_env(barrel_mcp, roots_changed_handler, {Mod, Fun})) was already in place; this PR closes the inverse direction. - New
test/barrel_mcp_client_roots_SUITEintegration test stands up a real Streamable HTTP server with a roots-changed handler that forwards to the test process; asserts the notification round-trips end-to-end.
- Registered
resource_templateentries are now matched against incomingresources/readURIs and routed to the template's handler. Previously templates only appeared inresources/templates/list; reading any URI matching one returnedResource not found. - New
barrel_mcp_uri_templatemodule implementing RFC 6570 Level 1 (simple{var}expansion).match/2returns a binary-keyed map of substituted values;expand/2is the inverse. 13 eunit cases cover single / multi-variable, literal-only, malformed templates, and round-trip. - The substituted variables flow into the handler's
Argsmap alongside the original requestparams, so afile:///{path}template matched againstfile:///etc/hostslets the handler read<<"path">>. - Direction A of the Python interop suite reads
file:///etc/hostsagainst thefile:///{path}fixture template and asserts the handler's expandedpathvalue round-trips.
1.2.0 - 2026-05-03
A large release that consolidates everything since 1.1.0:
hardened security on both HTTP transports, full server-side
spec parity for MCP 2025-11-25 (including the new tasks/*
surface and the three server-to-client primitives), the
agent-host story (federation registry, multi-server aggregator,
LLM provider tool-shape bridge), a Python interop harness that
exercises every wire surface against mcp 1.27.0 in both
directions, server-side cursor pagination on every */list
endpoint, the OAuth Client Credentials grant from
modelcontextprotocol/ext-auth, and three runnable example
apps. The default protocol_version env is now 2025-11-25
(was 2025-03-26).
Breaking wire-level changes since 1.1.0: hosts that produced or consumed these envelopes need to update:
notifications/tasks/changedwas renamed tonotifications/tasks/status(the spec method name).tools/callforlong_running => truetools wraps the immediate response asCreateTaskResult({<<"task">> => Task}) instead of the flat{taskId, status}.- Task envelopes use
lastUpdatedAt(wasupdatedAt) and include attlfield (alwaysnullfor now). tasks/cancelreturns the cancelledTaskinstead of{}.tasks/resultreturns aCallToolResult({<<"content">>, <<"structuredContent">>?}) instead of the raw stored value.- Task status vocabulary is now
working | completed | failed | cancelled(wasrunning | success | error | cancelled). - Task timestamps are RFC 3339 strings (were integer milliseconds).
initializeadvertisestasks.list/get/cancel/resultas objects, not bare booleans.- POST
tools/callclients on Streamable HTTP must now list bothapplication/jsonandtext/event-streaminAccept(or*/*). Originis structurally validated on every Streamable HTTP method; public binds require explicitallowed_origins.barrel_mcp_http_streamdefaults to loopback ({127, 0, 0, 1}).- Top-level JSON-RPC arrays (batch requests) are rejected with
-32600. - JSON-RPC
idMUST be a string or integer;nulland other shapes are rejected.
wait_for_tool/2now does a 50ms lookahead after every tool outcome to absorb a pending{cancelled, _}message that races with the worker's response. A cooperative arity-2 handler that returns{tool_error, ...}on cancel could deliver its outcome to the waiter's mailbox before the session-emitted{cancelled, _}, depending on scheduler, which made the HTTP path emit a JSON-RPCisError: trueenvelope instead of the spec-mandated 200 + empty body. With the lookahead the cancel always wins.
barrel_mcp_client_auth_oauthnow supports the OAuth 2.1client_credentialsgrant for unattended agent hosts. Passauth => {oauth_client_credentials, Config}on the connect spec; required keys aretoken_endpointandclient_id, plus eitherclient_secret(HTTP Basic per RFC 6749) orclient_assertion(private_key_jwt, RFC 7523). Optionalscopes,resource.- New public exchanger
barrel_mcp_client_auth_oauth:client_credentials/2for direct use outside the auth-handle flow. - The library fetches the token eagerly during
init/1(so a misconfigured client fails fast) and re-acquires via the same grant on every 401, no refresh_token involved. Reuses the existing PRM + AS metadata discovery code. - Implements the OAuth Client Credentials extension from
modelcontextprotocol/ext-auth. The Enterprise-Managed Authorization extension (token-exchange + JWT bearer assertions) is left for follow-up; ask if you need it.
guides/features.md's roadmap section called out a "periodic deadline timer" and "client-sideLast-Event-IDresume" as missing. Both turn out to be either by-design (default request timeout already bounds every call; explicitinfinityis a deliberate caller choice) or already shipped (the transport'sreopen_sseloop preservessse_last_event_idacross server-initiated SSE closes, and a full client restart re-initializes the session anyway). Replaced the roadmap section with notes explaining each.guides/tools-resources-prompts.mdnow calls out thatresources/subscribeis scoped to the callingMcp-Session-Id: when a client re-initializes, the new session id has no carry-over subscriptions and must subscribe again. Matches the spec's session-lifecycle model; previously implicit.
- New example app showing the
barrel_mcp_agentaggregator + router end-to-end.agent_host:run/0connects two clients to one in-process MCP server under differentServerIds, callsbarrel_mcp_agent:list_tools/0to surface the namespaced catalog, and routes a<<"beta:echo">>call through the right client. CT case asserts the namespaced names appear and the routed result round-trips. - Closes the docs loop for
barrel_mcp_agent(the module shipped without a runnable example). - Picked up by
make examples-testautomatically (the existingfor ex in examples/*/loop).
tools/list,resources/list,resources/templates/list,prompts/list, andtasks/listnow accept an opaquecursorparameter and emitnextCursorwhen more entries remain. Page size is 50, sorted by name (ortaskIdfor tasks). Existing single-shot callers see the first page transparently.- Direction A of the Python interop suite registers 60 dummy tools and walks
tools/listviacursoruntil exhausted, asserting at least onenextCursorwas emitted, no duplicates across pages, and that all fixture tools are visible across the walk.
- Direction A's
list_tools,list_resources,read_resource,list_prompts,get_prompt,list_resource_templates, andcompletenow assert the actual field values returned by the Erlang server (descriptions, mime types, prompt argument names, content text, completion suggestions) instead of just presence checks. Same for Direction B against the Python FastMCP server.
Direction A (Python client → Erlang server) now exercises every wire surface defined by the MCP spec:
ping,prompts/get,resources/templates/list,completion/complete.- Tool result variants:
structuredContentandisError: true. notifications/tools/list_changed(auto-emitted byreg/unreg).notifications/cancelledend-to-end: start a long-running task, cancel mid-flight viaexperimental.cancel_task, verify the cooperative arity-2 worker observes{cancel, RequestId}in its mailbox.notifications/tasks/statuscaptured via themessage_handlerwhile running other long-running tools.
Direction B (Erlang client → Python FastMCP server) now exercises:
tools/list,tools/call,resources/list,resources/read,prompts/list,prompts/get,ping.
Together with sampling / elicitation / roots / progress / subscribe / tasks already covered, every spec wire surface is now verified against the reference SDK on every CI run.
- The Task status-change notification was emitted under method
notifications/tasks/changed. The reference Python SDK'sServerNotificationdiscriminated union uses the spec method namenotifications/tasks/status. Renamed everywhere to match. Hosts that subscribed tonotifications/tasks/changedneed to switch to the new name.
- Direction A of the Python interop suite now exercises
notifications/progressend-to-end. A server-sideprogress_echotool emits three progress events through the arity-2 handler'sCtx.emit_progress; the reference Python SDK auto-attaches a progress token oncall_tooland routes the inbound notifications to aprogress_callback. Verifies the progress token plumbing, SSE delivery of progress notifications, and the SDK's progress dispatch.
- Wire change.
tools/callforlong_running => truetools now returns the spec-shapedCreateTaskResultenvelope{<<"task">> => Task}(the full Task object with taskId, status, createdAt, lastUpdatedAt, ttl) instead of the flat{taskId, status}shape that was rejected by the reference Python SDK with a pydantic ValidationError. Hosts that previously readResult.taskIdneed to readResult.task.taskId. - Wire change. The task collector now stores tool results as the spec-shaped
CallToolResult({content, structuredContent?}) instead of the raw value, sotasks/resultreturns a payload that decodes asCallToolResultagainst the reference SDK. Previouslytasks/resultcould surface bare strings, which the JSON-RPC envelope validator rejected. - Direction A of the Python interop suite now exercises the full long-running flow end-to-end:
experimental.call_tool_as_task→ pollexperimental.get_taskuntilcompleted→ fetchexperimental.get_task_result(..., CallToolResult). Both wire shapes above are now validated againstmcp 1.27.0's pydantic models on every CI run.
- Direction A now exercises the remaining two server-to-client primitives end-to-end against the reference SDK: a server-side tool calls
barrel_mcp:elicit_create/3(form-mode payload), the Pythonelicitation_callbackreturns anacceptaction with a fixed colour, and the tool surfaces that colour as text. Same shape forroots/list: a tool callsbarrel_mcp:roots_list/1, the Pythonlist_roots_callbackreturns one fixed root, and the tool surfaces its name. With sampling already covered, every server-to-client primitive is now wire-validated against the reference implementation on every CI run.
- Direction A of the Python interop suite now exercises the full sampling round-trip against the reference SDK: a server-side tool calls
barrel_mcp:sampling_create_message/3, the Pythonsampling_callbackreturns a canned reply, the tool surfaces that text as its result. Verifies that the pending-request map, SSE delivery, response correlation, and capability gating all interoperate with the reference implementation.
- Direction A of the Python interop suite now exercises the full subscribe round-trip: subscribe to a URI, trigger a server-side
notify_resource_updated/1, wait for the inboundnotifications/resources/updatedto arrive on the client SSE stream, unsubscribe. Verifies thatbarrel_mcp_session:subscribe_resource/2,notify_resource_updated/1, and the SSE delivery path all interoperate correctly with the reference implementation's notification handling.
- Renamed the wire field
updatedAt→lastUpdatedAton every Task envelope (tasks/get,tasks/list,notifications/tasks/changed). The reference Python SDK models the field aslastUpdatedAt, and accepts no other name. - Added a
ttlfield to every Task envelope (alwaysnullfor now, we don't yet honour client-supplied TTLs). The Python SDK'sTaskmodel requires the field to be present. tasks/cancelnow returns the cancelled Task instead of{}. MatchesCancelTaskResultin the reference SDK; existing barrel_mcp clients that pattern-match{ok, _}are unaffected.- Extended the Direction A interop test to call
experimental.list_tasks(), exercising the Task wire shape end-to-end against the reference pydantic models.
initializenow advertisestasks.list/tasks.get/tasks.cancel/tasks.resultas the spec-shaped empty objects (#{}) instead of baretruebooleans. Caught by the new Python interop tests; the reference Python SDK rejectsboolfor these fields with a pydanticValidationError.listChangedstays a boolean (the spec keeps that one as bool).
- New
test/interop/directory pairing a Python MCP client (client.py, Streamable HTTP) and a Python FastMCP server (server.py, stdio) with the officialmcpSDK pinned to~= 1.27.0. - New
test/barrel_mcp_python_interop_SUITECommon Test suite drives both directions: Python client → Erlang server (initialize, list_tools / call_tool, list_resources / read_resource, list_prompts, set_logging_level), and Erlang client → Python server (list_tools, call_tool round-trip). make interop-setupcreates the venv,make interop-testruns the suite. The CT cases skip cleanly whenINTEROP_PYTHONis unset, so the defaultrebar3 ctloop is unaffected by missing Python tooling.- New
interopCI job runs both directions on Linux with Python 3.12 + OTP 28.
- New module sitting on top of
barrel_mcp_clients. Aggregatestools/listacross every connected MCP client, rewrites each tool name to<<"ServerId<sep>ToolName">>(default separator:), and routes a namespacedcall_tool/2,3back to the correct client. to_anthropic/0,1andto_openai/0,1return the aggregated catalog directly in provider format, ready to hand to a model.- Closes the orchestration gap for hosts running an agent loop against multiple MCP servers.
- New module bridging MCP tool definitions and the tool shapes the LLM provider APIs expect.
to_anthropic/1,to_openai/1translate MCPtools/listentries (single map or list) to the Anthropic Messages API and OpenAI Chat Completions tool shapes.from_anthropic_call/1,from_openai_call/1translate a model's tool-call back to the(Name, Arguments)pairbarrel_mcp_client:call_tool/4expects. Accepts both parsed arguments (already a map) and the wire form (JSON string).- Closes the bridge an agent host needs when it hands MCP tools to a model and routes the model's tool calls back through an MCP client.
- Resource handlers may now return a list of pre-built content blocks; each block is passed through verbatim, with
uriauto-injected when the handler omits it. - The
#{text := _}and#{blob := _, mimeType := _}map shapes accept optionalmimeType(text only, blob already requires it) andannotationskeys, matching the spec's per-content metadata. Both flow through to the wire undermimeType/annotations.
reg_tool/4,reg_resource/4,reg_prompt/4, andreg_resource_template/4accept a newannotationsoption, a free-form map surfaced verbatim underannotationsin the matching*/listpayload. The MCP spec definesreadOnlyHint/destructiveHint/idempotentHint/openWorldHintfor tools, andaudience/priorityfor resources, prompts, and templates. Registrations without annotations omit the field on the wire.
- The previous
logging/setLevelhandler was a no-op stub that accepted any payload and returned{}. It now validates the requested level against the eight RFC 5424 levels (debug, info, notice, warning, error, critical, alert, emergency), persists the chosen level on the session, and rejects unknown levels with-32602. - New
barrel_mcp:notify_log/3,4façade. Emitsnotifications/messageto a session and is silently dropped when the event level is below the session's configured level. Default session level isinfo, matching the spec. - New helpers
barrel_mcp_session:set_log_level/2,get_log_level/1,log_level_priority/1.
- New
barrel_mcp:roots_list/1,2façade. Sendsroots/listto the connected client behind a session id and returns the host's roots. Requires the client to have declaredrootscapability in itsinitializerequest and an active SSE stream. - New helpers
barrel_mcp:list_sessions_with_roots/0,barrel_mcp_session:has_roots/1,barrel_mcp_session:list_roots_capable/0.
- New
barrel_mcp:elicit_create/3façade. Sendselicitation/createto the client behind a session id and blocks until the client responds (ortimeout_mselapses, default 30s). Requires the client to have declaredelicitationcapability in itsinitializerequest and an active SSE stream. Mirrors the existingsampling_create_message/3flow. - New helpers
barrel_mcp:list_sessions_with_elicitation/0,barrel_mcp_session:has_elicitation/1,barrel_mcp_session:list_elicitation_capable/0. - Internally, the server's pending-request map now carries the response tag, so sampling and elicitation responses route back to the correct caller without colliding.
- Status vocabulary aligned with spec. Internal
running | success | error | cancelledreplaced withworking | completed | failed | cancelledon the wire (intasks/get,tasks/list,notifications/tasks/changed, and the immediatetools/callresponse whenlong_running => true). - RFC 3339 timestamps.
createdAtandupdatedAtare emitted as ISO 8601 strings viacalendar:system_time_to_rfc3339/1instead of integer milliseconds. tasks/resultmethod. New JSON-RPC method to fetch the recorded result for acompletedtask (or the recorded error forfailed); returnsTask not yet completeforworking,Task cancelledforcancelled, andTask not foundotherwise. New client wrapperbarrel_mcp_client:tasks_result/2.- Tasks capability shape. Advertised as
#{list, get, cancel, result, listChanged}instead of the bare#{listChanged}placeholder.
- API-key auth verification.
barrel_mcp_auth_apikey:verify_key/2no longer returnsokfor any HMAC-formatted stored value (it was self-comparingStoredwith itself). The 2-arity helper now rejects HMAC formats with{error, pepper_required}; a newverify_key/3takes the pepper and does a constant-time HMAC compare. The provider state now keepspepper, sohash_keys => truewith a configured pepper actually verifies HMAC keys end to end. - Async tools/call works on stdio and legacy HTTP.
barrel_mcp_protocol:handle/2returns{async, AsyncPlan}fortools/call; both transports now drive the plan via the newbarrel_mcp_protocol:drive_async_plan/2helper. Tool calls over stdio went from broken to functional. - Session cleanup no longer self-calls. The cleanup timer in
barrel_mcp_sessionpreviously routed throughgen_server:call(?MODULE, ...)from inside its ownhandle_infoand would deadlock. The cleanup is now inlined inhandle_info(cleanup, _). - Basic auth unknown-user timing. The unknown-user fake check now runs the same PBKDF2 work as the configured-user path via a precomputed dummy hash. Previously the configured
hash_passwords => falsepath used a fast SHA-256 compare while the unknown-user path always did PBKDF2, leaking username existence. - Streamable HTTP: Accept strictness. POST clients must list both
application/jsonandtext/event-stream(or*/*).application/jsonalone now returns 406. - Streamable HTTP: initialize with unknown session id → 404. Previously silently created a fresh session; now forces the client to re-initialize without a session header.
- Legacy HTTP transport hardened. Reuses the Streamable HTTP
validate_origin/2,cors_response_headers/3, andextract_headers/2helpers. No more wildcardAccess-Control-Allow-Origin; auth headers come from the configured provider'sauth_headers/1callback (customheader_nameflows through CORS and into header extraction). tasks/cancelactually stops the worker. Long-running tools now record their worker pid on the task;tasks/cancelsends{cancel, RequestId}to the worker before transitioning the stored status. Cooperative arity-2 handlers can abort cleanly; arity-1 handlers still run to completion but their result is dropped because the task is in a terminal state.
- Long-running tools return a
taskIdimmediately; clients track them viatasks/list,tasks/get,tasks/cancelandnotifications/tasks/changed. Opt in withreg_tool/4'slong_running => true. - Tools can return structured output via
{structured, Data}or{structured, Data, Content}; the response includesstructuredContent. Opt-invalidate_output => trueschema-checks the output and surfaces failures asisError: true. completion/completeis backed by a registry. Hosts callbarrel_mcp:reg_completion(Ref, Mod, Fun, Opts)to provide suggestions for prompt or resource-template arguments. Thecompletionscapability is advertised when at least one is registered.- Tool, resource, prompt, and resource-template registrations accept
titleandicons; the matching*/listresponses surface them. - Streamable HTTP keeps a per-session ring buffer of recent SSE events. Reconnecting clients with
Last-Event-IDget every event newer than that id replayed before live mode; an out-of-window id yields a syntheticnotifications/replay_truncated. Buffer size configurable viastart/1'ssse_buffer_size.
- Server protocol bumped to
2025-11-25.initializenegotiates with the client: when the client requests a version we speak, we echo it; otherwise we reply with our preferred version. Capabilities advertised ininitializenow includelistChanged: trueontools,resources, andprompts. - Async tool execution.
barrel_mcp_protocol:handle/2returns{async, AsyncPlan}fortools/call; the transport invokes the spawn closure to start a worker, records the in-flight entry, and waits on its mailbox. Tool handlers may export arity 1 (legacy) or arity 2 ((Args, Ctx): the new shape that receives session/progress context). notifications/cancelledwired end-to-end. Inbound cancel finds the in-flight worker viabarrel_mcp_session:cancel_in_flight/2, sends{cancel, RequestId}to the worker and{cancelled, RequestId}to the waiter. Per the MCP spec the cancelled HTTP request closes with 200 + empty body; no JSON-RPC response is emitted.notifications/progressemit + handler context. New façadesbarrel_mcp:notify_progress/3,4. Arity-2 tool handlers receiveCtxwith anemit_progressfunction bound to the session's progress token, so they can emit progress without knowing about sessions.notifications/roots/list_changeddispatch hook. Configurable viaapplication:set_env(barrel_mcp, roots_changed_handler, {Mod, Fun}).. No-op when unset.resources/templates/listreal registry. Newbarrel_mcp:reg_resource_template/4,unreg_resource_template/1,list_resource_templates/0. The protocol method now returns the registered templates instead of an empty stub.- Server-side input validation.
reg_tool/4acceptsvalidate_input => true; the registry runsbarrel_mcp_schema:validate/2against the tool'sinput_schemabefore invoking the handler. Failures surface to the client asisError: truecontent. - Tool error reporting via
isError: true. Handlers may return{tool_error, Content}; the transport wraps it as#{<<"content">> => Content, <<"isError">> => true}. */list_changednotifications.barrel_mcp_registry:reg/4,5andunreg/2automatically broadcast the matchingnotifications/<kind>/list_changedenvelope to every active SSE session. Newbarrel_mcp:notify_list_changed/1for out-of-band catalogue changes.- Auth hardening.
barrel_mcp_auth_basic:hash_password/1,2now defaults to PBKDF2-SHA256 (100k iterations, random salt). Stored formatpbkdf2-sha256$<iters>$<b64(salt)>$<b64(hash)>. Publicverify_password/2accepts the new format and the legacy hex SHA-256 digest (the latter logs a deprecation warning).barrel_mcp_auth_apikey:hash_key/2adds an HMAC-SHA-256 keyed format (hmac-sha256$<b64(hash)>). Publicverify_key/2honours both formats with constant-time comparison.
- Origin validation. Streamable HTTP and the legacy
barrel_mcp_httpnow validate theOriginheader on POST/GET/DELETE/OPTIONS usinguri_string:parse/1(structural scheme/host/port match, no binary prefix matching). New optionsallowed_originsandallow_missing_origin. The literalOrigin: nullvalue is treated as a distinct present origin and is rejected unless explicitly allowed. - Default bind to loopback. Both transports default to
{127, 0, 0, 1}. Public binds require an explicitallowed_origins; the start function refuses with{error, allowed_origins_required}otherwise. - CORS tightening.
Access-Control-Allow-Originnow echoes the validatedOrigin(no wildcard) withVary: Origin, and is omitted entirely when noOriginis sent. TheAccess-Control-Allow-Headersallow-list is derived from the configured auth provider via a new optionalauth_headers/1callback onbarrel_mcp_auth. Custom API-key header names are honoured both in CORS and inextract_headers. - Streamable HTTP response shape. Notifications and POSTed responses to server-initiated requests now return 202 Accepted with empty body. Missing
Mcp-Session-Idon a non-initialize request returns 400 Bad Request; unknown/invalid id returns 404 Not Found.initializeis the only request that may run without a session. MCP-Protocol-Versionserver validation. Present-but-unsupported header → 400 with the supported list. Missing header on a session that has completed initialize falls back to the session-stored negotiated version. Pre-init / no session falls back to2025-03-26per spec compatibility guidance. New?MCP_SUPPORTED_VERSIONSmacro.- JSON-RPC id strictness.
barrel_mcp_protocol:handle/2anddecode_envelope/1now reject ids that are notbinaryorinteger(includingnull) with-32600 Invalid Request. - Batch rejection. Top-level JSON arrays are explicitly rejected with
-32600 Batch requests are not supportedat both the HTTP boundary and insidehandle/2. - ETS visibility.
barrel_mcp_sessions,barrel_mcp_resource_subs, andbarrel_mcp_pending_requestsare nowprotected. Every public mutator onbarrel_mcp_session(create, update_activity, delete, set_client_capabilities, set_protocol_version, set_sse_pid, subscribe_resource, unsubscribe_resource, deliver_response, cleanup_expired) routes through the gen_server. - New
test/barrel_mcp_http_stream_security_SUITE.erlcovers Origin matching, session lookup, version validation, response shape, batch / id strictness, and ETS protection.
- Spec-conformant MCP client (
barrel_mcp_client)- Rewritten as a
gen_statem(connecting→initializing→ready→closing). - Async transports forward inbound JSON-RPC envelopes as
{mcp_in, _, _}messages. - Streamable HTTP client transport (
barrel_mcp_client_http): POST withapplication/json, text/event-stream, parses SSE from POST and from a long-lived GET stream, capturesMcp-Session-Id, sendsMCP-Protocol-Versionafter init, DELETE on close, 401 retry through pluggable auth. - Stdio client transport (
barrel_mcp_client_stdio) extracted into its own gen_server. - Targets MCP
2025-11-25and negotiates downward through2025-06-18,2025-03-26,2024-11-05. - Server-initiated requests/notifications routed through a
barrel_mcp_client_handlerbehaviour with sync, error, and async reply forms; default no-op handler ships inbarrel_mcp_client_handler_default. - Capability-shaped initialize payload (booleans become spec objects on the wire).
- Resource subscription notifications routed back to the subscribing process.
- Pagination, cancellation, and progress-token plumbing on
tools/call.
- Rewritten as a
- Federation registry (
barrel_mcp_clients): one supervised connection per server id, looked up viabarrel_mcp:start_client/2,whereis_client/1,list_clients/0,stop_client/1. - Auth behaviour (
barrel_mcp_client_auth) with a static-bearer implementation; OAuth 2.1 + PKCE planned for a follow-up. - JSON-RPC envelope helpers (
encode_request/3,encode_notification/2,encode_response/2,encode_error/3,decode_envelope/1) shared between client and server. - New tests:
barrel_mcp_client_tests(loopback handshake / call_tool / version downgrade),barrel_mcp_client_handler_tests,barrel_mcp_clients_tests,barrel_mcp_protocol_envelope_tests. - New doc:
guides/features.mdsummarising the client surface and roadmap.
notifications/initializedis now the spec name; legacy bareinitializedstill accepted for one release.- CORS on
barrel_mcp_http_streamexposesmcp-protocol-versionandlast-event-id.
barrel_mcp_pagination:walk/1,2: cursor walker shared by every*/listpaged helper, with a configurable max-pages guard.barrel_mcp_client:list_tools_all/1,list_resources_all/1,list_resource_templates_all/1,list_prompts_all/1: walk every page and return the union.barrel_mcp_schema:validate/2: minimal JSON Schema validator covering type/properties/required/enum/items/oneOf/anyOf/allOf/min-max-length/pattern/min-max-items/uniqueItems/min-max/exclusive bounds. Returnsokor{error, [{Path, Reason}]}. Hosts use it to pre-flight LLM-generated tool args before calling the server.
- Progress dispatch: when a caller passes
progress_tokentocall_tool/4, the client registers the caller pid against that token and routes inboundnotifications/progressto it as{mcp_progress, Token, Params}. The mapping clears automatically when the request settles, is cancelled, or times out. - Periodic ping:
ping_interval(defaultinfinity, opt-in) sendspingwhile inready. Afterping_failure_thresholdconsecutive failures (default3), the connection closes with reasonping_failed.
guides/building-a-client.md: task-oriented walkthrough for hosting MCP clients onbarrel_mcp(transport choice, connect spec, lifecycle, capability negotiation, tool calls, server-initiated requests via the handler behaviour, OAuth, federation, schema validation, error reference).guides/internals.md: architecture and behaviour contracts (module map, supervision tree, state machine, message flow, transport/handler/auth contracts, ETS layout, wire format).examples/echo_client/: minimal MCP host that boots a local server, lists tools, callsecho. Common-test suite asserts the round-trip.examples/sampling_host/: host implementingbarrel_mcp_client_handlerto answersampling/createMessage. Common-test suite covers the full server-to-client round-trip.test/snippet_check.escript+test/doc_snippets_SUITE.erl: extracts every```erlangfenced block from the new guides and example READMEs and verifies it compiles. Wired intorebar3 ct.Makefilewithexamples-setupandexamples-testtargets; CI runs example suites on OTP 27 + 28.- Per-function
@docand-specon the public client surface (barrel_mcp_client,barrel_mcp_clients,barrel_mcp_client_handlerexample). - ex_doc sidebar reorganised: client modules grouped, new "Building a Client" / "Client Internals" pages.
barrel_mcp_client_auth_oauth: OAuth 2.1 + PKCE per the MCP authorization spec.- Discovery helpers hosts can use during initial token acquisition:
parse_www_authenticate/1,discover_protected_resource/1(RFC 9728),discover_authorization_server/1(RFC 8414, with OpenID Connect fallback). - PKCE primitives:
gen_code_verifier/0,code_challenge/1(S256),build_authorization_url/2. - Token endpoint:
exchange_code/2(authorization-code grant) andrefresh_token/2(refresh grant). Both honour the RFC 8707resourceparameter and support confidential-client HTTP Basic. - Behaviour implementation that attaches
Authorization: Bearer ...and refreshes transparently on 401 when arefresh_tokenwas supplied.
- Discovery helpers hosts can use during initial token acquisition:
barrel_mcp_client_auth:new({oauth, Config})is now wired through;Configacceptsaccess_token(required),refresh_token,token_endpoint,client_id,client_secret,resource,scopes. The interactive authorization-code redirect step stays a host concern; once the host has tokens it hands them to the client and the library handles refresh.
-
MCP Streamable HTTP Transport (
barrel_mcp_http_stream)- Protocol version 2025-03-26 support for Claude Code integration
- POST with JSON or SSE streaming responses
- GET for server-to-client notification streams (SSE)
- DELETE for session termination
- OPTIONS for CORS preflight
- HTTPS/TLS support
- See
guides/http-stream.mdfor usage
-
Session Management (
barrel_mcp_session)- ETS-based session tracking for Streamable HTTP transport
- Sessions identified via
Mcp-Session-Idheader - Configurable TTL with automatic cleanup (default: 30 minutes)
- SSE stream lifecycle management
-
Custom Authentication Provider (
barrel_mcp_auth_custom)- Simplified interface for custom authentication modules
- Only requires
init/1andauthenticate/2callbacks - Automatically extracts tokens from Bearer and X-API-Key headers
- See
guides/custom-authentication.mdfor usage
- Protocol version updated to
2025-03-26for Streamable HTTP transport - Supervisor now includes session manager child spec
- Added
cryptoto application dependencies
1.0.0 - 2025-12-29
Initial release of barrel_mcp, an Erlang implementation of the Model Context Protocol (MCP) 2024-11-05.
- Tools - Register and call tools with JSON Schema validation
- Resources - Register and read resources with URI-based addressing
- Prompts - Register and retrieve prompts with argument substitution
- Registry - ETS + persistent_term based handler registry for fast lookups
- HTTP Transport - Cowboy-based HTTP server for MCP over HTTP
- stdio Transport - stdin/stdout transport for Claude Desktop integration
- Blocking mode via
start_stdio/0 - Supervised mode via
start_stdio_link/0
- Blocking mode via
- MCP Client - Connect to external MCP servers
- HTTP transport support via hackney
- Tool listing and calling
- Resource listing and reading
- Prompt listing and retrieval
- Pluggable authentication system via
barrel_mcp_authbehaviour - Built-in providers:
barrel_mcp_auth_none- No authentication (default)barrel_mcp_auth_bearer- JWT/Bearer token authentication (HS256 built-in)barrel_mcp_auth_apikey- API key authenticationbarrel_mcp_auth_basic- HTTP Basic authentication
- Scope-based authorization
- Constant-time credential comparison
- Comprehensive EDoc documentation for all public APIs
- HexDocs integration via rebar3_ex_doc
- Guides:
- Getting Started
- stdio Transport
- Authentication
- Tools, Resources & Prompts
- MCP Client
- JSON-RPC 2.0
- MCP 2024-11-05 specification
- Methods: initialize, ping, tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get