Enabling remote MCP Dynamic Client Registration (zero-config connectors)
Remote MCP connectors — Claude.ai's web connector, the Gemini CLI — discover an
OAuth2 authorization server and then register themselves before they can run
the authorization-code flow. They do this through RFC 7591 Dynamic Client
Registration (DCR): an unauthenticated POST /api/v1/oauth2/register call that
mints a client on the fly. This runbook covers turning that on safely.
DCR is off by default. With it off, behavior is unchanged: the server does
not advertise a registration_endpoint, connectors cannot self-register, and
the only path to a working client is the manual config route (an admin creates an
OAuth2 app and hands the connector its client_id). Turning DCR on is a
deliberate, per-instance trust-boundary decision — read §1 before flipping it.
1. What enabling DCR does (and the trust-boundary change)
When you set oauth2.dcr.enabled=true, two things change:
- Discovery advertises registration.
GET /.well-known/oauth-authorization-server(RFC 8414) starts including aregistration_endpointpointing atPOST /api/v1/oauth2/register. The discovery document is servedCache-Control: no-store, so a connector (or a caching proxy) always sees the current state — flipping DCR off stops advertising the endpoint on the very next fetch, with no stale-cache window. - The server accepts anonymous registration.
POST /api/v1/oauth2/registerbegins honoring unauthenticated RFC 7591 requests.
That second change is the one to understand. Claude.ai's /register call is a
server-side call with no end-user session — there is no cookie, no bearer
token, nothing to authenticate against. So this endpoint cannot be
session-gated: enabling DCR widens the registration trust boundary to anonymous
callers on the public internet. The guardrails in §2 (mcp:read ceiling,
redirect-URI allow-list, per-IP throttle, global cap) are what keep that boundary
safe. Do not enable DCR without them configured.
The same
/api/v1/oauth2/registerendpoint also serves authenticated, admin-IAT (initial access token) registration. That path is not gated byoauth2.dcr.enabledand is unaffected here — this runbook is about the anonymous path thatoauth2.dcr.enabledswitches on.
2. Settings (keys, defaults, what they guard)
All four are standard app_settings keys — set them through the Admin Settings UI
(Admin → Settings → OAuth2 / MCP) or the settings API.
Changes take effect immediately; no restart.
| Setting | Type | Default | What it does |
|---|---|---|---|
oauth2.dcr.enabled |
bool | false |
The kill switch. Off → no registration_endpoint advertised, anonymous registration refused. On → both enabled. |
oauth2.dcr.redirect_uri_allowlist |
host list | claude.ai |
Comma/space-separated hosts. An anonymous registrant's redirect_uri host must exactly match a listed host (case-insensitive) or be an RFC 8252 loopback. |
oauth2.dcr.max_clients |
positive int | 1000 |
Instance-wide cap on dynamically-registered clients. At the cap, registration is rejected (max_clients). |
oauth2.dcr.max_per_hour |
positive int | 20 |
Per-source-IP throttle over a rolling 1 h window. Over-limit → HTTP 429 (rate_limited). |
Allow-list matching is exact-host, not suffix
This is the most important guardrail to get right. The allow-list is an exact, case-insensitive host match — there is no suffix or subdomain matching:
claude.aiadmits aredirect_uriwhose host is exactlyclaude.ai.claude.aidoes not admitevil-claude.ai(suffix tricks) orlogin.claude.ai(subdomain). Each host you intend to trust must be listed explicitly.- Loopback is always allowed, regardless of the list:
http://localhost,http://127.0.0.1,http://[::1], on any port. This is RFC 8252 §7.3 and is what lets local CLI clients in general — any client that listens on a loopback redirect, the Gemini CLI being one example — register without you enumerating ephemeral ports.
Add hosts only as you onboard connectors; keep the list as short as the set of connectors you actually support.
Configure (UI or API)
UI: Admin → Settings, OAuth2 / MCP group, set the four values, Save.
API (admin bearer token; body is a JSON object {"value":"..."} where the value
is always a string — even for ints and bools — the same convention as every
other setting; see the configuration settings reference in admin-docs, e.g.
{ "value": "2048" }):
# Allow-list and caps FIRST, then flip the switch last.
curl -fsS -X PUT https://<vetrix-host>/api/v1/admin/settings/oauth2.dcr.redirect_uri_allowlist \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"value":"claude.ai"}'
curl -fsS -X PUT https://<vetrix-host>/api/v1/admin/settings/oauth2.dcr.max_per_hour \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"value":"20"}'
curl -fsS -X PUT https://<vetrix-host>/api/v1/admin/settings/oauth2.dcr.max_clients \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"value":"1000"}'
# Flip the switch LAST, once the guardrails above are in place.
curl -fsS -X PUT https://<vetrix-host>/api/v1/admin/settings/oauth2.dcr.enabled \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"value":"true"}'
3. Security posture (what an anonymous registrant can and cannot get)
A client minted through the anonymous path is tightly constrained by construction. Document and re-confirm these when you audit the instance:
- Public by default; confidential when explicitly requested. An anonymous
registration that omits
token_endpoint_auth_method(or sends"none") produces a public client — noclient_secretis ever minted for that case, and the authorization-code flow relies on PKCE (S256), not a shared secret. But an anonymous caller that explicitly requestsclient_secret_basicorclient_secret_postis honored: the server registers a confidential client and mints + returns aclient_secretin the RFC 7591 response — this is required for Claude.ai's own zero-config connector, which registers confidential from Anthropic's servers and expects a secret. There is noconfidential_not_allowedrejection for this case. As the handler's own header comment puts it, the security boundary for the anonymous path "is NOT a public-only restriction but the redirect_uri host allow-list, themcp:readscope ceiling, the per-IP throttle, and themax_clientscap" — all four of which are enforced for both client types (below). mcp:readscope ceiling. A DCR'd client may only ever holdmcp:read. An empty scope request defaults tomcp:read; any other scope is rejected (invalid_scope). There is no path for an anonymous registrant to obtain a write or admin scope.- Redirect-URI host allow-list — §2 above.
- Per-IP rate limit + global cap —
oauth2.dcr.max_per_hourandoauth2.dcr.max_clientsfrom §2. - Per-repo audience binding. Tokens issued to a DCR'd client are audience-bound
to the specific MCP resource (
{HTTPBase}/api/v1/mcp/{owner}/{repo}, validated against the RFC 8707resourceparameter) — they are not bearer-anywhere credentials. - Admin-owned. Anonymous-registered clients are owned by the instance admin
(
oauth2_apps.owner_idis set to the admin), which is what makes them visible and manageable in the admin OAuth2 apps view (§5, §6).
4. Verify a connector end to end
After enabling, confirm the full path works before you announce it.
- Discovery advertises registration:
curl -fsS https://<vetrix-host>/.well-known/oauth-authorization-server | \ jq '{registration_endpoint, scopes_supported, token_endpoint_auth_methods_supported}' # expected: registration_endpoint = "https://<vetrix-host>/api/v1/oauth2/register" # and the response is served Cache-Control: no-store (check headers with -i) - An anonymous register succeeds for an allow-listed redirect:
curl -fsS -X POST https://<vetrix-host>/api/v1/oauth2/register \ -H "Content-Type: application/json" \ -d '{"client_name":"verify-probe","redirect_uris":["https://claude.ai/api/mcp/auth_callback"],"token_endpoint_auth_method":"none","scope":"mcp:read"}' | \ jq '{client_id, token_endpoint_auth_method, scope, client_secret}' # expected: a client_id, token_endpoint_auth_method "none", scope "mcp:read", # and NO client_secret field. Delete this probe client afterward (§6). - A non-allow-listed redirect is refused (sanity-check the guard):
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<vetrix-host>/api/v1/oauth2/register \ -H "Content-Type: application/json" \ -d '{"client_name":"verify-probe-bad","redirect_uris":["https://evil-claude.ai/cb"],"token_endpoint_auth_method":"none"}' # expected: 400, and an oauth2.register_reject audit row with reason # redirect_uri_not_allowlisted (§5). - Real connector: add the instance as a remote MCP connector in Claude.ai (or
the Gemini CLI), let it run discovery → register → authorize → consent, and
confirm a tool call returns. The connector should never prompt you for a
client_id.
Delete the probe client from step 2 once verified (§6).
5. Monitoring / audit
Every registration attempt emits an audit event. Watch these to confirm healthy use and to spot abuse. Query through the instance-wide audit log (admin only — see the audit-log reference in admin-docs):
GET /api/v1/admin/audit-log?limit=200
Authorization: Bearer <admin-jwt>
| Action | Meaning | Key fields |
|---|---|---|
oauth2.register |
A client was registered successfully. | actor_type = admin_iat (authenticated admin-IAT path) or anonymous_dcr (zero-config connector); source_ip; redirect_hosts; client_type (public / confidential — for anonymous_dcr this is public when the caller omitted token_endpoint_auth_method (or sent "none"), and confidential when it explicitly requested client_secret_basic/client_secret_post; both are legitimate for this actor_type). |
oauth2.register_reject |
A registration was refused. | reason (below); plus source_ip and redirect_hosts where applicable. |
oauth2.register_reject reasons:
reason |
Trigger |
|---|---|
dcr_disabled |
Anonymous registration attempted while oauth2.dcr.enabled=false. |
max_clients |
The oauth2.dcr.max_clients cap was already reached. |
invalid_redirect_uri |
The redirect_uri was malformed / not a usable URI. |
invalid_scope |
A scope other than mcp:read was requested. |
redirect_uri_not_allowlisted |
An https redirect_uri host is not on oauth2.dcr.redirect_uri_allowlist and is not loopback. |
rate_limited |
The per-IP oauth2.dcr.max_per_hour window was exceeded (also returns HTTP 429). |
What healthy vs. abusive looks like
- Healthy: a small, steady trickle of
oauth2.registerwithactor_type=anonymous_dcr,redirect_hostsmatching your allow-list, andclient_typesplit betweenpublicandconfidentialdepending on which connector registered (Claude.ai's own zero-config connector registers confidential and expects aclient_secret) — one per connector onboarding, not per request. - Abuse signal: a spike of
oauth2.register_rejectconcentrated on a singlesource_ip— especiallyrate_limited(someone hammering the endpoint) orredirect_uri_not_allowlisted(someone probing redirect hosts trying to slip one past the allow-list). The guardrails already refuse all of these; the audit trail is how you notice and decide whether to throttle the source upstream or disable DCR (§6).
A practical watch: pivot oauth2.register_reject by source_ip over the last
hour; any single IP responsible for a large share of rejects is your investigation
target.
6. Incident response
Suspend or delete one rogue client
Dynamically-registered clients are admin-owned and appear in the admin OAuth2
apps view. They are flagged there with is_dynamically_registered, so you can
tell a self-registered connector from a manually-created app at a glance.
To deal with a single bad client:
- Find it in Admin → OAuth2 Apps, filter to dynamically-registered.
- Delete (or suspend) the app. This removes the
client_idso it can no longer run the authorization-code flow. - Revoke its outstanding tokens so any already-issued
mcp:readaccess / refresh tokens stop working immediately — deleting the app does not retroactively un-issue a live token. Use the admin OAuth2 apps view's token controls (orPOST /api/v1/oauth2/revokeper token).
This is the surgical response: one connector misbehaves, you remove just that one without affecting the rest.
Disable DCR fast (kill switch)
If you need to stop all new self-registration immediately — a registration flood, a suspected abuse campaign, or simply rolling DCR back — flip the kill switch:
UI: Admin → Settings → OAuth2 / MCP → Enable MCP DCR → off → Save.
API:
curl -fsS -X PUT https://<vetrix-host>/api/v1/admin/settings/oauth2.dcr.enabled \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"value":"false"}'
Effect is immediate:
- The discovery document stops advertising
registration_endpointon the next fetch — there is no stale-cache window because discovery is servedCache-Control: no-store(§1). - New anonymous
POST /api/v1/oauth2/registercalls are refused withoauth2.register_reject/dcr_disabled.
Disabling DCR does not delete the clients already registered, and does not revoke their tokens — it only stops new registrations. If you are responding to abuse, also delete the offending clients and revoke their tokens as above. To wind the whole feature down, disable DCR and then clean up any dynamically-registered clients you no longer want from the admin OAuth2 apps view.
7. Troubleshooting
Symptom: connector OAuth fails with PKCE S256 code_challenge required (and/or an over-broad consent screen)
An operator follows Claude.ai's prompt — "automatic registration isn't
supported — add an OAuth Client ID" — pastes a client_id into the connector's
Advanced settings, and the OAuth flow dead-ends. The two tells:
- The authorization request is rejected with the error string
PKCE S256 code_challenge required. - And/or the consent screen asks for far more than
mcp:read— an over-broad scope set such asadmin:repo/write:repo(the whole catalog), for what should be a read-only MCP connector.
Cause: the manual "OAuth Client ID" path is generic OAuth, not MCP DCR
Claude.ai has two ways to reach a client_id:
| MCP-native DCR path (supported) | Manual "add OAuth Client ID" path (generic OAuth) | |
|---|---|---|
| Discovers scope from | Protected-Resource Metadata → narrow mcp:read |
Authorization-server scopes_supported → the full catalog (incl. admin:repo, write:repo) |
| PKCE | PKCE S256 on every authorization request | No PKCE — omits code_challenge entirely |
Where the client_id comes from |
Self-registered via POST /api/v1/oauth2/register |
Pasted by hand into the connector's Advanced settings |
Vetrix requires PKCE S256 for the MCP authorization-code flow (this is by
design). The manual generic-OAuth path sends no code_challenge, so the server
rejects it with PKCE S256 code_challenge required; and because it reads the full
scopes_supported rather than the resource's narrow mcp:read, it also produces
the over-broad consent screen. The manual client_id path therefore cannot
work with Claude.ai — DCR is the only supported path.
Remedy: switch the connector to DCR
-
Enable DCR with
claude.aiallow-listed (if not already on — see §2). The settings body is the object form{"value":"..."}(a JSON object whosevalueis always a string, even for bools):# claude.ai must be in the redirect-URI allow-list... curl -fsS -X PUT https://<vetrix-host>/api/v1/admin/settings/oauth2.dcr.redirect_uri_allowlist \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"value":"claude.ai"}' # ...then turn DCR on (do this last, after the guardrails in §2). curl -fsS -X PUT https://<vetrix-host>/api/v1/admin/settings/oauth2.dcr.enabled \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"value":"true"}' -
Remove the manually-pasted
client_idfrom the Claude.ai connector's Advanced settings. With that field cleared, the connector discovers theregistration_endpoint(§1) and self-registers via DCR — minting a fresh public, PKCE,mcp:read-onlyclient_id. The connector should never prompt you for aclient_idonce DCR is reachable (§4 step 4). -
Optionally, delete the leftover manually-created OAuth app from Vetrix admin (Admin → OAuth2 Apps). A hand-created app is not flagged
is_dynamically_registered, so it is easy to distinguish from the self-registered connector; remove it (and revoke any tokens it issued) so it can't be reused — same procedure as §6.
After clearing the manual client_id, re-run the connector and walk the full
discovery → register → authorize → consent path (§4). The consent screen should
now request only mcp:read, and the PKCE S256 code_challenge required error is
gone.
References
- The end-user connector setup walkthrough in user-docs.
- Settings:
oauth2.dcr.enabled,oauth2.dcr.redirect_uri_allowlist,oauth2.dcr.max_clients,oauth2.dcr.max_per_hour— admin-settings keys; see the configuration settings reference in admin-docs. - Endpoints:
GET /.well-known/oauth-authorization-server(RFC 8414 discovery),POST /api/v1/oauth2/register(RFC 7591 DCR),POST /api/v1/oauth2/revoke(token revocation). - Audit events:
oauth2.register,oauth2.register_reject— query via the audit-log reference in admin-docs.