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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user