The broker can act as an OAuth 2.1 resource server, aligned with the
MCP Authorization specification
(2025-06-18). This lets you expose it publicly: every HTTP/WebSocket client
request must then carry a valid bearer token, providers must authenticate to
occupy a slot, and the _all aggregate is filtered per caller.
Authorization is opt-in. With no
authconfiguration the broker performs no authentication and behaves exactly as before, appropriate only behind a trusted network boundary (loopback, private LAN, sidecar). The MCP spec itself classifies authorization as OPTIONAL.
Two independent concerns:
| Concern | Who | Mechanism |
|---|---|---|
| Client authorization | MCP clients calling a slot | OAuth 2.1 bearer token (resource server) |
| Provider authentication | Engines connecting to serve a slot | Shared secret (or a custom authenticator) |
Out of scope (by the spec): the stdio transport (credentials come from the environment) and in-process loopback clients (the broker’s own components).
OAuth scopes remain available as coarse gates. For industrial namespace authorization based on who, what, and where, see Hierarchical authorization.
The broker is only a resource server. It never issues tokens: an external authorization server (AS) does that, and the broker just advertises it and validates the tokens presented to it.
Each slot exposed over HTTP is a distinct protected resource. Its canonical
resource identifier (RFC 8707) is the slot’s /mcp endpoint:
<publicBaseUrl>/<slot>/mcp
That identifier is what the client must request a token for (the resource
parameter) and what the broker checks the token’s aud claim against. A token
minted for one slot cannot be replayed against another, or against a different
service.
The broker never forwards a client’s token to an upstream provider: no token passthrough. Upstream/remote providers keep their own credentials.
Standard RFC 9728 / OAuth 2.1 discovery:
Client Broker (resource server) Authorization Server
| request without token | |
|-------------------------------->| |
| 401 + WWW-Authenticate | |
|<--------------------------------| |
| GET .well-known/… | |
|-------------------------------->| |
| Protected Resource Metadata | |
|<--------------------------------| |
| discover AS metadata, run OAuth 2.1 (PKCE) |
|--------------------------------------------------------------------->|
| access token (aud = <base>/<slot>/mcp) |
|<---------------------------------------------------------------------|
| request WITH bearer token | |
|-------------------------------->| |
| MCP response | |
|<--------------------------------| |
Served unauthenticated (it is public discovery data) at:
GET <publicBaseUrl>/.well-known/oauth-protected-resource/<slot>/mcp
{
"resource": "https://mcp.example.com/weather/mcp",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["mcp:call"],
"bearer_methods_supported": ["header"]
}
On a missing or invalid token the broker replies 401 with an RFC 9728 §5.1
header pointing at that slot’s metadata:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/weather/mcp", error="invalid_token", error_description="Missing bearer token"
On a valid token that lacks the required scope, 403:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer resource_metadata="…", error="insufficient_scope", scope="mcp:call"
The token is only ever read from the Authorization: Bearer header, never from
the query string (OAuth 2.1 §5). The same rules apply to the raw WebSocket
client transport (/<slot>), where the challenge is returned during the upgrade
handshake.
The default validator verifies JWT access tokens statelessly: it checks the
signature against the AS’s JWKS, the issuer, the audience (aud must equal the
slot’s canonical resource), and expiry. No per-request round-trip to the AS, no
shared secret. A pluggable ITokenValidator seam lets a deployment swap in
opaque-token introspection (RFC 7662) or any custom logic without touching the
enforcement code.
Three layers, all optional:
| Setting | Effect |
|---|---|
requiredScopes |
Baseline scope(s) any caller must hold to reach any slot. Empty ⇒ any valid token passes. |
perSlotScopes[slot] |
Override for one slot. E.g. require an admin scope on _broker. |
providerScopes[provider] |
Deprecated. Narrows the _all aggregate by scope and combines restrictively with hierarchical policies. |
_all per-client filtering_all is the content-confidentiality enforcement point. When providerScopes
is set, a client’s view of the aggregate is narrowed to the providers it is
authorized for:
tools/list and prompts/list only return entries from visible providers.tools/call / prompts/get to a provider the caller may not see return the
same “unknown” error as a missing name, so a forbidden provider’s
existence never leaks.A provider not listed in providerScopes stays visible to every authenticated
caller (opt-in per provider).
Note:
_broker’sproviders_listtool is token-gated (and can require an admin scope viaperSlotScopes), but it is not filtered per provider. Use_allwhen you need per-caller content confidentiality.
When roles, assignments, or denies are configured, the broker also
evaluates each MCP operation against a compiled hierarchical policy:
JWT subjects + functional capability + resource path -> allow or deny
Roles define what a caller may do. Assignments bind roles to subjects and resource subtrees. Explicit denies override every allow. Technical slot names can be mapped to stable paths. Industrial examples use a domain-neutral, ISA-95 / IEC 62264-aligned profile such as:
/enterprise/site/area/line/cell/asset
Here, line and cell are compact identifiers for the ISA-95 Production Line
and Work Cell concepts. The final asset is a project-specific extension. The
broker does not claim or enforce full ISA-95 compliance, and UMD-style
namespaces remain mappable to the same resource paths.
The policy engine applies to direct slot transports and to every provider
exposed through _all. Unauthorized aggregate calls use the same error as an
unknown provider or tool. Provider identities may also be restricted to
specific resource subtrees.
The complete model, configuration, validation rules, examples, provider
principals, and providerScopes migration procedure are documented in
Hierarchical authorization.
A separate concern from client authorization: a provider is the backend that
connects into the broker to serve a slot, not an OAuth client acting for a
resource owner. Authenticating it is what stops a stranger from occupying a free
slot (ws://host/provider/<slot>) and impersonating the real engine.
When enabled, every provider connecting to /provider/<slot> (slot-level) or
the multiplexed /providers socket (socket-level) must present a shared secret,
via either header, checked in constant time:
X-Provider-Token: <secret>
# or
Authorization: Bearer <secret>
A failed handshake is rejected with 401 before the connection is accepted.
The default is a single shared secret; swap in a custom IProviderAuthenticator
for per-slot secrets, mTLS, or a signed handshake.
A custom authenticator can return a structured IProviderPrincipal with
allowedResources. The broker then rejects a dedicated registration outside
that namespace before the slot becomes visible. A multiplexed connection checks
each announced slot independently. The shared-secret implementation remains
unrestricted for backward compatibility.
A provider identity may declare an authorization domain
(broker/authorization/declare) and have the broker decide for it. A domain is
a capability vocabulary, so several identities may share it, one subtree each:
/production/site2 next to /production/site1); a subtree that
contains, or is contained in, another identity’s is refused with
namespace "<n>" overlaps "<m>", owned by provider "<id>" in domain "<d>",
because one resource would have two decision makers;capabilities and resultsRequired; a declaration that differs is refused
(capability vocabulary differs from the domain's), so a role means the same
thing on the whole domain. A sole owner may still change it;broker/authorize stays bounded to the asking provider’s own subtree: the
owner of /production/site1 gets no decision about /production/site2
(undeclared-resource);the security file may fix the owners from startup, as protectedSlots fixes
protections, which removes the race for a domain when a site starts:
{ "authorization": { "domains": {
"history": [
{ "owner": "mcp-history-site1", "namespace": "/production/site1" },
{ "owner": "mcp-history-site2", "namespace": "/production/site2" }
] } } }
A listed domain may only be declared by its listed identities, each on its subtree or below. Listed subtrees of two owners that overlap stop the broker at start. An unlisted domain keeps first come, first served. The section grants no caller anything;
broker://domains on _broker lists each domain, its vocabulary and, for
each subtree, the owner, its slot, whether it declared yet (declared),
whether the security file lists it (configured) and whether it is
connected. It shows a reader only the subtrees the policy grants it one of
the domain’s capabilities on, or all of them with broker.authorization.admin
on _broker;(provider identity, slot). Declaring again
replaces only that slot’s declaration. broker/authorize, concrete resource
checks, declared limit patterns, resultsRequired and budgetUnits use the
declaration of the slot the request arrived on;broker/authorization/withdraw with
no parameter, on the slot it declared from (a clean shutdown, or handing the
domain to a new identity), or by an operator with
broker_declaration_withdraw({ principal, slot? }) on _broker, which needs
broker.authorization.admin and is audited with the operator’s subjects.
Either frees every subtree the slot owned, the domains it replaced included;
the subtree is free once no slot of its owner holds it any more. Protected slots
stay closed: they belong to the security file. A declaring provider that stays
disconnected past the threshold (5 minutes by default,
withDeclarationOfflineThreshold()) shows up in broker_diagnose as
declaration-owner-offline;Different domains may use overlapping namespaces. Capability grants remain
qualified by domain: scada.* grants nothing for vannes.*. Policy resource
paths such as /site/** are shared across domains. Operator resourceLimits
apply across slots; provider patterns apply only to their declaring slot.
Each declaration retains its own protects confirmations, so replacing a
sibling declaration does not remove them. Existing declarations protecting
another configured slot remain supported.
Use a domain-qualified native identifier in checks, for example
resource: "vannes:/site/nord/valves/V-012". This is a recommendation, not a
validation requirement. resourcePath remains the path evaluated by policy;
native identifiers also participate in the concrete resource escape check.
Decision and result audit events include the declaration’s slot and domain.
broker_info.authority and broker://authority list both on each declaration.
A domain is a capability vocabulary (<domain>.*), not a place: where a
capability applies is the resource path, assigned by subtree. Several sites are
therefore served by one owner of one domain, not by one domain per site, which
would turn every role into one copy per site.
| Endpoint | Auth when enabled |
|---|---|
POST/GET /<slot>/mcp, /<slot>/sse, /<slot>/messages |
Client bearer token for <slot> |
WS /<slot> |
Client bearer token for <slot> (checked at upgrade) |
GET /.well-known/oauth-protected-resource/<slot>/mcp |
None (public discovery) |
WS /provider/<slot>, WS /providers |
Provider shared secret (if provider auth enabled) |
OPTIONS (CORS preflight) |
None (returns 204) |
_broker is a regular slot: once client auth is on, /_broker/mcp requires a
token like any other, and perSlotScopes["_broker"] can gate it behind an admin
scope.
Authentication and per-frame policy fail at different moments, and say so differently:
| Refusal | When | What the client sees |
|---|---|---|
| Token absent, invalid, or missing a required scope | Before the request reaches the transport | HTTP 401 / 403 with WWW-Authenticate |
| Hierarchical policy denies the operation the frame carries | Once the session is established and the frame is read | HTTP 200 with JSON-RPC { "error": { "code": -32001, "message": "Forbidden" } } |
The second is not an HTTP failure: the transport did its job, the operation was
refused. This is the shape the raw WebSocket transport has always returned, and
on /<slot>/mcp it is now the same, because the session owns the exchange by
the time the frame is inspected. A client must therefore check the JSON-RPC
body, not the status code alone.
See the Node implementation’s config reference
for the concrete auth block, environment variables, and the builder API
(withJwtAuth, withProviderSecret, withAuthorizationPolicy,
withPolicyEngine, withSlotResourceResolver).
A provider can describe instances it cannot enumerate:
"resources": [
{ "resourcePattern": "/nord/valves/{id}", "limits": { "minValue": 0, "maxValue": 100 } },
{ "resourcePattern": "/nord/valves/{id}", "where": { "id": "V-1\\d{2}" }, "limits": { "maxValue": 60 } },
{ "resource": "valve:V-012", "resourcePath": "/nord/valves/V-012", "limits": { "maxValue": 40 } }
]
Patterns support literals, * for one segment, final ** for zero or more segments, and {name} for one named segment. where constrains named segments with RE2 expressions matching the whole segment. Names are unique within a pattern. Patterns must remain inside the declaration namespace and the provider’s allowedResources. A pattern entry cannot also contain concrete identifiers or effect; concrete entries cannot contain where. Invalid declarations are refused atomically, preserving the previous declaration.
Operators independently tighten limits through the security file:
"authorization": {
"resourceLimits": [
{ "id": "maintenance-nord", "pattern": "/nord/valves/{id}", "where": { "id": "V-0\\d{2}" }, "limits": { "maxValue": 80 } }
]
}
Rules may be installed before any provider declares the paths. They grant no rights, apply to checks by callers and providers, and stay active until removed. Changing the file changes its hash and policyVersion; restart to load the new file. Embedded brokers use WsTunnelBuilder.withResourceLimits(rules).
Every applicable concrete entry, declaration pattern and operator rule contributes to the intersection. Minimums take the highest value, maximums the lowest; allowedValues and destinations intersect. Numeric ranges also filter allowed values. An impossible range or empty list denies with reason empty-limits. Policy denials remain denials. A concrete identifier under another path still denies with undeclared-resource.
Successful constrained checks return effect: "allow-with-constraints", allowed: false and obligations.constraints. The provider applies these constraints before acting. broker/budget/reserve uses exactly the same intersection. Audit decisions include limitSources, such as declaration:/nord/valves/{id}, declaration:valve:V-012 and security:maintenance-nord. broker_info.authority and the live broker://authority resource expose the patterns and operator rules. broker_diagnose reports the last 20 limit problems, including empty-limits and invalid-limit-pattern.
Patterns compile when accepted or when configuration loads, and are indexed by their leading literal segments. The performance target of 10 microseconds with 1,000 patterns applies to indexed paths. Rules sharing the same literal prefix still require checking each candidate; npm run bench:limits --workspace @cyanmycelium/mcp-broker measures both cases.