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
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)
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_idso 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 advertisesclient_id_metadata_document_supported: true, VS Code uses its product-configured metadata URL asclient_idinstead of performing DCR.Acceptance criteria
client_idin/oauth/authorizeand/oauth/tokenand complete the full authorization code + PKCE flow without calling/oauth/register/.well-known/oauth-authorization-server,/.well-known/openid-configuration) advertise"client_id_metadata_document_supported": truewhen CIMD is enabledTechnical design
URL detection and coexistence with DCR
client_idis treated as CIMD when it starts withhttps://GetClientfinds an existing client (DCR or static), use it. CIMD fetch only onErrNotFound. This prevents collisions with existing client IDs.Storage decorator pattern
Fosite resolves clients via
Storage.GetClient(ctx, id string)in bothNewAuthorizeRequest(authorize endpoint) andNewAccessRequest(token endpoint). A decorator wrappingstorage.Storagethat interceptsGetClienthandles both flows transparently.Storageinterface methods delegate unchanged to the inner implementation.pkg/authserver/server/provider.gowherefosite.NewOAuth2Provideris called.ClientAuthenticationStrategyhook only covers the token endpoint, not the authorize endpoint, so the Storage decorator is the correct integration point.Metadata document fetch and validation
Content-Type: application/jsonclient_idin the document must exactly match the fetch URLredirect_urisvalidated via existingoauth.ValidateRedirectURI(RFC 8252 loopback or HTTPS)grant_typeslimited toauthorization_code+ optionallyrefresh_tokenresponse_typeslimited tocodetoken_endpoint_auth_methodmust benoneor absentscope, if present, validated againstConfig.ScopesSupported; if absent, defaults toregistration.DefaultScopesclient_namecapped at 256 charactersSSRF protection
http.TransportwithDialContexthook: resolve hostname, validate resolved IP, then connect (DNS rebinding defense)Client construction
fosite.DefaultClientwrapped inLoopbackClient(public client, same as DCR)Audienceset fromConfig.AllowedAudiences(server policy, not from metadata document -- same as DCR)client_idclaim in issued JWTs will be the HTTPS URL string (format change from UUID-style DCR IDs, no code change needed)Caching
Storageinterface methods)client_idURL, stores constructedfosite.ClientCache-Control: max-age, clamped to configurable max (default 1h) and min (default 5m)Configuration
CIMDEnabled boolfield inpkg/authserver/config.goClientIDMetadataDocumentSupported boolfield inAuthorizationServerMetadata(pkg/oauth/discovery.go) with tagjson:"client_id_metadata_document_supported,omitempty"Key files
pkg/authserver/config.gopkg/oauth/discovery.gopkg/authserver/server/handlers/discovery.gopkg/authserver/storage/cimd.gopkg/authserver/server/registration/cimd.go,ssrf.gopkg/authserver/server/provider.gopkg/authserver/storage/redis.goOut of scope