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.1.5] — 2026-07-27
Added
OAuth 2.1 authorization-server facade —
Noizu.MCP.Auth.Server. The library now implements an authorization server, because the alternative was worse: Claude Desktop and claude.ai authenticate to an MCP server with dynamic client registration (RFC 7591) or a client-id metadata document, and Authentik has not supported DCR since the request was filed in 2024. The facade owns OAuth client, code and token semantics and delegates authenticating the human to the host's existing IdP login, so the IdP never sees an MCP client.One forward mounts the whole thing:
scope "/oauth" do pipe_through :browser_session # session YES, require_authenticated NO forward "/", Noizu.MCP.Auth.Server.Router, MCPConfig.as_opts() endServer.config/1+%Server.Config{}(raises at boot on an issuer with a path, a resource outside it, or a missing key — every one of which otherwise presents in production as every client silently refusing to authenticate);MetadataPlug(RFC 8414, aliased at/.well-known/openid-configuration),RegistrationPlug(RFC 7591),AuthorizePlug(+ the consent decision),TokenPlug,RevokePlug(RFC 7009),JWKSPlug,ApiKeyTokenPlugandRouter;Client,Tokens,Consent,CIMD(+CIMD.ReqFetcher),Upstream(+Upstream.HostSession,Upstream.OIDC).Storebehaviour with two adapters.Store.ETS(in-memory; mutations run through a GenServer, which is what makes single-use redemption atomic) andStore.Ecto(Postgres, raw SQL, zero Ecto schemas — the library owns no tables). Adapters receive raw codes and tokens and MUST hash them before persisting or comparing.take_authorization_code/2androtate_refresh_token/3are atomic and distinguish "never existed" from "already used" — the second is a replay, and a replay revokes the whole refresh family.Noizu.MCP.Auth.Server.StoreConformanceCaseis the shared battery, including 20-task races on both.priv/liquibase/noizu_mcp_oauth.yaml— the host table template (six tables,subjectas plaintextwith an optional FK block commented out).guides/authorization_server.mdandguides/mcp_client_compatibility.md. The compatibility guide records the verified client matrix and the failures that are silent on the server: Claude offers CIMD only when the metadata advertises both the flag and"none"; Claude Code fails the connection on a rejectedAuthorizationheader instead of falling back to OAuth; Claude egresses from160.79.104.0/21; loopback redirect URIs must be matched port-agnostically.Resource-server verifiers.
Noizu.MCP.Auth.JWTVerifierbinds a mount to a single canonical resource URI: a token minted forhttps://host/mcpis rejected athttps://host/mcp/learningand vice versa, so one mount cannot be used as a confused deputy for its neighbour. The algorithm allowlist comes from config only, never from the token header.Noizu.MCP.Auth.ApiKeyVerifieraccepts a raw API key presented as a bearer token, validated by a host-supplied{module, function}against its own key store.Noizu.MCP.Auth.ChainVerifiertries verifiers in order and takes the first success — one mount serving an interactive agent holding an OAuth token and a headless script holding an API key. Chain failure is uniform: no indication of which link rejected the credential.Noizu.MCP.Auth.Resource— canonical resource-URI normalization and comparison (RFC 8707/9728). Byte-exact matching is the contract; only scheme/host case and the default port normalize. No trailing-slash coercion.Authorization-server security core under
Noizu.MCP.Auth.Server(the rest of the facade — store, clients, tokens, plugs — lands next):PKCE(S256 only, verified against the RFC 7636 test vector),RedirectURI(exact matching, port-agnostic for loopback callbacks because Claude Code binds an ephemeral port; label-boundary host matching, soevil-claude.ainever matchesclaude.ai),SSRF(https-only, IPv4/IPv6/v4-mapped denylist including169.254.169.254, no redirects, 64 KiB cap, 5 s timeout),Secret(PBKDF2-HMAC-SHA256 with an overridable:secret_hasher, SHA-256 token hashes, constant-time compare via:crypto.hash_equals/2),Errors(RFC 6749/8414 codes;error_descriptionnever reflects input), andParams(single-value extraction — a repeated parameter is rejected, not resolved to one arm).Multi-resource protected-resource metadata.
ProtectedResourceMetadataPlugtakes aresources:map of path suffix to per-resource options plusdefault_resource, so one forward answers several RFC 9728 path-inserted suffixes each with its ownresourcevalue. An unknown suffix is a 404, never another mount's document. The document is now served withAccess-Control-Allow-Origin(default*) and answersOPTIONSwith 204 — without CORS, claude.ai's browser-context discovery fails silently.Transport plug options.
origins: :mcp_clients(localhost plus the browser MCP hosts, seemcp_client_origins/0);cors:— answers preflights and, on every response, setsAccess-Control-Expose-Headers: WWW-Authenticate, Mcp-Session-Id, Mcp-Protocol-Version, without which a browser client cannot read the 401 challenge at all and so can never start OAuth;auth[:scope], advertised in the challenge; and a derivableauth[:resource_metadata], accepting a binary,{module, function}(called with the conn),{module, function, args}, a 1-arity fun, or:derive(built from the request and the forward's mount path). Mounting withoutauth:now logs a warning in:prod.Noizu.MCP.Auth.WWWAuthenticate.bearer_challenge/1— builds a challenge from a keyword list, droppingnilvalues.
Known test failures
Running the full suite (see guides/authorization_server.md) reports exactly
one failure:
| Test | Status |
|---|---|
Noizu.MCP.StdioE2ETest "handshake, tools/list, tools/call over a real subprocess" | Known; stdio transport work in progress separately. Only appears under --include e2e. |
Anything else is a real regression. There are no known intermittent failures.
Fixed
Store.Ecto.revoke_access_token/2no longer reports a revocation it did not perform.:track_access_tokensdefaults tofalse, so a caller reaching the adapter directly — an admin "sign out everywhere", a purge job — without threading the option got:okback while nothing was revoked. Silent-success revocation is a security claim, not a convenience. An absent option now raises with an actionable message; an explicitfalsekeeps its deliberate no-op. Scoped to this function:access_token_revoked?/2still answersfalseon absence (the documented "untracked is not revoked" degradation, matchingStore.ETS, which callers fail closed on) andpurge_expired/2is unchanged. Callers going throughServer.config/1always have the option threaded and are unaffected.The whole
Storeconformance battery now runs a second time undersubject_type: :uuidagainst realuuidcolumns (test/noizu/mcp/auth/server/store_ecto_uuid_test.exs). Theput_consent/2bug below was one missed coercion out of eight call sites, and a text-only battery structurally could not catch it — a text bind against a text column is valid whether or not it was coerced. Any future write that forgetsdump_subject/2now fails in the library rather than in a host application's authorize leg. Conformance subjects come from the test context; the text pass is unchanged.Store.Ecto.put_consent/2did not encode a uuidsubject. Withsubject_type: :uuid— the shape both first-party host apps run — recording consent was the first statement in the authorize leg to touch the subject column, and it passed the subject through raw. Every authorization request therefore died withDBConnection.EncodeError: Postgrex expected a binary of 16 bytesbefore a code was ever issued, making OAuth against a uuid-keyed host completely non-functional. It was the one subject write of eight that misseddump_subject/2; the codes, refresh-token, access-token and consent read paths all had it. Found by the new Postgres-backed E2E suite below.Three intermittent test failures eliminated. All were test-harness timing artifacts; none was a transport race and none could affect a real client.
StreamableHTTPTestasserted the JSON fast path while the plug'ssse_commit_afterwindow (200ms) could legitimately elapse under full-suite load, upgrading to SSE exactly as designed — the matrix tests now use an explicit generous window, and the timer-driven upgrade, which previously had no deliberate coverage and was reached only by accident, is now pinned by its own test. Both inspector failures shared one cause:collect_untilreturned on a 300ms receive gap, discarding both its deadline and its condition at the first quiet moment in the stream; it now waits for the deadline. A suite that fails at random makes every subsequent "green" unfalsifiable, which is the same defect as a silent skip wearing different clothes.An incomplete test run no longer reports success. The
Store.Ectoconformance battery (gated onMCP_OAUTH_TEST_DATABASE_URL) and every:e2esuite are opt-in, and a plainmix testskipped both in silence — the skipped run and the full run printed the same passing count in the same words. AStore.Ectothat had never performed a singleINSERTshipped behind that number.test/test_helper.exsnow prints a banner naming what did not execute and fails the run viaNoizu.MCP.CoverageGateTest; setMCP_SKIP_FULL_COVERAGE=1to acknowledge a deliberately partial run. The full command is inguides/authorization_server.md:MCP_OAUTH_TEST_DATABASE_URL="postgres://USER:PASS@127.0.0.1:5432/noizu_mcp_test" \ mix test --include e2e --include slowThe authorization-server E2E now also runs against
Store.Ectoon real Postgres, with a uuid subject (test/noizu/mcp/auth/server/e2e_ecto_test.exs). The existing E2E ran againstStore.ETSonly, which exercises none of the SQL and none of the uuid encoding — which is how aStore.Ectodefect reached two mounted applications behind a green suite. The new suite covers discovery → DCR → authorize → token →tools/call→ refresh, the CIMD variant, code and refresh replay, and the headline cross-mount assertion: a token minted for/mcpis rejected at/mcp/learningand vice versa. It failed on its first run, which is how theput_consent/2bug above was found.Store.EctoTestno longer invalidates its own module on teardown. The test repo was dropped from anon_exitcallback, but the repo is already stopped by then; the resulting raise was reported as "failure on setup_all callback" and invalidated all 31 tests in the module — 31 phantom failures per run, in which a real one could hide. Cleanup now happens on the way in, where it is idempotent.Header injection in
WWW-Authenticate.WWWAuthenticate.format/2interpolated parameter values into the header unescaped. Values now go throughescape_quoted/1, which escapes\and"and rejects CR/LF/NUL and other control characters (raising rather than emitting a header whose shape an attacker chose); parameter names are validated as HTTP tokens. The most exposed value is a derivedresource_metadataURL, which can carry whatever theHostheader said.
Changed
- The 401 challenge no longer carries
error="invalid_request"when no credential was presented (RFC 6750 §3.1:errordescribes a failed request, and a client that has not presented a token yet has not failed at anything).error="invalid_token"for a rejected token is unchanged. This is the one behavioral change for existing consumers: a client asserting onerrorin the no-credential 401 needs updating; clients that readresource_metadata— which is every conformant MCP client — are unaffected. {:ecto_sql, "~> 3.11", optional: true}added for the forthcomingStore.Ectoadapter. A no-op for every current consumer.
[0.1.4] — 2026-07-16
Added
Verbosity-leveled descriptions. Anywhere a description string is accepted — a tool's
description:/title:, a toolkit@mcp description:, afield ... description:— a variant list is now also accepted, tailoring the wording to a requested verbosity level (domain0..9,0= tersest):use Noizu.MCP.Server.Tool, description: [ {{:verbosity, {2, 3}}, "Medium description."}, {{:verbosity, 0}, "Terse."}, default: "Definitive fallback text" ]Keys:
{:verbosity, n},{:verbosity, {lo, hi}},{:verbosity, [n, ...]},default:(fallback text), anddefault_verbosity:(annotation-level default level). Bare strings are unchanged and cover every level.Noizu.MCP.Description— normalized variant struct compiled at@before_compile;compile/2validates the domain and rejects malformed keys, out-of-domain levels, duplicate level coverage, and inverted ranges at compile time.resolve/2gap-fills uncovered levels to the nearest covered level (ties prefer the lower level).Noizu.MCP.RenderCtx— render context (verbosity,runner,model,defaults) threaded through every description render site;effective_verbosity/1resolves the defaults chain (built-in default5). Server/global default verbosity viause Noizu.MCP.Server, default_verbosity: Nor the:noizu_mcp, :default_verbosityapplication env.Noizu.MCP.Types.Tool.to_map/2andNoizu.MCP.Server.Tool.Fields.to_json_schema/2take aRenderCtx; the arity-1 forms delegate withRenderCtx.default/0, so single-string tools render exactly as before.tools/listderives the context from session assigns (:render_ctx, or:verbosity/:runner/:model).
Backwards compatible: runner/model are carried but not yet consulted (seam
for per-runner descriptions).
Inline
@evalannotations (description tuning). Attach eval specs to a tool to continuously grade the rendered descriptions it advertises across model × verbosity permutations. Classic tools take anevals:useoption; toolkit functions take an@evalmodule attribute that drains onto the following@mcptool (mirroring how@mcpis collected):@eval name: :simple_task, prompt: [%{role: "user", content: "Read config.exs"}], rubric: [reads_path: "the call passes the requested path"] @mcp description: "Read a file", input: [path: [type: :string, required: true]] def read_file(%{path: path}, _ctx), do: File.read(path)name(atom/string, unique per tool),prompt(message list or string), andrubric(non-empty keyword ofcriterion: "description") are validated at compile time.Noizu.MCP.Eval— eval spec compilation (compile_specs/2) and introspection (list/1→[{tool_name, [%Noizu.MCP.Eval.Spec{}]}]). Eval specs live onNoizu.MCP.Server.Tool.Spec.evalsand are never serialized onto the wire —Types.Tool.to_map/1,2(including_meta) carries no eval content.mix noizu.mcp.eval --server Mod [--tool T] [--runner R --model M] [--verbosity N|all] [--output path.json] [--gate]— the eval harness (Noizu.MCP.Eval.Harness). For each(tool, eval, permutation)it renders the tool schema through the §0/§2/§3 pipeline for thatRenderCtx, runs the prompt via a pluggableNoizu.MCP.Eval.Runner, and grades each rubric criterion via a pluggableNoizu.MCP.Eval.Judge, emitting a JSON report;--gateexits non-zero on any failing criterion.Runner/judge adapters are selected via the
:noizu_mcp:eval_runner/:eval_judgeapplication env. A deterministic no-LLM stub pair (Noizu.MCP.Eval.Runner.Stub/Noizu.MCP.Eval.Judge.Stub) ships for tests/CI; real LLM adapters are app-layer follow-ups. Eval-score persistence (themcp_description_evalstable, spec §4) is a backend follow-up, out of lib scope.
[0.1.0] — 2026-06-13
Initial release. Targets MCP specification revision 2025-11-25 (negotiates down to 2025-06-18; 2025-03-26 is deliberately unsupported — it would require JSON-RPC batching, which later revisions removed).
Server
use Noizu.MCP.Serverwith declarativetool/resource/resource_template/promptregistration; capabilities derived automatically from what you register or implement.- Hidden items:
hidden: trueon any tool/prompt/resource/resource-template definition or registration (visible: falseis an alias for tools) omits it from list responses while leaving it callable by name;include_hidden:on theFeatures.*.list_registeredhelpers enables session-gated listings, and the built-inNoizu.MCP.Server.Tools.Catalogdiscovery tool exposes full definitions of unpublished items to agents. - Toolkits:
use Noizu.MCP.Server.Toolkitdefines many tools in one module via@mcpfunction annotations (arity 0–2), with data-form input/output specs or raw JSON Schemas; registration opts (hidden:/visible:/category:) apply to the whole kit. All tool modules share one runtime protocol —__mcp_tools__/0returning normalizedNoizu.MCP.Server.Tool.Specdescriptors. - Category metadata:
category: "..."on tools (toolkit default, per-@mcp, classicuseoption, or registration override) rides in_meta.categoryon the wire and is filterable through the catalog tool. - Compile-time
input/outputfield DSL compiling to JSON Schema (2020-12), validated with JSV; handlers receive atom-keyed, default-applied, enum-cast arguments. Raw JSON Schema escape hatch (input_schema %{...}), also accepted as raw JSON text decoded at compile time. - Input-validation failures return
isError: truetool results per SEP-1303 so models can self-correct. - Resources with RFC 6570 templates, subscriptions and fan-out
(
notify_resource_updated/1), prompts with arguments, completion, pagination with opaque cursors,logging/setLevel, list-changed notifications (notify_changed/1). - Behaviour-only escape hatch: every DSL-generated callback
(
handle_list_tools/2,handle_call_tool/3, …) can be hand-written. - Handlers run in supervised Tasks — slow tools never block ping, cancellation, or progress; crashes are sanitized.
Noizu.MCP.Ctx: progress, logging, cancellation checks, per-session state, and server-initiatedsample/2,elicit/3,list_roots/1.
Inspector
mix mcp.clientMix task launchingNoizu.MCP.Inspector— a native localhost-only HTML MCP client analogous to the officialmcp devtool. Supports three target modes: in-processuse Noizu.MCP.Servermodule, stdio subprocess (with--cd/--env), and remote Streamable HTTP (--url/--bearer).- Browser UI (vanilla ES modules, no build step) with tabs: Connection, Tools (JSON-Schema-generated forms, inline progress, cancel), Resources (read, subscribe, template expansion + completion), Prompts (args + completion, message preview), History (raw JSON-RPC frame log), Notifications, and Pending.
- Pending tab parks server-initiated sampling and elicitation requests for human-in-the-loop responses; tool calls run with infinite timeout while parked.
- REST + SSE bridge with per-session 500-event ring buffer (
Last-Event-IDreplay) and seven SSE event types:frame,notification,progress,call_result,pending_request,pending_resolved,status. - Config export endpoint produces
claude_desktop-style entries for the current target. - Security: binds
127.0.0.1only; random 256-bit bearer token per run required on every/apicall; localhostOrigincheck on SSE; module targets resolve only already-loaded atoms. Noizu.MCP.Inspector.start_link/1for programmatic embedding.
Client
Noizu.MCP.Client: sync calls, async request handles with cancel, per-request timeouts, progress callbacks, automatic pagination.Noizu.MCP.Client.Handlerbehaviour answering server-initiated sampling, elicitation, and roots requests.
Transports
- stdio (server and client) with automatic Logger-to-stderr diversion on the server side.
- Streamable HTTP server as a Plug (Phoenix-mountable or standalone on
Bandit): sessions, adaptive JSON↔SSE responses, general GET stream,
Last-Event-IDresumability backed by a bounded event store, origin validation, DELETE teardown. - Streamable HTTP client on Req: ordered POSTs, SSE streaming, GET stream with reconnect/resume.
- In-memory
Noizu.MCP.Transport.Testpair plus theNoizu.MCP.Testhelper module (async-safe ExUnit testing).
Authorization (OAuth 2.1)
- Resource-server enforcement:
Noizu.MCP.Auth.TokenVerifierbehaviour,WWW-Authenticatechallenges,insufficient_scope, RFC 9728 protected-resource metadata plug. - Client strategies:
Noizu.MCP.Auth.Static(bearer) andNoizu.MCP.Auth.OAuth(RFC 9728 + RFC 8414/OIDC discovery, PKCE S256, RFC 8707 resource indicators, refresh, scope step-up) with a host-appauthorize_usercallback for the browser leg.