feat: auto-discover OAuth2 config for MCP servers via RFC 7591 DCR (#23406)

## Problem

When adding an external MCP server with `auth_type=oauth2`, admins
currently must manually provide:
- `oauth2_client_id`
- `oauth2_client_secret`
- `oauth2_auth_url`
- `oauth2_token_url`

This requires the admin to manually register an OAuth2 client with the
external MCP server's authorization server first — a friction-heavy
process that contradicts the MCP spec's vision of plug-and-play
discovery.

## Solution

When an admin creates an MCP server config with `auth_type=oauth2` and
omits the OAuth2 fields, Coder now automatically discovers and registers
credentials following the MCP authorization spec:

1. **Protected Resource Metadata (RFC 9728)** — Fetches
`/.well-known/oauth-protected-resource` from the MCP server to discover
its authorization server. Falls back to probing the server URL for a
`WWW-Authenticate` header with a `resource_metadata` parameter.

2. **Authorization Server Metadata (RFC 8414)** — Fetches
`/.well-known/oauth-authorization-server` from the discovered auth
server to find all endpoints.

3. **Dynamic Client Registration (RFC 7591)** — Registers Coder as an
OAuth2 client at the auth server's registration endpoint, obtaining a
`client_id` and `client_secret` automatically.

The discovered/generated credentials are stored in the MCP server
config, and the existing per-user OAuth2 connect flow works unchanged.

### Backward compatibility

- **Manual config still works**: If all three fields
(`oauth2_client_id`, `oauth2_auth_url`, `oauth2_token_url`) are
provided, the existing behavior is unchanged.
- **Partial config is rejected**: Providing some but not all fields
returns a clear error explaining the two options.
- **Discovery failure is clear**: If auto-discovery fails, the error
message explains what went wrong and suggests manual configuration.

## Changes

- **New package `coderd/mcpauth`** — Self-contained discovery and DCR
logic with no `codersdk` dependency
- **Modified `coderd/mcp.go`** — `createMCPServerConfig` handler now
attempts auto-discovery when OAuth2 fields are omitted
- **Tests** — Unit tests for discovery (happy path, WWW-Authenticate
fallback, no registration endpoint, registration failure) and
`parseResourceMetadataParam` helper
This commit is contained in:
Kyle Carberry
2026-03-23 19:26:47 -04:00
committed by GitHub
parent c344d7c00e
commit acbfb90c30
2 changed files with 266 additions and 2 deletions
+97 -2
View File
@@ -6,12 +6,16 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/mark3labs/mcp-go/client/transport"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
@@ -107,9 +111,37 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
// Validate auth-type-dependent fields.
switch req.AuthType {
case "oauth2":
if req.OAuth2ClientID == "" || req.OAuth2AuthURL == "" || req.OAuth2TokenURL == "" {
// When the admin does not provide OAuth2 credentials, attempt
// automatic discovery and Dynamic Client Registration (RFC 7591)
// using the MCP server URL. This follows the MCP authorization
// spec: discover the authorization server via Protected Resource
// Metadata (RFC 9728) and Authorization Server Metadata
// (RFC 8414), then register a client dynamically.
if req.OAuth2ClientID == "" && req.OAuth2AuthURL == "" && req.OAuth2TokenURL == "" {
callbackURL := fmt.Sprintf("%s/api/experimental/mcp/servers/{id}/oauth2/callback", api.AccessURL.String())
result, err := discoverAndRegisterMCPOAuth2(ctx, strings.TrimSpace(req.URL), callbackURL)
if err != nil {
api.Logger.Warn(ctx, "mcp oauth2 auto-discovery failed",
slog.F("url", req.URL),
slog.Error(err),
)
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "OAuth2 auto-discovery failed. Provide oauth2_client_id, oauth2_auth_url, and oauth2_token_url manually, or ensure the MCP server supports RFC 9728 (Protected Resource Metadata) and RFC 7591 (Dynamic Client Registration).",
Detail: err.Error(),
})
return
}
req.OAuth2ClientID = result.clientID
req.OAuth2ClientSecret = result.clientSecret
req.OAuth2AuthURL = result.authURL
req.OAuth2TokenURL = result.tokenURL
if req.OAuth2Scopes == "" {
req.OAuth2Scopes = result.scopes
}
} else if req.OAuth2ClientID == "" || req.OAuth2AuthURL == "" || req.OAuth2TokenURL == "" {
// Partial manual config: all three fields are required together.
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "OAuth2 auth type requires oauth2_client_id, oauth2_auth_url, and oauth2_token_url.",
Message: "OAuth2 auth type requires either all of oauth2_client_id, oauth2_auth_url, and oauth2_token_url (manual configuration), or none of them (automatic discovery via RFC 7591).",
})
return
}
@@ -919,3 +951,66 @@ func coalesceStringSlice(ss []string) []string {
}
return ss
}
// mcpOAuth2Discovery holds the result of MCP OAuth2 auto-discovery
// and Dynamic Client Registration.
type mcpOAuth2Discovery struct {
clientID string
clientSecret string
authURL string
tokenURL string
scopes string // space-separated
}
// discoverAndRegisterMCPOAuth2 uses the mcp-go library's OAuthHandler to
// perform the MCP OAuth2 discovery and Dynamic Client Registration flow:
//
// 1. Discover the authorization server via Protected Resource Metadata
// (RFC 9728) and Authorization Server Metadata (RFC 8414).
// 2. Register a client via Dynamic Client Registration (RFC 7591).
// 3. Return the discovered endpoints and generated credentials.
func discoverAndRegisterMCPOAuth2(ctx context.Context, mcpServerURL, callbackURL string) (*mcpOAuth2Discovery, error) {
// Per the MCP spec, the authorization base URL is the MCP server
// URL with the path component discarded (scheme + host only).
parsed, err := url.Parse(mcpServerURL)
if err != nil {
return nil, xerrors.Errorf("parse MCP server URL: %w", err)
}
origin := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
oauthHandler := transport.NewOAuthHandler(transport.OAuthConfig{
RedirectURI: callbackURL,
TokenStore: transport.NewMemoryTokenStore(),
})
oauthHandler.SetBaseURL(origin)
// Step 1: Discover authorization server metadata (RFC 9728 + RFC 8414).
metadata, err := oauthHandler.GetServerMetadata(ctx)
if err != nil {
return nil, xerrors.Errorf("discover authorization server: %w", err)
}
if metadata.AuthorizationEndpoint == "" {
return nil, xerrors.New("authorization server metadata missing authorization_endpoint")
}
if metadata.TokenEndpoint == "" {
return nil, xerrors.New("authorization server metadata missing token_endpoint")
}
if metadata.RegistrationEndpoint == "" {
return nil, xerrors.New("authorization server does not advertise a registration_endpoint (dynamic client registration may not be supported)")
}
// Step 2: Register a client via Dynamic Client Registration (RFC 7591).
if err := oauthHandler.RegisterClient(ctx, "Coder"); err != nil {
return nil, xerrors.Errorf("dynamic client registration: %w", err)
}
scopes := strings.Join(metadata.ScopesSupported, " ")
return &mcpOAuth2Discovery{
clientID: oauthHandler.GetClientID(),
clientSecret: oauthHandler.GetClientSecret(),
authURL: metadata.AuthorizationEndpoint,
tokenURL: metadata.TokenEndpoint,
scopes: scopes,
}, nil
}