Ctrl K
ComunidadGuías…

coderd/mcp.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package coderd

import (
	"bytes"
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/netip"
	"net/url"
	"slices"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/google/uuid"
	"golang.org/x/oauth2"
	"golang.org/x/xerrors"

	"cdr.dev/slog/v3"
	"github.com/coder/coder/v2/coderd/audit"
	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbauthz"
	"github.com/coder/coder/v2/coderd/database/dbtime"
	"github.com/coder/coder/v2/coderd/httpapi"
	"github.com/coder/coder/v2/coderd/httpmw"
	"github.com/coder/coder/v2/coderd/promoauth"
	"github.com/coder/coder/v2/coderd/rbac"
	"github.com/coder/coder/v2/coderd/rbac/policy"
	"github.com/coder/coder/v2/coderd/util/ptr"
	"github.com/coder/coder/v2/coderd/x/chatd/mcpclient"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/safedial"
)

// mcpProtocolVersion is copied from the official SDK, which does not export
// protocol version constants.
const mcpProtocolVersion = "2026-07-28"

// oidcMCPTokenSource implements mcpclient.UserOIDCTokenSource using
// the same refresh strategy as provisionerdserver.ObtainOIDCAccessToken.
// The logic is duplicated to avoid importing provisionerdserver from
// coderd; keep the two in sync.
type oidcMCPTokenSource struct {
	db     database.Store
	config promoauth.OAuth2Config
	logger slog.Logger
}

// newOIDCMCPTokenSource returns nil when no OIDC provider is
// configured. mcpclient treats a nil source the same as "no token
// available" and omits the Authorization header.
func newOIDCMCPTokenSource(db database.Store, config promoauth.OAuth2Config, logger slog.Logger) mcpclient.UserOIDCTokenSource {
	if config == nil {
		return nil
	}
	return &oidcMCPTokenSource{
		db:     db,
		config: config,
		logger: logger,
	}
}

// OIDCAccessToken refreshes and persists the user's OIDC token.
func (s *oidcMCPTokenSource) OIDCAccessToken(ctx context.Context, userID uuid.UUID) (string, error) {
	//nolint:gocritic // user_links read needs system access; the
	// caller's user identity is supplied via the userID parameter.
	dbCtx := dbauthz.AsSystemRestricted(ctx)
	link, err := s.db.GetUserLinkByUserIDLoginType(dbCtx, database.GetUserLinkByUserIDLoginTypeParams{
		UserID:    userID,
		LoginType: database.LoginTypeOIDC,
	})
	if errors.Is(err, sql.ErrNoRows) {
		return "", nil
	}
	if err != nil {
		return "", xerrors.Errorf("get oidc user link: %w", err)
	}

	if shouldRefresh, expiresAt := shouldRefreshOIDCToken(link); shouldRefresh {
		token, err := s.config.TokenSource(ctx, &oauth2.Token{
			AccessToken:  link.OAuthAccessToken,
			RefreshToken: link.OAuthRefreshToken,
			// Use the expiresAt returned by shouldRefreshOIDCToken.
			// It will force a refresh with an expired time.
			Expiry: expiresAt,
		}).Token()
		if err != nil {
			// Don't fail the request; the upstream MCP server will see no
			// Authorization header and can return a 401 if it requires one.
			s.logger.Warn(ctx, "failed to refresh OIDC token for MCP request",
				slog.F("user_id", userID),
				slog.Error(err),
			)
			return "", nil
		}
		link.OAuthAccessToken = token.AccessToken
		link.OAuthRefreshToken = token.RefreshToken
		link.OAuthExpiry = token.Expiry

		// Persist on a detached context so a canceled chat request
		// cannot drop a refresh-token rotation, see PR #24332.
		persistCtx, persistCancel := context.WithTimeout(
			context.WithoutCancel(dbCtx), 10*time.Second,
		)
		link, err = s.db.UpdateUserLink(persistCtx, database.UpdateUserLinkParams{
			UserID:                 userID,
			LoginType:              database.LoginTypeOIDC,
			OAuthAccessToken:       link.OAuthAccessToken,
			OAuthAccessTokenKeyID:  sql.NullString{}, // set by dbcrypt if required
			OAuthRefreshToken:      link.OAuthRefreshToken,
			OAuthRefreshTokenKeyID: sql.NullString{}, // set by dbcrypt if required
			OAuthExpiry:            link.OAuthExpiry,
			Claims:                 link.Claims,
		})
		persistCancel()
		if err != nil {
			return "", xerrors.Errorf("update user link after oidc refresh: %w", err)
		}
		s.logger.Info(ctx, "refreshed expired OIDC token for MCP request",
			slog.F("user_id", userID),
		)
	}

	return link.OAuthAccessToken, nil
}

// shouldRefreshOIDCToken mirrors provisionerdserver.shouldRefreshOIDCToken.
// See that function for the rationale behind the 10-minute pre-expiry
// buffer.
func shouldRefreshOIDCToken(link database.UserLink) (bool, time.Time) {
	if link.OAuthRefreshToken == "" {
		return false, link.OAuthExpiry
	}
	if link.OAuthExpiry.IsZero() {
		// A zero expiry means the token never expires.
		return false, link.OAuthExpiry
	}
	expiresAt := link.OAuthExpiry.Add(-time.Minute * 10)
	return expiresAt.Before(dbtime.Now()), expiresAt
}

// @Summary List MCP server configs
// @ID list-mcp-server-configs
// @Security CoderSessionToken
// @Tags MCP
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Success 200 {array} codersdk.MCPServerConfig
// @Router /api/v2/organizations/{organization}/mcp-servers [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	apiKey := httpmw.APIKey(r)
	organization := httpmw.OrganizationParam(r)

	// Full view: disabled configs included, management fields unredacted,
	// fetched with system access behind this gate. Auditors get it to
	// inspect audit-logged resources but stay subject to per-server ACLs
	// elsewhere. Other members see enabled ACL-granted configs, redacted.
	// The update leg also requires config read so a custom role granting
	// update without read cannot lift the read filtering below.
	hasFullView := ((api.Authorize(r, policy.ActionRead, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) &&
		api.Authorize(r, policy.ActionUpdate, rbac.ResourceMCPServerConfig.InOrg(organization.ID))) ||
		api.Authorize(r, policy.ActionRead, rbac.ResourceAuditLog.InOrg(organization.ID))) &&
		api.mcpServerConfigReadInKeyScope(r, organization.ID)

	var configs []database.MCPServerConfig
	var err error
	if hasFullView {
		//nolint:gocritic // The update-or-audit gate above owns this authorization.
		configs, err = api.Database.GetMCPServerConfigsByOrganization(dbauthz.AsSystemRestricted(ctx), organization.ID)
	} else if api.mcpServerConfigReadInKeyScope(r, organization.ID) {
		seen := make(map[uuid.UUID]struct{})
		for _, action := range []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete, policy.ActionShare} {
			prepared, prepareErr := api.HTTPAuth.AuthorizeSQLFilter(r, action, rbac.ResourceMCPServerConfig.Type)
			if prepareErr != nil {
				httpapi.InternalServerError(rw, prepareErr)
				return
			}
			authorized, queryErr := api.Database.GetAuthorizedMCPServerConfigs(ctx, organization.ID, prepared)
			if queryErr != nil {
				err = queryErr
				break
			}
			for _, config := range authorized {
				if _, ok := seen[config.ID]; ok {
					continue
				}
				seen[config.ID] = struct{}{}
				configs = append(configs, config)
			}
		}
		slices.SortFunc(configs, func(a, b database.MCPServerConfig) int {
			return strings.Compare(a.DisplayName, b.DisplayName)
		})
		// Management-authorized callers keep disabled configs in the redacted
		// list so they can still reach and manage them.
		configs = slices.DeleteFunc(configs, func(config database.MCPServerConfig) bool {
			return !config.Enabled &&
				!api.Authorize(r, policy.ActionUpdate, config) &&
				!api.Authorize(r, policy.ActionDelete, config) &&
				!api.Authorize(r, policy.ActionShare, config)
		})
	}
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Failed to list MCP server configs.",
			Detail:  err.Error(),
		})
		return
	}

	// Read and refresh OAuth2 tokens only when the caller may view them.
	// Without that permission, auth_connected remains false.
	var userTokens []database.MCPServerUserToken
	tokenReadErr := api.HTTPAuth.Authorizer.Authorize(ctx, httpmw.UserAuthorization(ctx),
		policy.ActionReadPersonal, rbac.ResourceUserObject(apiKey.UserID).RBACObject())
	if tokenReadErr == nil {
		userTokens, err = api.Database.GetMCPServerUserTokensByUserID(ctx, apiKey.UserID)
		if err != nil {
			httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
				Message: "Failed to get user tokens.",
				Detail:  err.Error(),
			})
			return
		}
	}

	// Build a config lookup for the refresh helper.
	configByID := make(map[uuid.UUID]database.MCPServerConfig, len(configs))
	for _, c := range configs {
		configByID[c.ID] = c
	}

	tokenMap := make(map[uuid.UUID]bool, len(userTokens))
	for _, tok := range userTokens {
		cfg, ok := configByID[tok.MCPServerConfigID]
		if !ok {
			continue
		}
		tokenMap[tok.MCPServerConfigID] = api.refreshMCPUserToken(ctx, cfg, tok)
	}

	resp := make([]codersdk.MCPServerConfig, 0, len(configs))
	for _, config := range configs {
		var sdkConfig codersdk.MCPServerConfig
		if hasFullView {
			sdkConfig = convertMCPServerConfig(config)
		} else {
			sdkConfig = convertMCPServerConfigRedacted(config)
		}
		if config.AuthType == "oauth2" {
			sdkConfig.AuthConnected = tokenMap[config.ID]
		}
		resp = append(resp, sdkConfig)
	}

	httpapi.Write(ctx, rw, http.StatusOK, resp)
}

// mcpServerConfigReadInKeyScope reports whether the caller's API key scope
// permits reading MCP server configs. The full-view list fetch bypasses row
// authorization with system access, so the key's scope must be enforced here.
// Owner roles make the authorize outcome depend only on the scope dimension.
func (api *API) mcpServerConfigReadInKeyScope(r *http.Request, organizationID uuid.UUID) bool {
	caller := httpmw.UserAuthorization(r.Context())
	scopeOnly := rbac.Subject{
		Type:         caller.Type,
		FriendlyName: caller.FriendlyName,
		ID:           caller.ID,
		Roles:        rbac.RoleIdentifiers{rbac.RoleOwner()},
		Scope:        caller.Scope,
	}
	err := api.HTTPAuth.Authorizer.Authorize(r.Context(), scopeOnly,
		policy.ActionRead, rbac.ResourceMCPServerConfig.InOrg(organizationID))
	return err == nil
}

// @Summary Create MCP server config
// @ID create-mcp-server-config
// @Security CoderSessionToken
// @Tags MCP
// @Accept json
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Param request body codersdk.CreateMCPServerConfigRequest true "Create MCP server config request"
// @Success 201 {object} codersdk.MCPServerConfig
// @Router /api/v2/organizations/{organization}/mcp-servers [post]
// @x-apidocgen {"skip": true}
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	apiKey := httpmw.APIKey(r)
	organization := httpmw.OrganizationParam(r)
	auditor := api.Auditor.Load()
	aReq, commitAudit := audit.InitRequest[database.MCPServerConfig](rw, &audit.RequestParams{
		Audit:          *auditor,
		Log:            api.Logger,
		Request:        r,
		Action:         database.AuditActionCreate,
		OrganizationID: organization.ID,
	})
	defer commitAudit()
	if !api.Authorize(r, policy.ActionCreate, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) {
		httpapi.Forbidden(rw)
		return
	}

	var req codersdk.CreateMCPServerConfigRequest
	if !httpapi.Read(ctx, rw, r, &req) {
		return
	}

	if req.AuthType == "user_oidc" && !api.authorizeUserOIDCMCPServerConfig(rw, r) {
		return
	}

	if trimmed := strings.TrimSpace(req.OAuth2RevocationURL); trimmed != "" {
		if err := mcpclient.ValidateRevocationEndpoint(trimmed); err != nil {
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: "Invalid OAuth2 revocation URL.",
				Detail:  "oauth2_revocation_url must be an https URL (loopback hosts may use http).",
			})
			return
		}
	}

	if !validateMCPOAuth2ResourceRequest(ctx, rw, req.OAuth2Resource) {
		return
	}

	customHeadersJSON, err := marshalCustomHeaders(req.CustomHeaders)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Invalid custom headers.",
			Detail:  err.Error(),
		})
		return
	}

	prev := database.MCPServerConfig{ID: uuid.New(), OrganizationID: organization.ID}
	change := mcpServerConfigChangeFromCreate(req)
	w, err := planMCPServerConfigWrite(prev, change, customHeadersJSON)
	if err != nil {
		writeInvalidMCPServerAuth(ctx, rw, err)
		return
	}
	var registration *mcpOAuth2Discovery
	if w.registerFor != "" {
		// Create-only callers cannot read configs. This pre-DCR check reveals nothing
		// beyond the insert's conflict response, which remains authoritative for races.
		//nolint:gocritic // Restrict system access to this existence check.
		_, err := api.Database.GetMCPServerConfigByOrganizationAndSlug(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerConfigByOrganizationAndSlugParams{
			OrganizationID: organization.ID,
			Slug:           w.next.Slug,
		})
		switch {
		case err == nil:
			httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
				Message: "MCP server config already exists.",
			})
			return
		case !errors.Is(err, sql.ErrNoRows):
			httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
				Message: "Failed to create MCP server config.",
				Detail:  err.Error(),
			})
			return
		}

		registration, err = api.registerMCPOAuth2Client(ctx, prev.ID, w.registerFor)
		if err != nil {
			writeMCPOAuth2RegistrationFailed(ctx, rw, err)
			return
		}
	}
	next, err := w.withRegistration(prev, change, registration)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Failed to create MCP server config.",
			Detail:  err.Error(),
		})
		return
	}

	inserted, err := api.Database.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{
		ID:                       next.ID,
		OrganizationID:           next.OrganizationID,
		DisplayName:              next.DisplayName,
		Slug:                     next.Slug,
		Description:              next.Description,
		IconURL:                  next.IconURL,
		Transport:                next.Transport,
		Url:                      next.Url,
		AuthType:                 next.AuthType,
		OAuth2ClientID:           next.OAuth2ClientID,
		OAuth2ClientSecret:       next.OAuth2ClientSecret,
		OAuth2ClientSecretKeyID:  next.OAuth2ClientSecretKeyID,
		OAuth2AuthURL:            next.OAuth2AuthURL,
		OAuth2TokenURL:           next.OAuth2TokenURL,
		OAuth2RevocationURL:      next.OAuth2RevocationURL,
		OAuth2Resource:           next.OAuth2Resource,
		OAuth2Scopes:             next.OAuth2Scopes,
		OAuth2ClientRegistration: next.OAuth2ClientRegistration,
		APIKeyHeader:             next.APIKeyHeader,
		APIKeyValue:              next.APIKeyValue,
		APIKeyValueKeyID:         next.APIKeyValueKeyID,
		CustomHeaders:            next.CustomHeaders,
		CustomHeadersKeyID:       next.CustomHeadersKeyID,
		SigningSecret:            next.SigningSecret,
		SigningSecretKeyID:       next.SigningSecretKeyID,
		ToolAllowList:            next.ToolAllowList,
		ToolDenyList:             next.ToolDenyList,
		Availability:             next.Availability,
		Enabled:                  next.Enabled,
		ModelIntent:              next.ModelIntent,
		AllowInPlanMode:          next.AllowInPlanMode,
		ForwardCoderHeaders:      next.ForwardCoderHeaders,
		GroupACL: database.ChatACL{
			organization.ID.String(): {Permissions: []policy.Action{policy.ActionRead}},
		},
		UserACL:   database.ChatACL{},
		CreatedBy: apiKey.UserID,
		UpdatedBy: apiKey.UserID,
	})
	if err != nil {
		switch {
		case database.IsUniqueViolation(err):
			httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
				Message: "MCP server config already exists.",
				Detail:  err.Error(),
			})
			return
		case database.IsCheckViolation(err):
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: "Invalid MCP server config.",
				Detail:  err.Error(),
			})
			return
		default:
			httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
				Message: "Failed to create MCP server config.",
				Detail:  err.Error(),
			})
			return
		}
	}

	aReq.New = inserted

	httpapi.Write(ctx, rw, http.StatusCreated, convertMCPServerConfig(inserted))
}

// @Summary Get MCP server config
// @ID get-mcp-server-config
// @Security CoderSessionToken
// @Tags MCP
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Param mcpserverconfig path string true "MCP server config ID" format(uuid)
// @Success 200 {object} codersdk.MCPServerConfig
// @Router /api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig} [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	apiKey := httpmw.APIKey(r)
	config := httpmw.MCPServerConfigParam(r)
	if !api.mcpServerConfigReadInKeyScope(r, config.OrganizationID) {
		httpapi.ResourceNotFound(rw)
		return
	}

	// Same full-view rule as listMCPServerConfigs: admins and auditors.
	hasFullView := api.Authorize(r, policy.ActionUpdate, config) ||
		api.Authorize(r, policy.ActionRead, rbac.ResourceAuditLog.InOrg(config.OrganizationID))
	if !hasFullView && !config.Enabled &&
		!api.Authorize(r, policy.ActionDelete, config) &&
		!api.Authorize(r, policy.ActionShare, config) {
		httpapi.ResourceNotFound(rw)
		return
	}

	var sdkConfig codersdk.MCPServerConfig
	if hasFullView {
		sdkConfig = convertMCPServerConfig(config)
	} else {
		sdkConfig = convertMCPServerConfigRedacted(config)
	}

	// Refresh readable token state so AuthConnected reflects the current
	// OAuth status.
	if config.AuthType == "oauth2" {
		tok, err := api.Database.GetMCPServerUserToken(ctx, database.GetMCPServerUserTokenParams{
			MCPServerConfigID: config.ID,
			UserID:            apiKey.UserID,
		})
		if err == nil {
			sdkConfig.AuthConnected = api.refreshMCPUserToken(ctx, config, tok)
			// Token visibility is separately scoped, so denial means disconnected here.
		} else if !errors.Is(err, sql.ErrNoRows) && !dbauthz.IsNotAuthorizedError(err) {
			httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
				Message: "Failed to get user token.",
				Detail:  err.Error(),
			})
			return
		}
	}

	httpapi.Write(ctx, rw, http.StatusOK, sdkConfig)
}

var errUserOIDCRequiresDeploymentPerms = xerrors.New("managing user_oidc MCP server configs requires deployment-level permissions")

var errMCPConfigChangedDuringRegistration = xerrors.New("MCP server config changed during OAuth2 client registration")

var errMCPConfigSupersededDuringAuth = xerrors.New("MCP server config superseded during authorization")

// authorizeUserOIDCMCPServerConfig requires deployment-level permission because
// user_oidc sends each chat owner's upstream OIDC access token to the configured
// URL without a per-user consent step.
func (api *API) authorizeUserOIDCMCPServerConfig(rw http.ResponseWriter, r *http.Request) bool {
	if api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
		return true
	}
	httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
		Message: "Managing user_oidc MCP server configs requires deployment-level permissions.",
	})
	return false
}

// touchesUserOIDC reports whether updating c with req changes a user_oidc
// config or makes c one.
func touchesUserOIDC(c database.MCPServerConfig, req codersdk.UpdateMCPServerConfigRequest) bool {
	return c.AuthType == "user_oidc" || (req.AuthType != nil && *req.AuthType == "user_oidc")
}

// Preserve the param middleware's 404 concealment. Write denial is a 403.
func (api *API) getMCPServerConfigForMutation(rw http.ResponseWriter, r *http.Request, action policy.Action) (database.MCPServerConfig, bool) {
	config := httpmw.MCPServerConfigParam(r)
	if !api.Authorize(r, action, config) {
		httpapi.Forbidden(rw)
		return database.MCPServerConfig{}, false
	}
	return config, true
}

// @Summary Update MCP server config
// @ID update-mcp-server-config
// @Security CoderSessionToken
// @Tags MCP
// @Accept json
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Param mcpserverconfig path string true "MCP server config ID" format(uuid)
// @Param request body codersdk.UpdateMCPServerConfigRequest true "Update MCP server config request"
// @Success 200 {object} codersdk.MCPServerConfig
// @Router /api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig} [patch]
// @x-apidocgen {"skip": true}
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	apiKey := httpmw.APIKey(r)
	auditor := api.Auditor.Load()
	aReq, commitAudit := audit.InitRequest[database.MCPServerConfig](rw, &audit.RequestParams{
		Audit:   *auditor,
		Log:     api.Logger,
		Request: r,
		Action:  database.AuditActionWrite,
	})
	defer commitAudit()

	// Set Old before the requested-action check so callers admitted for a
	// different action are audited when this handler denies them with 403.
	aReq.Old = httpmw.MCPServerConfigParam(r)
	aReq.UpdateOrganizationID(aReq.Old.OrganizationID)

	existing, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionUpdate)
	if !ok {
		return
	}

	var req codersdk.UpdateMCPServerConfigRequest
	if !httpapi.Read(ctx, rw, r, &req) {
		return
	}

	// Authorize before the client registration below, which reaches the
	// remote provider. The transaction checks again on the locked row.
	if touchesUserOIDC(existing, req) && !api.authorizeUserOIDCMCPServerConfig(rw, r) {
		return
	}

	// Validated here rather than via a struct tag because an empty
	// string is a valid value that clears the stored URL.
	if req.OAuth2RevocationURL != nil {
		if trimmed := strings.TrimSpace(*req.OAuth2RevocationURL); trimmed != "" {
			if err := httpapi.Validate.VarCtx(ctx, trimmed, "url"); err != nil {
				httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
					Message: "Invalid OAuth2 revocation URL.",
					Detail:  "oauth2_revocation_url must be a valid URL or an empty string.",
				})
				return
			}
			// Same policy as RevokeOAuth2Token, so stored URLs are
			// not refused later at disconnect time.
			if err := mcpclient.ValidateRevocationEndpoint(trimmed); err != nil {
				httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
					Message: "Invalid OAuth2 revocation URL.",
					Detail:  "oauth2_revocation_url must be an https URL (loopback hosts may use http).",
				})
				return
			}
		}
	}

	if req.OAuth2Resource != nil && !validateMCPOAuth2ResourceRequest(ctx, rw, *req.OAuth2Resource) {
		return
	}

	// Pre-validate custom headers before entering the transaction.
	var customHeadersJSON string
	if req.CustomHeaders != nil {
		var chErr error
		customHeadersJSON, chErr = marshalCustomHeaders(*req.CustomHeaders)
		if chErr != nil {
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: "Invalid custom headers.",
				Detail:  chErr.Error(),
			})
			return
		}
	}

	// Plan on the unlocked row first, so that the network calls of a
	// registration do not run while the row is locked.
	w, err := planMCPServerConfigWrite(existing, req, customHeadersJSON)
	if err != nil {
		writeInvalidMCPServerAuth(ctx, rw, err)
		return
	}
	var registration *mcpOAuth2Discovery
	if w.registerFor != "" {
		registration, err = api.registerMCPOAuth2Client(ctx, existing.ID, w.registerFor)
		if err != nil {
			writeMCPOAuth2RegistrationFailed(ctx, rw, err)
			return
		}
	}

	var updated database.MCPServerConfig
	err = api.Database.InTx(func(tx database.Store) error {
		// Lock and re-fetch the row so omitted fields and the audit baseline
		// match the row this update replaces, and so grant invalidation
		// serializes with in-flight OAuth callbacks verifying the config.
		//nolint:gocritic // The update write reauthorizes the locked row.
		current, err := tx.GetMCPServerConfigByIDForUpdate(dbauthz.AsSystemRestricted(ctx), existing.ID)
		if err != nil {
			return err
		}
		existing = current
		aReq.Old = current

		if touchesUserOIDC(existing, req) && !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
			return errUserOIDCRequiresDeploymentPerms
		}

		w, err := planMCPServerConfigWrite(existing, req, customHeadersJSON)
		if err != nil {
			return err
		}
		next, err := w.withRegistration(existing, req, registration)
		if err != nil {
			return err
		}

		if mcpOAuth2GrantBindingOf(next) != mcpOAuth2GrantBindingOf(existing) {
			if err := tx.DeleteMCPServerUserTokensByConfigID(ctx, existing.ID); err != nil {
				return xerrors.Errorf("invalidate MCP server user tokens: %w", err)
			}
		}

		updatedConfig, err := tx.UpdateMCPServerConfig(ctx, database.UpdateMCPServerConfigParams{
			DisplayName:              next.DisplayName,
			Slug:                     next.Slug,
			Description:              next.Description,
			IconURL:                  next.IconURL,
			Transport:                next.Transport,
			Url:                      next.Url,
			AuthType:                 next.AuthType,
			OAuth2ClientID:           next.OAuth2ClientID,
			OAuth2ClientSecret:       next.OAuth2ClientSecret,
			OAuth2ClientSecretKeyID:  next.OAuth2ClientSecretKeyID,
			OAuth2AuthURL:            next.OAuth2AuthURL,
			OAuth2TokenURL:           next.OAuth2TokenURL,
			OAuth2RevocationURL:      next.OAuth2RevocationURL,
			OAuth2Resource:           next.OAuth2Resource,
			OAuth2Scopes:             next.OAuth2Scopes,
			OAuth2ClientRegistration: next.OAuth2ClientRegistration,
			APIKeyHeader:             next.APIKeyHeader,
			APIKeyValue:              next.APIKeyValue,
			APIKeyValueKeyID:         next.APIKeyValueKeyID,
			CustomHeaders:            next.CustomHeaders,
			CustomHeadersKeyID:       next.CustomHeadersKeyID,
			SigningSecret:            next.SigningSecret,
			SigningSecretKeyID:       next.SigningSecretKeyID,
			ToolAllowList:            next.ToolAllowList,
			ToolDenyList:             next.ToolDenyList,
			Availability:             next.Availability,
			Enabled:                  next.Enabled,
			ModelIntent:              next.ModelIntent,
			AllowInPlanMode:          next.AllowInPlanMode,
			ForwardCoderHeaders:      next.ForwardCoderHeaders,
			UpdatedBy:                apiKey.UserID,
			ID:                       existing.ID,
		})
		if err != nil {
			return err
		}
		updated = updatedConfig
		return nil
	}, nil)
	if err != nil {
		var authErr invalidMCPServerAuthError
		switch {
		case errors.Is(err, errUserOIDCRequiresDeploymentPerms):
			httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
				Message: "Managing user_oidc MCP server configs requires deployment-level permissions.",
			})
			return
		case errors.Is(err, errMCPConfigChangedDuringRegistration):
			httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
				Message: "MCP server config changed during OAuth2 client registration. Try again.",
			})
			return
		case errors.As(err, &authErr):
			writeInvalidMCPServerAuth(ctx, rw, authErr)
			return
		case httpapi.Is404Error(err):
			httpapi.ResourceNotFound(rw)
			return
		case database.IsUniqueViolation(err):
			httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
				Message: "MCP server config slug already exists.",
				Detail:  err.Error(),
			})
			return
		case database.IsCheckViolation(err):
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: "Invalid MCP server config.",
				Detail:  err.Error(),
			})
			return
		default:
			httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
				Message: "Failed to update MCP server config.",
				Detail:  err.Error(),
			})
			return
		}
	}

	aReq.New = updated

	httpapi.Write(ctx, rw, http.StatusOK, convertMCPServerConfig(updated))
}

// applyMCPServerConfigChange returns prev with every field that req sets
// replaced. When the auth type changes, it clears the fields that the new
// auth type does not use, so stale secrets do not persist.
func applyMCPServerConfigChange(prev database.MCPServerConfig, req codersdk.UpdateMCPServerConfigRequest, customHeadersJSON string) database.MCPServerConfig {
	setTrimmed := func(dst *string, v *string) {
		if v != nil {
			*dst = strings.TrimSpace(*v)
		}
	}

	next := prev
	setTrimmed(&next.DisplayName, req.DisplayName)
	setTrimmed(&next.Slug, req.Slug)
	setTrimmed(&next.Description, req.Description)
	setTrimmed(&next.IconURL, req.IconURL)
	setTrimmed(&next.Transport, req.Transport)
	setTrimmed(&next.Url, req.URL)
	setTrimmed(&next.AuthType, req.AuthType)
	setTrimmed(&next.OAuth2ClientID, req.OAuth2ClientID)
	if req.OAuth2ClientSecret != nil {
		next.OAuth2ClientSecret = strings.TrimSpace(*req.OAuth2ClientSecret)
		// Clear the key ID when the secret is explicitly updated.
		next.OAuth2ClientSecretKeyID = sql.NullString{}
	}
	setTrimmed(&next.OAuth2AuthURL, req.OAuth2AuthURL)
	setTrimmed(&next.OAuth2TokenURL, req.OAuth2TokenURL)
	setTrimmed(&next.OAuth2RevocationURL, req.OAuth2RevocationURL)
	setTrimmed(&next.OAuth2Resource, req.OAuth2Resource)
	setTrimmed(&next.OAuth2Scopes, req.OAuth2Scopes)
	setTrimmed(&next.APIKeyHeader, req.APIKeyHeader)
	if req.APIKeyValue != nil {
		next.APIKeyValue = strings.TrimSpace(*req.APIKeyValue)
		next.APIKeyValueKeyID = sql.NullString{}
	}
	if req.CustomHeaders != nil {
		next.CustomHeaders = customHeadersJSON
		next.CustomHeadersKeyID = sql.NullString{}
	}
	if req.SigningSecret != nil {
		next.SigningSecret = strings.TrimSpace(*req.SigningSecret)
		next.SigningSecretKeyID = sql.NullString{}
	}
	if req.ToolAllowList != nil {
		next.ToolAllowList = coalesceStringSlice(trimStringSlice(*req.ToolAllowList))
	}
	if req.ToolDenyList != nil {
		next.ToolDenyList = coalesceStringSlice(trimStringSlice(*req.ToolDenyList))
	}
	setTrimmed(&next.Availability, req.Availability)
	next.Enabled = ptr.NilToDefault(req.Enabled, prev.Enabled)
	next.ModelIntent = ptr.NilToDefault(req.ModelIntent, prev.ModelIntent)
	next.AllowInPlanMode = ptr.NilToDefault(req.AllowInPlanMode, prev.AllowInPlanMode)
	next.ForwardCoderHeaders = ptr.NilToDefault(req.ForwardCoderHeaders, prev.ForwardCoderHeaders)

	if next.AuthType != prev.AuthType {
		next = clearUnusedMCPServerAuthFields(next)
	}
	return next
}

// clearUnusedMCPServerAuthFields returns c with the credential fields that
// c.AuthType does not use cleared. An unknown auth type keeps every field.
func clearUnusedMCPServerAuthFields(c database.MCPServerConfig) database.MCPServerConfig {
	var usesOAuth2, usesAPIKey, usesCustomHeaders bool
	switch c.AuthType {
	case "oauth2":
		usesOAuth2 = true
	case "api_key":
		usesAPIKey = true
	case "custom_headers":
		usesCustomHeaders = true
	case "none", "user_oidc":
		// user_oidc forwards the calling user's OIDC access token
		// from user_links at request time, so no admin-configured
		// secrets are stored on the row.
	default:
		return c
	}
	if !usesOAuth2 {
		c.OAuth2ClientID = ""
		c.OAuth2ClientSecret = ""
		c.OAuth2ClientSecretKeyID = sql.NullString{}
		c.OAuth2AuthURL = ""
		c.OAuth2TokenURL = ""
		c.OAuth2RevocationURL = ""
		c.OAuth2Resource = ""
		c.OAuth2Scopes = ""
		c.OAuth2ClientRegistration = string(codersdk.MCPServerOAuth2ClientRegistrationManual)
	}
	if !usesAPIKey {
		c.APIKeyHeader = ""
		c.APIKeyValue = ""
		c.APIKeyValueKeyID = sql.NullString{}
	}
	if !usesCustomHeaders {
		c.CustomHeaders = "{}"
		c.CustomHeadersKeyID = sql.NullString{}
	}
	return c
}

// mcpServerConfigWrite is the result of planning a write to an MCP server
// config.
type mcpServerConfigWrite struct {
	// next is the config to store, without the client that a pending
	// registration adds.
	next database.MCPServerConfig
	// registerFor is the server URL that a new dynamic OAuth2 client must
	// be registered for, or "" when the write needs no registration.
	registerFor string
}

// withRegistration returns the config to store. registration must be the
// client registered for w.registerFor, or nil when w needs no client.
func (w mcpServerConfigWrite) withRegistration(prev database.MCPServerConfig, req codersdk.UpdateMCPServerConfigRequest, registration *mcpOAuth2Discovery) (database.MCPServerConfig, error) {
	var registeredFor string
	if registration != nil {
		registeredFor = registration.serverURL
	}
	if registeredFor != w.registerFor {
		return database.MCPServerConfig{}, errMCPConfigChangedDuringRegistration
	}
	if registration == nil {
		return w.next, nil
	}
	return applyOAuth2Registration(prev, w.next, req, registration), nil
}

// invalidMCPServerAuthError reports auth fields that make a config unusable.
type invalidMCPServerAuthError struct {
	message string
}

func (e invalidMCPServerAuthError) Error() string {
	return e.message
}

func writeInvalidMCPServerAuth(ctx context.Context, rw http.ResponseWriter, err error) {
	httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
		Message: err.Error(),
	})
}

func writeMCPOAuth2RegistrationFailed(ctx context.Context, rw http.ResponseWriter, err error) {
	httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
		Message: "OAuth2 auto-discovery failed. Set oauth2_client_registration to manual and provide oauth2_client_id, oauth2_auth_url, and oauth2_token_url, or ensure the MCP server supports RFC 9728 (Protected Resource Metadata), RFC 8414 (Authorization Server Metadata), and RFC 7591 (Dynamic Client Registration).",
		Detail:  err.Error(),
	})
}

// planMCPServerConfigWrite applies req to prev and decides whether the
// result needs a new dynamic OAuth2 client. The client registration
// method and the auth checks apply only when req sets an auth field, so
// that a request such as an enabled toggle still works on a config with
// invalid auth fields.
func planMCPServerConfigWrite(prev database.MCPServerConfig, req codersdk.UpdateMCPServerConfigRequest, customHeadersJSON string) (mcpServerConfigWrite, error) {
	next := applyMCPServerConfigChange(prev, req, customHeadersJSON)
	if !setsMCPServerAuthField(req) {
		return mcpServerConfigWrite{next: next}, nil
	}

	if next.AuthType == "oauth2" {
		switch {
		case req.OAuth2ClientRegistration != nil:
			next.OAuth2ClientRegistration = string(*req.OAuth2ClientRegistration)
		case next.OAuth2ClientID == "" && next.OAuth2AuthURL == "" && next.OAuth2TokenURL == "":
			next.OAuth2ClientRegistration = string(codersdk.MCPServerOAuth2ClientRegistrationDynamic)
		case next.OAuth2ClientRegistration == "":
			next.OAuth2ClientRegistration = string(codersdk.MCPServerOAuth2ClientRegistrationManual)
		}
		if next.OAuth2ClientRegistration == string(codersdk.MCPServerOAuth2ClientRegistrationDynamic) {
			if suppliesOAuth2Client(prev, req) {
				return mcpServerConfigWrite{}, invalidMCPServerAuthError{
					message: "Set oauth2_client_registration to manual to edit the OAuth2 client ID, client secret, auth URL, or token URL.",
				}
			}
			// Coder owns the client of a dynamic config. A new registration
			// replaces it; otherwise the registered client stays.
			next.OAuth2ClientID = prev.OAuth2ClientID
			next.OAuth2ClientSecret = prev.OAuth2ClientSecret
			next.OAuth2ClientSecretKeyID = prev.OAuth2ClientSecretKeyID
			next.OAuth2AuthURL = prev.OAuth2AuthURL
			next.OAuth2TokenURL = prev.OAuth2TokenURL
		}
	}

	registerFor := oauth2RegistrationKey(next)
	if registerFor == oauth2RegistrationKey(prev) {
		registerFor = ""
	}
	if registerFor == "" {
		if err := validateMCPServerConfigAuth(next); err != nil {
			return mcpServerConfigWrite{}, err
		}
	}
	return mcpServerConfigWrite{next: next, registerFor: registerFor}, nil
}

func setsMCPServerAuthField(req codersdk.UpdateMCPServerConfigRequest) bool {
	return req.AuthType != nil ||
		req.URL != nil ||
		req.OAuth2ClientRegistration != nil ||
		req.OAuth2ClientID != nil ||
		req.OAuth2ClientSecret != nil ||
		req.OAuth2AuthURL != nil ||
		req.OAuth2TokenURL != nil ||
		req.OAuth2RevocationURL != nil ||
		req.OAuth2Resource != nil ||
		req.OAuth2Scopes != nil ||
		req.APIKeyHeader != nil ||
		req.APIKeyValue != nil ||
		req.CustomHeaders != nil
}

// oauth2RegistrationKey returns the server URL that the dynamic OAuth2
// client of c must be registered for, or "" when c has no dynamic client.
func oauth2RegistrationKey(c database.MCPServerConfig) string {
	if c.AuthType != "oauth2" || c.OAuth2ClientRegistration != string(codersdk.MCPServerOAuth2ClientRegistrationDynamic) {
		return ""
	}
	return c.Url
}

// suppliesOAuth2Client reports whether req sets an OAuth2 client field to
// a value that is not empty and is not the stored value. A dynamic config
// cannot use such a value, because Coder owns its client.
func suppliesOAuth2Client(prev database.MCPServerConfig, req codersdk.UpdateMCPServerConfigRequest) bool {
	supplies := func(v *string, prevValue string) bool {
		if v == nil {
			return false
		}
		trimmed := strings.TrimSpace(*v)
		return trimmed != "" && trimmed != prevValue
	}
	return supplies(req.OAuth2ClientID, prev.OAuth2ClientID) ||
		supplies(req.OAuth2ClientSecret, prev.OAuth2ClientSecret) ||
		supplies(req.OAuth2AuthURL, prev.OAuth2AuthURL) ||
		supplies(req.OAuth2TokenURL, prev.OAuth2TokenURL)
}

// validateMCPServerConfigAuth checks that c has the credentials that its
// auth type needs.
func validateMCPServerConfigAuth(c database.MCPServerConfig) error {
	switch c.AuthType {
	case "oauth2":
		if c.OAuth2ClientID == "" || c.OAuth2AuthURL == "" || c.OAuth2TokenURL == "" {
			return invalidMCPServerAuthError{
				message: "Manual OAuth2 client registration requires oauth2_client_id, oauth2_auth_url, and oauth2_token_url. Set oauth2_client_registration to dynamic for automatic discovery (RFC 7591).",
			}
		}
	case "api_key":
		if c.APIKeyHeader == "" || c.APIKeyValue == "" {
			return invalidMCPServerAuthError{
				message: "API key auth type requires api_key_header and api_key_value.",
			}
		}
	case "custom_headers":
		if c.CustomHeaders == "" || c.CustomHeaders == "{}" {
			return invalidMCPServerAuthError{
				message: "Custom headers auth type requires at least one custom header.",
			}
		}
	}
	return nil
}

// applyOAuth2Registration returns next with the client of registration.
// For the scopes, resource, and revocation URL, the discovered value
// wins unless req changes the field from prev to a non-empty value.
func applyOAuth2Registration(prev, next database.MCPServerConfig, req codersdk.UpdateMCPServerConfigRequest, registration *mcpOAuth2Discovery) database.MCPServerConfig {
	discoveredUnlessOverridden := func(v *string, prevValue, discovered string) string {
		if v != nil {
			if trimmed := strings.TrimSpace(*v); trimmed != "" && trimmed != prevValue {
				return trimmed
			}
		}
		return discovered
	}

	next.OAuth2ClientID = registration.clientID
	next.OAuth2ClientSecret = registration.clientSecret
	next.OAuth2ClientSecretKeyID = sql.NullString{}
	next.OAuth2AuthURL = registration.authURL
	next.OAuth2TokenURL = registration.tokenURL
	next.OAuth2Scopes = discoveredUnlessOverridden(req.OAuth2Scopes, prev.OAuth2Scopes, registration.scopes)
	next.OAuth2Resource = discoveredUnlessOverridden(req.OAuth2Resource, prev.OAuth2Resource, registration.resource)
	next.OAuth2RevocationURL = discoveredUnlessOverridden(req.OAuth2RevocationURL, prev.OAuth2RevocationURL, registration.revocationURL)
	return next
}

// mcpServerConfigChangeFromCreate returns req as an update that sets
// every field, so that create plans its write against an empty config.
func mcpServerConfigChangeFromCreate(req codersdk.CreateMCPServerConfigRequest) codersdk.UpdateMCPServerConfigRequest {
	change := codersdk.UpdateMCPServerConfigRequest{
		DisplayName:         &req.DisplayName,
		Slug:                &req.Slug,
		Description:         &req.Description,
		IconURL:             &req.IconURL,
		Transport:           &req.Transport,
		URL:                 &req.URL,
		AuthType:            &req.AuthType,
		OAuth2ClientID:      &req.OAuth2ClientID,
		OAuth2ClientSecret:  &req.OAuth2ClientSecret,
		OAuth2AuthURL:       &req.OAuth2AuthURL,
		OAuth2TokenURL:      &req.OAuth2TokenURL,
		OAuth2RevocationURL: &req.OAuth2RevocationURL,
		OAuth2Resource:      &req.OAuth2Resource,
		OAuth2Scopes:        &req.OAuth2Scopes,
		APIKeyHeader:        &req.APIKeyHeader,
		APIKeyValue:         &req.APIKeyValue,
		CustomHeaders:       &req.CustomHeaders,
		ToolAllowList:       &req.ToolAllowList,
		ToolDenyList:        &req.ToolDenyList,
		Availability:        &req.Availability,
		Enabled:             &req.Enabled,
		ModelIntent:         &req.ModelIntent,
		AllowInPlanMode:     &req.AllowInPlanMode,
		ForwardCoderHeaders: &req.ForwardCoderHeaders,
		SigningSecret:       &req.SigningSecret,
	}
	if req.OAuth2ClientRegistration != "" {
		change.OAuth2ClientRegistration = &req.OAuth2ClientRegistration
	}
	return change
}

// mcpOAuth2GrantBinding holds the config fields that a stored user grant
// is bound to: the destination, the auth flow, the token and revocation
// endpoints, the OAuth client, and the resource indicator. When any of
// them changes, stored tokens must not be used, so that they cannot be
// sent to another endpoint, client, or resource.
type mcpOAuth2GrantBinding struct {
	url           string
	authType      string
	tokenURL      string
	revocationURL string
	clientID      string
	resource      string
}

func mcpOAuth2GrantBindingOf(c database.MCPServerConfig) mcpOAuth2GrantBinding {
	return mcpOAuth2GrantBinding{
		url:           c.Url,
		authType:      c.AuthType,
		tokenURL:      c.OAuth2TokenURL,
		revocationURL: c.OAuth2RevocationURL,
		clientID:      c.OAuth2ClientID,
		resource:      c.OAuth2Resource,
	}
}

// @Summary Delete MCP server config
// @ID delete-mcp-server-config
// @Security CoderSessionToken
// @Tags MCP
// @Param organization path string true "Organization name or ID"
// @Param mcpserverconfig path string true "MCP server config ID" format(uuid)
// @Success 204
// @Router /api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig} [delete]
// @x-apidocgen {"skip": true}
func (api *API) deleteMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	auditor := api.Auditor.Load()
	aReq, commitAudit := audit.InitRequest[database.MCPServerConfig](rw, &audit.RequestParams{
		Audit:   *auditor,
		Log:     api.Logger,
		Request: r,
		Action:  database.AuditActionDelete,
	})
	defer commitAudit()

	// Set Old before the requested-action check so callers admitted for a
	// different action are audited when this handler denies them with 403.
	aReq.Old = httpmw.MCPServerConfigParam(r)
	aReq.UpdateOrganizationID(aReq.Old.OrganizationID)

	config, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionDelete)
	if !ok {
		return
	}

	err := api.Database.InTx(func(tx database.Store) error {
		// Re-fetch under a row lock so the audit record describes the
		// row this request actually removes, not a middleware snapshot
		// that a concurrent update may have made stale.
		//nolint:gocritic // The delete write reauthorizes the locked row.
		current, err := tx.GetMCPServerConfigByIDForUpdate(dbauthz.AsSystemRestricted(ctx), config.ID)
		if err != nil {
			return err
		}
		aReq.Old = current
		return tx.DeleteMCPServerConfigByID(ctx, current.ID)
	}, nil)
	if err != nil {
		if httpapi.Is404Error(err) {
			httpapi.ResourceNotFound(rw)
			return
		}
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Failed to delete MCP server config.",
			Detail:  err.Error(),
		})
		return
	}

	rw.WriteHeader(http.StatusNoContent)
}

