Skip to content

Support Client ID Metadata Document (CIMD) in the embedded authorization server #4825

Description

@jhrozek

As an MCP client (VS Code, Claude Code, or any spec-compliant client) connecting to a ToolHive-managed MCP server locally, I want to use an HTTPS URL as my client_id so the authorization server fetches my client metadata automatically, without requiring a separate DCR registration step.

Background

The MCP 2025-11-25 spec defines client registration priority as: pre-registered credentials > CIMD (preferred) > DCR > user prompt. ToolHive's embedded AS currently supports only DCR (RFC 7591). CIMD (draft-ietf-oauth-client-id-metadata-document) is the spec-preferred mechanism but neither ToolHive, Ory Hydra, nor Obot implement it today.

In the local flow, the MCP client (VS Code, Claude Code) is the OAuth client. The thv proxy is the resource server and hosts the embedded AS. The proxy itself does not need CIMD awareness for this flow.

VS Code already implements CIMD client-side support (mainThreadAuthentication.ts:160-170): when the AS advertises client_id_metadata_document_supported: true, VS Code uses its product-configured metadata URL as client_id instead of performing DCR.

Acceptance criteria

  • MCP clients can pass an HTTPS URL as client_id in /oauth/authorize and /oauth/token and complete the full authorization code + PKCE flow without calling /oauth/register
  • Discovery documents (/.well-known/oauth-authorization-server, /.well-known/openid-configuration) advertise "client_id_metadata_document_supported": true when CIMD is enabled
  • Existing DCR continues to work alongside CIMD (no regression)
  • CIMD is opt-in via configuration (disabled by default)

Technical design

URL detection and coexistence with DCR

  • A client_id is treated as CIMD when it starts with https://
  • Inner storage is checked first: if GetClient finds an existing client (DCR or static), use it. CIMD fetch only on ErrNotFound. This prevents collisions with existing client IDs.

Storage decorator pattern

Fosite resolves clients via Storage.GetClient(ctx, id string) in both NewAuthorizeRequest (authorize endpoint) and NewAccessRequest (token endpoint). A decorator wrapping storage.Storage that intercepts GetClient handles both flows transparently.

  • All other Storage interface methods delegate unchanged to the inner implementation.
  • Wire the decorator at pkg/authserver/server/provider.go where fosite.NewOAuth2Provider is called.
  • Fosite has no built-in CIMD support. The ClientAuthenticationStrategy hook only covers the token endpoint, not the authorize endpoint, so the Storage decorator is the correct integration point.

Metadata document fetch and validation

  • Document must be served over HTTPS with Content-Type: application/json
  • Response body capped at 10 KB, fetch timeout 5 seconds
  • client_id in the document must exactly match the fetch URL
  • redirect_uris validated via existing oauth.ValidateRedirectURI (RFC 8252 loopback or HTTPS)
  • grant_types limited to authorization_code + optionally refresh_token
  • response_types limited to code
  • token_endpoint_auth_method must be none or absent
  • scope, if present, validated against Config.ScopesSupported; if absent, defaults to registration.DefaultScopes
  • client_name capped at 256 characters
  • Unknown fields ignored (forward compatibility)

SSRF protection

  • Reject fetches to private/internal IP ranges (10/8, 172.16/12, 192.168/16, 127/8, ::1, fe80::/10, 169.254/16)
  • Custom http.Transport with DialContext hook: resolve hostname, validate resolved IP, then connect (DNS rebinding defense)
  • HTTP redirects limited to 1 hop; redirect target re-validated against same blocklist
  • Optional configurable domain allowlist for locked-down deployments
  • Threat model note: embedded AS runs on the user's machine, so "internal" means the user's LAN. Risk is lower than cloud-deployed AS but still worth protecting (malicious MCP server config could probe LAN services).

Client construction

  • Construct fosite.DefaultClient wrapped in LoopbackClient (public client, same as DCR)
  • Audience set from Config.AllowedAudiences (server policy, not from metadata document -- same as DCR)
  • client_id claim in issued JWTs will be the HTTPS URL string (format change from UUID-style DCR IDs, no code change needed)

Caching

  • Cache lives inside the Storage decorator as an internal detail (no new Storage interface methods)
  • Keyed by client_id URL, stores constructed fosite.Client
  • TTL from HTTP Cache-Control: max-age, clamped to configurable max (default 1h) and min (default 5m)
  • Max entries configurable (default 100), LRU eviction
  • Redis-backed deployments: serialize to Redis with TTL for cross-replica sharing
  • Forced refresh on redirect_uri mismatch deferred to follow-up (first cut uses TTL-only expiry)

Configuration

  • New CIMDEnabled bool field in pkg/authserver/config.go
  • New ClientIDMetadataDocumentSupported bool field in AuthorizationServerMetadata (pkg/oauth/discovery.go) with tag json:"client_id_metadata_document_supported,omitempty"

Key files

Area Files
Config pkg/authserver/config.go
Discovery metadata type pkg/oauth/discovery.go
Discovery handler pkg/authserver/server/handlers/discovery.go
Storage decorator (new) pkg/authserver/storage/cimd.go
Validation + SSRF (new) pkg/authserver/server/registration/cimd.go, ssrf.go
Wiring pkg/authserver/server/provider.go
Redis cache extension pkg/authserver/storage/redis.go

Out of scope

  • Consent screen displaying CIMD client hostname (no consent UI exists today; deferred until one is built)
  • Proxy-side CIMD when connecting to remote MCP servers (separate issue)

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions