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", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy