From 99653371e1c9e68d11943f4ffb261f3a36c7b424 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 2 Jul 2025 13:46:02 +0200 Subject: [PATCH] feat(mcp): add experiment control for MCP server HTTP endpoints - Add ExperimentMCPServerHTTP constant for controlled rollout - Refactor OAuth2 middleware into generic experiment middleware - Make experiment middleware variadic to support multiple experiments - Apply experiment gating to /api/experimental/mcp/http routes - Maintain development mode bypass for testing flexibility - Remove OAuth2-specific middleware in favor of reusable pattern Change-Id: Ia5b3d0615f4a5a45e5a233b1ea92e8bdc0a5f17e Signed-off-by: Thomas Kosiewski --- coderd/apidoc/docs.go | 7 +++-- coderd/apidoc/swagger.json | 7 +++-- coderd/coderd.go | 7 +++-- coderd/httpmw/experiments.go | 50 ++++++++++++++++++++++++++++++---- coderd/oauth2.go | 14 ---------- codersdk/deployment.go | 37 ++++++++++++++++++++----- docs/reference/api/schemas.md | 1 + go.mod | 2 +- site/src/api/typesGenerated.ts | 2 ++ 9 files changed, 93 insertions(+), 34 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ce420cbf1a6b4..e102b6f22fd4a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12551,11 +12551,13 @@ const docTemplate = `{ "notifications", "workspace-usage", "web-push", - "oauth2" + "oauth2", + "mcp-server-http" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentExample": "This isn't used for anything.", + "ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentOAuth2": "Enables OAuth2 provider functionality.", "ExperimentWebPush": "Enables web push notifications through the browser.", @@ -12567,7 +12569,8 @@ const docTemplate = `{ "ExperimentNotifications", "ExperimentWorkspaceUsage", "ExperimentWebPush", - "ExperimentOAuth2" + "ExperimentOAuth2", + "ExperimentMCPServerHTTP" ] }, "codersdk.ExternalAuth": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0cfb7944c7c65..95a08f2f53c9b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11232,11 +11232,13 @@ "notifications", "workspace-usage", "web-push", - "oauth2" + "oauth2", + "mcp-server-http" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentExample": "This isn't used for anything.", + "ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentOAuth2": "Enables OAuth2 provider functionality.", "ExperimentWebPush": "Enables web push notifications through the browser.", @@ -11248,7 +11250,8 @@ "ExperimentNotifications", "ExperimentWorkspaceUsage", "ExperimentWebPush", - "ExperimentOAuth2" + "ExperimentOAuth2", + "ExperimentMCPServerHTTP" ] }, "codersdk.ExternalAuth": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 9a6255ca0ecb6..08915bc29d8fb 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -922,7 +922,7 @@ func New(options *Options) *API { // logging into Coder with an external OAuth2 provider. r.Route("/oauth2", func(r chi.Router) { r.Use( - api.oAuth2ProviderMiddleware, + httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2), ) r.Route("/authorize", func(r chi.Router) { r.Use( @@ -973,6 +973,9 @@ func New(options *Options) *API { r.Get("/prompts", api.aiTasksPrompts) }) r.Route("/mcp", func(r chi.Router) { + r.Use( + httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2, codersdk.ExperimentMCPServerHTTP), + ) // MCP HTTP transport endpoint with mandatory authentication r.Mount("/http", api.mcpHTTPHandler()) }) @@ -1473,7 +1476,7 @@ func New(options *Options) *API { r.Route("/oauth2-provider", func(r chi.Router) { r.Use( apiKeyMiddleware, - api.oAuth2ProviderMiddleware, + httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2), ) r.Route("/apps", func(r chi.Router) { r.Get("/", api.oAuth2ProviderApps) diff --git a/coderd/httpmw/experiments.go b/coderd/httpmw/experiments.go index 7c802725b91e6..7884443c1d011 100644 --- a/coderd/httpmw/experiments.go +++ b/coderd/httpmw/experiments.go @@ -3,21 +3,59 @@ package httpmw import ( "fmt" "net/http" + "strings" + "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" ) -func RequireExperiment(experiments codersdk.Experiments, experiment codersdk.Experiment) func(next http.Handler) http.Handler { +// RequireExperiment returns middleware that checks if all required experiments are enabled. +// If any experiment is disabled, it returns a 403 Forbidden response with details about the missing experiments. +func RequireExperiment(experiments codersdk.Experiments, requiredExperiments ...codersdk.Experiment) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !experiments.Enabled(experiment) { - httpapi.Write(r.Context(), w, http.StatusForbidden, codersdk.Response{ - Message: fmt.Sprintf("Experiment '%s' is required but not enabled", experiment), - }) - return + for _, experiment := range requiredExperiments { + if !experiments.Enabled(experiment) { + var experimentNames []string + for _, exp := range requiredExperiments { + experimentNames = append(experimentNames, string(exp)) + } + + // Print a message that includes the experiment names + // even if some experiments are already enabled. + var message string + if len(requiredExperiments) == 1 { + message = fmt.Sprintf("%s functionality requires enabling the '%s' experiment.", + requiredExperiments[0].DisplayName(), requiredExperiments[0]) + } else { + message = fmt.Sprintf("This functionality requires enabling the following experiments: %s", + strings.Join(experimentNames, ", ")) + } + + httpapi.Write(r.Context(), w, http.StatusForbidden, codersdk.Response{ + Message: message, + }) + return + } } + next.ServeHTTP(w, r) }) } } + +// RequireExperimentWithDevBypass checks if ALL the given experiments are enabled, +// but bypasses the check in development mode (buildinfo.IsDev()). +func RequireExperimentWithDevBypass(experiments codersdk.Experiments, requiredExperiments ...codersdk.Experiment) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if buildinfo.IsDev() { + next.ServeHTTP(w, r) + return + } + + RequireExperiment(experiments, requiredExperiments...)(next).ServeHTTP(w, r) + }) + } +} diff --git a/coderd/oauth2.go b/coderd/oauth2.go index 4f935e1f5b4fc..88f108c5fc13b 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -16,7 +16,6 @@ import ( "github.com/sqlc-dev/pqtype" - "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" @@ -37,19 +36,6 @@ const ( displaySecretLength = 6 // Length of visible part in UI (last 6 characters) ) -func (api *API) oAuth2ProviderMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - if !api.Experiments.Enabled(codersdk.ExperimentOAuth2) && !buildinfo.IsDev() { - httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{ - Message: "OAuth2 provider functionality requires enabling the 'oauth2' experiment.", - }) - return - } - - next.ServeHTTP(rw, r) - }) -} - // @Summary Get OAuth2 applications. // @ID get-oauth2-applications // @Security CoderSessionToken diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 1421cd082e8ba..b24e321b8e434 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -16,6 +16,8 @@ import ( "github.com/google/uuid" "golang.org/x/mod/semver" + "golang.org/x/text/cases" + "golang.org/x/text/language" "golang.org/x/xerrors" "github.com/coreos/go-oidc/v3/oidc" @@ -3342,8 +3344,33 @@ const ( ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking. ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser. ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality. + ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality. ) +func (e Experiment) DisplayName() string { + switch e { + case ExperimentExample: + return "Example Experiment" + case ExperimentAutoFillParameters: + return "Auto-fill Template Parameters" + case ExperimentNotifications: + return "SMTP and Webhook Notifications" + case ExperimentWorkspaceUsage: + return "Workspace Usage Tracking" + case ExperimentWebPush: + return "Browser Push Notifications" + case ExperimentOAuth2: + return "OAuth2 Provider Functionality" + case ExperimentMCPServerHTTP: + return "MCP HTTP Server Functionality" + default: + // Split on hyphen and convert to title case + // e.g. "web-push" -> "Web Push", "mcp-server-http" -> "Mcp Server Http" + caser := cases.Title(language.English) + return caser.String(strings.ReplaceAll(string(e), "-", " ")) + } +} + // ExperimentsKnown should include all experiments defined above. var ExperimentsKnown = Experiments{ ExperimentExample, @@ -3352,6 +3379,7 @@ var ExperimentsKnown = Experiments{ ExperimentWorkspaceUsage, ExperimentWebPush, ExperimentOAuth2, + ExperimentMCPServerHTTP, } // ExperimentsSafe should include all experiments that are safe for @@ -3369,14 +3397,9 @@ var ExperimentsSafe = Experiments{} // @typescript-ignore Experiments type Experiments []Experiment -// Returns a list of experiments that are enabled for the deployment. +// Enabled returns a list of experiments that are enabled for the deployment. func (e Experiments) Enabled(ex Experiment) bool { - for _, v := range e { - if v == ex { - return true - } - } - return false + return slices.Contains(e, ex) } func (c *Client) Experiments(ctx context.Context) (Experiments, error) { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 618a462390166..281a3a8a19e61 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3040,6 +3040,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `workspace-usage` | | `web-push` | | `oauth2` | +| `mcp-server-http` | ## codersdk.ExternalAuth diff --git a/go.mod b/go.mod index cd92b8f3a36dd..d12b102238423 100644 --- a/go.mod +++ b/go.mod @@ -206,7 +206,7 @@ require ( golang.org/x/sync v0.14.0 golang.org/x/sys v0.33.0 golang.org/x/term v0.32.0 - golang.org/x/text v0.25.0 // indirect + golang.org/x/text v0.25.0 golang.org/x/tools v0.33.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da google.golang.org/api v0.231.0 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 05adcd927be0f..4ab5403081a60 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -794,6 +794,7 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning"; export type Experiment = | "auto-fill-parameters" | "example" + | "mcp-server-http" | "notifications" | "oauth2" | "web-push" @@ -802,6 +803,7 @@ export type Experiment = export const Experiments: Experiment[] = [ "auto-fill-parameters", "example", + "mcp-server-http", "notifications", "oauth2", "web-push",