Files
coder/coderd/mcp_test.go
Michael Suchacz fa7cd62c8b feat: send RFC 8707 resource parameter on MCP server OAuth2 flows (#30651)
Coder's MCP OAuth2 client never sent the RFC 8707 `resource` parameter,
which the MCP authorization spec requires on authorization and token
requests. Authorization servers that enforce it cannot be connected:
with Dagster+, discovery and dynamic client registration succeed, then
the authorize request fails with "MCP OAuth requests must include
resource".

MCP server configs get an `oauth2_resource` field (migration 000618,
admin-only in API responses, audited). Coder sends it on the authorize
redirect, the authorization-code exchange, and token refresh. Refresh
now goes through `clientcredentials` with `grant_type=refresh_token`,
because `oauth2.Config` cannot add parameters to refresh requests; it
keeps the same auth-style detection and error types, and adds no
dependency.

Auto-discovery fills the field with the most specific Protected Resource
Metadata `resource` that covers the server URL, stored as advertised. As
in the MCP SDKs, a resource covers URLs on its origin at or under its
path, so `/mcp` covers `/mcp/` but `/mcp/` does not cover `/mcp`. URLs
compare in RFC 3986 normal form (default ports, percent-encodings, dot
segments, and an empty path as `/`), ports ignore leading zeros, IPv6
literals compare by address, and `%2F` stays inside its segment. Unlike
the SDKs, a resource with a query, even an empty one, only covers a
server URL with the same query. When none covers it, Coder uses the
server URL with the scheme and host lowercased and user information and
fragment removed, or sends no resource when that is not a valid resource
indicator. An explicit value in the create request wins.

Manual configurations send no `resource` unless an admin sets one,
because some providers reject it (Microsoft Entra ID v2 fails with
AADSTS901002). Values must be absolute RFC 3986 URIs without a fragment,
user information, or an IPv6 zone identifier.

The resource binds grants like the other OAuth settings: changing it
deletes stored user tokens, and a callback whose config resource changed
after the user started connecting returns 409 before the code is
redeemed. The connect step records the resource in a callback-scoped
cookie for that check.

Discovery only runs when a configuration is created, so existing
auto-discovered configurations, including the Dagster+ one that prompted
this, keep an empty resource until an admin sets the new "Resource URL"
field or re-creates the server.

![MCP server edit form showing the Resource URL field filled by
auto-discovery](https://github.com/user-attachments/assets/319bc8a0-f6e1-4b7d-8878-8e341c94e590)

## Testing

- Go: discovery (most specific match, path and trailing-slash
boundaries, query matching including empty queries, equivalent ports,
IPv6 literals, escapes, and dot segments including escaped ones, the
normalized fallback and an invalid one, explicit override), validation
(URI characters, brackets, IP literals, user information, trimming),
grant deletion on change, clearing on an auth type change, redaction for
members, refresh with and without a resource, the 409 when the resource
changes mid-flow (the authorization server never receives the code), and
a connect and callback flow against an authorization server that rejects
requests without the resource. Each new test was checked to fail when
the behavior it covers is broken, except two malformed IP literal hosts
that Go's URL parser already rejects.
- Vitest: the form round-trips the stored value, and clearing the field
sends `""` on update.
- Remote UAT through Coder Agents
([chat](https://dogfood.cdr.dev/agents/58695d5b-2112-400b-88f7-ab64c678f756))
on a test deployment with loopback MCP and authorization server mocks:
- Round 1 on 2b875068e0: literal verdict FAIL. When the resource
changed between connect and callback, the callback redeemed the code for
the new resource and stored a grant. The 409 binding fixes it.
- Round 2 on 4b54ffb5f9: PASS. The mid-flow change returned 409 with no
token request in every variant (strict and lenient servers, set,
cleared, abandoned flows, parallel configs); auto-discovery, connect,
refresh, validation, and the docs matched. Minor observations: the
binding cookie is an unkeyed digest, so a member who edits their own
HttpOnly cookie can redeem a code with the current resource, which a
fresh connect also gives them; other settings changed mid-flow still
reach the token endpoint, as on main; the 409 popup shows raw JSON; the
UI does not warn that a resource change disconnects users; validation
toasts omit the server's detail.
- Round 3 on the final head 2d6abfac49: PASS. The head moved during the
round, so it ran in four parts, the last on 2d6abfac49. That part
covered the matching rules (queries including empty ones, default and
zero-padded ports, `%2F`, escaped dot segments, trailing slashes, IPv6
literals), the invalid fallback (stored empty, no `resource` sent),
every validation value through the API and the UI, and the discovery,
connect, refresh, and mid-flow 409 flows. The equivalent-encoding cases
last ran live on 5c23fe00dca; unit tests cover them on the final head.
Observation: the path-aware metadata URL is built from the decoded path,
so `/mcp%2Ftenant` fetches metadata for `/mcp/tenant`, as on main.
- `frontend-review` and the eight-role `coder-frontend-review` ran on
the `site/` diff; every role passes after the label, help text, row
layout, API doc wording, and form test fixes. Declined: linking field
descriptions with `aria-describedby`, because the form's shared `Field`
primitive renders every description without one, so it belongs in a
change to all fields.

## Review record

| Review | Commit | Findings | Disposition |
|---|---|---|---|
| [Codex round
1](https://github.com/coder/coder/pull/30651#pullrequestreview-5492085359)
| 4b54ffb5f9 | P2: a resource with another query was treated as
covering the server URL | Reproduced with a failing test, fixed in
bf0879560c, resolved |
| [Codex round
2](https://github.com/coder/coder/pull/30651#pullrequestreview-5492305363)
| bf0879560c | P2: explicit default ports; P2: `%2F` treated as a
segment boundary; P2: `url.Parse` accepts spaces and invalid escapes |
Each reproduced with a failing test, fixed in 16a1d783fa, resolved |
| [Codex round
3](https://github.com/coder/coder/pull/30651#pullrequestreview-5492677517)
| 16a1d783fa | P1: docs wrapped mid-sentence (DOC1); P2: equivalent
percent-encodings compared unequal; P2: brackets accepted outside an IP
literal host | Fixed in 8d617f0528 (the P2s reproduced with failing
tests; the same pass also covers the query, dot segment, and user
information cases), resolved |
| [Codex round
4](https://github.com/coder/coder/pull/30651#pullrequestreview-5493033885)
| 8d617f0528 | P2: the server URL fallback was not validated; P2:
escaped dot segments were compared before decoding | Each reproduced
with a failing test, fixed in 5c23fe00dc, resolved |
| [Codex round
5](https://github.com/coder/coder/pull/30651#pullrequestreview-5493342889)
| 5c23fe00dc | P2: an empty query (`?`) was treated as no query |
Reproduced with a failing test, fixed in 72c019ff5e, resolved |
| [Codex round
6](https://github.com/coder/coder/pull/30651#pullrequestreview-5493524846)
| 72c019ff5e | P2: `/mcp/` covered `/mcp`; P2: equivalent IPv6 literals
compared unequal; P2: malformed IP literals accepted | The first two
reproduced with failing tests. Go 1.26.5 `url.Parse` already rejects the
hosts named in the third, but it accepted an IPv6 zone identifier. All
fixed in ef2df2c4e7, resolved |
| [Codex round
7](https://github.com/coder/coder/pull/30651#pullrequestreview-5493668131)
| ef2df2c4e7 | P2: leading zeros in a port made equal origins differ |
Reproduced with a failing test, fixed in 2d6abfac49, resolved |
| [Codex round
8](https://github.com/coder/coder/pull/30651#issuecomment-6128743972) |
2d6abfac49 | None: "Didn't find any major issues" | Clean verdict on
the current head |

> Xum opened this PR on behalf of @ibetitsmike.
2026-10-13 23:03:08 +02:00

4434 lines
163 KiB
Go

package coderd_test
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/audit"
"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/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
// mcpDeploymentValues returns deployment values for tests of the MCP
// server config endpoints.
func mcpDeploymentValues(t testing.TB) *codersdk.DeploymentValues {
t.Helper()
return coderdtest.DeploymentValues(t)
}
// newMCPClient creates a test server and returns the admin client.
func newMCPClient(t testing.TB) *codersdk.Client {
t.Helper()
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
return coderdtest.New(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
}
// createMCPServerConfig is a helper that creates a minimal enabled
// MCP server config with auth_type=none.
func createMCPServerConfig(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, slug string, enabled bool) codersdk.MCPServerConfig {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
config, err := client.CreateMCPServerConfig(ctx, organizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Test Server " + slug,
Slug: slug,
Description: "A test MCP server.",
IconURL: "https://example.com/icon.png",
Transport: "streamable_http",
URL: "https://mcp.example.com/" + slug,
AuthType: "none",
Availability: "default_on",
Enabled: enabled,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
return config
}
func newMCPDiscoveryServer(t testing.TB, registrationRequests *atomic.Int64) string {
t.Helper()
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + "http://" + r.Host + `",
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
"token_endpoint": "` + "http://" + r.Host + `/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"response_types_supported": ["code"]
}`))
case "/register":
registrationRequests.Add(1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_id": "discovered-client-id",
"client_secret": "discovered-client-secret"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(authServer.Close)
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource/mcp":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `/mcp",
"authorization_servers": ["` + authServer.URL + `"]
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
return mcpServer.URL + "/mcp"
}
func TestMCPServerConfigLegacyRoutesRemoved(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "legacy-route-test", true)
for _, path := range []string{
"/api/experimental/mcp/servers",
"/api/experimental/mcp/servers/" + config.ID.String(),
"/api/experimental/mcp/servers/" + config.ID.String() + "/oauth2/connect",
} {
res, err := client.Request(ctx, http.MethodGet, path, nil)
require.NoError(t, err)
res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode, path)
}
}
func TestMCPServerConfigsCRUD(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
// Create a config with all fields populated including OAuth2
// secrets so we can verify they are not leaked.
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "My MCP Server",
Slug: "my-mcp-server",
Description: "Integration test server.",
IconURL: "https://example.com/icon.png",
Transport: "streamable_http",
URL: "https://mcp.example.com/v1",
AuthType: "oauth2",
OAuth2ClientID: "client-id-123",
OAuth2ClientSecret: "super-secret-value",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: "https://auth.example.com/token",
OAuth2Scopes: "read write",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, created.ID)
require.Equal(t, firstUser.OrganizationID, created.OrganizationID)
require.Equal(t, "My MCP Server", created.DisplayName)
require.Equal(t, "my-mcp-server", created.Slug)
require.Equal(t, "Integration test server.", created.Description)
require.Equal(t, "streamable_http", created.Transport)
require.Equal(t, "https://mcp.example.com/v1", created.URL)
require.Equal(t, "oauth2", created.AuthType)
require.Equal(t, "client-id-123", created.OAuth2ClientID)
require.Equal(t, "default_on", created.Availability)
require.True(t, created.Enabled)
require.False(t, created.AllowInPlanMode)
require.False(t, created.ForwardCoderHeaders)
// Verify the secret is indicated but never returned.
require.True(t, created.HasOAuth2Secret)
// Verify the config appears in the list and direct get responses.
configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, configs, 1)
require.Equal(t, created.ID, configs[0].ID)
require.True(t, configs[0].HasOAuth2Secret)
require.False(t, configs[0].AllowInPlanMode)
require.False(t, configs[0].ForwardCoderHeaders)
fetched, err := client.MCPServerConfigByID(ctx, created.OrganizationID, created.ID)
require.NoError(t, err)
require.Equal(t, created.ID, fetched.ID)
require.False(t, fetched.AllowInPlanMode)
require.False(t, fetched.ForwardCoderHeaders)
// Update display name, availability, allow_in_plan_mode, and
// forward_coder_headers.
newName := "Renamed Server"
newAvail := "force_on"
allowInPlanMode := true
forwardCoderHeaders := true
updated, err := client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
DisplayName: &newName,
Availability: &newAvail,
AllowInPlanMode: &allowInPlanMode,
ForwardCoderHeaders: &forwardCoderHeaders,
})
require.NoError(t, err)
require.Equal(t, "Renamed Server", updated.DisplayName)
require.Equal(t, "force_on", updated.Availability)
require.True(t, updated.AllowInPlanMode)
require.True(t, updated.ForwardCoderHeaders)
// Unchanged fields should remain the same.
require.Equal(t, "my-mcp-server", updated.Slug)
require.Equal(t, "oauth2", updated.AuthType)
// Verify the update took effect through the list and direct get.
configs, err = client.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, configs, 1)
require.Equal(t, "Renamed Server", configs[0].DisplayName)
require.Equal(t, "force_on", configs[0].Availability)
require.True(t, configs[0].AllowInPlanMode)
require.True(t, configs[0].ForwardCoderHeaders)
fetched, err = client.MCPServerConfigByID(ctx, created.OrganizationID, created.ID)
require.NoError(t, err)
require.True(t, fetched.AllowInPlanMode)
require.True(t, fetched.ForwardCoderHeaders)
// Delete it.
err = client.DeleteMCPServerConfig(ctx, created.OrganizationID, created.ID)
require.NoError(t, err)
// Verify it's gone.
configs, err = client.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Empty(t, configs)
}
func TestMCPServerConfigSigningSecret(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
db, pubsub := dbtestutil.NewDB(t)
client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: pubsub})
firstUser := coderdtest.CreateFirstUser(t, client)
path := "/api/v2/organizations/" + firstUser.OrganizationID.String() + "/mcp-servers"
secret := strings.Repeat("a", 64)
request := func(method, path string, body any, status int) codersdk.MCPServerConfig {
t.Helper()
res, err := client.Request(ctx, method, path, body)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, status, res.StatusCode)
var response struct {
codersdk.MCPServerConfig
SigningSecret *string `json:"signing_secret"`
}
require.NoError(t, json.NewDecoder(res.Body).Decode(&response))
require.Nil(t, response.SigningSecret, "secrets must never be returned")
return response.MCPServerConfig
}
created := request(http.MethodPost, path, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Signed", Slug: "signed", Transport: "streamable_http",
URL: "https://mcp.example.com", AuthType: "none", Availability: "default_on",
Enabled: true, ForwardCoderHeaders: true, SigningSecret: secret,
}, http.StatusCreated)
require.True(t, created.HasSigningSecret)
path += "/" + created.ID.String()
for _, tc := range []struct {
name string
req codersdk.UpdateMCPServerConfigRequest
want string
}{
{"preserve", codersdk.UpdateMCPServerConfigRequest{}, secret},
{"replace", codersdk.UpdateMCPServerConfigRequest{SigningSecret: new(strings.Repeat("b", 64))}, strings.Repeat("b", 64)},
{"clear", codersdk.UpdateMCPServerConfigRequest{SigningSecret: new("")}, ""},
} {
updated := request(http.MethodPatch, path, tc.req, http.StatusOK)
require.Equal(t, tc.want != "", updated.HasSigningSecret)
stored, err := db.GetMCPServerConfigByID(ctx, created.ID)
require.NoError(t, err)
require.Equal(t, tc.want, stored.SigningSecret)
read := request(http.MethodGet, path, nil, http.StatusOK)
require.Equal(t, updated.HasSigningSecret, read.HasSigningSecret)
}
member, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
_, err := member.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, created.ID,
codersdk.UpdateMCPServerConfigRequest{SigningSecret: &secret})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
}
func TestMCPServerConfigWrongOrganization(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, client)
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "wrong-org", true)
otherOrganization := dbgen.Organization(t, db, database.Organization{})
_, err := client.MCPServerConfigByID(ctx, otherOrganization.ID, config.ID)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
}
func TestMCPServerConfigsAudit(t *testing.T) {
t.Parallel()
newAuditedMCPClient := func(t testing.TB) (*codersdk.Client, *audit.MockAuditor) {
t.Helper()
mAudit := audit.NewMock()
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
Auditor: mAudit,
})
return client, mAudit
}
t.Run("Create", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, mAudit := newAuditedMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
mAudit.ResetLogs()
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Audit Create",
Slug: "audit-create",
Transport: "streamable_http",
URL: "https://mcp.example.com/audit",
AuthType: "api_key",
APIKeyHeader: "X-Api-Key",
APIKeyValue: "super-secret-api-key",
CustomHeaders: map[string]string{
"X-Extra": "plaintext-header-value",
},
Availability: "default_on",
Enabled: true,
})
require.NoError(t, err)
logs := mAudit.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, database.AuditActionCreate, logs[0].Action)
require.Equal(t, database.ResourceTypeMCPServerConfig, logs[0].ResourceType)
require.Equal(t, created.ID, logs[0].ResourceID)
require.Equal(t, "Audit Create", logs[0].ResourceTarget)
require.Equal(t, firstUser.UserID, logs[0].UserID)
require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID)
require.EqualValues(t, http.StatusCreated, logs[0].StatusCode)
})
t.Run("Update", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, mAudit := newAuditedMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-update", true)
mAudit.ResetLogs()
newName := "Audit Update"
updated, err := client.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
DisplayName: &newName,
})
require.NoError(t, err)
require.Equal(t, newName, updated.DisplayName)
logs := mAudit.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, database.AuditActionWrite, logs[0].Action)
require.Equal(t, database.ResourceTypeMCPServerConfig, logs[0].ResourceType)
require.Equal(t, config.ID, logs[0].ResourceID)
require.Equal(t, newName, logs[0].ResourceTarget)
require.Equal(t, firstUser.UserID, logs[0].UserID)
require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID)
require.EqualValues(t, http.StatusOK, logs[0].StatusCode)
})
t.Run("Delete", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, mAudit := newAuditedMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-delete", true)
mAudit.ResetLogs()
err := client.DeleteMCPServerConfig(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
logs := mAudit.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, database.AuditActionDelete, logs[0].Action)
require.Equal(t, database.ResourceTypeMCPServerConfig, logs[0].ResourceType)
require.Equal(t, config.ID, logs[0].ResourceID)
require.Equal(t, firstUser.UserID, logs[0].UserID)
require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID)
require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode)
})
t.Run("DeleteAuditsPersistedRow", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
db, ps := dbtestutil.NewDB(t)
store := &staleMCPServerConfigReadStore{Store: db}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
Auditor: mAudit,
Database: store,
Pubsub: ps,
})
firstUser := coderdtest.CreateFirstUser(t, client)
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-delete-stale", true)
// Simulate a concurrent update landing between the param
// middleware read and the delete transaction.
store.stale.Store(true)
mAudit.ResetLogs()
err := client.DeleteMCPServerConfig(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
logs := mAudit.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, config.DisplayName, logs[0].ResourceTarget)
})
t.Run("AutoDiscoveryFailureNotAudited", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, mAudit := newAuditedMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
mAudit.ResetLogs()
// Discovery fails immediately: nothing listens on the URL.
// The partially inserted row is cleaned up, so no audit
// entry may reference it.
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Audit Discovery Failure",
Slug: "audit-discovery-failure",
Transport: "streamable_http",
URL: "http://127.0.0.1:1",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Empty(t, mAudit.AuditLogs())
})
t.Run("CreateNotAuditedWhenInsertFails", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
db, ps := dbtestutil.NewDB(t)
store := &failingMCPServerConfigInsertStore{Store: db}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
Auditor: mAudit,
Database: store,
Pubsub: ps,
})
firstUser := coderdtest.CreateFirstUser(t, client)
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + r.Host + `",
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
"token_endpoint": "` + "http://" + r.Host + `/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"response_types_supported": ["code"]
}`))
case "/register":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_id": "update-failure-client-id",
"client_secret": "update-failure-client-secret"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(authServer.Close)
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource/v1/mcp":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `",
"authorization_servers": ["` + authServer.URL + `"]
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
store.fail.Store(true)
mAudit.ResetLogs()
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Audit Update Failure",
Slug: "audit-update-failure",
Transport: "streamable_http",
URL: mcpServer.URL + "/v1/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode())
configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Empty(t, configs)
require.Empty(t, mAudit.AuditLogs())
})
t.Run("DeletedResourceMarked", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, _ := newAuditedMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-is-deleted", true)
deletedID := uuid.New()
err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
OrganizationID: firstUser.OrganizationID,
Action: codersdk.AuditActionWrite,
ResourceType: codersdk.ResourceTypeMCPServerConfig,
ResourceID: config.ID,
})
require.NoError(t, err)
err = client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
OrganizationID: firstUser.OrganizationID,
Action: codersdk.AuditActionDelete,
ResourceType: codersdk.ResourceTypeMCPServerConfig,
ResourceID: deletedID,
})
require.NoError(t, err)
logs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{
Pagination: codersdk.Pagination{Limit: 25},
})
require.NoError(t, err)
byResourceID := make(map[uuid.UUID]codersdk.AuditLog, len(logs.AuditLogs))
for _, alog := range logs.AuditLogs {
byResourceID[alog.ResourceID] = alog
}
require.Contains(t, byResourceID, config.ID)
require.False(t, byResourceID[config.ID].IsDeleted)
require.Contains(t, byResourceID, deletedID)
require.True(t, byResourceID[deletedID].IsDeleted)
})
t.Run("WriteDeniedConcealed", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, mAudit := newAuditedMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-denied", true)
mAudit.ResetLogs()
newName := "denied"
_, err := memberClient.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
DisplayName: &newName,
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
require.Empty(t, mAudit.AuditLogs())
})
t.Run("DeleteDeniedConcealed", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, mAudit := newAuditedMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-delete-denied", true)
mAudit.ResetLogs()
err := memberClient.DeleteMCPServerConfig(ctx, firstUser.OrganizationID, config.ID)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
require.Empty(t, mAudit.AuditLogs())
})
}
// failingMCPServerConfigInsertStore fails config inserts once armed.
type failingMCPServerConfigInsertStore struct {
database.Store
fail atomic.Bool
}
func (s *failingMCPServerConfigInsertStore) InsertMCPServerConfig(ctx context.Context, arg database.InsertMCPServerConfigParams) (database.MCPServerConfig, error) {
if s.fail.Load() {
return database.MCPServerConfig{}, xerrors.New("injected insert failure")
}
return s.Store.InsertMCPServerConfig(ctx, arg)
}
// staleMCPServerConfigReadStore corrupts plain config reads once armed,
// simulating a concurrent update that outdates the param middleware's
// snapshot. Locked ForUpdate reads stay untouched.
type staleMCPServerConfigReadStore struct {
database.Store
stale atomic.Bool
}
func (s *staleMCPServerConfigReadStore) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) {
config, err := s.Store.GetMCPServerConfigByID(ctx, id)
if err == nil && s.stale.Load() {
config.DisplayName = "stale middleware snapshot"
}
return config, err
}
func TestMCPServerConfigsNonAdmin(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
// Admin creates two configs: one enabled, one disabled.
_ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "enabled-server", true)
_ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "disabled-server", false)
// Admin sees both.
adminConfigs, err := adminClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, adminConfigs, 2)
// Regular user sees only the enabled one.
memberConfigs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, memberConfigs, 1)
require.Equal(t, "enabled-server", memberConfigs[0].Slug)
// Auditors list ACL-restricted configs but cannot read them row-level.
restricted := createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "restricted-server", true)
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, restricted.ID, codersdk.UpdateMCPServerConfigACLRequest{
GroupRoles: map[string]codersdk.MCPServerConfigRole{
firstUser.OrganizationID.String(): codersdk.MCPServerConfigRoleDeleted,
},
})
require.NoError(t, err)
// Auditors need the full management view of the MCP configs their
// audit logs reference.
for name, roles := range map[string][]rbac.RoleIdentifier{
"SiteAuditor": {rbac.RoleAuditor()},
"OrgAuditor": {rbac.ScopedRoleOrgAuditor(firstUser.OrganizationID)},
} {
auditorClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, roles...)
auditorConfigs, err := auditorClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err, name)
require.Len(t, auditorConfigs, 3, name)
for _, config := range auditorConfigs {
require.NotEmpty(t, config.URL, "%s: %s", name, config.Slug)
if !config.Enabled {
fetched, err := auditorClient.MCPServerConfigByID(ctx, config.OrganizationID, config.ID)
require.NoError(t, err, name)
require.NotEmpty(t, fetched.URL, name)
}
}
_, err = auditorClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, restricted.ID)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr, name)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), name)
}
}
func TestMCPServerConfigsScopedKeyFullView(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
_ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "enabled-server", true)
_ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "disabled-server", false)
_, auditor := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID,
rbac.ScopedRoleOrgAuditor(firstUser.OrganizationID))
// MCP config scopes are not user-mintable, so seed scoped keys directly.
newScopedClient := func(userID uuid.UUID, scopes ...database.APIKeyScope) *codersdk.Client {
_, token := dbgen.APIKey(t, db, database.APIKey{
UserID: userID,
Scopes: append(database.APIKeyScopes{"organization:read"}, scopes...),
})
client := codersdk.New(adminClient.URL)
client.SetSessionToken(token)
return client
}
// A key whose scope covers reading MCP configs keeps the full view.
fullViewConfigs, err := newScopedClient(firstUser.UserID,
"mcp_server_config:update", "mcp_server_config:read").MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, fullViewConfigs, 2)
// Update-or-audit roles make the caller full-view eligible, but a key
// scoped without mcp_server_config:read must not receive config data.
for name, client := range map[string]*codersdk.Client{
"AdminUpdateScope": newScopedClient(firstUser.UserID, "mcp_server_config:update"),
"AuditorAuditScope": newScopedClient(auditor.ID, "audit_log:read"),
} {
configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err, name)
require.Empty(t, configs, name)
}
}
func TestMCPServerConfigScopedKeyWithoutPersonalTokenRead(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "OAuth Server",
Slug: "oauth-server-scoped-key",
Transport: "streamable_http",
URL: "https://mcp.example.com/oauth",
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: "https://auth.example.com/token",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
//nolint:gocritic // Seeding token state requires system access.
_, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
MCPServerConfigID: created.ID,
UserID: firstUser.UserID,
AccessToken: "access-token",
TokenType: "Bearer",
Expiry: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
})
require.NoError(t, err)
_, token := dbgen.APIKey(t, db, database.APIKey{
UserID: firstUser.UserID,
Scopes: database.APIKeyScopes{
"mcp_server_config:read",
"organization:read",
},
})
scopedClient := codersdk.New(adminClient.URL)
scopedClient.SetSessionToken(token)
configs, err := scopedClient.MCPServerConfigs(ctx, created.OrganizationID)
require.NoError(t, err)
require.Len(t, configs, 1)
require.False(t, configs[0].AuthConnected)
config, err := scopedClient.MCPServerConfigByID(ctx, created.OrganizationID, created.ID)
require.NoError(t, err)
require.False(t, config.AuthConnected)
}
func TestMCPServerConfigScopedKeyRefreshPersistsRotatedToken(t *testing.T) {
t.Parallel()
tests := []struct {
name string
authConnected func(*testing.T, context.Context, *codersdk.Client, uuid.UUID, uuid.UUID) bool
}{
{
name: "List",
authConnected: func(t *testing.T, ctx context.Context, client *codersdk.Client, organizationID, _ uuid.UUID) bool {
configs, err := client.MCPServerConfigs(ctx, organizationID)
require.NoError(t, err)
require.Len(t, configs, 1)
return configs[0].AuthConnected
},
},
{
name: "Get",
authConnected: func(t *testing.T, ctx context.Context, client *codersdk.Client, organizationID, configID uuid.UUID) bool {
config, err := client.MCPServerConfigByID(ctx, organizationID, configID)
require.NoError(t, err)
return config.AuthConnected
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
require.Equal(t, "old-refresh", r.Form.Get("refresh_token"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"fresh-access","refresh_token":"rotated-refresh","token_type":"Bearer","expires_in":3600}`))
}))
t.Cleanup(tokenSrv.Close)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "OAuth Refresh " + test.name,
Slug: "oauth-refresh-" + strings.ToLower(test.name),
Transport: "streamable_http",
URL: "https://mcp.example.com/refresh",
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: tokenSrv.URL,
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
//nolint:gocritic // Seeding token state requires system access.
_, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
MCPServerConfigID: created.ID,
UserID: firstUser.UserID,
AccessToken: "expired-access",
RefreshToken: "old-refresh",
TokenType: "Bearer",
Expiry: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
})
require.NoError(t, err)
_, token := dbgen.APIKey(t, db, database.APIKey{
UserID: firstUser.UserID,
Scopes: database.APIKeyScopes{
database.ApiKeyScopeMcpServerConfigRead,
database.ApiKeyScopeOrganizationRead,
database.ApiKeyScopeUserReadPersonal,
},
})
scopedClient := codersdk.New(adminClient.URL)
scopedClient.SetSessionToken(token)
require.True(t, test.authConnected(t, ctx, scopedClient, created.OrganizationID, created.ID))
//nolint:gocritic // Verifying persisted state requires system access.
row, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
MCPServerConfigID: created.ID,
UserID: firstUser.UserID,
})
require.NoError(t, err)
require.Equal(t, "fresh-access", row.AccessToken)
require.Equal(t, "rotated-refresh", row.RefreshToken)
})
}
}
func TestMCPServerConfigACL(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
Auditor: mAudit,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
groupMemberClient, groupMember := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
nonMemberClient, nonMember := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
group := dbgen.Group(t, db, database.Group{OrganizationID: firstUser.OrganizationID})
dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: group.ID, UserID: groupMember.ID})
config := createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "acl-server", true)
mAudit.ResetLogs()
err := adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
GroupRoles: map[string]codersdk.MCPServerConfigRole{
firstUser.OrganizationID.String(): codersdk.MCPServerConfigRoleDeleted,
group.ID.String(): codersdk.MCPServerConfigRoleRead,
},
})
require.NoError(t, err)
logs := mAudit.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, database.AuditActionWrite, logs[0].Action)
require.Equal(t, config.ID, logs[0].ResourceID)
require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode)
aclResponse, err := adminClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
require.Len(t, aclResponse.Groups, 1)
require.Equal(t, group.ID, aclResponse.Groups[0].ID)
require.Equal(t, 1, aclResponse.Groups[0].TotalMemberCount)
configs, err := groupMemberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, configs, 1)
_, err = groupMemberClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
configs, err = nonMemberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Empty(t, configs)
_, err = nonMemberClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
_, err = groupMemberClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
mAudit.ResetLogs()
err = groupMemberClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
require.Empty(t, mAudit.AuditLogs())
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
UserRoles: map[string]codersdk.MCPServerConfigRole{
nonMember.ID.String(): codersdk.MCPServerConfigRoleRead,
},
})
require.NoError(t, err)
_, err = nonMemberClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
// The sparse user-only update must merge with the existing ACL, not
// clobber the earlier group grant.
aclResponse, err = adminClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
require.Len(t, aclResponse.Groups, 1)
require.Equal(t, group.ID, aclResponse.Groups[0].ID)
require.Len(t, aclResponse.Users, 1)
require.Equal(t, nonMember.ID, aclResponse.Users[0].ID)
// Conflicting roles under two spellings of one principal are
// ambiguous and must be rejected rather than resolved by map order.
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
UserRoles: map[string]codersdk.MCPServerConfigRole{
nonMember.ID.String(): codersdk.MCPServerConfigRoleRead,
"urn:uuid:" + nonMember.ID.String(): codersdk.MCPServerConfigRoleDeleted,
},
})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Error(), "duplicate entries for ID "+nonMember.ID.String())
// Noncanonical UUID spellings pass validation, so grants and
// deletions must canonicalize to hit the same ACL keys RBAC reads.
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
UserRoles: map[string]codersdk.MCPServerConfigRole{
strings.ToUpper(groupMember.ID.String()): codersdk.MCPServerConfigRoleRead,
},
})
require.NoError(t, err)
aclResponse, err = adminClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
require.Len(t, aclResponse.Users, 2)
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
UserRoles: map[string]codersdk.MCPServerConfigRole{
"urn:uuid:" + nonMember.ID.String(): codersdk.MCPServerConfigRoleDeleted,
strings.ToUpper(groupMember.ID.String()): codersdk.MCPServerConfigRoleDeleted,
},
})
require.NoError(t, err)
aclResponse, err = adminClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
require.Empty(t, aclResponse.Users)
_, err = nonMemberClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID)
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
otherOrg := dbgen.Organization(t, db, database.Organization{})
otherGroup := dbgen.Group(t, db, database.Group{OrganizationID: otherOrg.ID})
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
GroupRoles: map[string]codersdk.MCPServerConfigRole{
otherGroup.ID.String(): codersdk.MCPServerConfigRoleRead,
},
})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Error(), otherGroup.ID.String())
foreignUser := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: otherOrg.ID, UserID: foreignUser.ID})
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
UserRoles: map[string]codersdk.MCPServerConfigRole{
foreignUser.ID.String(): codersdk.MCPServerConfigRoleRead,
},
})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Error(), foreignUser.ID.String())
}
// mcpServerConfigDeleteRaceStore deletes the config right after the param
// middleware read once armed, so the handler's locked re-fetch sees a
// concurrently deleted row.
type mcpServerConfigDeleteRaceStore struct {
database.Store
armed atomic.Bool
}
func (s *mcpServerConfigDeleteRaceStore) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) {
config, err := s.Store.GetMCPServerConfigByID(ctx, id)
if err == nil && s.armed.CompareAndSwap(true, false) {
if err := s.DeleteMCPServerConfigByID(ctx, id); err != nil {
return database.MCPServerConfig{}, err
}
}
return config, err
}
func TestMCPServerConfigACLConcurrentDelete(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
db, ps := dbtestutil.NewDB(t)
store := &mcpServerConfigDeleteRaceStore{Store: db}
adminClient := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
Database: store,
Pubsub: ps,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
config := createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "acl-delete-race", true)
store.armed.Store(true)
err := adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
}
// TestMCPServerConfigsSecretsNeverLeaked is a load-bearing test that
// ensures secret fields (OAuth2 client secret, API key value, custom
// headers) are never present in API responses for any caller. If this
// test fails, it means a code change accidentally started exposing
// secrets. See: https://github.com/coder/coder/pull/23227#discussion_r2959461109
func TestMCPServerConfigsSecretsNeverLeaked(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
// Create a config with ALL secret fields populated.
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Secrets Test",
Slug: "secrets-test",
Transport: "streamable_http",
URL: "https://mcp.example.com/secrets",
AuthType: "oauth2",
OAuth2ClientID: "client-id-secret-test",
OAuth2ClientSecret: "THIS-IS-A-SECRET-VALUE",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: "https://auth.example.com/token",
OAuth2RevocationURL: "https://auth.example.com/revoke",
OAuth2Resource: "https://mcp.example.com/secrets",
OAuth2Scopes: "read write",
APIKeyHeader: "X-Api-Key",
APIKeyValue: "THIS-IS-A-SECRET-API-KEY",
CustomHeaders: map[string]string{"X-Custom": "THIS-IS-A-SECRET-HEADER"},
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
// The sentinel values we must never see in any JSON response.
secrets := []string{
"THIS-IS-A-SECRET-VALUE",
"THIS-IS-A-SECRET-API-KEY",
"THIS-IS-A-SECRET-HEADER",
}
assertNoSecrets := func(t *testing.T, label string, v interface{}) {
t.Helper()
data, err := json.Marshal(v)
require.NoError(t, err)
jsonStr := string(data)
for _, secret := range secrets {
assert.False(t, strings.Contains(jsonStr, secret),
"%s: JSON response contains secret %q", label, secret)
}
}
// Verify the create response doesn't leak secrets.
assertNoSecrets(t, "admin create response", created)
// Verify boolean indicators are set correctly.
require.True(t, created.HasOAuth2Secret, "HasOAuth2Secret should be true")
require.Equal(t, "https://mcp.example.com/secrets", created.OAuth2Resource)
require.True(t, created.HasAPIKey, "HasAPIKey should be true")
require.True(t, created.HasCustomHeaders, "HasCustomHeaders should be true")
// Admin list endpoint.
adminConfigs, err := adminClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.NotEmpty(t, adminConfigs)
for _, cfg := range adminConfigs {
assertNoSecrets(t, "admin list", cfg)
}
// Admin get-by-ID endpoint.
adminSingle, err := adminClient.MCPServerConfigByID(ctx, created.OrganizationID, created.ID)
require.NoError(t, err)
assertNoSecrets(t, "admin get-by-id", adminSingle)
// Non-admin list endpoint.
memberConfigs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.NotEmpty(t, memberConfigs)
for _, cfg := range memberConfigs {
assertNoSecrets(t, "member list", cfg)
// Non-admin should also not see admin-only fields.
assert.Empty(t, cfg.OAuth2ClientID, "member should not see OAuth2ClientID")
assert.Empty(t, cfg.OAuth2AuthURL, "member should not see OAuth2AuthURL")
assert.Empty(t, cfg.OAuth2TokenURL, "member should not see OAuth2TokenURL")
assert.Empty(t, cfg.OAuth2RevocationURL, "member should not see OAuth2RevocationURL")
assert.Empty(t, cfg.OAuth2Resource, "member should not see OAuth2Resource")
assert.Empty(t, cfg.APIKeyHeader, "member should not see APIKeyHeader")
assert.Empty(t, cfg.OAuth2Scopes, "member should not see OAuth2Scopes")
assert.Empty(t, cfg.URL, "member should not see URL")
assert.Empty(t, cfg.Transport, "member should not see Transport")
}
// Non-admin get-by-ID endpoint.
memberSingle, err := memberClient.MCPServerConfigByID(ctx, created.OrganizationID, created.ID)
require.NoError(t, err)
assertNoSecrets(t, "member get-by-id", memberSingle)
assert.Empty(t, memberSingle.OAuth2ClientID, "member should not see OAuth2ClientID")
assert.Empty(t, memberSingle.OAuth2AuthURL, "member should not see OAuth2AuthURL")
assert.Empty(t, memberSingle.OAuth2TokenURL, "member should not see OAuth2TokenURL")
assert.Empty(t, memberSingle.OAuth2Scopes, "member should not see OAuth2Scopes")
assert.Empty(t, memberSingle.OAuth2Resource, "member should not see OAuth2Resource")
assert.Empty(t, memberSingle.APIKeyHeader, "member should not see APIKeyHeader")
assert.Empty(t, memberSingle.URL, "member should not see URL")
assert.Empty(t, memberSingle.Transport, "member should not see Transport")
}
func TestMCPServerConfigsAuthConnected(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
// Create an oauth2 server config (enabled).
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "OAuth Server",
Slug: "oauth-server",
Transport: "streamable_http",
URL: "https://mcp.example.com/oauth",
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: "https://auth.example.com/token",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
// Regular user lists configs — auth_connected should be false
// because no token has been stored.
memberConfigs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, memberConfigs, 1)
require.Equal(t, created.ID, memberConfigs[0].ID)
require.False(t, memberConfigs[0].AuthConnected)
// Also create a non-oauth server. It should report
// auth_connected=true because no auth is needed.
_ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "no-auth-server", true)
// And a user_oidc server. user_oidc never requires a per-user
// connect step, so auth_connected is always true regardless of
// whether the calling user has an OIDC link.
_, err = adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "User OIDC Server",
Slug: "user-oidc-server",
Transport: "streamable_http",
URL: "https://mcp.example.com/oidc",
AuthType: "user_oidc",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
memberConfigs, err = memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, memberConfigs, 3)
for _, cfg := range memberConfigs {
switch cfg.AuthType {
case "none", "user_oidc":
require.True(t, cfg.AuthConnected, "%s should report auth_connected", cfg.AuthType)
default:
require.False(t, cfg.AuthConnected, "%s should not report auth_connected", cfg.AuthType)
}
}
}
func TestMCPServerConfigsUserOIDCClearsFields(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
// Start with an oauth2 config that has a client secret, then
// switch the auth_type to user_oidc and verify all auth-specific
// fields are cleared.
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Switch Server",
Slug: "switch-server",
Transport: "streamable_http",
URL: "https://mcp.example.com/v1",
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2ClientSecret: "secret-value",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: "https://auth.example.com/token",
OAuth2RevocationURL: "https://auth.example.com/revoke",
OAuth2Resource: "https://mcp.example.com/v1",
OAuth2Scopes: "read write",
Availability: "default_off",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.True(t, created.HasOAuth2Secret)
require.Equal(t, "cid", created.OAuth2ClientID)
require.Equal(t, "https://auth.example.com/revoke", created.OAuth2RevocationURL)
newRevocationURL := "https://auth.example.com/revoke2"
updated, err := client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
OAuth2RevocationURL: &newRevocationURL,
})
require.NoError(t, err)
require.Equal(t, newRevocationURL, updated.OAuth2RevocationURL)
invalidURL := "not a url"
_, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
OAuth2RevocationURL: &invalidURL,
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
// Plaintext URLs are rejected on save, not later at disconnect.
plaintextURL := "http://auth.example.com/revoke"
_, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
OAuth2RevocationURL: &plaintextURL,
})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
_, err = client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Plaintext Revoke",
Slug: "plaintext-revoke",
Transport: "streamable_http",
URL: "https://mcp.example.com/plaintext",
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: "https://auth.example.com/token",
OAuth2RevocationURL: plaintextURL,
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
// An explicit empty string clears the stored URL.
emptyURL := ""
updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
OAuth2RevocationURL: &emptyURL,
})
require.NoError(t, err)
require.Empty(t, updated.OAuth2RevocationURL)
updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
OAuth2RevocationURL: &newRevocationURL,
})
require.NoError(t, err)
require.Equal(t, newRevocationURL, updated.OAuth2RevocationURL)
// RFC 8707 resource indicators must be valid absolute URIs without a
// fragment, on both create and update. Create rejects them before
// auto-discovery so an invalid value causes no client registration.
var discoveryRequests atomic.Int64
discoveryProbe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
discoveryRequests.Add(1)
http.NotFound(w, r)
}))
t.Cleanup(discoveryProbe.Close)
for _, invalidResource := range []string{
"https://mcp.example.com/v1#frag",
"/v1/mcp",
"https://mcp.example.com/a b",
"https://mcp.example.com/mcp?tenant=%zz",
"https://mcp.example.com/a[b]",
"https://a]b.example.com/mcp",
"https://[x]/mcp",
"https://[::1][x]/mcp",
"https://[fe80::1%25en0]/mcp",
"https://user@mcp.example.com/mcp",
} {
_, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
OAuth2Resource: &invalidResource,
})
require.ErrorAs(t, err, &sdkErr, invalidResource)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode(), invalidResource)
require.Equal(t, "Invalid OAuth2 resource.", sdkErr.Message, invalidResource)
_, err = client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Invalid Resource",
Slug: "invalid-resource",
Transport: "streamable_http",
URL: discoveryProbe.URL + "/mcp",
AuthType: "oauth2",
OAuth2Resource: invalidResource,
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.ErrorAs(t, err, &sdkErr, invalidResource)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode(), invalidResource)
require.Equal(t, "Invalid OAuth2 resource.", sdkErr.Message, invalidResource)
}
require.Zero(t, discoveryRequests.Load(), "invalid resource must be rejected before discovery")
emptyResource := ""
updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
OAuth2Resource: &emptyResource,
})
require.NoError(t, err)
require.Empty(t, updated.OAuth2Resource)
newResource := "https://mcp.example.com/v2"
paddedResource := " " + newResource + " "
updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
OAuth2Resource: &paddedResource,
})
require.NoError(t, err)
require.Equal(t, newResource, updated.OAuth2Resource)
newAuth := "user_oidc"
updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
AuthType: &newAuth,
})
require.NoError(t, err)
require.Equal(t, "user_oidc", updated.AuthType)
require.False(t, updated.HasOAuth2Secret, "oauth2 secret should be cleared")
require.False(t, updated.HasAPIKey, "api key should remain unset")
require.False(t, updated.HasCustomHeaders, "custom headers should remain unset")
require.Empty(t, updated.OAuth2ClientID)
require.Empty(t, updated.OAuth2AuthURL)
require.Empty(t, updated.OAuth2TokenURL)
require.Empty(t, updated.OAuth2RevocationURL)
require.Empty(t, updated.OAuth2Resource)
require.Empty(t, updated.OAuth2Scopes)
require.Empty(t, updated.APIKeyHeader)
}
// TestMCPServerConfigsAuthTypeChangeClearsOAuth2Resource covers the
// non-OIDC auth types; TestMCPServerConfigsUserOIDCClearsFields covers
// user_oidc.
func TestMCPServerConfigsAuthTypeChangeClearsOAuth2Resource(t *testing.T) {
t.Parallel()
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
header := "X-Api-Key"
value := "api-key-value"
customHeaders := map[string]string{"X-Custom": "custom-value"}
cases := []struct {
authType string
req codersdk.UpdateMCPServerConfigRequest
}{
{authType: "none"},
{authType: "api_key", req: codersdk.UpdateMCPServerConfigRequest{APIKeyHeader: &header, APIKeyValue: &value}},
{authType: "custom_headers", req: codersdk.UpdateMCPServerConfigRequest{CustomHeaders: &customHeaders}},
}
for _, tc := range cases {
t.Run(tc.authType, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
slug := "clear-resource-" + strings.ReplaceAll(tc.authType, "_", "-")
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Clear Resource " + tc.authType,
Slug: slug,
Transport: "streamable_http",
URL: "https://mcp.example.com/" + slug,
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: "https://auth.example.com/token",
OAuth2Resource: "https://mcp.example.com/" + slug,
Availability: "default_off",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.NotEmpty(t, created.OAuth2Resource)
req := tc.req
req.AuthType = &tc.authType
updated, err := client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, req)
require.NoError(t, err)
require.Equal(t, tc.authType, updated.AuthType)
require.Empty(t, updated.OAuth2Resource)
})
}
}
func TestMCPServerConfigsAPIKeyRequiresHeaderAndValue(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
base := codersdk.CreateMCPServerConfigRequest{
DisplayName: "API Key Server",
Slug: "api-key-server",
Transport: "streamable_http",
URL: "https://mcp.example.com/api-key",
AuthType: "api_key",
APIKeyHeader: "Authorization",
APIKeyValue: "Bearer secret",
Availability: "default_off",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
}
requireBadRequest := func(t *testing.T, err error) {
t.Helper()
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
}
// Whitespace-only values are trimmed before saving, so they count as empty.
blankHeader := base
blankHeader.APIKeyHeader = " "
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, blankHeader)
requireBadRequest(t, err)
blankValue := base
blankValue.APIKeyValue = " "
_, err = client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, blankValue)
requireBadRequest(t, err)
noAuth := base
noAuth.AuthType = "none"
noAuth.APIKeyHeader = ""
noAuth.APIKeyValue = ""
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, noAuth)
require.NoError(t, err)
apiKeyAuth := "api_key"
_, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
AuthType: &apiKeyAuth,
})
requireBadRequest(t, err)
header := "Authorization"
value := "Bearer secret"
updated, err := client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
AuthType: &apiKeyAuth,
APIKeyHeader: &header,
APIKeyValue: &value,
})
require.NoError(t, err)
require.True(t, updated.HasAPIKey)
empty := ""
_, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
APIKeyHeader: &empty,
})
requireBadRequest(t, err)
_, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
APIKeyValue: &empty,
})
requireBadRequest(t, err)
disabled := false
updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
Enabled: &disabled,
})
require.NoError(t, err)
require.False(t, updated.Enabled)
require.True(t, updated.HasAPIKey)
}
func TestMCPServerConfigsUserOIDCDirect(t *testing.T) {
t.Parallel()
// Create with user_oidc and confirm validation accepts the value
// while no auth-specific fields are persisted on the row.
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "User OIDC Direct",
Slug: "user-oidc-direct",
Transport: "streamable_http",
URL: "https://mcp.example.com/oidc-direct",
AuthType: "user_oidc",
Availability: "default_off",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.Equal(t, "user_oidc", created.AuthType)
require.False(t, created.HasOAuth2Secret)
require.False(t, created.HasAPIKey)
require.False(t, created.HasCustomHeaders)
}
func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) {
t.Parallel()
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
_, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
newConfig := func(ctx context.Context, t *testing.T, slug string) codersdk.MCPServerConfig {
t.Helper()
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Grant Invalidation " + slug,
Slug: slug,
Transport: "streamable_http",
URL: "https://mcp.example.com/" + slug,
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: "https://auth.example.com/token",
OAuth2RevocationURL: "https://auth.example.com/revoke",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
return created
}
seedToken := func(ctx context.Context, t *testing.T, configID uuid.UUID) {
t.Helper()
//nolint:gocritic // Seeding a member grant requires system access.
_, err := db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
MCPServerConfigID: configID,
UserID: member.ID,
AccessToken: "access-token",
RefreshToken: "refresh-token",
TokenType: "Bearer",
Expiry: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
})
require.NoError(t, err)
}
tokenExists := func(ctx context.Context, t *testing.T, configID uuid.UUID) bool {
t.Helper()
//nolint:gocritic // Verifying persisted state requires system access.
_, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
MCPServerConfigID: configID,
UserID: member.ID,
})
if errors.Is(err, sql.ErrNoRows) {
return false
}
require.NoError(t, err)
return true
}
t.Run("URLChangeDeletesGrants", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
config := newConfig(ctx, t, "grant-url-change")
seedToken(ctx, t, config.ID)
newURL := "https://mcp.example.com/grant-url-change-moved"
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
URL: &newURL,
})
require.NoError(t, err)
require.False(t, tokenExists(ctx, t, config.ID))
})
t.Run("AuthTypeChangeDeletesGrants", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
config := newConfig(ctx, t, "grant-auth-change")
seedToken(ctx, t, config.ID)
authType := "none"
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
AuthType: &authType,
})
require.NoError(t, err)
require.False(t, tokenExists(ctx, t, config.ID))
})
t.Run("TokenURLChangeDeletesGrants", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
config := newConfig(ctx, t, "grant-token-url-change")
seedToken(ctx, t, config.ID)
movedEndpoint := "https://auth.example.com/other-endpoint"
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
OAuth2TokenURL: &movedEndpoint,
})
require.NoError(t, err)
require.False(t, tokenExists(ctx, t, config.ID))
})
t.Run("RevocationURLChangeDeletesGrants", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
config := newConfig(ctx, t, "grant-revocation-url-change")
seedToken(ctx, t, config.ID)
movedEndpoint := "https://auth.example.com/other-revocation-endpoint"
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
OAuth2RevocationURL: &movedEndpoint,
})
require.NoError(t, err)
require.False(t, tokenExists(ctx, t, config.ID))
})
t.Run("ClientIDChangeDeletesGrants", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
config := newConfig(ctx, t, "grant-client-id-change")
seedToken(ctx, t, config.ID)
newClientID := "cid-2"
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
OAuth2ClientID: &newClientID,
})
require.NoError(t, err)
require.False(t, tokenExists(ctx, t, config.ID))
})
t.Run("ResourceChangeDeletesGrants", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
config := newConfig(ctx, t, "grant-resource-change")
seedToken(ctx, t, config.ID)
resource := "https://mcp.example.com/grant-resource-change"
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
OAuth2Resource: &resource,
})
require.NoError(t, err)
require.False(t, tokenExists(ctx, t, config.ID))
})
t.Run("UnrelatedChangeKeepsGrants", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
config := newConfig(ctx, t, "grant-unrelated")
seedToken(ctx, t, config.ID)
displayName := "Grant Invalidation renamed"
newSecret := "rotated-secret"
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
DisplayName: &displayName,
OAuth2ClientSecret: &newSecret,
})
require.NoError(t, err)
require.True(t, tokenExists(ctx, t, config.ID))
})
}
func TestMCPServerConfigsOAuth2ScopedKeyAuthorization(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
var exchangeRequests atomic.Int64
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
exchangeRequests.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"scoped-access-token","token_type":"Bearer"}`))
}))
t.Cleanup(tokenServer.Close)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "OAuth Scoped Key",
Slug: "oauth-scoped-key",
Transport: "streamable_http",
URL: "https://mcp.example.com/oauth-scoped-key",
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: tokenServer.URL,
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
_, token := dbgen.APIKey(t, db, database.APIKey{
UserID: firstUser.UserID,
Scopes: database.APIKeyScopes{
database.ApiKeyScopeMcpServerConfigRead,
database.ApiKeyScopeOrganizationRead,
database.ApiKeyScopeUserReadPersonal,
},
})
scopedClient := codersdk.New(adminClient.URL)
scopedClient.SetSessionToken(token)
scopedClient.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
t.Run("ConnectRequiresPersonalTokenUpdate", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
connectURL := scopedClient.MCPServerOAuth2ConnectURL(created.OrganizationID, created.ID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, connectURL, nil)
require.NoError(t, err)
req.AddCookie(&http.Cookie{Name: codersdk.SessionTokenCookie, Value: scopedClient.SessionToken()})
res, err := scopedClient.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusForbidden, res.StatusCode)
})
t.Run("CallbackRequiresPersonalTokenUpdateBeforeExchange", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
state := "scoped-key-state"
callbackURL, err := scopedClient.URL.Parse(
"/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback",
)
require.NoError(t, err)
query := callbackURL.Query()
query.Set("code", "one-time-code")
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: scopedClient.SessionToken()})
req.AddCookie(&http.Cookie{Name: "mcp_oauth2_state_" + created.ID.String(), Value: state})
res, err := scopedClient.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusForbidden, res.StatusCode)
require.Zero(t, exchangeRequests.Load())
})
}
func TestMCPServerConfigsOAuth2CallbackRejectsSupersededConfig(t *testing.T) {
t.Parallel()
movedURL := "https://attacker.example.com/superseded"
runMCPServerConfigsOAuth2CallbackSupersessionTest(t, "superseded-callback", codersdk.UpdateMCPServerConfigRequest{
URL: &movedURL,
})
}
func TestMCPServerConfigsOAuth2CallbackRejectsSupersededRevocationURL(t *testing.T) {
t.Parallel()
movedRevocationURL := "https://attacker.example.com/revoke"
runMCPServerConfigsOAuth2CallbackSupersessionTest(t, "superseded-revocation-callback", codersdk.UpdateMCPServerConfigRequest{
OAuth2RevocationURL: &movedRevocationURL,
})
}
func TestMCPServerConfigsOAuth2CallbackRejectsSupersededResource(t *testing.T) {
t.Parallel()
movedResource := "https://attacker.example.com/mcp"
runMCPServerConfigsOAuth2CallbackSupersessionTest(t, "superseded-resource-callback", codersdk.UpdateMCPServerConfigRequest{
OAuth2Resource: &movedResource,
})
}
func runMCPServerConfigsOAuth2CallbackSupersessionTest(
t *testing.T,
slug string,
updateRequest codersdk.UpdateMCPServerConfigRequest,
) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
var configID atomic.Pointer[uuid.UUID]
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if id := configID.Load(); id != nil {
_, err := adminClient.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, *id, updateRequest)
assert.NoError(t, err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"superseded-access-token","token_type":"Bearer","expires_in":3600}`))
}))
t.Cleanup(tokenServer.Close)
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Superseded Callback " + slug,
Slug: slug,
Transport: "streamable_http",
URL: "https://mcp.example.com/" + slug,
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: tokenServer.URL + "/token",
OAuth2RevocationURL: "https://auth.example.com/revoke",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
configID.Store(&created.ID)
state := "superseded-state"
callbackURL, err := memberClient.URL.Parse(
"/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback",
)
require.NoError(t, err)
q := callbackURL.Query()
q.Set("code", "superseded-auth-code")
q.Set("state", state)
callbackURL.RawQuery = q.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_" + created.ID.String(), Value: state})
res, err := memberClient.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusConflict, res.StatusCode)
//nolint:gocritic // Verifying persisted state requires system access.
_, err = db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
MCPServerConfigID: created.ID,
UserID: member.ID,
})
require.ErrorIs(t, err, sql.ErrNoRows,
"no grant may be stored for a callback completed against a superseded config")
}
func TestMCPServerConfigsUserOIDCRequiresDeploymentPerms(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID,
rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID))
newRequest := func(slug, authType string) codersdk.CreateMCPServerConfigRequest {
return codersdk.CreateMCPServerConfigRequest{
DisplayName: "OIDC Gate " + slug,
Slug: slug,
Transport: "streamable_http",
URL: "https://mcp.example.com/" + slug,
AuthType: authType,
Availability: "default_off",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
}
}
orgAdminOwned, err := orgAdminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, newRequest("org-admin-none", "none"))
require.NoError(t, err)
var sdkErr *codersdk.Error
_, err = orgAdminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, newRequest("org-admin-oidc", "user_oidc"))
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
userOIDC := "user_oidc"
_, err = orgAdminClient.UpdateMCPServerConfig(ctx, orgAdminOwned.OrganizationID, orgAdminOwned.ID, codersdk.UpdateMCPServerConfigRequest{
AuthType: &userOIDC,
})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
deploymentOwned, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, newRequest("deployment-oidc", "user_oidc"))
require.NoError(t, err)
// The URL determines where chat owners' OIDC tokens are sent.
newURL := "https://attacker.example.com/exfil"
_, err = orgAdminClient.UpdateMCPServerConfig(ctx, deploymentOwned.OrganizationID, deploymentOwned.ID, codersdk.UpdateMCPServerConfigRequest{
URL: &newURL,
})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
updatedURL := "https://mcp.example.com/deployment-oidc-v2"
updated, err := adminClient.UpdateMCPServerConfig(ctx, deploymentOwned.OrganizationID, deploymentOwned.ID, codersdk.UpdateMCPServerConfigRequest{
URL: &updatedURL,
})
require.NoError(t, err)
require.Equal(t, updatedURL, updated.URL)
}
func TestMCPServerConfigsAvailability(t *testing.T) {
t.Parallel()
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
validValues := []string{"force_on", "default_on", "default_off"}
for _, av := range validValues {
av := av
t.Run(av, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Server " + av,
Slug: "server-" + av,
Transport: "streamable_http",
URL: "https://mcp.example.com/" + av,
AuthType: "none",
Availability: av,
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.Equal(t, av, created.Availability)
})
}
t.Run("InvalidAvailability", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Bad Availability",
Slug: "bad-avail",
Transport: "streamable_http",
URL: "https://mcp.example.com/bad",
AuthType: "none",
Availability: "always_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
}
func TestMCPServerConfigsUniqueSlug(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "First",
Slug: "test-server",
Transport: "streamable_http",
URL: "https://mcp.example.com/first",
AuthType: "none",
Availability: "default_off",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
// Attempt to create another config with the same slug.
_, err = client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Second",
Slug: "test-server",
Transport: "streamable_http",
URL: "https://mcp.example.com/second",
AuthType: "none",
Availability: "default_off",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusConflict, sdkErr.StatusCode())
}
func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) {
t.Parallel()
newDisconnectFixture := func(t *testing.T, slug, revocationURL string) (memberClient *codersdk.Client, memberID uuid.UUID, db database.Store, configID uuid.UUID) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "OAuth Disconnect " + slug,
Slug: slug,
Transport: "streamable_http",
URL: "https://mcp.example.com/" + slug,
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: "https://auth.example.com/token",
OAuth2RevocationURL: revocationURL,
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
return memberClient, member.ID, db, created.ID
}
seedToken := func(t *testing.T, db database.Store, configID, userID uuid.UUID) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Seeding test state requires system access.
_, err := db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
MCPServerConfigID: configID,
UserID: userID,
AccessToken: "access-token",
RefreshToken: "refresh-token",
TokenType: "Bearer",
Expiry: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
})
require.NoError(t, err)
}
requireTokenDeleted := func(t *testing.T, db database.Store, configID, userID uuid.UUID) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Verifying persisted state requires system access.
_, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
MCPServerConfigID: configID,
UserID: userID,
})
require.ErrorIs(t, err, sql.ErrNoRows)
}
t.Run("ScopedKeyWithoutPersonalTokenUpdate", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
memberClient, memberID, db, configID := newDisconnectFixture(t, "disc-scoped-key", "")
seedToken(t, db, configID, memberID)
_, token := dbgen.APIKey(t, db, database.APIKey{
UserID: memberID,
Scopes: database.APIKeyScopes{
database.ApiKeyScopeUserReadPersonal,
},
})
scopedClient := codersdk.New(memberClient.URL)
scopedClient.SetSessionToken(token)
_, err := scopedClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
//nolint:gocritic // Verifying persisted state requires system access.
row, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
MCPServerConfigID: configID,
UserID: memberID,
})
require.NoError(t, err)
require.Equal(t, "refresh-token", row.RefreshToken)
})
t.Run("ScopedKeyWithPersonalTokenUpdateOnly", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
memberClient, memberID, db, configID := newDisconnectFixture(t, "disc-update-only", "")
seedToken(t, db, configID, memberID)
_, token := dbgen.APIKey(t, db, database.APIKey{
UserID: memberID,
Scopes: database.APIKeyScopes{
database.ApiKeyScopeUserUpdatePersonal,
},
})
scopedClient := codersdk.New(memberClient.URL)
scopedClient.SetSessionToken(token)
_, err := scopedClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID)
require.NoError(t, err)
requireTokenDeleted(t, db, configID, memberID)
})
t.Run("NoToken", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
memberClient, _, _, configID := newDisconnectFixture(t, "disc-no-token", "")
resp, err := memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID)
require.NoError(t, err)
require.False(t, resp.TokenRevoked)
require.Empty(t, resp.TokenRevocationError)
})
t.Run("RemovedOrgMemberCanDisconnect", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
coderdtest.CreateFirstUser(t, adminClient)
secondOrg := dbgen.Organization(t, db, database.Organization{})
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, secondOrg.ID)
config := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
OrganizationID: secondOrg.ID,
AuthType: "oauth2",
Enabled: true,
})
seedToken(t, db, config.ID, member.ID)
//nolint:gocritic // Seeding test state requires system access.
systemCtx := dbauthz.AsSystemRestricted(ctx)
err := db.DeleteOrganizationMember(systemCtx, database.DeleteOrganizationMemberParams{
OrganizationID: secondOrg.ID,
UserID: member.ID,
})
require.NoError(t, err)
// A token owner removed from the organization can no longer
// read the config, but must still be able to delete the
// stored token.
_, err = memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, config.ID)
require.NoError(t, err)
requireTokenDeleted(t, db, config.ID, member.ID)
})
t.Run("RevokesAtProvider", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
var gotForm atomic.Pointer[url.Values]
revokeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
form := r.PostForm
gotForm.Store(&form)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(revokeSrv.Close)
memberClient, memberID, db, configID := newDisconnectFixture(t, "disc-revoke", revokeSrv.URL)
seedToken(t, db, configID, memberID)
resp, err := memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID)
require.NoError(t, err)
require.True(t, resp.TokenRevoked)
require.Empty(t, resp.TokenRevocationError)
form := gotForm.Load()
require.NotNil(t, form)
require.Equal(t, "refresh-token", form.Get("token"))
require.Equal(t, "refresh_token", form.Get("token_type_hint"))
require.Equal(t, "cid", form.Get("client_id"))
requireTokenDeleted(t, db, configID, memberID)
})
t.Run("RefreshCannotRestoreDisconnectedToken", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
refreshStarted := make(chan struct{})
releaseRefresh := make(chan struct{})
var releaseOnce sync.Once
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(refreshStarted)
select {
case <-releaseRefresh:
case <-r.Context().Done():
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"fresh-access","refresh_token":"fresh-refresh","token_type":"Bearer","expires_in":3600}`))
}))
t.Cleanup(tokenSrv.Close)
t.Cleanup(func() { releaseOnce.Do(func() { close(releaseRefresh) }) })
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "OAuth Disconnect Refresh Race",
Slug: "disc-refresh-race",
Transport: "streamable_http",
URL: "https://mcp.example.com/disc-refresh-race",
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: tokenSrv.URL,
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
//nolint:gocritic // Seeding test state requires system access.
_, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
MCPServerConfigID: created.ID,
UserID: member.ID,
AccessToken: "expired-access",
RefreshToken: "old-refresh",
TokenType: "Bearer",
Expiry: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
})
require.NoError(t, err)
type configResult struct {
configs []codersdk.MCPServerConfig
err error
}
result := make(chan configResult, 1)
go func() {
configs, listErr := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
result <- configResult{configs: configs, err: listErr}
}()
select {
case <-refreshStarted:
case <-ctx.Done():
t.Fatal("timed out waiting for token refresh")
}
_, err = memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, created.ID)
require.NoError(t, err)
releaseOnce.Do(func() { close(releaseRefresh) })
var listed configResult
select {
case listed = <-result:
case <-ctx.Done():
t.Fatal("timed out waiting for refreshed config response")
}
require.NoError(t, listed.err)
require.Len(t, listed.configs, 1)
require.False(t, listed.configs[0].AuthConnected)
requireTokenDeleted(t, db, created.ID, member.ID)
})
t.Run("NoRevocationURL", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
memberClient, memberID, db, configID := newDisconnectFixture(t, "disc-no-url", "")
seedToken(t, db, configID, memberID)
resp, err := memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID)
require.NoError(t, err)
require.False(t, resp.TokenRevoked)
require.Empty(t, resp.TokenRevocationError)
requireTokenDeleted(t, db, configID, memberID)
})
t.Run("ProviderError", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
revokeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(revokeSrv.Close)
memberClient, memberID, db, configID := newDisconnectFixture(t, "disc-err", revokeSrv.URL)
seedToken(t, db, configID, memberID)
// Members get a generic error; provider bodies may echo the secret.
resp, err := memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID)
require.NoError(t, err)
require.False(t, resp.TokenRevoked)
require.NotEmpty(t, resp.TokenRevocationError)
require.NotContains(t, resp.TokenRevocationError, "HTTP 500")
requireTokenDeleted(t, db, configID, memberID)
})
t.Run("OnlyDisconnectsCallingUser", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
otherClient, other := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "OAuth Disconnect Isolation",
Slug: "disc-isolation",
Transport: "streamable_http",
URL: "https://mcp.example.com/disc-isolation",
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: "https://auth.example.com/token",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
for _, userID := range []uuid.UUID{member.ID, other.ID} {
//nolint:gocritic // Seeding test state requires system access.
_, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
MCPServerConfigID: created.ID,
UserID: userID,
AccessToken: "valid-access",
TokenType: "Bearer",
Expiry: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
})
require.NoError(t, err)
}
requireAuthConnected := func(client *codersdk.Client, want bool) {
t.Helper()
configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, configs, 1)
require.Equal(t, want, configs[0].AuthConnected)
}
requireAuthConnected(memberClient, true)
requireAuthConnected(otherClient, true)
_, err = memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, created.ID)
require.NoError(t, err)
requireAuthConnected(memberClient, false)
requireAuthConnected(otherClient, true)
_, err = memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, created.ID)
require.NoError(t, err)
})
}
func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) {
t.Parallel()
t.Run("Success", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
registrationStarted := make(chan struct{})
completeRegistration := make(chan struct{})
var blockRegistration sync.Once
var completeRegistrationOnce sync.Once
releaseRegistration := func() {
completeRegistrationOnce.Do(func() {
close(completeRegistration)
})
}
// Stand up a mock auth server that serves RFC 8414 metadata and
// a RFC 7591 dynamic client registration endpoint.
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + r.Host + `",
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
"token_endpoint": "` + "http://" + r.Host + `/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"revocation_endpoint": "` + "http://" + r.Host + `/revoke",
"response_types_supported": ["code"],
"scopes_supported": ["read", "write"]
}`))
case "/register":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
blockRegistration.Do(func() {
close(registrationStarted)
<-completeRegistration
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_id": "auto-discovered-client-id",
"client_secret": "auto-discovered-client-secret"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(authServer.Close)
// Stand up a mock MCP server that serves RFC 9728 Protected
// Resource Metadata at the path-aware well-known URL.
// The URL used for the config ends with /v1/mcp, so the
// path-aware metadata URL is
// /.well-known/oauth-protected-resource/v1/mcp.
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource/v1/mcp":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `",
"authorization_servers": ["` + authServer.URL + `"]
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, client)
type createResult struct {
config codersdk.MCPServerConfig
err error
}
createdCh := make(chan createResult, 1)
testutil.Go(t, func() {
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Auto-Discovery Server",
Slug: "auto-discovery",
Transport: "streamable_http",
URL: mcpServer.URL + "/v1/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
createdCh <- createResult{config: created, err: err}
})
t.Cleanup(releaseRegistration)
testutil.TryReceive(ctx, t, registrationStarted)
//nolint:gocritic // Verifying persisted state requires system access.
_, err := db.GetMCPServerConfigByOrganizationAndSlug(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerConfigByOrganizationAndSlugParams{
OrganizationID: firstUser.OrganizationID,
Slug: "auto-discovery",
})
releaseRegistration()
require.ErrorIs(t, err, sql.ErrNoRows)
create := testutil.RequireReceive(ctx, t, createdCh)
require.NoError(t, create.err)
created := create.config
require.Equal(t, "auto-discovered-client-id", created.OAuth2ClientID)
require.True(t, created.HasOAuth2Secret)
require.Equal(t, authServer.URL+"/authorize", created.OAuth2AuthURL)
require.Equal(t, authServer.URL+"/token", created.OAuth2TokenURL)
require.Equal(t, authServer.URL+"/revoke", created.OAuth2RevocationURL)
require.Equal(t, "read write", created.OAuth2Scopes)
// The origin-only PRM resource covers the /v1/mcp server URL.
require.Equal(t, mcpServer.URL, created.OAuth2Resource)
// Explicit values win over the discovered ones.
overridden, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Auto-Discovery Override",
Slug: "auto-discovery-override",
Transport: "streamable_http",
URL: mcpServer.URL + "/v1/mcp",
AuthType: "oauth2",
OAuth2RevocationURL: "https://override.example.com/revoke",
OAuth2Resource: "https://override.example.com/mcp",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.Equal(t, "https://override.example.com/revoke", overridden.OAuth2RevocationURL)
require.Equal(t, "https://override.example.com/mcp", overridden.OAuth2Resource)
})
t.Run("CreateOnlyScopeAllowed", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, client)
// MCP config scopes are not user-mintable, so seed a create-only key.
_, token := dbgen.APIKey(t, db, database.APIKey{
UserID: firstUser.UserID,
Scopes: database.APIKeyScopes{
"mcp_server_config:create",
"organization:read",
},
})
scopedClient := codersdk.New(client.URL)
scopedClient.SetSessionToken(token)
var registrationRequests atomic.Int64
created, err := scopedClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Create Only Discovery",
Slug: "create-only-discovery",
Transport: "streamable_http",
URL: newMCPDiscoveryServer(t, &registrationRequests),
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
})
require.NoError(t, err)
require.Equal(t, "discovered-client-id", created.OAuth2ClientID)
require.EqualValues(t, 1, registrationRequests.Load())
})
t.Run("ExistingSlugSkipsRegistration", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
createMCPServerConfig(t, client, firstUser.OrganizationID, "discovery-conflict", true)
var registrationRequests atomic.Int64
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Discovery Conflict",
Slug: " discovery-conflict ",
Transport: "streamable_http",
URL: newMCPDiscoveryServer(t, &registrationRequests),
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusConflict, sdkErr.StatusCode())
require.Equal(t, "MCP server config already exists.", sdkErr.Message)
require.Zero(t, registrationRequests.Load())
})
t.Run("PathAwareTakesPriority", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Auth server that returns "path-scope" as the supported
// scope.
pathAuthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + "http://" + r.Host + `",
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
"token_endpoint": "` + "http://" + r.Host + `/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"response_types_supported": ["code"],
"scopes_supported": ["path-scope"]
}`))
case "/register":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_id": "path-client-id",
"client_secret": "path-client-secret"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(pathAuthServer.Close)
// Auth server that returns "root-scope" as the supported
// scope.
rootAuthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + "http://" + r.Host + `",
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
"token_endpoint": "` + "http://" + r.Host + `/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"response_types_supported": ["code"],
"scopes_supported": ["root-scope"]
}`))
case "/register":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_id": "root-client-id",
"client_secret": "root-client-secret"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(rootAuthServer.Close)
// MCP server serves different protected resource metadata at
// path-aware vs root URLs, each pointing to a different auth
// server.
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource/v1/mcp":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `/v1/mcp",
"authorization_servers": ["` + pathAuthServer.URL + `"]
}`))
case "/.well-known/oauth-protected-resource":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `",
"authorization_servers": ["` + rootAuthServer.URL + `"]
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Priority Test",
Slug: "priority-test",
Transport: "streamable_http",
URL: mcpServer.URL + "/v1/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
// The path-aware auth server returns "path-scope", the root
// auth server returns "root-scope". If path-aware takes
// priority, we get "path-scope".
require.Equal(t, "path-client-id", created.OAuth2ClientID)
require.Equal(t, "path-scope", created.OAuth2Scopes)
})
// Verify discovery works when the protected resource metadata
// is only available at the root-level well-known URL (no path
// component). This covers servers that don't use path-aware
// metadata.
t.Run("RootLevelFallback", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + r.Host + `",
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
"token_endpoint": "` + "http://" + r.Host + `/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"response_types_supported": ["code"],
"scopes_supported": ["all"]
}`))
case "/register":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_id": "root-client-id",
"client_secret": "root-client-secret"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(authServer.Close)
// MCP server only serves metadata at the root well-known
// URL, NOT at the path-aware location.
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `",
"authorization_servers": ["` + authServer.URL + `"]
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Root Fallback Server",
Slug: "root-fallback",
Transport: "streamable_http",
URL: mcpServer.URL + "/v1/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.Equal(t, "root-client-id", created.OAuth2ClientID)
require.True(t, created.HasOAuth2Secret)
require.Equal(t, authServer.URL+"/authorize", created.OAuth2AuthURL)
require.Equal(t, authServer.URL+"/token", created.OAuth2TokenURL)
require.Equal(t, "all", created.OAuth2Scopes)
})
// Verify that when the authorization server issuer URL has a
// path component (e.g. https://github.com/login/oauth), the
// discovery uses the path-aware metadata URL per RFC 8414 §3.1.
t.Run("PathAwareAuthServerMetadata", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Auth server that serves metadata at the path-aware URL.
// The issuer URL is http://host/login/oauth, so the
// metadata URL should be
// /.well-known/oauth-authorization-server/login/oauth.
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server/login/oauth":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + "http://" + r.Host + `/login/oauth",
"authorization_endpoint": "` + "http://" + r.Host + `/login/oauth/authorize",
"token_endpoint": "` + "http://" + r.Host + `/login/oauth/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"response_types_supported": ["code"],
"scopes_supported": ["repo", "read:org"]
}`))
case "/register":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_id": "path-aware-client-id"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(authServer.Close)
// MCP server that points to an auth server with a path
// in its issuer URL (like GitHub's /login/oauth).
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource/mcp":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `/mcp",
"authorization_servers": ["` + authServer.URL + `/login/oauth"]
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Path-Aware Auth",
Slug: "path-aware-auth",
Transport: "streamable_http",
URL: mcpServer.URL + "/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.Equal(t, "path-aware-client-id", created.OAuth2ClientID)
require.Equal(t, authServer.URL+"/login/oauth/authorize", created.OAuth2AuthURL)
require.Equal(t, authServer.URL+"/login/oauth/token", created.OAuth2TokenURL)
require.Equal(t, "repo read:org", created.OAuth2Scopes)
})
// Regression test: verify that during dynamic client registration
// the redirect_uris sent to the authorization server contain the
// real config UUID, NOT the literal string "{id}". Before the
// fix, the callback URL was built before the config row existed,
// so it contained "{id}" literally, which caused "redirect URIs
// not approved" errors when the user later tried to connect.
t.Run("RedirectURIContainsRealConfigID", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Buffered channel so the handler never blocks.
registeredRedirectURI := make(chan string, 1)
// Stand up a mock auth server that captures the redirect_uris
// from the RFC 7591 Dynamic Client Registration request.
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + "http://" + r.Host + `",
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
"token_endpoint": "` + "http://" + r.Host + `/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"response_types_supported": ["code"],
"scopes_supported": ["read", "write"]
}`))
case "/register":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Decode the registration body and capture redirect_uris.
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if uris, ok := body["redirect_uris"].([]interface{}); ok && len(uris) > 0 {
if uri, ok := uris[0].(string); ok {
registeredRedirectURI <- uri
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_id": "test-client-id",
"client_secret": "test-client-secret"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(authServer.Close)
// Stand up a mock MCP server that returns RFC 9728 Protected
// Resource Metadata pointing to the auth server.
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource/v1/mcp",
"/.well-known/oauth-protected-resource":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `",
"authorization_servers": ["` + authServer.URL + `"]
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
// Create config with auth_type=oauth2 but no OAuth2 fields to
// trigger auto-discovery and dynamic client registration.
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Redirect URI Test",
Slug: "redirect-uri-test",
Transport: "streamable_http",
URL: mcpServer.URL + "/v1/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.Equal(t, "test-client-id", created.OAuth2ClientID)
require.True(t, created.HasOAuth2Secret)
// The registration request has already completed by the time
// CreateMCPServerConfig returns, so the URI is in the channel.
var redirectURI string
select {
case redirectURI = <-registeredRedirectURI:
case <-ctx.Done():
t.Fatal("timed out waiting for registration redirect URI")
}
// Core assertion: the redirect URI must NOT contain the
// literal placeholder "{id}". Before the fix the callback
// URL was built before the database insert, so it had
// "{id}" where the UUID should be.
require.NotContains(t, redirectURI, "{id}",
"redirect URI sent during registration must not contain the literal \"{id}\" placeholder")
// Verify the redirect URI contains the real config UUID that
// was assigned by the database.
require.Contains(t, redirectURI, created.ID.String(),
"redirect URI should contain the actual config UUID")
// Sanity-check the full path structure.
require.Contains(t, redirectURI,
"/api/experimental/mcp/servers/"+created.ID.String()+"/oauth2/callback",
"redirect URI should have the expected callback path")
// Double-check that the ID segment is a valid UUID (not some
// other placeholder or malformed value).
pathParts := strings.Split(redirectURI, "/")
var foundUUID bool
for _, part := range pathParts {
if _, err := uuid.Parse(part); err == nil {
foundUUID = true
require.Equal(t, created.ID.String(), part,
"UUID in redirect URI path should match created config ID")
break
}
}
require.True(t, foundUUID,
"redirect URI path should contain a valid UUID segment")
})
t.Run("PartialOAuth2FieldsRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
// Provide client_id but omit auth_url and token_url.
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Partial Fields",
Slug: "partial-oauth2",
Transport: "streamable_http",
URL: "https://mcp.example.com/partial",
AuthType: "oauth2",
OAuth2ClientID: "only-client-id",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "automatic discovery")
})
t.Run("DiscoveryFailure", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// MCP server that returns 404 for the well-known endpoint and
// a non-401 status for the root — discovery has nothing to latch
// onto.
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
}))
t.Cleanup(mcpServer.Close)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, client)
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Will Fail",
Slug: "discovery-fail",
Transport: "streamable_http",
URL: mcpServer.URL + "/v1/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "auto-discovery failed")
//nolint:gocritic // Verifying persisted state requires system access.
_, err = db.GetMCPServerConfigByOrganizationAndSlug(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerConfigByOrganizationAndSlugParams{
OrganizationID: firstUser.OrganizationID,
Slug: "discovery-fail",
})
require.ErrorIs(t, err, sql.ErrNoRows)
})
t.Run("ManualConfigStillWorks", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
// Providing all three OAuth2 fields bypasses discovery entirely.
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Manual Config",
Slug: "manual-oauth2",
Transport: "streamable_http",
URL: "https://mcp.example.com/manual",
AuthType: "oauth2",
OAuth2ClientID: "manual-client-id",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: "https://auth.example.com/token",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.Equal(t, "manual-client-id", created.OAuth2ClientID)
require.Equal(t, "https://auth.example.com/authorize", created.OAuth2AuthURL)
require.Equal(t, "https://auth.example.com/token", created.OAuth2TokenURL)
})
}
// TestMCPServerConfigsOAuth2AutoDiscoverySSRF is a regression test for
// CDM-02-002: OAuth2 auto-discovery followed attacker-controlled
// redirects to internal addresses. The canary on 127.0.0.2 stands in
// for an internal-only service (e.g. cloud metadata) and must never
// be reached, while the attacker's MCP server on 127.0.0.1 is
// reachable via the test allowlist.
func TestMCPServerConfigsOAuth2AutoDiscoverySSRF(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
canaryLn, err := net.Listen("tcp", "127.0.0.2:0")
if err != nil {
t.Skipf("cannot bind 127.0.0.2 (loopback aliasing unsupported?): %v", err)
}
var canaryHits atomic.Int64
canary := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
canaryHits.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"internal":"secret"}`))
}))
_ = canary.Listener.Close()
canary.Listener = canaryLn
canary.Start()
t.Cleanup(canary.Close)
// Attacker-controlled MCP server: redirects every discovery
// fetch to the internal canary.
attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, canary.URL+r.URL.Path, http.StatusFound)
}))
t.Cleanup(attacker.Close)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
// Allow only the attacker's address so the initial fetch
// succeeds; the canary's address stays blocked.
MCPAllowedPrivateCIDRs: []netip.Prefix{
netip.MustParsePrefix("127.0.0.1/32"),
},
})
firstUser := coderdtest.CreateFirstUser(t, client)
_, err = client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "SSRF Attacker",
Slug: "ssrf-attacker",
Transport: "streamable_http",
URL: attacker.URL + "/v1/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "auto-discovery failed")
require.EqualValues(t, 0, canaryHits.Load(), "internal canary must never be contacted via attacker redirect")
// The partially created config must have been cleaned up.
configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
for _, config := range configs {
require.NotEqual(t, "ssrf-attacker", config.Slug)
}
}
// TestMCPServerOAuth2ResourceIndicator runs the connect and callback flow
// against an authorization server that, like Dagster+, rejects requests
// without the expected RFC 8707 resource parameter.
func TestMCPServerOAuth2ResourceIndicator(t *testing.T) {
t.Parallel()
const resource = "https://mcp.example.com/mcp"
invalidTarget := func(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_target","error_description":"MCP OAuth requests must include resource"}`))
}
var redeemedCodes sync.Map
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/authorize":
if r.URL.Query().Get("resource") != resource {
invalidTarget(w)
return
}
redirect, err := url.Parse(r.URL.Query().Get("redirect_uri"))
if !assert.NoError(t, err) {
http.Error(w, "bad redirect_uri", http.StatusBadRequest)
return
}
q := redirect.Query()
q.Set("code", "code-"+r.URL.Query().Get("state"))
q.Set("state", r.URL.Query().Get("state"))
redirect.RawQuery = q.Encode()
http.Redirect(w, r, redirect.String(), http.StatusFound)
case "/token":
if err := r.ParseForm(); err != nil {
invalidTarget(w)
return
}
redeemedCodes.Store(r.PostForm.Get("code"), struct{}{})
if r.PostForm.Get("resource") != resource {
invalidTarget(w)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"resource-access-token","token_type":"Bearer","expires_in":3600,"refresh_token":"resource-refresh-token"}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(authServer.Close)
adminClient := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
memberClient.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
createConfig := func(ctx context.Context, t *testing.T, slug, oauth2Resource string) codersdk.MCPServerConfig {
t.Helper()
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Resource " + slug,
Slug: slug,
Transport: "streamable_http",
URL: "https://mcp.example.com/mcp",
AuthType: "oauth2",
OAuth2ClientID: "resource-client",
OAuth2AuthURL: authServer.URL + "/authorize",
OAuth2TokenURL: authServer.URL + "/token",
OAuth2Resource: oauth2Resource,
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
return created
}
connect := func(ctx context.Context, t *testing.T, config codersdk.MCPServerConfig) (*url.URL, []*http.Cookie) {
t.Helper()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, memberClient.MCPServerOAuth2ConnectURL(config.OrganizationID, config.ID), nil)
require.NoError(t, err)
req.AddCookie(&http.Cookie{Name: codersdk.SessionTokenCookie, Value: memberClient.SessionToken()})
res, err := memberClient.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
location, err := res.Location()
require.NoError(t, err)
return location, res.Cookies()
}
authorize := func(ctx context.Context, t *testing.T, authorizeURL *url.URL) *url.URL {
t.Helper()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL.String(), nil)
require.NoError(t, err)
res, err := memberClient.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusFound, res.StatusCode, "strict authorization server rejected the authorize request")
callbackURL, err := res.Location()
require.NoError(t, err)
return callbackURL
}
callback := func(ctx context.Context, t *testing.T, callbackURL *url.URL, cookies []*http.Cookie) int {
t.Helper()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, callbackURL.String(), nil)
require.NoError(t, err)
req.AddCookie(&http.Cookie{Name: codersdk.SessionTokenCookie, Value: memberClient.SessionToken()})
for _, c := range cookies {
req.AddCookie(&http.Cookie{Name: c.Name, Value: c.Value})
}
res, err := memberClient.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
return res.StatusCode
}
t.Run("ConfiguredResourceIsSent", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
config := createConfig(ctx, t, "resource-configured", resource)
authorizeURL, cookies := connect(ctx, t, config)
require.Equal(t, resource, authorizeURL.Query().Get("resource"))
callbackURL := authorize(ctx, t, authorizeURL)
require.Equal(t, http.StatusOK, callback(ctx, t, callbackURL, cookies), "callback must exchange the code for the authorized resource")
fetched, err := memberClient.MCPServerConfigByID(ctx, config.OrganizationID, config.ID)
require.NoError(t, err)
require.True(t, fetched.AuthConnected, "callback must store the exchanged token")
})
t.Run("ResourceChangedAfterConnectIsRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
config := createConfig(ctx, t, "resource-changed", resource)
authorizeURL, cookies := connect(ctx, t, config)
callbackURL := authorize(ctx, t, authorizeURL)
movedResource := "https://mcp.example.com/moved"
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
OAuth2Resource: &movedResource,
})
require.NoError(t, err)
require.Equal(t, http.StatusConflict, callback(ctx, t, callbackURL, cookies))
_, redeemed := redeemedCodes.Load(callbackURL.Query().Get("code"))
require.False(t, redeemed, "a code authorized for one resource must not be redeemed for another")
})
t.Run("ManualConfigWithoutResourceSendsNone", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
config := createConfig(ctx, t, "resource-unset", "")
authorizeURL, _ := connect(ctx, t, config)
require.False(t, authorizeURL.Query().Has("resource"),
"manual configs without oauth2_resource must not send a resource parameter")
})
}
// nolint:bodyclose
func TestMCPServerOAuth2PKCE(t *testing.T) {
t.Parallel()
t.Run("ConnectSetsPKCEParams", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
// Create an OAuth2 MCP server config.
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "PKCE Test",
Slug: "pkce-test",
Transport: "streamable_http",
URL: "https://mcp.example.com/pkce",
AuthType: "oauth2",
OAuth2ClientID: "test-client",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: "https://auth.example.com/token",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
// Prevent the HTTP client from following redirects so we
// can inspect the response headers and cookies directly.
memberClient.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
connectURL := memberClient.MCPServerOAuth2ConnectURL(created.OrganizationID, created.ID)
req, err := http.NewRequestWithContext(ctx, "GET", connectURL, nil)
require.NoError(t, err)
req.AddCookie(&http.Cookie{
Name: codersdk.SessionTokenCookie,
Value: memberClient.SessionToken(),
})
res, err := memberClient.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
// The redirect URL must contain PKCE query parameters.
location, err := res.Location()
require.NoError(t, err)
query := location.Query()
require.Equal(t, "S256", query.Get("code_challenge_method"),
"connect redirect must include code_challenge_method=S256")
require.NotEmpty(t, query.Get("code_challenge"),
"connect redirect must include a code_challenge")
// 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() {
switch c.Name {
case "mcp_oauth2_state_" + created.ID.String():
stateCookie = c
case "mcp_oauth2_verifier_" + created.ID.String():
verifierCookie = c
}
}
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))
expectedChallenge := base64.RawURLEncoding.EncodeToString(h[:])
require.Equal(t, expectedChallenge, query.Get("code_challenge"),
"code_challenge must equal base64url(SHA256(verifier))")
})
t.Run("CallbackSendsVerifier", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Track the code_verifier received by the mock token endpoint.
receivedVerifier := make(chan string, 1)
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" && r.Method == http.MethodPost {
if err := r.ParseForm(); err == nil {
receivedVerifier <- r.FormValue("code_verifier")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"access_token": "test-access-token",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "test-refresh-token"
}`))
return
}
http.NotFound(w, r)
}))
t.Cleanup(tokenServer.Close)
adminClient := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "PKCE Callback Test",
Slug: "pkce-callback",
Transport: "streamable_http",
URL: "https://mcp.example.com/pkce-cb",
AuthType: "oauth2",
OAuth2ClientID: "test-client",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: tokenServer.URL + "/token",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
memberClient.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
// Simulate the callback with a known state and verifier.
state := "test-state-value"
verifier := "test-verifier-value-that-is-at-least-43-chars-long-for-pkce-spec"
callbackURL, err := memberClient.URL.Parse(
"/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback",
)
require.NoError(t, err)
q := callbackURL.Query()
q.Set("code", "test-auth-code")
q.Set("state", state)
callbackURL.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", 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_" + created.ID.String(),
Value: state,
})
req.AddCookie(&http.Cookie{
Name: "mcp_oauth2_verifier_" + created.ID.String(),
Value: verifier,
})
res, err := memberClient.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode,
"callback should succeed when given valid state, verifier, and code")
// Verify the mock token endpoint received the code_verifier.
var gotVerifier string
select {
case gotVerifier = <-receivedVerifier:
case <-ctx.Done():
t.Fatal("timed out waiting for token exchange")
}
require.Equal(t, verifier, gotVerifier,
"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() {
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) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Token endpoint that does not require a code_verifier.
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" && r.Method == http.MethodPost {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"access_token": "no-pkce-token",
"token_type": "Bearer"
}`))
return
}
http.NotFound(w, r)
}))
t.Cleanup(tokenServer.Close)
adminClient := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "No PKCE Callback",
Slug: "no-pkce-callback",
Transport: "streamable_http",
URL: "https://mcp.example.com/no-pkce",
AuthType: "oauth2",
OAuth2ClientID: "test-client",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: tokenServer.URL + "/token",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
memberClient.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
// Call the callback without a verifier cookie to verify
// backwards compatibility with providers that don't use PKCE.
state := "test-state-no-pkce"
callbackURL, err := memberClient.URL.Parse(
"/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback",
)
require.NoError(t, err)
q := callbackURL.Query()
q.Set("code", "test-auth-code")
q.Set("state", state)
callbackURL.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", 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_" + created.ID.String(),
Value: state,
})
// Deliberately omit the verifier cookie.
res, err := memberClient.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode,
"callback without verifier cookie should still succeed")
})
}
func TestChatWithMCPServerIDs(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
expClient := codersdk.NewExperimentalClient(client)
// Create the chat model config required for creating a chat.
_ = createChatModelForMCP(t, expClient)
// Create enabled MCP server configs.
mcpConfigA := createMCPServerConfig(t, client, firstUser.OrganizationID, "chat-mcp-server-a", true)
mcpConfigB := createMCPServerConfig(t, client, firstUser.OrganizationID, "chat-mcp-server-b", true)
// Create a chat referencing the MCP servers.
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "hello with mcp server",
},
},
MCPServerIDs: []uuid.UUID{mcpConfigA.ID, mcpConfigB.ID},
})
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, chat.ID)
require.ElementsMatch(t, []uuid.UUID{mcpConfigA.ID, mcpConfigB.ID}, chat.MCPServerIDs)
// Fetch the chat and verify the MCP server IDs persist.
fetched, err := expClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.ElementsMatch(t, []uuid.UUID{mcpConfigA.ID, mcpConfigB.ID}, fetched.MCPServerIDs)
err = client.DeleteMCPServerConfig(ctx, mcpConfigA.OrganizationID, mcpConfigA.ID)
require.NoError(t, err)
fetched, err = expClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.NotContains(t, fetched.MCPServerIDs, mcpConfigA.ID)
require.Contains(t, fetched.MCPServerIDs, mcpConfigB.ID)
}
func createChatModelForMCP(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel {
t.Helper()
return coderdtest.CreateOpenAICompatChatModel(t, client, "")
}
func TestMCPOAuth2DiscoveryEdgeCases(t *testing.T) {
t.Parallel()
t.Run("EmptyAuthorizationServers", func(t *testing.T) {
t.Parallel()
// When the path-aware PRM returns an empty
// authorization_servers array, discovery should fall
// back to the root-level PRM.
t.Run("RootFallback", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + "http://" + r.Host + `",
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
"token_endpoint": "` + "http://" + r.Host + `/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"response_types_supported": ["code"],
"scopes_supported": ["fallback-scope"]
}`))
case "/register":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_id": "fallback-client-id",
"client_secret": "fallback-client-secret"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(authServer.Close)
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource/v1/mcp":
// Path-aware: empty authorization_servers.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `/v1/mcp",
"authorization_servers": []
}`))
case "/.well-known/oauth-protected-resource":
// Root: valid authorization_servers.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `",
"authorization_servers": ["` + authServer.URL + `"]
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Empty Auth Servers Fallback",
Slug: "empty-as-fallback",
Transport: "streamable_http",
URL: mcpServer.URL + "/v1/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.Equal(t, "fallback-client-id", created.OAuth2ClientID)
require.Equal(t, authServer.URL+"/authorize", created.OAuth2AuthURL)
require.Equal(t, authServer.URL+"/token", created.OAuth2TokenURL)
require.Equal(t, "fallback-scope", created.OAuth2Scopes)
})
// When both path-aware and root PRM return empty
// authorization_servers, discovery should fail.
t.Run("BothEmpty", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource/v1/mcp",
"/.well-known/oauth-protected-resource":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `",
"authorization_servers": []
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Both Empty",
Slug: "both-empty-as",
Transport: "streamable_http",
URL: mcpServer.URL + "/v1/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "auto-discovery failed")
})
})
// When the path-aware PRM returns malformed JSON,
// discovery should fall back to the root-level PRM.
t.Run("MalformedJSONFromDiscovery", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + "http://" + r.Host + `",
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
"token_endpoint": "` + "http://" + r.Host + `/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"response_types_supported": ["code"],
"scopes_supported": ["json-fallback"]
}`))
case "/register":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_id": "json-fallback-client",
"client_secret": "json-fallback-secret"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(authServer.Close)
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource/v1/mcp":
// Return valid HTTP 200 but invalid JSON.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`not json`))
case "/.well-known/oauth-protected-resource":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `",
"authorization_servers": ["` + authServer.URL + `"]
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Malformed JSON Fallback",
Slug: "malformed-json",
Transport: "streamable_http",
URL: mcpServer.URL + "/v1/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.Equal(t, "json-fallback-client", created.OAuth2ClientID)
require.Equal(t, authServer.URL+"/authorize", created.OAuth2AuthURL)
require.Equal(t, authServer.URL+"/token", created.OAuth2TokenURL)
require.Equal(t, "json-fallback", created.OAuth2Scopes)
})
// When the path-aware auth server metadata is missing required
// endpoints, discovery should fall back to the root-level
// metadata URL.
t.Run("AuthServerMetadataMissingEndpoints", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Auth server that returns incomplete metadata at the
// path-aware URL but complete metadata at the root URL.
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server/auth":
// Path-aware: missing required endpoints.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + "http://" + r.Host + `/auth"
}`))
case "/.well-known/oauth-authorization-server":
// Root-level: complete metadata.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + "http://" + r.Host + `",
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
"token_endpoint": "` + "http://" + r.Host + `/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"response_types_supported": ["code"],
"scopes_supported": ["endpoint-fallback"]
}`))
case "/register":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_id": "endpoint-fallback-client",
"client_secret": "endpoint-fallback-secret"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(authServer.Close)
// PRM points to auth server with a path (/auth) so that
// discoverAuthServerMetadata tries the path-aware URL first.
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource/v1/mcp":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `/v1/mcp",
"authorization_servers": ["` + authServer.URL + `/auth"]
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Missing Endpoints Fallback",
Slug: "missing-endpoints",
Transport: "streamable_http",
URL: mcpServer.URL + "/v1/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.Equal(t, "endpoint-fallback-client", created.OAuth2ClientID)
require.Equal(t, authServer.URL+"/authorize", created.OAuth2AuthURL)
require.Equal(t, authServer.URL+"/token", created.OAuth2TokenURL)
require.Equal(t, "endpoint-fallback", created.OAuth2Scopes)
})
// When both RFC 8414 metadata URLs (path-aware and root) fail,
// discovery should fall back to the OIDC well-known URL.
// The auth server issuer has a path (/login/oauth) so the
// OIDC URL is {issuer}/.well-known/openid-configuration =
// /login/oauth/.well-known/openid-configuration.
t.Run("OIDCFallback", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login/oauth/.well-known/openid-configuration":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + "http://" + r.Host + `/login/oauth",
"authorization_endpoint": "` + "http://" + r.Host + `/login/oauth/authorize",
"token_endpoint": "` + "http://" + r.Host + `/login/oauth/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"response_types_supported": ["code"],
"scopes_supported": ["oidc-scope"]
}`))
case "/register":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_id": "oidc-client-id",
"client_secret": "oidc-client-secret"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(authServer.Close)
// PRM points to auth server with a path (/login/oauth)
// so that RFC 8414 URLs are tried first and fail.
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource/v1/mcp":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `/v1/mcp",
"authorization_servers": ["` + authServer.URL + `/login/oauth"]
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "OIDC Fallback",
Slug: "oidc-fallback",
Transport: "streamable_http",
URL: mcpServer.URL + "/v1/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.Equal(t, "oidc-client-id", created.OAuth2ClientID)
require.Equal(t, authServer.URL+"/login/oauth/authorize", created.OAuth2AuthURL)
require.Equal(t, authServer.URL+"/login/oauth/token", created.OAuth2TokenURL)
require.Equal(t, "oidc-scope", created.OAuth2Scopes)
})
// When the registration endpoint returns a response
// without a client_id, the entire discovery flow should
// fail.
t.Run("RegistrationMissingClientID", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + "http://" + r.Host + `",
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
"token_endpoint": "` + "http://" + r.Host + `/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"response_types_supported": ["code"]
}`))
case "/register":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Return response with client_secret but no
// client_id.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_secret": "secret-without-id"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(authServer.Close)
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource/v1/mcp":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `/v1/mcp",
"authorization_servers": ["` + authServer.URL + `"]
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Missing Client ID",
Slug: "missing-client-id",
Transport: "streamable_http",
URL: mcpServer.URL + "/v1/mcp",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "auto-discovery failed")
})
// Regression test for the exact scenario that motivated the PR:
// an MCP server URL with a trailing slash (like
// https://api.githubcopilot.com/mcp/).
t.Run("TrailingSlashURL", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"issuer": "` + "http://" + r.Host + `",
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
"token_endpoint": "` + "http://" + r.Host + `/token",
"registration_endpoint": "` + "http://" + r.Host + `/register",
"response_types_supported": ["code"],
"scopes_supported": ["read"]
}`))
case "/register":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"client_id": "trailing-slash-client",
"client_secret": "trailing-slash-secret"
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(authServer.Close)
// Serve protected resource metadata at the path-aware URL
// WITH the trailing slash: /.well-known/oauth-protected-resource/mcp/
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-protected-resource/mcp/":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"resource": "` + "http://" + r.Host + `/mcp/",
"authorization_servers": ["` + authServer.URL + `"]
}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mcpServer.Close)
client := newMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
// URL has a trailing slash, matching the GitHub Copilot URL
// pattern: https://api.githubcopilot.com/mcp/
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Trailing Slash",
Slug: "trailing-slash",
Transport: "streamable_http",
URL: mcpServer.URL + "/mcp/",
AuthType: "oauth2",
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.Equal(t, "trailing-slash-client", created.OAuth2ClientID)
require.True(t, created.HasOAuth2Secret)
})
}
func TestMCPServerConfigsRevokedGrant(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
var tokenEndpointHits atomic.Int64
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
tokenEndpointHits.Add(1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"grant revoked"}`))
}))
t.Cleanup(tokenSrv.Close)
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Revoked Server",
Slug: "revoked-server",
Transport: "streamable_http",
URL: "https://mcp.example.com/v1",
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: tokenSrv.URL,
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
require.False(t, created.AuthConnected)
// Seed an expired token whose refresh the provider rejects with
// invalid_grant.
//nolint:gocritic // Seeding test state requires system access.
seeded, err := db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
MCPServerConfigID: created.ID,
UserID: member.ID,
AccessToken: "expired-access",
RefreshToken: "dead-refresh",
TokenType: "Bearer",
Expiry: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
})
require.NoError(t, err)
// First list: the refresh fails permanently, so the server is
// reported as not connected and the failure is persisted.
configs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, configs, 1)
require.False(t, configs[0].AuthConnected)
// The oauth2 package may probe both client auth styles, so the
// exact count varies; what matters is that it never grows again.
hitsAfterFirstList := tokenEndpointHits.Load()
require.Positive(t, hitsAfterFirstList)
//nolint:gocritic // Verifying persisted state requires system access.
row, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
MCPServerConfigID: created.ID,
UserID: member.ID,
})
require.NoError(t, err)
require.Empty(t, row.AccessToken)
require.Empty(t, row.RefreshToken)
require.False(t, row.Expiry.Valid)
require.Contains(t, row.OauthRefreshFailureReason, "invalid_grant")
// Second list: the cached failure short-circuits, so the provider
// is not called again.
configs, err = memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, configs, 1)
require.False(t, configs[0].AuthConnected)
require.Equal(t, hitsAfterFirstList, tokenEndpointHits.Load())
// The single-config endpoint agrees.
single, err := memberClient.MCPServerConfigByID(ctx, created.OrganizationID, created.ID)
require.NoError(t, err)
require.False(t, single.AuthConnected)
require.Equal(t, hitsAfterFirstList, tokenEndpointHits.Load())
// A stale optimistic-lock update must not clobber the row.
//nolint:gocritic // Exercising the query requires system access.
_, err = db.MarkMCPServerUserTokenRefreshFailure(dbauthz.AsSystemRestricted(ctx), database.MarkMCPServerUserTokenRefreshFailureParams{
ID: seeded.ID,
UpdatedAt: seeded.UpdatedAt,
OauthRefreshFailureReason: "stale",
})
require.ErrorIs(t, err, sql.ErrNoRows)
//nolint:gocritic // Verifying persisted state requires system access.
row, err = db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
MCPServerConfigID: created.ID,
UserID: member.ID,
})
require.NoError(t, err)
require.NotEqual(t, "stale", row.OauthRefreshFailureReason)
// Re-authenticating (upserting fresh token material) clears the
// failure and restores connected status.
//nolint:gocritic // Seeding test state requires system access.
_, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
MCPServerConfigID: created.ID,
UserID: member.ID,
AccessToken: "new-access",
RefreshToken: "new-refresh",
TokenType: "Bearer",
Expiry: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
})
require.NoError(t, err)
configs, err = memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, configs, 1)
require.True(t, configs[0].AuthConnected)
// The token is valid, so no refresh call is made.
require.Equal(t, hitsAfterFirstList, tokenEndpointHits.Load())
}
func TestMCPServerConfigsTransientRefreshFailure(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(tokenSrv.Close)
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Flaky Server",
Slug: "flaky-server",
Transport: "streamable_http",
URL: "https://mcp.example.com/v1",
AuthType: "oauth2",
OAuth2ClientID: "cid",
OAuth2AuthURL: "https://auth.example.com/authorize",
OAuth2TokenURL: tokenSrv.URL,
Availability: "default_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
//nolint:gocritic // Seeding test state requires system access.
_, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
MCPServerConfigID: created.ID,
UserID: member.ID,
AccessToken: "expired-access",
RefreshToken: "still-good-refresh",
TokenType: "Bearer",
Expiry: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
})
require.NoError(t, err)
configs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, configs, 1)
require.False(t, configs[0].AuthConnected)
// Transient failures must not destroy the token: a later refresh
// may succeed.
//nolint:gocritic // Verifying persisted state requires system access.
row, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
MCPServerConfigID: created.ID,
UserID: member.ID,
})
require.NoError(t, err)
require.Equal(t, "still-good-refresh", row.RefreshToken)
require.Empty(t, row.OauthRefreshFailureReason)
}