Vetrix Docs

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:

  1. Discovery advertises registration. GET /.well-known/oauth-authorization-server (RFC 8414) starts including a registration_endpoint pointing at POST /api/v1/oauth2/register. The discovery document is served Cache-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.
  2. The server accepts anonymous registration. POST /api/v1/oauth2/register begins 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/register endpoint also serves authenticated, admin-IAT (initial access token) registration. That path is not gated by oauth2.dcr.enabled and is unaffected here — this runbook is about the anonymous path that oauth2.dcr.enabled switches 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.ai admits a redirect_uri whose host is exactly claude.ai.
  • claude.ai does not admit evil-claude.ai (suffix tricks) or login.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 — no client_secret is ever minted for that case, and the authorization-code flow relies on PKCE (S256), not a shared secret. But an anonymous caller that explicitly requests client_secret_basic or client_secret_post is honored: the server registers a confidential client and mints + returns a client_secret in 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 no confidential_not_allowed rejection 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, the mcp:read scope ceiling, the per-IP throttle, and the max_clients cap" — all four of which are enforced for both client types (below).
  • mcp:read scope ceiling. A DCR'd client may only ever hold mcp:read. An empty scope request defaults to mcp: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 capoauth2.dcr.max_per_hour and oauth2.dcr.max_clients from §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 8707 resource parameter) — they are not bearer-anywhere credentials.
  • Admin-owned. Anonymous-registered clients are owned by the instance admin (oauth2_apps.owner_id is 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.

  1. 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)
    
  2. 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).
    
  3. 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).
    
  4. 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.register with actor_type=anonymous_dcr, redirect_hosts matching your allow-list, and client_type split between public and confidential depending on which connector registered (Claude.ai's own zero-config connector registers confidential and expects a client_secret) — one per connector onboarding, not per request.
  • Abuse signal: a spike of oauth2.register_reject concentrated on a single source_ip — especially rate_limited (someone hammering the endpoint) or redirect_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:

  1. Find it in Admin → OAuth2 Apps, filter to dynamically-registered.
  2. Delete (or suspend) the app. This removes the client_id so it can no longer run the authorization-code flow.
  3. Revoke its outstanding tokens so any already-issued mcp:read access / 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 (or POST /api/v1/oauth2/revoke per 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_endpoint on the next fetch — there is no stale-cache window because discovery is served Cache-Control: no-store (§1).
  • New anonymous POST /api/v1/oauth2/register calls are refused with oauth2.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 as admin: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

  1. Enable DCR with claude.ai allow-listed (if not already on — see §2). The settings body is the object form {"value":"..."} (a JSON object whose value is 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"}'
    
  2. Remove the manually-pasted client_id from the Claude.ai connector's Advanced settings. With that field cleared, the connector discovers the registration_endpoint (§1) and self-registers via DCR — minting a fresh public, PKCE, mcp:read-only client_id. The connector should never prompt you for a client_id once DCR is reachable (§4 step 4).

  3. 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.