mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+97
-2
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user