From acbfb90c30902985a9a90cecfd323a748655be7e Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 23 Mar 2026 19:26:47 -0400 Subject: [PATCH] feat: auto-discover OAuth2 config for MCP servers via RFC 7591 DCR (#23406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- coderd/mcp.go | 99 +++++++++++++++++++++++++- coderd/mcp_test.go | 169 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+), 2 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 39b41919ed..d6346900b3 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -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 +} diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 004ac387fe..7c0b2297ed 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -3,6 +3,7 @@ package coderd_test import ( "encoding/json" "net/http" + "net/http/httptest" "strings" "testing" @@ -430,6 +431,174 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { require.NoError(t, err) } +func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + // Stand up a mock auth server that serves RFC 8414 metadata and + // a RFC 7591 dynamic client registration endpoint. + authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/oauth-authorization-server": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "` + r.Host + `", + "authorization_endpoint": "` + "http://" + r.Host + `/authorize", + "token_endpoint": "` + "http://" + r.Host + `/token", + "registration_endpoint": "` + "http://" + r.Host + `/register", + "response_types_supported": ["code"], + "scopes_supported": ["read", "write"] + }`)) + case "/register": + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "client_id": "auto-discovered-client-id", + "client_secret": "auto-discovered-client-secret" + }`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(authServer.Close) + + // Stand up a mock MCP server that serves RFC 9728 Protected + // Resource Metadata pointing to the auth server above. + mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.well-known/oauth-protected-resource" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "resource": "` + "http://" + r.Host + `", + "authorization_servers": ["` + authServer.URL + `"] + }`)) + return + } + http.NotFound(w, r) + })) + t.Cleanup(mcpServer.Close) + + client := newMCPClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + // Create config with auth_type=oauth2 but no OAuth2 fields — + // the server should auto-discover them. + created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Auto-Discovery Server", + Slug: "auto-discovery", + Transport: "streamable_http", + URL: mcpServer.URL + "/v1/mcp", + AuthType: "oauth2", + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + require.NoError(t, err) + require.Equal(t, "auto-discovered-client-id", created.OAuth2ClientID) + require.True(t, created.HasOAuth2Secret) + require.Equal(t, authServer.URL+"/authorize", created.OAuth2AuthURL) + require.Equal(t, authServer.URL+"/token", created.OAuth2TokenURL) + require.Equal(t, "read write", created.OAuth2Scopes) + }) + + t.Run("PartialOAuth2FieldsRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newMCPClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + // Provide client_id but omit auth_url and token_url. + _, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Partial Fields", + Slug: "partial-oauth2", + Transport: "streamable_http", + URL: "https://mcp.example.com/partial", + AuthType: "oauth2", + OAuth2ClientID: "only-client-id", + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "automatic discovery") + }) + + t.Run("DiscoveryFailure", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + // MCP server that returns 404 for the well-known endpoint and + // a non-401 status for the root — discovery has nothing to latch + // onto. + mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + t.Cleanup(mcpServer.Close) + + client := newMCPClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Will Fail", + Slug: "discovery-fail", + Transport: "streamable_http", + URL: mcpServer.URL + "/v1/mcp", + AuthType: "oauth2", + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "auto-discovery failed") + }) + + t.Run("ManualConfigStillWorks", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newMCPClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + // Providing all three OAuth2 fields bypasses discovery entirely. + created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Manual Config", + Slug: "manual-oauth2", + Transport: "streamable_http", + URL: "https://mcp.example.com/manual", + AuthType: "oauth2", + OAuth2ClientID: "manual-client-id", + OAuth2AuthURL: "https://auth.example.com/authorize", + OAuth2TokenURL: "https://auth.example.com/token", + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + require.NoError(t, err) + require.Equal(t, "manual-client-id", created.OAuth2ClientID) + require.Equal(t, "https://auth.example.com/authorize", created.OAuth2AuthURL) + require.Equal(t, "https://auth.example.com/token", created.OAuth2TokenURL) + }) +} + func TestChatWithMCPServerIDs(t *testing.T) { t.Parallel()