Skip to content

Commit d7cac75

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 5a4fab9 commit d7cac75

File tree

9 files changed

+93
-34
lines changed

9 files changed

+93
-34
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: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import (
1616

1717
"github.com/google/uuid"
1818
"golang.org/x/mod/semver"
19+
"golang.org/x/text/cases"
20+
"golang.org/x/text/language"
1921
"golang.org/x/xerrors"
2022

2123
"github.com/coreos/go-oidc/v3/oidc"
@@ -3342,8 +3344,33 @@ const (
33423344
ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking.
33433345
ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser.
33443346
ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality.
3347+
ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality.
33453348
)
33463349

3350+
func (e Experiment) DisplayName() string {
3351+
switch e {
3352+
case ExperimentExample:
3353+
return "Example Experiment"
3354+
case ExperimentAutoFillParameters:
3355+
return "Auto-fill Template Parameters"
3356+
case ExperimentNotifications:
3357+
return "SMTP and Webhook Notifications"
3358+
case ExperimentWorkspaceUsage:
3359+
return "Workspace Usage Tracking"
3360+
case ExperimentWebPush:
3361+
return "Browser Push Notifications"
3362+
case ExperimentOAuth2:
3363+
return "OAuth2 Provider Functionality"
3364+
case ExperimentMCPServerHTTP:
3365+
return "MCP HTTP Server Functionality"
3366+
default:
3367+
// Split on hyphen and convert to title case
3368+
// e.g. "web-push" -> "Web Push", "mcp-server-http" -> "Mcp Server Http"
3369+
caser := cases.Title(language.English)
3370+
return caser.String(strings.ReplaceAll(string(e), "-", " "))
3371+
}
3372+
}
3373+
33473374
// ExperimentsKnown should include all experiments defined above.
33483375
var ExperimentsKnown = Experiments{
33493376
ExperimentExample,
@@ -3352,6 +3379,7 @@ var ExperimentsKnown = Experiments{
33523379
ExperimentWorkspaceUsage,
33533380
ExperimentWebPush,
33543381
ExperimentOAuth2,
3382+
ExperimentMCPServerHTTP,
33553383
}
33563384

33573385
// ExperimentsSafe should include all experiments that are safe for
@@ -3369,14 +3397,9 @@ var ExperimentsSafe = Experiments{}
33693397
// @typescript-ignore Experiments
33703398
type Experiments []Experiment
33713399

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

33823405
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.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ require (
206206
golang.org/x/sync v0.14.0
207207
golang.org/x/sys v0.33.0
208208
golang.org/x/term v0.32.0
209-
golang.org/x/text v0.25.0 // indirect
209+
golang.org/x/text v0.25.0
210210
golang.org/x/tools v0.33.0
211211
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
212212
google.golang.org/api v0.231.0

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)