// Redirects the user to the MCP server's OAuth2 authorization URL.
//
// @Summary Initiate MCP server OAuth2 connect
// @ID initiate-mcp-server-oauth2-connect
// @Security CoderSessionToken
// @Tags MCP
// @Param organization path string true "Organization name or ID"
// @Param mcpserverconfig path string true "MCP server config ID" format(uuid)
// @Success 307
// @Router /api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) mcpServerOAuth2Connect(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	config := httpmw.MCPServerConfigParam(r)

	if !config.Enabled {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "MCP server is not enabled.",
		})
		return
	}

	if config.AuthType != "oauth2" {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "MCP server does not use OAuth2 authentication.",
		})
		return
	}

	if config.OAuth2AuthURL == "" {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "MCP server OAuth2 authorization URL is not configured.",
		})
		return
	}

	// Do not start an authorization flow when its grant cannot be persisted.
	if !api.Authorize(r, policy.ActionUpdatePersonal, rbac.ResourceUserObject(httpmw.APIKey(r).UserID)) {
		httpapi.Forbidden(rw)
		return
	}

	// Build the authorization URL. The frontend opens this in a popup.
	// The callback URL is on our server; after the exchange we store
	// the token and close the popup.
	state := uuid.New().String()
	callbackPath := mcpServerOAuth2CallbackPath(config.ID)
	http.SetCookie(rw, api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{
		Name:     "mcp_oauth2_state_" + config.ID.String(),
		Value:    state,
		Path:     callbackPath,
		MaxAge:   600, // 10 minutes
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	}))

	// PKCE (RFC 7636) is required by many OAuth2 providers (e.g.
	// Linear). We always send it because it is harmless when the
	// server ignores it and essential when it does not.
	verifier := oauth2.GenerateVerifier()
	http.SetCookie(rw, api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{
		Name:     "mcp_oauth2_verifier_" + config.ID.String(),
		Value:    verifier,
		Path:     callbackPath,
		MaxAge:   600, // 10 minutes
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	}))
	// Always overwrite the binding so a cookie left by an abandoned flow
	// cannot fail this one.
	http.SetCookie(rw, api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{
		Name:     "mcp_oauth2_resource_" + config.ID.String(),
		Value:    mcpOAuth2ResourceBinding(config.OAuth2Resource),
		Path:     callbackPath,
		MaxAge:   600, // 10 minutes
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	}))

	oauth2Config := &oauth2.Config{
		ClientID:     config.OAuth2ClientID,
		ClientSecret: config.OAuth2ClientSecret,
		Endpoint: oauth2.Endpoint{
			AuthURL:  config.OAuth2AuthURL,
			TokenURL: config.OAuth2TokenURL,
		},
		RedirectURL: fmt.Sprintf("%s%s", api.AccessURL.String(), callbackPath),
	}
	var scopes []string
	if config.OAuth2Scopes != "" {
		scopes = strings.Split(config.OAuth2Scopes, " ")
	}
	oauth2Config.Scopes = scopes
	authOpts := []oauth2.AuthCodeOption{oauth2.S256ChallengeOption(verifier)}
	if config.OAuth2Resource != "" {
		authOpts = append(authOpts, oauth2.SetAuthURLParam("resource", config.OAuth2Resource))
	}
	authURL := oauth2Config.AuthCodeURL(state, authOpts...)
	http.Redirect(rw, r, authURL, http.StatusTemporaryRedirect)
}

// Exchanges the authorization code for tokens and stores them.
//
// @Summary Handle MCP server OAuth2 callback
// @ID handle-mcp-server-oauth2-callback
// @Security CoderSessionToken
// @Tags MCP
// @Param mcpServer path string true "MCP server config ID" format(uuid)
// @Param code query string false "Authorization code issued by the provider. Required together with state on success."
// @Param state query string false "Opaque state issued by the connect endpoint. Required together with code on success."
// @Param error query string false "Provider error code. Present instead of code when authorization fails."
// @Param error_description query string false "Provider error description accompanying error."
// @Produce text/html
// @Success 200
// @Router /api/experimental/mcp/servers/{mcpServer}/oauth2/callback [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	apiKey := httpmw.APIKey(r)

	mcpServerID, ok := parseMCPServerConfigID(rw, r)
	if !ok {
		return
	}

	// Authorization codes are one-time credentials. Do not consume one when
	// the resulting token cannot be persisted.
	if !api.Authorize(r, policy.ActionUpdatePersonal, rbac.ResourceUserObject(apiKey.UserID)) {
		httpapi.Forbidden(rw)
		return
	}

	config, err := api.Database.GetMCPServerConfigByID(ctx, mcpServerID)
	if err != nil {
		if httpapi.Is404Error(err) {
			httpapi.ResourceNotFound(rw)
			return
		}
		httpapi.InternalServerError(rw, err)
		return
	}

	if !config.Enabled {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "MCP server is not enabled.",
		})
		return
	}

	if config.AuthType != "oauth2" {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "MCP server does not use OAuth2 authentication.",
		})
		return
	}

	// Check if the OAuth2 provider returned an error (e.g., user
	// denied consent).
	if oauthError := r.URL.Query().Get("error"); oauthError != "" {
		desc := r.URL.Query().Get("error_description")
		if desc == "" {
			desc = oauthError
		}
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "OAuth2 provider returned an error.",
			Detail:  desc,
		})
		return
	}

	code := r.URL.Query().Get("code")
	if code == "" {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Missing authorization code.",
		})
		return
	}

	// Validate the state parameter for CSRF protection.
	expectedState := ""
	if cookie, err := r.Cookie("mcp_oauth2_state_" + config.ID.String()); err == nil {
		expectedState = cookie.Value
	}
	actualState := r.URL.Query().Get("state")
	if expectedState == "" || actualState != expectedState {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Invalid or missing OAuth2 state parameter.",
		})
		return
	}
	// Clear the state cookie.
	callbackPath := mcpServerOAuth2CallbackPath(config.ID)
	http.SetCookie(rw, api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{
		Name:     "mcp_oauth2_state_" + config.ID.String(),
		Value:    "",
		Path:     callbackPath,
		MaxAge:   -1,
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	}))

	// Recover the PKCE code_verifier set during the connect step.
	var exchangeOpts []oauth2.AuthCodeOption
	if verifierCookie, err := r.Cookie("mcp_oauth2_verifier_" + config.ID.String()); err == nil {
		exchangeOpts = append(exchangeOpts, oauth2.VerifierOption(verifierCookie.Value))
	}
	if config.OAuth2Resource != "" {
		exchangeOpts = append(exchangeOpts, oauth2.SetAuthURLParam("resource", config.OAuth2Resource))
	}
	// Clear the verifier cookie regardless of whether it was present.
	http.SetCookie(rw, api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{
		Name:     "mcp_oauth2_verifier_" + config.ID.String(),
		Value:    "",
		Path:     callbackPath,
		MaxAge:   -1,
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	}))

	// RFC 8707 binds the code to the resource named in the authorization
	// request. When an admin changed the resource since connect, fail before
	// the exchange so the code is never redeemed for a resource the user did
	// not authorize.
	resourceBinding := ""
	if resourceCookie, err := r.Cookie("mcp_oauth2_resource_" + config.ID.String()); err == nil {
		resourceBinding = resourceCookie.Value
	}
	http.SetCookie(rw, api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{
		Name:     "mcp_oauth2_resource_" + config.ID.String(),
		Value:    "",
		Path:     callbackPath,
		MaxAge:   -1,
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	}))
	if resourceBinding != mcpOAuth2ResourceBinding(config.OAuth2Resource) {
		writeMCPConfigSupersededDuringAuth(ctx, rw)
		return
	}

	// Exchange the authorization code for tokens.
	oauth2Config := &oauth2.Config{
		ClientID:     config.OAuth2ClientID,
		ClientSecret: config.OAuth2ClientSecret,
		Endpoint: oauth2.Endpoint{
			AuthURL:  config.OAuth2AuthURL,
			TokenURL: config.OAuth2TokenURL,
		},
		RedirectURL: fmt.Sprintf("%s%s", api.AccessURL.String(), callbackPath),
	}
	var scopes []string
	if config.OAuth2Scopes != "" {
		scopes = strings.Split(config.OAuth2Scopes, " ")
	}
	oauth2Config.Scopes = scopes

	exchangeClient := *api.mcpHTTPClient
	exchangeClient.CheckRedirect = safedial.CheckSameOriginRedirect
	if exchangeClient.Timeout == 0 {
		exchangeClient.Timeout = 30 * time.Second
	}
	exchangeCtx := context.WithValue(ctx, oauth2.HTTPClient, &exchangeClient)
	token, err := oauth2Config.Exchange(exchangeCtx, code, exchangeOpts...)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{
			Message: "Failed to exchange authorization code for token.",
			Detail:  "The OAuth2 token exchange with the upstream provider failed.",
		})
		return
	}

	// Store the token for the user.
	refreshToken := ""
	if token.RefreshToken != "" {
		refreshToken = token.RefreshToken
	}

	var expiry sql.NullTime
	if !token.Expiry.IsZero() {
		expiry = sql.NullTime{Time: token.Expiry, Valid: true}
	}

	err = api.Database.InTx(func(tx database.Store) error {
		// Hold the config lock through the grant write so a concurrent update
		// cannot invalidate grants and then have this callback recreate one
		// for the old config.
		current, err := tx.GetMCPServerConfigByIDForUpdate(ctx, config.ID)
		if err != nil {
			return xerrors.Errorf("re-read MCP server config: %w", err)
		}
		if mcpOAuth2GrantBindingOf(current) != mcpOAuth2GrantBindingOf(config) {
			return errMCPConfigSupersededDuringAuth
		}
		_, err = tx.UpsertMCPServerUserToken(ctx, database.UpsertMCPServerUserTokenParams{
			MCPServerConfigID: config.ID,
			UserID:            apiKey.UserID,
			AccessToken:       token.AccessToken,
			AccessTokenKeyID:  sql.NullString{},
			RefreshToken:      refreshToken,
			RefreshTokenKeyID: sql.NullString{},
			TokenType:         token.TokenType,
			Expiry:            expiry,
		})
		return err
	}, nil)
	if errors.Is(err, errMCPConfigSupersededDuringAuth) {
		writeMCPConfigSupersededDuringAuth(ctx, rw)
		return
	}
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Failed to store OAuth2 token.",
			Detail:  err.Error(),
		})
		return
	}

	// Respond with a simple HTML page that closes the popup window.
	rw.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline'")
	rw.Header().Set("Content-Type", "text/html; charset=utf-8")
	rw.WriteHeader(http.StatusOK)
	_, _ = rw.Write([]byte(`<!DOCTYPE html><html><body><script>
		if (window.opener) {
			window.opener.postMessage({type: "mcp-oauth2-complete", serverID: "` + config.ID.String() + `"}, "` + api.AccessURL.String() + `");
			window.close();
		} else {
			document.body.innerText = "Authentication successful. You may close this window.";
		}
	</script></body></html>`))
}

func writeMCPConfigSupersededDuringAuth(ctx context.Context, rw http.ResponseWriter) {
	httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
		Message: "MCP server configuration changed during authorization.",
		Detail:  "The server's destination or OAuth settings were updated while the connection was in progress. Reconnect to authorize against the current configuration.",
	})
}

// Removes the user's stored OAuth2 token for an MCP server.
// Provider revocation is best-effort and cannot block local deletion.
//
// @Summary Disconnect MCP server OAuth2 token
// @ID disconnect-mcp-server-oauth2-token
// @Security CoderSessionToken
// @Tags MCP
// @Produce json
// @Param mcpServer path string true "MCP server config ID" format(uuid)
// @Success 200 {object} codersdk.MCPServerOAuth2DisconnectResponse
// @Router /api/v2/mcp/servers/{mcpServer}/oauth2/disconnect [delete]
// @x-apidocgen {"skip": true}
func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	apiKey := httpmw.APIKey(r)

	configID, parsed := httpmw.ParseUUIDParam(rw, r, "mcpServer")
	if !parsed {
		return
	}

	if !api.Authorize(r, policy.ActionUpdatePersonal, rbac.ResourceUserObject(apiKey.UserID)) {
		httpapi.Forbidden(rw)
		return
	}

	var (
		config database.MCPServerConfig
		token  database.MCPServerUserToken
	)
	// Serializable isolation keeps the revoked token aligned with the row deleted locally.
	err := api.Database.InTx(func(tx database.Store) error {
		//nolint:gocritic // Update permission permits this revocation read.
		dbToken, err := tx.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
			MCPServerConfigID: configID,
			UserID:            apiKey.UserID,
		})
		if err != nil {
			return err
		}
		// Load the config only after the token is found so callers
		// without a token cannot probe which config IDs exist. The
		// system context keeps disconnect available to token owners
		// who can no longer read the config, such as users removed
		// from the organization.
		//nolint:gocritic // Token owners keep disconnect access without config read.
		dbConfig, err := tx.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), configID)
		if err != nil {
			return err
		}
		if err := tx.DeleteMCPServerUserToken(ctx, database.DeleteMCPServerUserTokenParams{
			MCPServerConfigID: configID,
			UserID:            apiKey.UserID,
		}); err != nil {
			return err
		}
		config = dbConfig
		token = dbToken
		return nil
	}, &database.TxOptions{Isolation: sql.LevelSerializable})
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			// Nonexistent config IDs take the same path, so they
			// cannot be probed either.
			httpapi.Write(ctx, rw, http.StatusOK, codersdk.MCPServerOAuth2DisconnectResponse{})
			return
		}
		if dbauthz.IsNotAuthorizedError(err) {
			httpapi.Forbidden(rw)
			return
		}
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Failed to disconnect OAuth2 token.",
			Detail:  err.Error(),
		})
		return
	}

	resp := codersdk.MCPServerOAuth2DisconnectResponse{}
	if config.AuthType == "oauth2" {
		// The local token is already deleted, so a client abort must
		// not cancel the provider revocation; it has its own timeout.
		revoked, err := mcpclient.RevokeOAuth2Token(context.WithoutCancel(ctx), api.mcpHTTPClient, config, token)
		resp.TokenRevoked = revoked
		if err != nil {
			api.Logger.Warn(ctx, "failed to revoke MCP oauth2 token at provider",
				slog.F("server_slug", config.Slug),
				slog.Error(err),
			)
			// Provider error bodies may echo the client secret, so
			// callers only get a generic message.
			resp.TokenRevocationError = "The OAuth provider rejected the revocation request."
		}
	}

	httpapi.Write(ctx, rw, http.StatusOK, resp)
}

// refreshMCPUserToken attempts to refresh an expired OAuth2 token
// for the given MCP server config. Returns true when the token is
// valid (either still fresh or successfully refreshed), false when
// the token is expired and cannot be refreshed. Permanent refresh
// failures (e.g. revoked grants) are persisted so subsequent calls
// skip the provider without a network call.
func (api *API) refreshMCPUserToken(
	ctx context.Context,
	cfg database.MCPServerConfig,
	tok database.MCPServerUserToken,
) bool {
	if cfg.AuthType != "oauth2" {
		return true
	}
	if tok.OauthRefreshFailureReason != "" {
		return false
	}
	if tok.RefreshToken == "" {
		// No refresh token; connected only if not expired (or no
		// expiry set).
		return !tok.Expiry.Valid || tok.Expiry.Time.After(time.Now())
	}

	result, err := mcpclient.RefreshOAuth2Token(ctx, api.mcpHTTPClient, cfg, tok)
	if err != nil {
		api.Logger.Warn(ctx, "failed to refresh MCP oauth2 token",
			slog.F("server_slug", cfg.Slug),
			slog.Error(err),
		)
		if mcpclient.IsPermanentRefreshError(err) {
			return api.markMCPTokenRefreshFailure(ctx, cfg, tok, err)
		}
		// Transient failure; the token is unusable right now but a
		// later refresh may succeed.
		return false
	}

	if result.Refreshed {
		var expiry sql.NullTime
		if !result.Expiry.IsZero() {
			expiry = sql.NullTime{Time: result.Expiry, Valid: true}
		}

		// The caller can read the token, but a read-only key cannot persist refresh
		// results. System access permits storing rotated credentials after a
		// successful refresh.
		//nolint:gocritic // Token refresh persistence follows an authorized read of the same token.
		persistCtx := dbauthz.AsSystemRestricted(ctx)
		_, err = api.Database.UpdateMCPServerUserTokenFromRefresh(
			persistCtx,
			database.UpdateMCPServerUserTokenFromRefreshParams{
				ID:                tok.ID,
				UpdatedAt:         tok.UpdatedAt,
				AccessToken:       result.AccessToken,
				AccessTokenKeyID:  sql.NullString{},
				RefreshToken:      result.RefreshToken,
				RefreshTokenKeyID: sql.NullString{},
				TokenType:         result.TokenType,
				Expiry:            expiry,
			},
		)
		if err != nil {
			if errors.Is(err, sql.ErrNoRows) {
				connected, readErr := api.currentMCPUserTokenConnected(ctx, tok)
				if readErr == nil {
					return connected
				}
				err = readErr
			}
			api.Logger.Warn(ctx, "failed to persist refreshed MCP oauth2 token",
				slog.F("server_slug", cfg.Slug),
				slog.Error(err),
			)
		}
	}

	return true
}

func (api *API) currentMCPUserTokenConnected(
	ctx context.Context,
	tok database.MCPServerUserToken,
) (bool, error) {
	//nolint:gocritic // Refresh reconciliation follows an authorized read of the same token.
	current, err := api.Database.GetMCPServerUserToken(
		dbauthz.AsSystemRestricted(ctx),
		database.GetMCPServerUserTokenParams{
			MCPServerConfigID: tok.MCPServerConfigID,
			UserID:            tok.UserID,
		},
	)
	if errors.Is(err, sql.ErrNoRows) {
		return false, nil
	}
	if err != nil {
		return false, err
	}
	return current.OauthRefreshFailureReason == "" &&
		current.AccessToken != "" &&
		(!current.Expiry.Valid || current.Expiry.Time.After(time.Now())), nil
}

// markMCPTokenRefreshFailure persists a permanent refresh failure so
// later status checks skip the provider. The updated_at optimistic
// lock loses to concurrent refreshes: in that case the winner's row
// determines whether the token is still usable.
func (api *API) markMCPTokenRefreshFailure(
	ctx context.Context,
	cfg database.MCPServerConfig,
	tok database.MCPServerUserToken,
	refreshErr error,
) bool {
	//nolint:gocritic // Refresh failure recording follows an authorized read of the same token.
	_, err := api.Database.MarkMCPServerUserTokenRefreshFailure(
		dbauthz.AsSystemRestricted(ctx),
		database.MarkMCPServerUserTokenRefreshFailureParams{
			ID:                        tok.ID,
			UpdatedAt:                 tok.UpdatedAt,
			OauthRefreshFailureReason: mcpclient.RefreshFailureReason(refreshErr),
		},
	)
	if err == nil {
		return false
	}

	if xerrors.Is(err, sql.ErrNoRows) {
		connected, readErr := api.currentMCPUserTokenConnected(ctx, tok)
		if readErr == nil {
			return connected
		}
		err = readErr
	}

	api.Logger.Warn(ctx, "failed to persist MCP oauth2 refresh failure",
		slog.F("server_slug", cfg.Slug),
		slog.Error(err),
	)
	return false
}

// mcpServerOAuth2CallbackPath returns the OAuth2 callback path for a
// config. This path is frozen: it is the redirect URI registered with
// external authorization servers, so it must not change when other MCP
// routes move. The route registration in coderd.go and the OAuth cookie
// Path values must stay aligned with it.
func mcpServerOAuth2CallbackPath(configID uuid.UUID) string {
	return fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/callback", configID)
}

// parseMCPServerConfigID extracts the MCP server config UUID from the
// "mcpServer" path parameter, which is part of the frozen callback
// route shape.
func parseMCPServerConfigID(rw http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
	mcpServerID, err := uuid.Parse(chi.URLParam(r, "mcpServer"))
	if err != nil {
		httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
			Message: "Invalid MCP server config ID.",
			Detail:  err.Error(),
		})
		return uuid.Nil, false
	}
	return mcpServerID, true
}

// convertMCPServerConfig converts a database MCP server config to the
// SDK type. Secrets are never returned; only has_* booleans are set.
// Admin-only fields (OAuth2 client ID, auth URLs, etc.) are included.
func convertMCPServerConfig(config database.MCPServerConfig) codersdk.MCPServerConfig {
	return codersdk.MCPServerConfig{
		ID:             config.ID,
		OrganizationID: config.OrganizationID,
		DisplayName:    config.DisplayName,
		Slug:           config.Slug,
		Description:    config.Description,
		IconURL:        config.IconURL,

		Transport: config.Transport,
		URL:       config.Url,

		AuthType:            config.AuthType,
		OAuth2ClientID:      config.OAuth2ClientID,
		HasOAuth2Secret:     config.OAuth2ClientSecret != "",
		OAuth2AuthURL:       config.OAuth2AuthURL,
		OAuth2TokenURL:      config.OAuth2TokenURL,
		OAuth2RevocationURL: config.OAuth2RevocationURL,
		OAuth2Resource:      config.OAuth2Resource,
		OAuth2Scopes:        config.OAuth2Scopes,

		OAuth2ClientRegistration: codersdk.MCPServerOAuth2ClientRegistration(config.OAuth2ClientRegistration),

		APIKeyHeader: config.APIKeyHeader,
		HasAPIKey:    config.APIKeyValue != "",

		HasCustomHeaders: len(config.CustomHeaders) > 0 && config.CustomHeaders != "{}",

		ToolAllowList: coalesceStringSlice(config.ToolAllowList),
		ToolDenyList:  coalesceStringSlice(config.ToolDenyList),

		Availability: config.Availability,

		Enabled:             config.Enabled,
		ModelIntent:         config.ModelIntent,
		AllowInPlanMode:     config.AllowInPlanMode,
		ForwardCoderHeaders: config.ForwardCoderHeaders,
		HasSigningSecret:    config.SigningSecret != "",
		CreatedAt:           config.CreatedAt,
		UpdatedAt:           config.UpdatedAt,

		// Default per-user auth state. Handlers that know the
		// calling user's token state (list/get) overwrite this.
		AuthConnected: config.AuthType != "oauth2",
	}
}

// convertMCPServerConfigRedacted is the same as convertMCPServerConfig
// but strips admin-only fields (OAuth2 details, API key header) for
// non-admin callers.
func convertMCPServerConfigRedacted(config database.MCPServerConfig) codersdk.MCPServerConfig {
	c := convertMCPServerConfig(config)
	c.URL = ""
	c.Transport = ""
	c.OAuth2ClientID = ""
	c.OAuth2AuthURL = ""
	c.OAuth2TokenURL = ""
	c.OAuth2RevocationURL = ""
	c.OAuth2Resource = ""
	c.OAuth2Scopes = ""
	c.OAuth2ClientRegistration = ""
	c.APIKeyHeader = ""
	return c
}

// marshalCustomHeaders encodes a map of custom headers to JSON for
// database storage. A nil map produces an empty JSON object.
func marshalCustomHeaders(headers map[string]string) (string, error) {
	if headers == nil {
		return "{}", nil
	}
	encoded, err := json.Marshal(headers)
	if err != nil {
		return "", err
	}
	return string(encoded), nil
}

// trimStringSlice trims whitespace from each element and drops empty
// strings.
func trimStringSlice(ss []string) []string {
	if ss == nil {
		return nil
	}
	out := make([]string, 0, len(ss))
	for _, s := range ss {
		if trimmed := strings.TrimSpace(s); trimmed != "" {
			out = append(out, trimmed)
		}
	}
	return out
}

// coalesceStringSlice returns ss if non-nil, otherwise an empty
// non-nil slice. This prevents pq.Array from sending NULL for
// NOT NULL text[] columns.
func coalesceStringSlice(ss []string) []string {
	if ss == nil {
		return []string{}
	}
	return ss
}

// parseMCPOAuth2Resource parses an RFC 8707 resource indicator, which
// must be an absolute URI without a fragment (RFC 8707 section 2).
func parseMCPOAuth2Resource(resource string) (*url.URL, error) {
	if strings.Contains(resource, "#") {
		return nil, xerrors.New("oauth2_resource must not contain a fragment")
	}
	if !isURIText(resource) {
		return nil, xerrors.New("oauth2_resource must be a valid URI")
	}
	parsed, err := url.Parse(resource)
	if err != nil {
		return nil, xerrors.New("oauth2_resource must be a valid URI")
	}
	// url.Parse validates an IP literal host, but it also accepts "[" and
	// "]" elsewhere and a zone identifier in an IP literal, neither of
	// which RFC 3986 allows.
	ipLiteral := strings.HasPrefix(parsed.Host, "[")
	if strings.ContainsAny(parsed.Opaque+parsed.EscapedPath()+parsed.RawQuery, "[]") ||
		(!ipLiteral && strings.ContainsAny(parsed.Host, "[]")) ||
		(ipLiteral && strings.Contains(parsed.Host, "%")) {
		return nil, xerrors.New("oauth2_resource must be a valid URI")
	}
	if !parsed.IsAbs() {
		return nil, xerrors.New("oauth2_resource must be an absolute URI")
	}
	// A resource indicator names a resource for the authorization server,
	// so it must not carry credentials.
	if parsed.User != nil {
		return nil, xerrors.New("oauth2_resource must not contain user information")
	}
	return parsed, nil
}

// isURIText reports whether s contains only characters RFC 3986 allows
// in a URI, with every "%" starting a percent-encoded octet. url.Parse
// alone accepts spaces and other characters a URI cannot contain.
func isURIText(s string) bool {
	for i := 0; i < len(s); i++ {
		c := s[i]
		switch {
		case c == '%':
			if i+2 >= len(s) || !isHexDigit(s[i+1]) || !isHexDigit(s[i+2]) {
				return false
			}
			i += 2
		case !isUnreserved(c) && strings.IndexByte(":/?#[]@!$&'()*+,;=", c) < 0:
			return false
		}
	}
	return true
}

// normalizePercentEncoding decodes percent-encoded unreserved characters
// and uppercases the hex digits of the remaining percent-encodings (RFC
// 3986 sections 6.2.2.1 and 6.2.2.2). Invalid escapes are left as they are.
func normalizePercentEncoding(s string) string {
	out := make([]byte, 0, len(s))
	for i := 0; i < len(s); i++ {
		if s[i] == '%' && i+2 < len(s) && isHexDigit(s[i+1]) && isHexDigit(s[i+2]) {
			if c := unhex(s[i+1])<<4 | unhex(s[i+2]); isUnreserved(c) {
				out = append(out, c)
			} else {
				out = append(out, strings.ToUpper(s[i:i+3])...)
			}
			i += 2
			continue
		}
		out = append(out, s[i])
	}
	return string(out)
}

func isUnreserved(c byte) bool {
	return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || ('0' <= c && c <= '9') ||
		c == '-' || c == '.' || c == '_' || c == '~'
}

func isHexDigit(c byte) bool {
	return ('0' <= c && c <= '9') || ('a' <= c && c <= 'f') || ('A' <= c && c <= 'F')
}

// unhex returns the value of a hex digit that isHexDigit accepted.
func unhex(c byte) byte {
	switch {
	case c >= 'a':
		return c - 'a' + 10
	case c >= 'A':
		return c - 'A' + 10
	default:
		return c - '0'
	}
}

// validateMCPOAuth2ResourceRequest writes a 400 response and returns
// false when a non-empty oauth2_resource is invalid. An empty value is
// valid because it means no resource parameter is sent.
func validateMCPOAuth2ResourceRequest(ctx context.Context, rw http.ResponseWriter, resource string) bool {
	trimmed := strings.TrimSpace(resource)
	if trimmed == "" {
		return true
	}
	if _, err := parseMCPOAuth2Resource(trimmed); err != nil {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Invalid OAuth2 resource.",
			Detail:  err.Error(),
		})
		return false
	}
	return true
}

// mcpOAuth2ResourceBinding returns the callback cookie value that ties an
// authorization flow to the resource indicator it requested. A digest keeps
// the cookie short and cookie-safe for any URI. No resource maps to an empty
// value, which a missing cookie also matches.
func mcpOAuth2ResourceBinding(resource string) string {
	if resource == "" {
		return ""
	}
	sum := sha256.Sum256([]byte(resource))
	return hex.EncodeToString(sum[:])
}

// mcpOAuth2ResourceIndicator picks the RFC 8707 resource indicator for
// an MCP server: the most specific advertised Protected Resource
// Metadata resource that covers the server URL, stored verbatim, else
// the server URL in canonical form, or "" when that is not a valid
// resource indicator. As in the MCP SDKs, a resource covers URLs on its
// origin at or under its path, so "/mcp" covers "/mcp/" but "/mcp/" does
// not cover "/mcp". Paths and queries compare in RFC 3986 normal form,
// where "%2F" does not end a segment, and IP addresses compare by value.
// Unlike the SDKs, a resource with a query, even an empty one, covers
// only URLs with the same query, which can select a tenant.
func mcpOAuth2ResourceIndicator(serverURL *url.URL, advertised []string) string {
	serverPath := comparableURIPath(serverURL)
	serverQuery, serverHasQuery := comparableURIQuery(serverURL)
	best := ""
	bestPathLen := -1
	bestHasQuery := false
	for _, raw := range advertised {
		candidate := strings.TrimSpace(raw)
		parsed, err := parseMCPOAuth2Resource(candidate)
		if err != nil {
			continue
		}
		if !sameOrigin(parsed, serverURL) {
			continue
		}
		query, hasQuery := comparableURIQuery(parsed)
		if hasQuery && (!serverHasQuery || query != serverQuery) {
			continue
		}
		entryPath := comparableURIPath(parsed)
		if entryPath != serverPath && !strings.HasPrefix(serverPath, withTrailingSlash(entryPath)) {
			continue
		}
		if len(entryPath) > bestPathLen || (len(entryPath) == bestPathLen && hasQuery && !bestHasQuery) {
			best = candidate
			bestPathLen = len(entryPath)
			bestHasQuery = hasQuery
		}
	}
	if best != "" {
		return best
	}

	canonical := *serverURL
	canonical.Scheme = strings.ToLower(canonical.Scheme)
	canonical.Host = strings.ToLower(canonical.Host)
	canonical.User = nil
	canonical.Fragment = ""
	canonical.RawFragment = ""
	fallback := canonical.String()
	// url.Parse accepts server URLs that are not valid resource
	// indicators, and sending none beats sending an invalid one.
	if _, err := parseMCPOAuth2Resource(fallback); err != nil {
		return ""
	}
	return fallback
}

// comparableURIPath returns u's escaped path with percent-encodings
// normalized and then dot segments removed, so "%2E%2E" is a dot segment.
// An empty path after an authority is "/" (RFC 3986 section 6.2.3).
func comparableURIPath(u *url.URL) string {
	normalized := *u
	// Normalizing escapes leaves the unescaped Path unchanged, so this
	// RawPath stays a valid encoding of it.
	normalized.RawPath = normalizePercentEncoding(u.EscapedPath())
	// Resolving a URL against itself removes dot segments and keeps escapes.
	path := normalized.ResolveReference(&normalized).EscapedPath()
	if path == "" && u.Host != "" {
		return "/"
	}
	return path
}

// comparableURIQuery returns u's query with percent-encodings normalized
// and whether u has a query. A trailing "?" is an empty query, which RFC
// 3986 distinguishes from no query.
func comparableURIQuery(u *url.URL) (string, bool) {
	return normalizePercentEncoding(u.RawQuery), u.RawQuery != "" || u.ForceQuery
}

// sameOrigin reports whether two URLs have the same scheme, host, and
// port, where an omitted port is the scheme's default.
func sameOrigin(a, b *url.URL) bool {
	return strings.EqualFold(a.Scheme, b.Scheme) &&
		sameHost(a.Hostname(), b.Hostname()) &&
		portOrDefault(a) == portOrDefault(b)
}

// sameHost compares IP addresses by value, so "::1" equals
// "0:0:0:0:0:0:0:1", and other hosts case-insensitively.
func sameHost(a, b string) bool {
	addrA, errA := netip.ParseAddr(a)
	addrB, errB := netip.ParseAddr(b)
	if errA == nil && errB == nil {
		return addrA == addrB
	}
	return strings.EqualFold(a, b)
}

func portOrDefault(u *url.URL) string {
	if port := u.Port(); port != "" {
		// An origin's port has no leading zeros (RFC 9110 section 4.3.1).
		if trimmed := strings.TrimLeft(port, "0"); trimmed != "" {
			return trimmed
		}
		return "0"
	}
	switch strings.ToLower(u.Scheme) {
	case "http":
		return "80"
	case "https":
		return "443"
	default:
		return ""
	}
}

func withTrailingSlash(path string) string {
	if strings.HasSuffix(path, "/") {
		return path
	}
	return path + "/"
}

// mcpOAuth2Discovery holds the result of MCP OAuth2 auto-discovery
// and Dynamic Client Registration.
type mcpOAuth2Discovery struct {
	serverURL     string // the MCP server URL the client is registered for
	clientID      string
	clientSecret  string
	authURL       string
	tokenURL      string
	revocationURL string
	resource      string // RFC 8707 resource indicator
	scopes        string // space-separated
}

// protectedResourceMetadata represents the response from a
// Protected Resource Metadata endpoint per RFC 9728 §2.
type protectedResourceMetadata struct {
	Resource             resourceIdentifiers `json:"resource"`
	AuthorizationServers []string            `json:"authorization_servers"`
	ScopesSupported      []string            `json:"scopes_supported,omitempty"`
}

// resourceIdentifiers tolerates both a single JSON string and an
// array of strings. RFC 9728 §2 defines "resource" as a string, but
// some servers (e.g. GitLab's official MCP server) return an array
// when the metadata document covers multiple resources.
type resourceIdentifiers []string

func (r *resourceIdentifiers) UnmarshalJSON(data []byte) error {
	var single string
	if err := json.Unmarshal(data, &single); err == nil {
		*r = resourceIdentifiers{single}
		return nil
	}
	var many []string
	if err := json.Unmarshal(data, &many); err != nil {
		return xerrors.New("resource must be a string or an array of strings")
	}
	*r = resourceIdentifiers(many)
	return nil
}

// authServerMetadata represents the response from an Authorization
// Server Metadata endpoint per RFC 8414 §2.
type authServerMetadata struct {
	Issuer                string   `json:"issuer"`
	AuthorizationEndpoint string   `json:"authorization_endpoint"`
	TokenEndpoint         string   `json:"token_endpoint"`
	RegistrationEndpoint  string   `json:"registration_endpoint,omitempty"`
	RevocationEndpoint    string   `json:"revocation_endpoint,omitempty"`
	ScopesSupported       []string `json:"scopes_supported,omitempty"`
}

