Skip to content

Commit 610c661

Browse files
committed
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 <tk@coder.com>
1 parent 51f84ce commit 610c661

File tree

8 files changed

+87
-33
lines changed

8 files changed

+87
-33
lines changed

coderd/apidoc/docs.go

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,7 @@ func New(options *Options) *API {
922922
// logging into Coder with an external OAuth2 provider.
923923
r.Route("/oauth2", func(r chi.Router) {
924924
r.Use(
925-
api.oAuth2ProviderMiddleware,
925+
httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2),
926926
)
927927
r.Route("/authorize", func(r chi.Router) {
928928
r.Use(
@@ -973,6 +973,9 @@ func New(options *Options) *API {
973973
r.Get("/prompts", api.aiTasksPrompts)
974974
})
975975
r.Route("/mcp", func(r chi.Router) {
976+
r.Use(
977+
httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2, codersdk.ExperimentMCPServerHTTP),
978+
)
976979
// MCP HTTP transport endpoint with mandatory authentication
977980
r.Mount("/http", api.mcpHTTPHandler())
978981
})
@@ -1473,7 +1476,7 @@ func New(options *Options) *API {
14731476
r.Route("/oauth2-provider", func(r chi.Router) {
14741477
r.Use(
14751478
apiKeyMiddleware,
1476-
api.oAuth2ProviderMiddleware,
1479+
httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2),
14771480
)
14781481
r.Route("/apps", func(r chi.Router) {
14791482
r.Get("/", api.oAuth2ProviderApps)

coderd/httpmw/experiments.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,59 @@ package httpmw
33
import (
44
"fmt"
55
"net/http"
6+
"strings"
67

8+
"github.com/coder/coder/v2/buildinfo"
79
"github.com/coder/coder/v2/coderd/httpapi"
810
"github.com/coder/coder/v2/codersdk"
911
)
1012

11-
func RequireExperiment(experiments codersdk.Experiments, experiment codersdk.Experiment) func(next http.Handler) http.Handler {
13+
// RequireExperiment returns middleware that checks if all required experiments are enabled.
14+
// If any experiment is disabled, it returns a 403 Forbidden response with details about the missing experiments.
15+
func RequireExperiment(experiments codersdk.Experiments, requiredExperiments ...codersdk.Experiment) func(next http.Handler) http.Handler {
1216
return func(next http.Handler) http.Handler {
1317
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14-
if !experiments.Enabled(experiment) {
15-
httpapi.Write(r.Context(), w, http.StatusForbidden, codersdk.Response{
16-
Message: fmt.Sprintf("Experiment '%s' is required but not enabled", experiment),
17-
})
18-
return
18+
for _, experiment := range requiredExperiments {
19+
if !experiments.Enabled(experiment) {
20+
var experimentNames []string
21+
for _, exp := range requiredExperiments {
22+
experimentNames = append(experimentNames, string(exp))
23+
}
24+
25+
// Print a message that includes the experiment names
26+
// even if some experiments are already enabled.
27+
var message string
28+
if len(requiredExperiments) == 1 {
29+
message = fmt.Sprintf("%s functionality requires enabling the '%s' experiment.",
30+
requiredExperiments[0].DisplayName(), requiredExperiments[0])
31+
} else {
32+
message = fmt.Sprintf("This functionality requires enabling the following experiments: %s",
33+
strings.Join(experimentNames, ", "))
34+
}
35+
36+
httpapi.Write(r.Context(), w, http.StatusForbidden, codersdk.Response{
37+
Message: message,
38+
})
39+
return
40+
}
1941
}
42+
2043
next.ServeHTTP(w, r)
2144
})
2245
}
2346
}
47+
48+
// RequireExperimentWithDevBypass checks if ALL the given experiments are enabled,
49+
// but bypasses the check in development mode (buildinfo.IsDev()).
50+
func RequireExperimentWithDevBypass(experiments codersdk.Experiments, requiredExperiments ...codersdk.Experiment) func(next http.Handler) http.Handler {
51+
return func(next http.Handler) http.Handler {
52+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+
if buildinfo.IsDev() {
54+
next.ServeHTTP(w, r)
55+
return
56+
}
57+
58+
RequireExperiment(experiments, requiredExperiments...)(next).ServeHTTP(w, r)
59+
})
60+
}
61+
}

coderd/oauth2.go

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import (
1616

1717
"github.com/sqlc-dev/pqtype"
1818

19-
"github.com/coder/coder/v2/buildinfo"
2019
"github.com/coder/coder/v2/coderd/audit"
2120
"github.com/coder/coder/v2/coderd/database"
2221
"github.com/coder/coder/v2/coderd/database/db2sdk"
@@ -37,19 +36,6 @@ const (
3736
displaySecretLength = 6 // Length of visible part in UI (last 6 characters)
3837
)
3938

40-
func (api *API) oAuth2ProviderMiddleware(next http.Handler) http.Handler {
41-
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
42-
if !api.Experiments.Enabled(codersdk.ExperimentOAuth2) && !buildinfo.IsDev() {
43-
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
44-
Message: "OAuth2 provider functionality requires enabling the 'oauth2' experiment.",
45-
})
46-
return
47-
}
48-
49-
next.ServeHTTP(rw, r)
50-
})
51-
}
52-
5339
// @Summary Get OAuth2 applications.
5440
// @ID get-oauth2-applications
5541
// @Security CoderSessionToken

codersdk/deployment.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3342,8 +3342,30 @@ const (
33423342
ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking.
33433343
ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser.
33443344
ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality.
3345+
ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality.
33453346
)
33463347

3348+
func (e Experiment) DisplayName() string {
3349+
switch e {
3350+
case ExperimentExample:
3351+
return "Example Experiment"
3352+
case ExperimentAutoFillParameters:
3353+
return "Auto-fill Template Parameters"
3354+
case ExperimentNotifications:
3355+
return "SMTP and Webhook Notifications"
3356+
case ExperimentWorkspaceUsage:
3357+
return "Workspace Usage Tracking"
3358+
case ExperimentWebPush:
3359+
return "Browser Push Notifications"
3360+
case ExperimentOAuth2:
3361+
return "OAuth2 Provider Functionality"
3362+
case ExperimentMCPServerHTTP:
3363+
return "MCP HTTP Server Functionality"
3364+
default:
3365+
return string(e)
3366+
}
3367+
}
3368+
33473369
// ExperimentsKnown should include all experiments defined above.
33483370
var ExperimentsKnown = Experiments{
33493371
ExperimentExample,
@@ -3352,6 +3374,7 @@ var ExperimentsKnown = Experiments{
33523374
ExperimentWorkspaceUsage,
33533375
ExperimentWebPush,
33543376
ExperimentOAuth2,
3377+
ExperimentMCPServerHTTP,
33553378
}
33563379

33573380
// ExperimentsSafe should include all experiments that are safe for
@@ -3369,14 +3392,9 @@ var ExperimentsSafe = Experiments{}
33693392
// @typescript-ignore Experiments
33703393
type Experiments []Experiment
33713394

3372-
// Returns a list of experiments that are enabled for the deployment.
3395+
// Enabled returns a list of experiments that are enabled for the deployment.
33733396
func (e Experiments) Enabled(ex Experiment) bool {
3374-
for _, v := range e {
3375-
if v == ex {
3376-
return true
3377-
}
3378-
}
3379-
return false
3397+
return slices.Contains(e, ex)
33803398
}
33813399

33823400
func (c *Client) Experiments(ctx context.Context) (Experiments, error) {

docs/reference/api/schemas.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/api/typesGenerated.ts

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)