From 29067f4644208d3a06725756b4a176c49afc2352 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:32:46 +0000 Subject: [PATCH 1/6] test(coderd): pin frozen MCP OAuth2 callback path --- coderd/mcp_test.go | 24 ++++++++++++++++++++---- coderd/rbac/roles.go | 6 ++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index e303294471b23..6e97116ca5b50 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -2146,16 +2146,30 @@ func TestMCPServerOAuth2PKCE(t *testing.T) { require.NotEmpty(t, query.Get("code_challenge"), "connect redirect must include a code_challenge") - // A verifier cookie must be set. - var verifierCookie *http.Cookie + // The callback path is frozen because it is registered as a + // redirect URI with external authorization servers. + frozenCallbackPath := "/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback" + redirectURI, err := url.Parse(query.Get("redirect_uri")) + require.NoError(t, err) + require.Equal(t, frozenCallbackPath, redirectURI.Path, + "outbound redirect_uri must use the frozen callback path") + + var stateCookie, verifierCookie *http.Cookie for _, c := range res.Cookies() { - if c.Name == "mcp_oauth2_verifier_"+created.ID.String() { + switch c.Name { + case "mcp_oauth2_state_" + created.ID.String(): + stateCookie = c + case "mcp_oauth2_verifier_" + created.ID.String(): verifierCookie = c - break } } + require.NotNil(t, stateCookie, "response must set a state cookie") + require.Equal(t, frozenCallbackPath, stateCookie.Path, + "state cookie must be scoped to the frozen callback path") require.NotNil(t, verifierCookie, "response must set a PKCE verifier cookie") require.NotEmpty(t, verifierCookie.Value) + require.Equal(t, frozenCallbackPath, verifierCookie.Path, + "verifier cookie must be scoped to the frozen callback path") // Verify the code_challenge matches SHA256(verifier). h := sha256.Sum256([]byte(verifierCookie.Value)) @@ -2264,6 +2278,8 @@ func TestMCPServerOAuth2PKCE(t *testing.T) { if c.Name == "mcp_oauth2_verifier_"+created.ID.String() { require.Equal(t, -1, c.MaxAge, "verifier cookie must be cleared after callback") + require.Equal(t, callbackURL.Path, c.Path, + "cleared verifier cookie must be scoped to the frozen callback path") } } }) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 4798fefccfcd2..e8cc6af66aef0 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -1159,8 +1159,7 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { ResourceOrganization.Type: {policy.ActionRead}, // Can read available roles. ResourceAssignOrgRole.Type: {policy.ActionRead}, - // TODO(mafredri): Remove once CODAGT-712 replaces this grant with - // per-config ACL evaluation. + // TODO(mafredri): remove once CODAGT-712 adds per-config ACL evaluation. ResourceMCPServerConfig.Type: {policy.ActionRead}, } @@ -1239,8 +1238,7 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { ResourceOrganization.Type: {policy.ActionRead}, // Can read available roles. ResourceAssignOrgRole.Type: {policy.ActionRead}, - // TODO(mafredri): Remove once CODAGT-712 replaces this grant with - // per-config ACL evaluation. + // TODO(mafredri): remove once CODAGT-712 adds per-config ACL evaluation. ResourceMCPServerConfig.Type: {policy.ActionRead}, } From d0678a7d9453d4ab278cb8a98ad844a772ee7d4a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:27:03 +0000 Subject: [PATCH 2/6] docs(docs/ai-coder): clarify MCP settings page needs deployment access --- docs/ai-coder/agents/platform-controls/mcp-servers.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/ai-coder/agents/platform-controls/mcp-servers.md b/docs/ai-coder/agents/platform-controls/mcp-servers.md index a44319a19d011..969b5f62d6f04 100644 --- a/docs/ai-coder/agents/platform-controls/mcp-servers.md +++ b/docs/ai-coder/agents/platform-controls/mcp-servers.md @@ -178,3 +178,8 @@ Creating or updating a server with `auth_type` set to `user_oidc` also requires Members only see enabled servers in their own organizations. Sensitive fields such as API keys and client secrets are redacted in API responses. + +The **MCP servers** settings page is part of deployment settings, so opening +it in the dashboard also requires permission to edit deployment +configuration. Organization admins without that permission manage servers +through the API. From 20faa41e0eefbb66362cf3ff6bbdede0aaa712dd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:51:36 +0000 Subject: [PATCH 3/6] test(enterprise/coderd): pin OAuth callback token binding and disconnect body concealment Add a cross-organization callback test proving tokens bind to (user, org-scoped config) with no cross-org reuse, and restore the full-response equality check that a concealed config's disconnect is indistinguishable from a nonexistent ID. --- enterprise/coderd/mcp_test.go | 113 ++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/enterprise/coderd/mcp_test.go b/enterprise/coderd/mcp_test.go index aad14dd9c5fcf..43b08c548e258 100644 --- a/enterprise/coderd/mcp_test.go +++ b/enterprise/coderd/mcp_test.go @@ -1,13 +1,18 @@ package coderd_test import ( + "fmt" "net/http" + "net/http/httptest" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" @@ -135,4 +140,112 @@ func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) { requireMCPServerConfigRequestStatus(t, otherClient, test.method, test.path, test.body, wantStatus) }) } + + // Status alone would not catch a body-level existence leak: the whole + // disconnect response for a concealed config must be indistinguishable + // from disconnecting a nonexistent config ID. + t.Run("OAuthDisconnectBodyMatchesNonexistent", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + hiddenResp, err := otherClient.MCPServerOAuth2DisconnectWithResponse(ctx, config.ID) + require.NoError(t, err) + missingResp, err := otherClient.MCPServerOAuth2DisconnectWithResponse(ctx, uuid.New()) + require.NoError(t, err) + require.Equal(t, missingResp, hiddenResp) + require.False(t, hiddenResp.TokenRevoked) + require.Empty(t, hiddenResp.TokenRevocationError) + }) +} + +func TestMCPServerConfigsOAuth2CallbackTokenBinding(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureMultipleOrganizations: 1, + }, + }, + }) + secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) + memberClient, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID, rbac.ScopedRoleOrgMember(secondOrg.ID)) + + newTokenServer := func(accessToken string) *httptest.Server { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, + `{"access_token":%q,"token_type":"Bearer","expires_in":3600,"refresh_token":"refresh-%s"}`, + accessToken, accessToken, + ) + })) + t.Cleanup(srv.Close) + return srv + } + createOAuthConfig := func(organizationID uuid.UUID, tokenURL string) codersdk.MCPServerConfig { + t.Helper() + config, err := client.CreateMCPServerConfig(ctx, organizationID, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Callback Binding", + Slug: "callback-binding", + Transport: "streamable_http", + URL: "https://mcp.example.com/callback-binding", + AuthType: "oauth2", + OAuth2ClientID: "client-" + organizationID.String(), + OAuth2AuthURL: "https://auth.example.com/authorize", + OAuth2TokenURL: tokenURL, + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + require.NoError(t, err) + return config + } + completeCallback := func(config codersdk.MCPServerConfig) { + t.Helper() + state := "state-" + config.ID.String() + callbackURL, err := memberClient.URL.Parse( + "/api/experimental/mcp/servers/" + config.ID.String() + "/oauth2/callback", + ) + require.NoError(t, err) + query := callbackURL.Query() + query.Set("code", "auth-code-"+config.ID.String()) + query.Set("state", state) + callbackURL.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, callbackURL.String(), nil) + require.NoError(t, err) + req.AddCookie(&http.Cookie{Name: codersdk.SessionTokenCookie, Value: memberClient.SessionToken()}) + req.AddCookie(&http.Cookie{Name: "mcp_oauth2_state_" + config.ID.String(), Value: state}) + res, err := memberClient.HTTPClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + } + tokenRow := func(configID uuid.UUID) database.MCPServerUserToken { + t.Helper() + //nolint:gocritic // Verifying persisted state requires system access. + row, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{ + MCPServerConfigID: configID, + UserID: member.ID, + }) + require.NoError(t, err) + return row + } + + // The same slug in both organizations proves tokens bind to the config + // ID, not the slug. + firstConfig := createOAuthConfig(firstUser.OrganizationID, newTokenServer("org-one-access-token").URL) + secondConfig := createOAuthConfig(secondOrg.ID, newTokenServer("org-two-access-token").URL) + + completeCallback(firstConfig) + firstToken := tokenRow(firstConfig.ID) + require.Equal(t, "org-one-access-token", firstToken.AccessToken) + + completeCallback(secondConfig) + firstToken = tokenRow(firstConfig.ID) + secondToken := tokenRow(secondConfig.ID) + require.Equal(t, "org-one-access-token", firstToken.AccessToken) + require.Equal(t, "org-two-access-token", secondToken.AccessToken) + require.NotEqual(t, firstToken.ID, secondToken.ID) } From 2b17c56ed98a1aee390fde1362eb255dd72a3541 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:49:22 +0000 Subject: [PATCH 4/6] test(enterprise/coderd): compare raw MCP disconnect responses SDK decoding normalizes 200 and 204 to the same zero-value struct and discards unknown JSON fields, so the concealment assertion could miss a status or extra-field existence leak. Compare status codes and raw bodies instead. --- enterprise/coderd/mcp_test.go | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/enterprise/coderd/mcp_test.go b/enterprise/coderd/mcp_test.go index 43b08c548e258..353b5b37e540d 100644 --- a/enterprise/coderd/mcp_test.go +++ b/enterprise/coderd/mcp_test.go @@ -1,7 +1,9 @@ package coderd_test import ( + "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "testing" @@ -141,20 +143,31 @@ func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) { }) } - // Status alone would not catch a body-level existence leak: the whole - // disconnect response for a concealed config must be indistinguishable - // from disconnecting a nonexistent config ID. + // Compare raw responses because SDK decoding can hide body differences + // that reveal whether the config exists. t.Run("OAuthDisconnectBodyMatchesNonexistent", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - hiddenResp, err := otherClient.MCPServerOAuth2DisconnectWithResponse(ctx, config.ID) - require.NoError(t, err) - missingResp, err := otherClient.MCPServerOAuth2DisconnectWithResponse(ctx, uuid.New()) - require.NoError(t, err) - require.Equal(t, missingResp, hiddenResp) - require.False(t, hiddenResp.TokenRevoked) - require.Empty(t, hiddenResp.TokenRevocationError) + rawDisconnect := func(id uuid.UUID) (int, string) { + res, err := otherClient.Request(ctx, http.MethodDelete, + "/api/experimental/mcp/servers/"+id.String()+"/oauth2/disconnect", nil) + require.NoError(t, err) + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + return res.StatusCode, string(body) + } + + hiddenStatus, hiddenBody := rawDisconnect(config.ID) + missingStatus, missingBody := rawDisconnect(uuid.New()) + require.Equal(t, missingStatus, hiddenStatus) + require.Equal(t, missingBody, hiddenBody) + + var disconnect codersdk.MCPServerOAuth2DisconnectResponse + require.NoError(t, json.Unmarshal([]byte(hiddenBody), &disconnect)) + require.False(t, disconnect.TokenRevoked) + require.Empty(t, disconnect.TokenRevocationError) }) } From 2aa844485dafa8624d6b0b735c3dbd97510282c6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:27:50 +0000 Subject: [PATCH 5/6] docs(docs/ai-coder): qualify API management for user OIDC MCP servers --- docs/ai-coder/agents/platform-controls/mcp-servers.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/ai-coder/agents/platform-controls/mcp-servers.md b/docs/ai-coder/agents/platform-controls/mcp-servers.md index 969b5f62d6f04..7e871a529561c 100644 --- a/docs/ai-coder/agents/platform-controls/mcp-servers.md +++ b/docs/ai-coder/agents/platform-controls/mcp-servers.md @@ -174,12 +174,9 @@ wins. | View enabled servers | Organization member | | OAuth2 connect and disconnect | Organization member | -Creating or updating a server with `auth_type` set to `user_oidc` also requires the `deployment_config:update` permission. - Members only see enabled servers in their own organizations. Sensitive fields such as API keys and client secrets are redacted in API responses. -The **MCP servers** settings page is part of deployment settings, so opening -it in the dashboard also requires permission to edit deployment -configuration. Organization admins without that permission manage servers -through the API. +The **MCP servers** settings page is part of deployment settings, so opening it in the dashboard also requires permission to edit deployment configuration. +Organization admins without that permission can manage servers through the API. +Creating or updating a server with `auth_type` set to `user_oidc` also requires the `deployment_config:update` permission. From 0e9fff34a68d0a29451cfd7da698a9bb7a049214 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:27:34 +0000 Subject: [PATCH 6/6] test(coderd): require verifier cookie clearing in MCP callback test --- coderd/mcp_test.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 6e97116ca5b50..01ad6805ca8e9 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -2274,14 +2274,17 @@ func TestMCPServerOAuth2PKCE(t *testing.T) { "token exchange must send the PKCE code_verifier") // Verify the verifier cookie is cleared in the response. + var clearedVerifier *http.Cookie for _, c := range res.Cookies() { if c.Name == "mcp_oauth2_verifier_"+created.ID.String() { - require.Equal(t, -1, c.MaxAge, - "verifier cookie must be cleared after callback") - require.Equal(t, callbackURL.Path, c.Path, - "cleared verifier cookie must be scoped to the frozen callback path") + clearedVerifier = c } } + require.NotNil(t, clearedVerifier, "callback must clear the verifier cookie") + require.Equal(t, -1, clearedVerifier.MaxAge, + "verifier cookie must be cleared after callback") + require.Equal(t, callbackURL.Path, clearedVerifier.Path, + "cleared verifier cookie must be scoped to the frozen callback path") }) t.Run("CallbackWithoutVerifierStillWorks", func(t *testing.T) {