// fetchJSON performs a GET request to the given URL with the
// standard MCP OAuth2 discovery headers and decodes the JSON
// response into dest. It returns nil on success or an error
// if the request fails or the server returns a non-200 status.
func fetchJSON(ctx context.Context, httpClient *http.Client, rawURL string, dest any) error {
	req, err := http.NewRequestWithContext(
		ctx, http.MethodGet, rawURL, nil,
	)
	if err != nil {
		return xerrors.Errorf("create request for %s: %w", rawURL, err)
	}
	req.Header.Set("Accept", "application/json")
	req.Header.Set("MCP-Protocol-Version", mcpProtocolVersion)

	resp, err := httpClient.Do(req)
	if err != nil {
		return xerrors.Errorf("GET %s: %w", rawURL, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return xerrors.Errorf(
			"GET %s returned HTTP %d", rawURL, resp.StatusCode,
		)
	}

	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		return xerrors.Errorf(
			"read response from %s: %w", rawURL, err,
		)
	}

	if err := json.Unmarshal(body, dest); err != nil {
		return xerrors.Errorf(
			"decode JSON from %s: %w", rawURL, err,
		)
	}

	return nil
}

// discoverProtectedResource discovers the Protected Resource
// Metadata for the given MCP server per RFC 9728 §3.1. It
// tries the path-aware well-known URL first, then falls back
// to the root-level URL.
//
// Path-aware: GET {origin}/.well-known/oauth-protected-resource{path}
// Root:       GET {origin}/.well-known/oauth-protected-resource
func discoverProtectedResource(
	ctx context.Context, httpClient *http.Client, origin, path string,
) (*protectedResourceMetadata, error) {
	var urls []string

	// Per RFC 9728 §3.1, when the resource URL contains a
	// path component, the well-known URI is constructed by
	// inserting the well-known prefix before the path.
	if path != "" && path != "/" {
		urls = append(
			urls,
			origin+"/.well-known/oauth-protected-resource"+path,
		)
	}
	// Always try the root-level URL as a fallback.
	urls = append(
		urls, origin+"/.well-known/oauth-protected-resource",
	)

	var lastErr error
	for _, u := range urls {
		var meta protectedResourceMetadata
		if err := fetchJSON(ctx, httpClient, u, &meta); err != nil {
			lastErr = err
			continue
		}
		if len(meta.AuthorizationServers) == 0 {
			lastErr = xerrors.Errorf(
				"protected resource metadata at %s "+
					"has no authorization_servers", u,
			)
			continue
		}
		return &meta, nil
	}

	return nil, xerrors.Errorf(
		"discover protected resource metadata: %w", lastErr,
	)
}

// discoverAuthServerMetadata discovers the Authorization Server
// Metadata per RFC 8414 §3.1. When the authorization server
// issuer URL has a path component, the metadata URL is
// path-aware. Falls back to root-level and OpenID Connect
// discovery as a last resort.
//
// Path-aware: {origin}/.well-known/oauth-authorization-server{path}
// Root:       {origin}/.well-known/oauth-authorization-server
// OpenID:     {issuer}/.well-known/openid-configuration
func discoverAuthServerMetadata(
	ctx context.Context, httpClient *http.Client, authServerURL string,
) (*authServerMetadata, error) {
	parsed, err := url.Parse(authServerURL)
	if err != nil {
		return nil, xerrors.Errorf(
			"parse auth server URL: %w", err,
		)
	}
	asOrigin := fmt.Sprintf(
		"%s://%s", parsed.Scheme, parsed.Host,
	)
	asPath := parsed.Path

	var urls []string

	// Per RFC 8414 §3.1, if the issuer URL has a path,
	// insert the well-known prefix before the path.
	if asPath != "" && asPath != "/" {
		urls = append(
			urls,
			asOrigin+"/.well-known/oauth-authorization-server"+asPath,
		)
	}
	// Root-level fallback.
	urls = append(
		urls,
		asOrigin+"/.well-known/oauth-authorization-server",
	)
	// OpenID Connect discovery as a last resort. Note: this is
	// tried after RFC 8414 (unlike the previous mcp-go code that
	// tried OIDC first) because RFC 8414 is the MCP spec's
	// recommended discovery mechanism.
	// Per OpenID Connect Discovery 1.0 §4, the well-known URL
	// is formed by appending to the full issuer (including
	// path), not just the origin.
	urls = append(
		urls,
		strings.TrimRight(authServerURL, "/")+
			"/.well-known/openid-configuration",
	)

	var lastErr error
	for _, u := range urls {
		var meta authServerMetadata
		if err := fetchJSON(ctx, httpClient, u, &meta); err != nil {
			lastErr = err
			continue
		}
		if meta.AuthorizationEndpoint == "" || meta.TokenEndpoint == "" {
			lastErr = xerrors.Errorf(
				"auth server metadata at %s missing required "+
					"endpoints", u,
			)
			continue
		}
		return &meta, nil
	}

	return nil, xerrors.Errorf(
		"discover auth server metadata: %w", lastErr,
	)
}

// registerOAuth2Client performs Dynamic Client Registration per
// RFC 7591 by POSTing client metadata to the registration
// endpoint and returning the assigned client_id and optional
// client_secret.
func registerOAuth2Client(
	ctx context.Context, httpClient *http.Client,
	registrationEndpoint, callbackURL, clientName string,
) (clientID string, clientSecret string, err error) {
	payload := map[string]any{
		"client_name":                clientName,
		"redirect_uris":              []string{callbackURL},
		"token_endpoint_auth_method": "none",
		"grant_types":                []string{"authorization_code", "refresh_token"},
		"response_types":             []string{"code"},
	}

	body, err := json.Marshal(payload)
	if err != nil {
		return "", "", xerrors.Errorf(
			"marshal registration request: %w", err,
		)
	}

	req, err := http.NewRequestWithContext(
		ctx, http.MethodPost,
		registrationEndpoint, bytes.NewReader(body),
	)
	if err != nil {
		return "", "", xerrors.Errorf(
			"create registration request: %w", err,
		)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")

	resp, err := httpClient.Do(req)
	if err != nil {
		return "", "", xerrors.Errorf(
			"POST %s: %w", registrationEndpoint, err,
		)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		return "", "", xerrors.Errorf(
			"read registration response: %w", err,
		)
	}

	if resp.StatusCode != http.StatusOK &&
		resp.StatusCode != http.StatusCreated {
		// Truncate to avoid leaking verbose upstream errors
		// through the API.
		const maxErrBody = 512
		errMsg := string(respBody)
		if len(errMsg) > maxErrBody {
			errMsg = errMsg[:maxErrBody] + "..."
		}
		return "", "", xerrors.Errorf(
			"registration endpoint returned HTTP %d: %s",
			resp.StatusCode, errMsg,
		)
	}

	var result struct {
		ClientID     string `json:"client_id"`
		ClientSecret string `json:"client_secret"`
	}
	if err := json.Unmarshal(respBody, &result); err != nil {
		return "", "", xerrors.Errorf(
			"decode registration response: %w", err,
		)
	}
	if result.ClientID == "" {
		return "", "", xerrors.New(
			"registration response missing client_id",
		)
	}

	return result.ClientID, result.ClientSecret, nil
}

// registerMCPOAuth2Client discovers the authorization server of the MCP
// server at serverURL and registers an OAuth2 client whose redirect URI
// is the callback of the config configID. A discovered revocation
// endpoint that fails the HTTPS policy is dropped instead of failing
// the registration.
func (api *API) registerMCPOAuth2Client(ctx context.Context, configID uuid.UUID, serverURL string) (*mcpOAuth2Discovery, error) {
	callbackURL := api.AccessURL.String() + mcpServerOAuth2CallbackPath(configID)
	// Discovery targets are attacker-influenced (the MCP server URL and
	// any endpoints or redirects it advertises), so all discovery traffic
	// goes through an SSRF-guarded client that refuses private/internal
	// destinations (CDM-02-002).
	httpClient := *api.mcpHTTPClient
	if httpClient.Timeout == 0 {
		httpClient.Timeout = 30 * time.Second
	}
	result, err := discoverAndRegisterMCPOAuth2(ctx, &httpClient, serverURL, callbackURL)
	if err != nil {
		api.Logger.Warn(ctx, "mcp oauth2 auto-discovery failed",
			slog.F("url", serverURL),
			slog.Error(err),
		)
		return nil, err
	}
	if result.revocationURL != "" {
		if err := mcpclient.ValidateRevocationEndpoint(result.revocationURL); err != nil {
			api.Logger.Warn(ctx, "ignoring discovered MCP oauth2 revocation endpoint",
				slog.F("url", serverURL),
				slog.Error(err),
			)
			result.revocationURL = ""
		}
	}
	result.serverURL = serverURL
	return result, nil
}

// discoverAndRegisterMCPOAuth2 performs the full MCP OAuth2
// discovery and Dynamic Client Registration flow:
//
//  1. Discover the authorization server via Protected Resource
//     Metadata (RFC 9728).
//  2. Fetch Authorization Server Metadata (RFC 8414).
//  3. Register a client via Dynamic Client Registration
//     (RFC 7591).
//  4. Return the discovered endpoints and credentials.
//
// Unlike a root-only approach, this implementation follows the
// path-aware well-known URI construction rules from RFC 9728
// §3.1 and RFC 8414 §3.1, which is required for servers that
// serve metadata at path-specific URLs (e.g.
// https://api.githubcopilot.com/mcp/).
func discoverAndRegisterMCPOAuth2(ctx context.Context, httpClient *http.Client, mcpServerURL, callbackURL string) (*mcpOAuth2Discovery, error) {
	// Parse the MCP server URL into origin and path.
	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)
	path := parsed.Path

	// Step 1: Discover the Protected Resource Metadata
	// (RFC 9728) to find the authorization server.
	prm, err := discoverProtectedResource(ctx, httpClient, origin, path)
	if err != nil {
		return nil, xerrors.Errorf(
			"protected resource discovery: %w", err,
		)
	}

	// Step 2: Fetch Authorization Server Metadata (RFC 8414)
	// from the first advertised authorization server.
	asMeta, err := discoverAuthServerMetadata(
		ctx, httpClient, prm.AuthorizationServers[0],
	)
	if err != nil {
		return nil, xerrors.Errorf(
			"auth server metadata discovery: %w", err,
		)
	}

	// Only RegistrationEndpoint needs checking here;
	// discoverAuthServerMetadata already validates that
	// AuthorizationEndpoint and TokenEndpoint are present.
	if asMeta.RegistrationEndpoint == "" {
		return nil, xerrors.New(
			"authorization server does not advertise a " +
				"registration_endpoint (dynamic client " +
				"registration may not be supported)",
		)
	}

	// Step 3: Register via Dynamic Client Registration
	// (RFC 7591).
	clientID, clientSecret, err := registerOAuth2Client(
		ctx, httpClient, asMeta.RegistrationEndpoint, callbackURL, "Coder",
	)
	if err != nil {
		return nil, xerrors.Errorf(
			"dynamic client registration: %w", err,
		)
	}

	scopes := strings.Join(asMeta.ScopesSupported, " ")

	return &mcpOAuth2Discovery{
		clientID:      clientID,
		clientSecret:  clientSecret,
		authURL:       asMeta.AuthorizationEndpoint,
		tokenURL:      asMeta.TokenEndpoint,
		revocationURL: asMeta.RevocationEndpoint,
		resource:      mcpOAuth2ResourceIndicator(parsed, prm.Resource),
		scopes:        scopes,
	}, nil
}