Files
coder/coderd/mcp/mcp_e2e_test.go
T
Bobby Ho fbac602456 feat!: add admin-controlled dynamic client registration toggle (#27316)
`POST /oauth2/register` (RFC 7591 Dynamic Client Registration) has
exactly one gate today: `ExperimentOAuth2`, a static, process-lifetime
flag that wraps the entire `/oauth2/*` route tree as an all-or-nothing
switch. That flag is scheduled for removal at GA, which would leave DCR
with zero admin control at all once it is gone.

Add a persistent, DCR-specific `oauth2_dcr_enabled` deployment setting,
independent of the experiment system, so admin control over DCR survives
GA. `POST /oauth2/register` checks the flag and rejects new
registrations with an RFC 7591-shaped `403` when disabled; discovery
metadata (`GET /.well-known/oauth-authorization-server`) conditionally
omits `registration_endpoint`. A new audited `GET`/`PUT
/api/v2/oauth2-provider/settings` endpoint lets an owner toggle it live,
no restart required. The setting defaults to disabled, matching the
canonical design proposal; disabling only stops new self-registrations,
clients that already registered continue to authorize and exchange
tokens normally.

Address issue described in
[ENG-3056](https://linear.app/codercom/issue/ENG-3056/oauth2-dcr-admin-configurable-enabledisable).

## Where this sits in the request path

```mermaid
sequenceDiagram
    autonumber
    participant A as Admin
    participant S as coderd
    participant DB as site_configs<br/>(oauth2_dcr_enabled)
    participant C as OAuth2/MCP Client

    Note over A,S: Admin toggles DCR (new)
    A->>S: PUT /api/v2/oauth2-provider/settings<br/>{dynamic_client_registration_enabled: false}
    S->>S: authorizeContext(ActionUpdate, ResourceDeploymentConfig)
    S->>DB: UPSERT oauth2_dcr_enabled = false
    S-->>A: 200 OK (audited)

    Note over C,S: Client discovery + registration afterward
    C->>S: GET /.well-known/oauth-authorization-server
    S->>DB: GetOAuth2DCREnabled (system ctx, every request, no cache)
    DB-->>S: false
    S-->>C: 200 metadata, registration_endpoint omitted

    C->>S: POST /oauth2/register
    S->>DB: GetOAuth2DCREnabled (system ctx, every request, no cache)
    DB-->>S: false
    S-->>C: 403 invalid_request,<br/>"Dynamic client registration is disabled"

    Note over C,S: A client that registered before the change is unaffected
    C->>S: GET /oauth2/authorize?client_id=...
    Note over S: no DCR-enabled check on this path
    S-->>C: 200 (proceeds normally)

    C->>S: PUT/DELETE /oauth2/clients/{client_id} (RFC 7592 self-management)
    Note over S: no DCR-enabled check on this path either
    S-->>C: 200 (proceeds normally)
```

## Files changed: manual vs. generated

Reviewers should focus on the **manual** files. The **generated** ones
are `make gen` output that follows mechanically from the manual changes
and don't need direct review.

<details>
<summary><b>Manual files (26)</b> — click to expand, grouped the same
way as "Suggested review order" below</summary>

**1. Database**

| File | What changed |
|---|---|
| `coderd/database/queries/siteconfig.sql` | New
`GetOAuth2DCREnabled`/`UpsertOAuth2DCREnabled` query pair on the
existing generic `site_configs` table. No schema change. |
| `coderd/database/dbauthz/dbauthz.go` | RBAC check
(`rbac.ResourceDeploymentConfig`) on the two new query methods; extends
the `subjectSystemOAuth2` system-actor role with read-only
`ResourceDeploymentConfig` access, needed so the public
discovery/registration endpoints can read the flag via
`dbauthz.AsSystemOAuth2`. |
| `coderd/database/dbauthz/dbauthz_test.go` | RBAC assertion coverage
for `GetOAuth2DCREnabled`/`UpsertOAuth2DCREnabled` in the
method-coverage test suite. |

**2. Request gating (the actual feature)**

| File | What changed |
|---|---|
| `coderd/oauth2provider/registration.go` | The actual gate:
`CreateDynamicClientRegistration` reads the flag first and returns an
RFC 7591-shaped `403` when disabled (defaults disabled if never
configured). |
| `coderd/oauth2provider/registration_test.go` | New unit test,
`TestCreateDynamicClientRegistration_DCREnabled`: calls the handler
directly (no HTTP server), covering enabled / explicitly disabled /
never-configured. |
| `coderd/oauth2provider/metadata.go` | `GetAuthorizationServerMetadata`
conditionally omits `registration_endpoint` from discovery metadata when
DCR is disabled. |
| `coderd/oauth2provider/metadata_test.go` | New unit test,
`TestGetAuthorizationServerMetadata_DCREnabled`: same three states, for
the discovery handler. |

**3. Admin settings endpoint**

| File | What changed |
|---|---|
| `codersdk/oauth2.go` | New `OAuth2ProviderSettings` SDK type plus
`Client.OAuth2ProviderSettings`/`PutOAuth2ProviderSettings` methods. |
| `coderd/oauth2.go` | New
`oauth2ProviderSettings`/`putOAuth2ProviderSettings` admin handlers
(audited via `audit.InitRequest`); updates the
`GetAuthorizationServerMetadata` call site to pass `api.Database`. |
| `coderd/coderd.go` | Registers `GET`/`PUT
/api/v2/oauth2-provider/settings`. |
| `coderd/oauth2_provider_settings_test.go` | New test file: admin
`GET`/`PUT` round-trip, default-disabled-before-any-`PUT`, and `403` for
a non-owner on both `GET` and `PUT`. |

**4. Audit wiring**

| File | What changed |
|---|---|
| `coderd/database/types.go` | New `database.OAuth2ProviderSettings`
audit-only struct (mirrors `NotificationsSettings`). |
| `coderd/audit/diff.go` | Adds the new struct to the `Auditable` type
union. |
| `coderd/audit/request.go` | Adds the new struct to all four dispatch
switches (`ResourceTarget`, `ResourceID`, `ResourceType`,
`ResourceRequiresOrgID`). |
| `codersdk/audit.go` | New API-facing
`ResourceTypeOAuth2ProviderSettings` constant and its `FriendlyString`
case. |
| `enterprise/audit/table.go` | Field-level audit action map
(`ActionTrack`/`ActionIgnore`) for the new struct. |
|
`coderd/database/migrations/000546_audit_oauth2_provider_settings.up.sql`
| Adds `oauth2_provider_settings` to the `resource_type` Postgres enum,
required for the audit wiring above (`resource_type` is a real enum, not
a Go-only value). |
|
`coderd/database/migrations/000546_audit_oauth2_provider_settings.down.sql`
| No-op (`ALTER TYPE ... ADD VALUE` can't be reverted). |

**5. Test-suite ripple from the disabled-by-default flip**

| File | What changed |
|---|---|
| `coderd/oauth2provider/oauth2providertest/helpers.go` | New shared
test helper, `EnableDCR`, since DCR now defaults to disabled and many
pre-existing tests need it turned on to register a client. |
| `coderd/oauth2_test.go` | Adds
`TestOAuth2DynamicClientRegistrationDisabled` (registers a client,
disables DCR, verifies new registration is rejected while the existing
client's self-management, authorize, and token exchange all keep
working); calls `EnableDCR` in every pre-existing test that registers a
client. |
| `coderd/oauth2_error_compliance_test.go` | Calls `EnableDCR` in every
test that registers a client, so RFC-error-format assertions aren't
masked by the new disabled-by-default gate. |
| `coderd/oauth2_metadata_validation_test.go` | Same: `EnableDCR` added
to every registration-dependent test. |
| `coderd/oauth2_security_test.go` | Same. |
| `coderd/oauth2provider/validation_test.go` | Same (near-duplicate of
`oauth2_metadata_validation_test.go` in a different package). |
| `coderd/oauth2provider/provider_test.go` | Same. |
| `coderd/mcp/mcp_e2e_test.go` | Same, for the MCP end-to-end
dynamic-registration flow test. |

</details>

<details>
<summary><b>Generated files (12)</b> — from <code>make gen</code>, no
need to review directly</summary>

`coderd/apidoc/docs.go`, `coderd/apidoc/swagger.json`,
`coderd/database/dbmetrics/querymetrics.go`,
`coderd/database/dbmock/dbmock.go`, `coderd/database/dump.sql`,
`coderd/database/models.go`, `coderd/database/querier.go`,
`coderd/database/queries.sql.go`, `docs/admin/security/audit-logs.md`,
`docs/reference/api/enterprise.md`, `docs/reference/api/schemas.md`,
`site/src/api/typesGenerated.ts`.

</details>

## Suggested review order

### 1. Database

Establishes the persisted setting and its RBAC rule; everything else
builds on `GetOAuth2DCREnabled`/`UpsertOAuth2DCREnabled`.

1. `coderd/database/queries/siteconfig.sql` — the two new queries. Same
boolean-encoding pattern as the existing
`oauth2_github_default_eligible` key right above them in the same file.
2. `coderd/database/dbauthz/dbauthz.go` — the RBAC wrapper for those two
queries, plus the `subjectSystemOAuth2` role extension (search this file
for `ResourceDeploymentConfig`, it appears in both spots).
3. `coderd/database/dbauthz/dbauthz_test.go` — asserts the RBAC checks
from (2) actually fire.

### 2. Request gating (the actual feature)

Where `POST /oauth2/register` and discovery metadata change behavior.

1. `coderd/oauth2provider/registration.go` — the primary gate. Read this
first; it's the feature.
2. `coderd/oauth2provider/registration_test.go` — its new unit test,
exercising the gate's three states directly against the handler.
3. `coderd/oauth2provider/metadata.go` — the same gating pattern applied
to the discovery `GET` endpoint.
4. `coderd/oauth2provider/metadata_test.go` — its new unit test.

### 3. Admin settings endpoint

How an owner flips the setting live.

1. `codersdk/oauth2.go` — the `OAuth2ProviderSettings` SDK type and
`Client` methods first; this is the public contract everything below
implements against.
2. `coderd/oauth2.go` — the `GET`/`PUT` handlers themselves.
3. `coderd/coderd.go` — route registration, to see where those handlers
get wired in.
4. `coderd/oauth2_provider_settings_test.go` — round-trip and permission
tests.

### 4. Audit wiring

Plumbing required so step 3's `PUT` is auditable; mechanical except for
(3).

1. `coderd/database/types.go` — the audit-only struct; everything else
in this layer exists to plumb it through.
2. `coderd/audit/diff.go` — adds it to the `Auditable` type union (the
compiler enforces this one).
3. `coderd/audit/request.go` — the four dispatch switches; the one part
of this layer worth reading closely.
4. `codersdk/audit.go` — the API-facing resource type constant.
5. `enterprise/audit/table.go` — the field-action map.
6.
`coderd/database/migrations/000546_audit_oauth2_provider_settings.{up,down}.sql`
— read last; a consequence of needing a new `resource_type` enum value
for (1)-(5), not a design decision of its own.

### 5. Test-suite ripple from the disabled-by-default flip

1. `coderd/oauth2provider/oauth2providertest/helpers.go` — the new
`EnableDCR` helper. Read first to understand the fix pattern before
seeing it applied repeatedly.
2. `coderd/oauth2_test.go` — next, since it also contains the new
`TestOAuth2DynamicClientRegistrationDisabled`, not just `EnableDCR` call
sites.
3. The rest, in any order, they're mechanical repeats of the same
one-line addition: `coderd/oauth2_error_compliance_test.go`,
`coderd/oauth2_metadata_validation_test.go`,
`coderd/oauth2_security_test.go`,
`coderd/oauth2provider/validation_test.go`,
`coderd/oauth2provider/provider_test.go`, `coderd/mcp/mcp_e2e_test.go`.

## Explicitly out of scope

Per the design proposal: rate limiting on `POST /oauth2/register`
(tracked separately), retroactively affecting already-registered clients
when DCR is disabled (this only gates new self-registration), and an
Initial Access Token requirement (a separate, follow-up ticket).
2026-07-28 16:59:33 -07:00

1571 lines
54 KiB
Go

package mcp_test
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/google/uuid"
mcpclient "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
mcpserver "github.com/coder/coder/v2/coderd/mcp"
"github.com/coder/coder/v2/coderd/oauth2provider/oauth2providertest"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/toolsdk"
"github.com/coder/coder/v2/testutil"
)
// mcpGeneratePKCE creates a PKCE verifier and S256 challenge for MCP
// e2e tests.
func mcpGeneratePKCE() (verifier, challenge string) {
verifier = uuid.NewString() + uuid.NewString()
h := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(h[:])
return verifier, challenge
}
func TestMCPHTTP_E2E_ClientIntegration(t *testing.T) {
t.Parallel()
// Setup Coder server with authentication
coderClient, closer, api := coderdtest.NewWithAPI(t, nil)
defer closer.Close()
_ = coderdtest.CreateFirstUser(t, coderClient)
// Create MCP client pointing to our endpoint
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
// Configure client with authentication headers using RFC 6750 Bearer token
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Start client
err := mcpClient.Start(ctx)
require.NoError(t, err)
// Initialize connection
initReq := mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "test-client",
Version: "1.0.0",
},
},
}
result, err := mcpClient.Initialize(ctx, initReq)
require.NoError(t, err)
require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name)
require.Equal(t, mcp.LATEST_PROTOCOL_VERSION, result.ProtocolVersion)
require.NotNil(t, result.Capabilities)
// Test tool listing
tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
require.NoError(t, err)
require.NotEmpty(t, tools.Tools)
// Verify we have some expected Coder tools
var foundTools []string
var userTool *mcp.Tool
var writeFileTool *mcp.Tool
for i := range tools.Tools {
tool := tools.Tools[i]
foundTools = append(foundTools, tool.Name)
switch tool.Name {
case toolsdk.ToolNameGetAuthenticatedUser:
userTool = &tools.Tools[i]
case toolsdk.ToolNameWorkspaceWriteFile:
writeFileTool = &tools.Tools[i]
}
}
// Check for some basic tools that should be available
assert.Contains(t, foundTools, toolsdk.ToolNameGetAuthenticatedUser, "Should have authenticated user tool")
require.NotNil(t, userTool)
require.NotNil(t, writeFileTool)
require.NotNil(t, userTool.Annotations.ReadOnlyHint)
require.NotNil(t, userTool.Annotations.DestructiveHint)
require.NotNil(t, userTool.Annotations.IdempotentHint)
require.NotNil(t, userTool.Annotations.OpenWorldHint)
assert.True(t, *userTool.Annotations.ReadOnlyHint)
assert.False(t, *userTool.Annotations.DestructiveHint)
assert.True(t, *userTool.Annotations.IdempotentHint)
assert.False(t, *userTool.Annotations.OpenWorldHint)
require.NotNil(t, writeFileTool.Annotations.ReadOnlyHint)
require.NotNil(t, writeFileTool.Annotations.DestructiveHint)
require.NotNil(t, writeFileTool.Annotations.IdempotentHint)
require.NotNil(t, writeFileTool.Annotations.OpenWorldHint)
assert.False(t, *writeFileTool.Annotations.ReadOnlyHint)
assert.True(t, *writeFileTool.Annotations.DestructiveHint)
assert.False(t, *writeFileTool.Annotations.IdempotentHint)
assert.False(t, *writeFileTool.Annotations.OpenWorldHint)
// Execute the authenticated user tool.
require.NotNil(t, userTool, "Expected to find "+toolsdk.ToolNameGetAuthenticatedUser+" tool")
// Execute the tool
toolReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: userTool.Name,
Arguments: map[string]any{},
},
}
toolResult, err := mcpClient.CallTool(ctx, toolReq)
require.NoError(t, err)
require.NotEmpty(t, toolResult.Content)
// Verify the result contains user information
assert.Len(t, toolResult.Content, 1)
if textContent, ok := toolResult.Content[0].(mcp.TextContent); ok {
assert.Equal(t, "text", textContent.Type)
assert.NotEmpty(t, textContent.Text)
} else {
t.Errorf("Expected TextContent type, got %T", toolResult.Content[0])
}
// Test ping functionality
err = mcpClient.Ping(ctx)
require.NoError(t, err)
}
func TestMCPHTTP_E2E_UnauthenticatedAccess(t *testing.T) {
t.Parallel()
// Setup Coder server
_, closer, api := coderdtest.NewWithAPI(t, nil)
defer closer.Close()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Test direct HTTP request to verify 401 status code
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
// Make a POST request without authentication (MCP over HTTP uses POST)
//nolint:gosec // Test code using controlled localhost URL
req, err := http.NewRequestWithContext(ctx, "POST", mcpURL, strings.NewReader(`{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}`))
require.NoError(t, err, "Should be able to create HTTP request")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err, "Should be able to make HTTP request")
defer resp.Body.Close()
// Verify we get 401 Unauthorized
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Should get HTTP 401 for unauthenticated access")
// Also test with MCP client to ensure it handles the error gracefully
mcpClient := newIsolatedMCPClient(t, mcpURL)
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
}
}()
// Start client and try to initialize - this should fail due to authentication
err = mcpClient.Start(ctx)
if err != nil {
// Authentication failed at transport level - this is expected
t.Logf("Unauthenticated access test successful: Transport-level authentication error: %v", err)
return
}
initReq := mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "test-client-unauth",
Version: "1.0.0",
},
},
}
_, err = mcpClient.Initialize(ctx, initReq)
require.Error(t, err, "Should fail during MCP initialization without authentication")
}
func TestMCPHTTP_E2E_ToolWithWorkspace(t *testing.T) {
t.Parallel()
coderClient, closer, api := coderdtest.NewWithAPI(t, nil)
defer closer.Close()
user := coderdtest.CreateFirstUser(t, coderClient)
r := dbfake.WorkspaceBuild(t, api.Database, database.WorkspaceTable{
Name: "myworkspace",
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).WithAgent().Do()
fs := afero.NewMemMapFs()
tmpdir := os.TempDir()
require.NoError(t, fs.MkdirAll(tmpdir, 0o755))
filePath := filepath.Join(tmpdir, "mcp-http-test.txt")
require.NoError(t, afero.WriteFile(fs, filePath, []byte("hello from mcp"), 0o644))
_ = agenttest.New(t, coderClient.URL, r.AgentToken, func(opts *agent.Options) {
opts.Filesystem = fs
})
coderdtest.NewWorkspaceAgentWaiter(t, coderClient, r.Workspace.ID).Wait()
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
require.NoError(t, mcpClient.Start(ctx))
_, err := mcpClient.Initialize(ctx, mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "test-client-workspace",
Version: "1.0.0",
},
},
})
require.NoError(t, err)
toolResult, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: toolsdk.ToolNameWorkspaceLS,
Arguments: map[string]any{
"workspace": r.Workspace.Name,
"path": tmpdir,
},
},
})
require.NoError(t, err)
require.NotEmpty(t, toolResult.Content)
textContent, ok := toolResult.Content[0].(mcp.TextContent)
require.True(t, ok, "expected TextContent type, got %T", toolResult.Content[0])
var response toolsdk.WorkspaceLSResponse
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response))
assert.Contains(t, response.Contents, toolsdk.WorkspaceLSFile{
Path: filePath,
IsDir: false,
})
}
func TestMCPHTTP_E2E_ErrorHandling(t *testing.T) {
t.Parallel()
// Setup Coder server
coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
defer closer.Close()
_ = coderdtest.CreateFirstUser(t, coderClient)
// Create MCP client
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Start and initialize client
err := mcpClient.Start(ctx)
require.NoError(t, err)
initReq := mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "test-client-errors",
Version: "1.0.0",
},
},
}
_, err = mcpClient.Initialize(ctx, initReq)
require.NoError(t, err)
// Test calling non-existent tool
toolReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "nonexistent_tool",
Arguments: map[string]any{},
},
}
_, err = mcpClient.CallTool(ctx, toolReq)
require.Error(t, err, "Should get error when calling non-existent tool")
require.Contains(t, err.Error(), "nonexistent_tool", "Should mention the tool name in error message")
t.Logf("Error handling test successful: Got expected error for non-existent tool")
}
func TestMCPHTTP_E2E_ConcurrentRequests(t *testing.T) {
t.Parallel()
// Setup Coder server
coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
defer closer.Close()
_ = coderdtest.CreateFirstUser(t, coderClient)
// Create MCP client
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Start and initialize client
err := mcpClient.Start(ctx)
require.NoError(t, err)
initReq := mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "test-client-concurrent",
Version: "1.0.0",
},
},
}
_, err = mcpClient.Initialize(ctx, initReq)
require.NoError(t, err)
// Test concurrent tool listings
const numConcurrent = 5
eg, egCtx := errgroup.WithContext(ctx)
for range numConcurrent {
eg.Go(func() error {
reqCtx, reqCancel := context.WithTimeout(egCtx, testutil.WaitLong)
defer reqCancel()
tools, err := mcpClient.ListTools(reqCtx, mcp.ListToolsRequest{})
if err != nil {
return err
}
if len(tools.Tools) == 0 {
return assert.AnError
}
return nil
})
}
// Wait for all concurrent requests to complete
err = eg.Wait()
require.NoError(t, err, "All concurrent requests should succeed")
t.Logf("Concurrent requests test successful: All %d requests completed successfully", numConcurrent)
}
func TestMCPHTTP_E2E_RFC6750_UnauthenticatedRequest(t *testing.T) {
t.Parallel()
// Setup Coder server
_, closer, api := coderdtest.NewWithAPI(t, nil)
defer closer.Close()
// Make a request without any authentication headers
req := &http.Request{
Method: "POST",
URL: mustParseURL(t, api.AccessURL.String()+mcpserver.MCPEndpoint),
Header: make(http.Header),
}
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Should get 401 Unauthorized
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
// RFC 6750 requires WWW-Authenticate header on 401 responses
wwwAuth := resp.Header.Get("WWW-Authenticate")
require.NotEmpty(t, wwwAuth, "RFC 6750 requires WWW-Authenticate header for 401 responses")
require.Contains(t, wwwAuth, "Bearer", "WWW-Authenticate header should indicate Bearer authentication")
require.Contains(t, wwwAuth, `realm="coder"`, "WWW-Authenticate header should include realm")
t.Logf("RFC 6750 WWW-Authenticate header test successful: %s", wwwAuth)
}
func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) {
t.Parallel()
// Setup Coder server with OAuth2 provider enabled
coderClient, closer, api := coderdtest.NewWithAPI(t, nil)
t.Cleanup(func() { closer.Close() })
_ = coderdtest.CreateFirstUser(t, coderClient)
oauth2providertest.EnableDCR(t, coderClient)
ctx := t.Context()
// Create OAuth2 app (for demonstration that OAuth2 provider is working)
_, err := coderClient.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "test-mcp-app",
CallbackURL: "http://localhost:3000/callback",
})
require.NoError(t, err)
// Test 1: OAuth2 Token Endpoint Error Format
t.Run("OAuth2TokenEndpointErrorFormat", func(t *testing.T) {
t.Parallel()
// Test that the /oauth2/tokens endpoint responds with proper OAuth2 error format
// Note: The endpoint is /oauth2/tokens (plural), not /oauth2/token (singular)
req := &http.Request{
Method: "POST",
URL: mustParseURL(t, api.AccessURL.String()+"/oauth2/tokens"),
Header: map[string][]string{
"Content-Type": {"application/x-www-form-urlencoded"},
},
Body: http.NoBody,
}
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// The OAuth2 token endpoint should return HTTP 400 for invalid requests
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
// Read and verify the response is OAuth2-compliant JSON error format
bodyBytes, err := io.ReadAll(resp.Body)
require.NoError(t, err)
t.Logf("OAuth2 tokens endpoint returned status: %d, body: %q", resp.StatusCode, string(bodyBytes))
// Should be valid JSON with OAuth2 error format
var errorResponse map[string]any
err = json.Unmarshal(bodyBytes, &errorResponse)
require.NoError(t, err, "Response should be valid JSON")
// Verify OAuth2 error format (RFC 6749 section 5.2)
require.NotEmpty(t, errorResponse["error"], "Error field should not be empty")
})
// Test 2: MCP with OAuth2 Bearer Token
t.Run("MCPWithOAuth2BearerToken", func(t *testing.T) {
t.Parallel()
// For this test, we'll use the user's regular session token formatted as a Bearer token
// In a real OAuth2 flow, this would be an OAuth2 access token
sessionToken := coderClient.SessionToken()
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + sessionToken,
}))
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Start and initialize MCP client with Bearer token
err = mcpClient.Start(ctx)
require.NoError(t, err)
initReq := mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "test-oauth2-client",
Version: "1.0.0",
},
},
}
result, err := mcpClient.Initialize(ctx, initReq)
require.NoError(t, err)
require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name)
// Test tool listing with OAuth2 Bearer token
tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
require.NoError(t, err)
require.NotEmpty(t, tools.Tools)
t.Logf("OAuth2 Bearer token MCP test successful: Found %d tools", len(tools.Tools))
})
// Test 3: Full OAuth2 Authorization Code Flow with Token Refresh
t.Run("OAuth2FullFlowWithTokenRefresh", func(t *testing.T) {
t.Parallel()
// Create an OAuth2 app specifically for this test
app, err := coderClient.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "test-oauth2-flow-app",
CallbackURL: "http://localhost:3000/callback",
})
require.NoError(t, err)
// Create a client secret for the app
secret, err := coderClient.PostOAuth2ProviderAppSecret(ctx, app.ID)
require.NoError(t, err)
// Step 1: Simulate authorization code flow by creating an authorization code
// In a real flow, this would be done through the browser consent page
// For testing, we'll create the code directly using the internal API
// First, we need to authorize the app (simulating user consent).
staticVerifier, staticChallenge := mcpGeneratePKCE()
authURL := fmt.Sprintf("%s/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&state=test_state&code_challenge=%s&code_challenge_method=S256",
api.AccessURL.String(), app.ID, "http://localhost:3000/callback", staticChallenge)
// Create an HTTP client that follows redirects but captures the final redirect.
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // Stop following redirects
},
}
// Make the authorization request (this would normally be done in a browser).
req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil)
require.NoError(t, err)
// Use RFC 6750 Bearer token for authentication.
req.Header.Set("Authorization", "Bearer "+coderClient.SessionToken())
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// The response should be a redirect to the consent page or directly to callback.
// For testing purposes, let's simulate the POST consent approval.
if resp.StatusCode == http.StatusOK {
// This means we got the consent page, now we need to POST consent.
consentReq, err := http.NewRequestWithContext(ctx, "POST", authURL, nil)
require.NoError(t, err)
consentReq.Header.Set("Authorization", "Bearer "+coderClient.SessionToken())
consentReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = client.Do(consentReq)
require.NoError(t, err)
defer resp.Body.Close()
}
// Extract authorization code from redirect URL.
require.True(t, resp.StatusCode >= 300 && resp.StatusCode < 400, "Expected redirect response")
location := resp.Header.Get("Location")
require.NotEmpty(t, location, "Expected Location header in redirect")
redirectURL, err := url.Parse(location)
require.NoError(t, err)
authCode := redirectURL.Query().Get("code")
require.NotEmpty(t, authCode, "Expected authorization code in redirect URL")
t.Logf("Successfully obtained authorization code: %s", authCode[:10]+"...")
// Step 2: Exchange authorization code for access token and refresh token.
tokenRequestBody := url.Values{
"grant_type": {"authorization_code"},
"client_id": {app.ID.String()},
"client_secret": {secret.ClientSecretFull},
"code": {authCode},
"redirect_uri": {"http://localhost:3000/callback"},
"code_verifier": {staticVerifier},
}
tokenReq, err := http.NewRequestWithContext(ctx, "POST", api.AccessURL.String()+"/oauth2/tokens",
strings.NewReader(tokenRequestBody.Encode()))
require.NoError(t, err)
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
tokenResp, err := client.Do(tokenReq)
require.NoError(t, err)
defer tokenResp.Body.Close()
require.Equal(t, http.StatusOK, tokenResp.StatusCode, "Token exchange should succeed")
// Parse token response
var tokenResponse map[string]any
err = json.NewDecoder(tokenResp.Body).Decode(&tokenResponse)
require.NoError(t, err)
accessToken, ok := tokenResponse["access_token"].(string)
require.True(t, ok, "Response should contain access_token")
require.NotEmpty(t, accessToken)
refreshToken, ok := tokenResponse["refresh_token"].(string)
require.True(t, ok, "Response should contain refresh_token")
require.NotEmpty(t, refreshToken)
tokenType, ok := tokenResponse["token_type"].(string)
require.True(t, ok, "Response should contain token_type")
require.Equal(t, "Bearer", tokenType)
t.Logf("Successfully obtained access token: %s...", accessToken[:10])
t.Logf("Successfully obtained refresh token: %s...", refreshToken[:10])
// Step 3: Use access token to authenticate with MCP endpoint
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + accessToken,
}))
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
}
}()
// Initialize and test the MCP connection with OAuth2 access token
err = mcpClient.Start(ctx)
require.NoError(t, err)
initReq := mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "test-oauth2-flow-client",
Version: "1.0.0",
},
},
}
result, err := mcpClient.Initialize(ctx, initReq)
require.NoError(t, err)
require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name)
// Test tool execution with OAuth2 access token
tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
require.NoError(t, err)
require.NotEmpty(t, tools.Tools)
// Find and execute the authenticated user tool
var userTool *mcp.Tool
for _, tool := range tools.Tools {
if tool.Name == toolsdk.ToolNameGetAuthenticatedUser {
userTool = &tool
break
}
}
require.NotNil(t, userTool, "Expected to find "+toolsdk.ToolNameGetAuthenticatedUser+" tool")
toolReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: userTool.Name,
Arguments: map[string]any{},
},
}
toolResult, err := mcpClient.CallTool(ctx, toolReq)
require.NoError(t, err)
require.NotEmpty(t, toolResult.Content)
t.Logf("Successfully executed tool with OAuth2 access token")
// Step 4: Refresh the access token using refresh token
refreshRequestBody := url.Values{
"grant_type": {"refresh_token"},
"client_id": {app.ID.String()},
"client_secret": {secret.ClientSecretFull},
"refresh_token": {refreshToken},
}
refreshReq, err := http.NewRequestWithContext(ctx, "POST", api.AccessURL.String()+"/oauth2/tokens",
strings.NewReader(refreshRequestBody.Encode()))
require.NoError(t, err)
refreshReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
refreshResp, err := client.Do(refreshReq)
require.NoError(t, err)
defer refreshResp.Body.Close()
require.Equal(t, http.StatusOK, refreshResp.StatusCode, "Token refresh should succeed")
// Parse refresh response
var refreshResponse map[string]any
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResponse)
require.NoError(t, err)
newAccessToken, ok := refreshResponse["access_token"].(string)
require.True(t, ok, "Refresh response should contain new access_token")
require.NotEmpty(t, newAccessToken)
require.NotEqual(t, accessToken, newAccessToken, "New access token should be different")
newRefreshToken, ok := refreshResponse["refresh_token"].(string)
require.True(t, ok, "Refresh response should contain new refresh_token")
require.NotEmpty(t, newRefreshToken)
t.Logf("Successfully refreshed token: %s...", newAccessToken[:10])
// Step 5: Use new access token to create another MCP connection
newMcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + newAccessToken,
}))
defer func() {
if closeErr := newMcpClient.Close(); closeErr != nil {
t.Logf("Failed to close new MCP client: %v", closeErr)
}
}()
// Test the new token works
err = newMcpClient.Start(ctx)
require.NoError(t, err)
newInitReq := mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "test-refreshed-token-client",
Version: "1.0.0",
},
},
}
newResult, err := newMcpClient.Initialize(ctx, newInitReq)
require.NoError(t, err)
require.Equal(t, mcpserver.MCPServerName, newResult.ServerInfo.Name)
// Verify we can still execute tools with the refreshed token
newTools, err := newMcpClient.ListTools(ctx, mcp.ListToolsRequest{})
require.NoError(t, err)
require.NotEmpty(t, newTools.Tools)
t.Logf("OAuth2 full flow test successful: app creation -> authorization -> token exchange -> MCP usage -> token refresh -> MCP usage with refreshed token")
})
// Test 4: Invalid Bearer Token
t.Run("InvalidBearerToken", func(t *testing.T) {
t.Parallel()
req := &http.Request{
Method: "POST",
URL: mustParseURL(t, api.AccessURL.String()+mcpserver.MCPEndpoint),
Header: map[string][]string{
"Authorization": {"Bearer invalid_token_value"},
"Content-Type": {"application/json"},
},
}
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Should get 401 Unauthorized
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
// Should have RFC 6750 compliant WWW-Authenticate header
wwwAuth := resp.Header.Get("WWW-Authenticate")
require.NotEmpty(t, wwwAuth)
require.Contains(t, wwwAuth, "Bearer")
require.Contains(t, wwwAuth, `realm="coder"`)
require.Contains(t, wwwAuth, "invalid_token")
t.Logf("Invalid Bearer token test successful: %s", wwwAuth)
})
// Test 5: Dynamic Client Registration with Unauthenticated MCP Access
t.Run("DynamicClientRegistrationWithMCPFlow", func(t *testing.T) {
t.Parallel()
// Step 1: Attempt unauthenticated MCP access
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
req := &http.Request{
Method: "POST",
URL: mustParseURL(t, mcpURL),
Header: make(http.Header),
}
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Should get 401 Unauthorized with WWW-Authenticate header
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
wwwAuth := resp.Header.Get("WWW-Authenticate")
require.NotEmpty(t, wwwAuth, "RFC 6750 requires WWW-Authenticate header for 401 responses")
require.Contains(t, wwwAuth, "Bearer", "WWW-Authenticate header should indicate Bearer authentication")
require.Contains(t, wwwAuth, `realm="coder"`, "WWW-Authenticate header should include realm")
t.Logf("Unauthenticated MCP access properly returned WWW-Authenticate: %s", wwwAuth)
// Step 2: Perform dynamic client registration (RFC 7591)
dynamicRegURL := api.AccessURL.String() + "/oauth2/register"
// Create dynamic client registration request
registrationRequest := map[string]any{
"client_name": "dynamic-mcp-client",
"redirect_uris": []string{"http://localhost:3000/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"token_endpoint_auth_method": "client_secret_basic",
}
regBody, err := json.Marshal(registrationRequest)
require.NoError(t, err)
regReq, err := http.NewRequestWithContext(ctx, "POST", dynamicRegURL, strings.NewReader(string(regBody)))
require.NoError(t, err)
regReq.Header.Set("Content-Type", "application/json")
// Dynamic client registration should not require authentication (public endpoint)
regResp, err := client.Do(regReq)
require.NoError(t, err)
defer regResp.Body.Close()
require.Equal(t, http.StatusCreated, regResp.StatusCode, "Dynamic client registration should succeed")
// Parse the registration response
var regResponse map[string]any
err = json.NewDecoder(regResp.Body).Decode(&regResponse)
require.NoError(t, err)
clientID, ok := regResponse["client_id"].(string)
require.True(t, ok, "Registration response should contain client_id")
require.NotEmpty(t, clientID)
clientSecret, ok := regResponse["client_secret"].(string)
require.True(t, ok, "Registration response should contain client_secret")
require.NotEmpty(t, clientSecret)
t.Logf("Successfully registered dynamic client: %s", clientID)
// Step 3: Perform OAuth2 authorization code flow with dynamically registered client.
dynamicVerifier, dynamicChallenge := mcpGeneratePKCE()
authURL := fmt.Sprintf("%s/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&state=dynamic_state&code_challenge=%s&code_challenge_method=S256",
api.AccessURL.String(), clientID, "http://localhost:3000/callback", dynamicChallenge)
// Create an HTTP client that captures redirects.
authClient := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // Stop following redirects
},
}
// Make the authorization request with authentication.
authReq, err := http.NewRequestWithContext(ctx, "GET", authURL, nil)
require.NoError(t, err)
authReq.Header.Set("Cookie", fmt.Sprintf("coder_session_token=%s", coderClient.SessionToken()))
authReq.Header.Set("Authorization", "Bearer "+coderClient.SessionToken())
authResp, err := authClient.Do(authReq)
require.NoError(t, err)
defer authResp.Body.Close()
// Handle the response - check for error first.
if authResp.StatusCode == http.StatusBadRequest {
// Read error response for debugging.
bodyBytes, err := io.ReadAll(authResp.Body)
require.NoError(t, err)
t.Logf("OAuth2 authorization error: %s", string(bodyBytes))
t.FailNow()
}
// Handle consent flow if needed.
if authResp.StatusCode == http.StatusOK {
// This means we got the consent page, now we need to POST consent.
consentReq, err := http.NewRequestWithContext(ctx, "POST", authURL, nil)
require.NoError(t, err)
consentReq.Header.Set("Cookie", fmt.Sprintf("coder_session_token=%s", coderClient.SessionToken()))
consentReq.Header.Set("Authorization", "Bearer "+coderClient.SessionToken())
consentReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
authResp, err = authClient.Do(consentReq)
require.NoError(t, err)
defer authResp.Body.Close()
}
// Extract authorization code from redirect.
require.True(t, authResp.StatusCode >= 300 && authResp.StatusCode < 400,
"Expected redirect response, got %d", authResp.StatusCode)
location := authResp.Header.Get("Location")
require.NotEmpty(t, location, "Expected Location header in redirect")
redirectURL, err := url.Parse(location)
require.NoError(t, err)
authCode := redirectURL.Query().Get("code")
require.NotEmpty(t, authCode, "Expected authorization code in redirect URL")
t.Logf("Successfully obtained authorization code: %s", authCode[:10]+"...")
// Step 4: Exchange authorization code for access token.
tokenRequestBody := url.Values{
"grant_type": {"authorization_code"},
"client_id": {clientID},
"client_secret": {clientSecret},
"code": {authCode},
"redirect_uri": {"http://localhost:3000/callback"},
"code_verifier": {dynamicVerifier},
}
tokenReq, err := http.NewRequestWithContext(ctx, "POST", api.AccessURL.String()+"/oauth2/tokens",
strings.NewReader(tokenRequestBody.Encode()))
require.NoError(t, err)
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
tokenResp, err := client.Do(tokenReq)
require.NoError(t, err)
defer tokenResp.Body.Close()
require.Equal(t, http.StatusOK, tokenResp.StatusCode, "Token exchange should succeed")
// Parse token response
var tokenResponse map[string]any
err = json.NewDecoder(tokenResp.Body).Decode(&tokenResponse)
require.NoError(t, err)
accessToken, ok := tokenResponse["access_token"].(string)
require.True(t, ok, "Response should contain access_token")
require.NotEmpty(t, accessToken)
refreshToken, ok := tokenResponse["refresh_token"].(string)
require.True(t, ok, "Response should contain refresh_token")
require.NotEmpty(t, refreshToken)
t.Logf("Successfully obtained access token: %s...", accessToken[:10])
// Step 5: Use access token to get user information via MCP
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + accessToken,
}))
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
}
}()
// Initialize MCP connection
err = mcpClient.Start(ctx)
require.NoError(t, err)
initReq := mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "test-dynamic-client",
Version: "1.0.0",
},
},
}
result, err := mcpClient.Initialize(ctx, initReq)
require.NoError(t, err)
require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name)
// Get user information
tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
require.NoError(t, err)
require.NotEmpty(t, tools.Tools)
// Find and execute the authenticated user tool
var userTool *mcp.Tool
for _, tool := range tools.Tools {
if tool.Name == toolsdk.ToolNameGetAuthenticatedUser {
userTool = &tool
break
}
}
require.NotNil(t, userTool, "Expected to find "+toolsdk.ToolNameGetAuthenticatedUser+" tool")
toolReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: userTool.Name,
Arguments: map[string]any{},
},
}
toolResult, err := mcpClient.CallTool(ctx, toolReq)
require.NoError(t, err)
require.NotEmpty(t, toolResult.Content)
// Extract user info from first token
var firstUserInfo string
if textContent, ok := toolResult.Content[0].(mcp.TextContent); ok {
firstUserInfo = textContent.Text
} else {
t.Errorf("Expected TextContent type, got %T", toolResult.Content[0])
}
require.NotEmpty(t, firstUserInfo)
t.Logf("Successfully retrieved user info with first token")
// Step 6: Refresh the token
refreshRequestBody := url.Values{
"grant_type": {"refresh_token"},
"client_id": {clientID},
"client_secret": {clientSecret},
"refresh_token": {refreshToken},
}
refreshReq, err := http.NewRequestWithContext(ctx, "POST", api.AccessURL.String()+"/oauth2/tokens",
strings.NewReader(refreshRequestBody.Encode()))
require.NoError(t, err)
refreshReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
refreshResp, err := client.Do(refreshReq)
require.NoError(t, err)
defer refreshResp.Body.Close()
require.Equal(t, http.StatusOK, refreshResp.StatusCode, "Token refresh should succeed")
// Parse refresh response
var refreshResponse map[string]any
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResponse)
require.NoError(t, err)
newAccessToken, ok := refreshResponse["access_token"].(string)
require.True(t, ok, "Refresh response should contain new access_token")
require.NotEmpty(t, newAccessToken)
require.NotEqual(t, accessToken, newAccessToken, "New access token should be different")
t.Logf("Successfully refreshed token: %s...", newAccessToken[:10])
// Step 7: Use refreshed token to get user information again via MCP
newMcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + newAccessToken,
}))
defer func() {
if closeErr := newMcpClient.Close(); closeErr != nil {
t.Logf("Failed to close new MCP client: %v", closeErr)
}
}()
// Initialize new MCP connection
err = newMcpClient.Start(ctx)
require.NoError(t, err)
newInitReq := mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "test-dynamic-client-refreshed",
Version: "1.0.0",
},
},
}
newResult, err := newMcpClient.Initialize(ctx, newInitReq)
require.NoError(t, err)
require.Equal(t, mcpserver.MCPServerName, newResult.ServerInfo.Name)
// Get user information with refreshed token
newTools, err := newMcpClient.ListTools(ctx, mcp.ListToolsRequest{})
require.NoError(t, err)
require.NotEmpty(t, newTools.Tools)
// Execute user tool again
newToolResult, err := newMcpClient.CallTool(ctx, toolReq)
require.NoError(t, err)
require.NotEmpty(t, newToolResult.Content)
// Extract user info from refreshed token
var secondUserInfo string
if textContent, ok := newToolResult.Content[0].(mcp.TextContent); ok {
secondUserInfo = textContent.Text
} else {
t.Errorf("Expected TextContent type, got %T", newToolResult.Content[0])
}
require.NotEmpty(t, secondUserInfo)
// Step 8: Compare user information before and after token refresh
// Parse JSON to compare the important fields, ignoring timestamp differences
var firstUser, secondUser map[string]any
err = json.Unmarshal([]byte(firstUserInfo), &firstUser)
require.NoError(t, err)
err = json.Unmarshal([]byte(secondUserInfo), &secondUser)
require.NoError(t, err)
// Compare key fields that should be identical
require.Equal(t, firstUser["id"], secondUser["id"], "User ID should be identical")
require.Equal(t, firstUser["username"], secondUser["username"], "Username should be identical")
require.Equal(t, firstUser["email"], secondUser["email"], "Email should be identical")
require.Equal(t, firstUser["status"], secondUser["status"], "Status should be identical")
require.Equal(t, firstUser["login_type"], secondUser["login_type"], "Login type should be identical")
require.Equal(t, firstUser["roles"], secondUser["roles"], "Roles should be identical")
require.Equal(t, firstUser["organization_ids"], secondUser["organization_ids"], "Organization IDs should be identical")
// Note: last_seen_at will be different since time passed between calls, which is expected
t.Logf("Dynamic client registration flow test successful: " +
"unauthenticated access → WWW-Authenticate → dynamic registration → OAuth2 flow → " +
"MCP usage → token refresh → MCP usage with consistent user info")
})
// Test 6: Verify duplicate client names are allowed (RFC 7591 compliance)
t.Run("DuplicateClientNamesAllowed", func(t *testing.T) {
t.Parallel()
dynamicRegURL := api.AccessURL.String() + "/oauth2/register"
clientName := "duplicate-name-test-client"
// Register first client with a specific name
registrationRequest1 := map[string]any{
"client_name": clientName,
"redirect_uris": []string{"http://localhost:3000/callback1"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"token_endpoint_auth_method": "client_secret_basic",
}
regBody1, err := json.Marshal(registrationRequest1)
require.NoError(t, err)
regReq1, err := http.NewRequestWithContext(ctx, "POST", dynamicRegURL, strings.NewReader(string(regBody1)))
require.NoError(t, err)
regReq1.Header.Set("Content-Type", "application/json")
client := &http.Client{}
regResp1, err := client.Do(regReq1)
require.NoError(t, err)
defer regResp1.Body.Close()
require.Equal(t, http.StatusCreated, regResp1.StatusCode, "First client registration should succeed")
var regResponse1 map[string]any
err = json.NewDecoder(regResp1.Body).Decode(&regResponse1)
require.NoError(t, err)
clientID1, ok := regResponse1["client_id"].(string)
require.True(t, ok, "First registration response should contain client_id")
require.NotEmpty(t, clientID1)
// Register second client with the same name
registrationRequest2 := map[string]any{
"client_name": clientName, // Same name as first client
"redirect_uris": []string{"http://localhost:3000/callback2"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"token_endpoint_auth_method": "client_secret_basic",
}
regBody2, err := json.Marshal(registrationRequest2)
require.NoError(t, err)
regReq2, err := http.NewRequestWithContext(ctx, "POST", dynamicRegURL, strings.NewReader(string(regBody2)))
require.NoError(t, err)
regReq2.Header.Set("Content-Type", "application/json")
regResp2, err := client.Do(regReq2)
require.NoError(t, err)
defer regResp2.Body.Close()
// This should succeed per RFC 7591 (no unique name requirement)
require.Equal(t, http.StatusCreated, regResp2.StatusCode,
"Second client registration with duplicate name should succeed (RFC 7591 compliance)")
var regResponse2 map[string]any
err = json.NewDecoder(regResp2.Body).Decode(&regResponse2)
require.NoError(t, err)
clientID2, ok := regResponse2["client_id"].(string)
require.True(t, ok, "Second registration response should contain client_id")
require.NotEmpty(t, clientID2)
// Verify client IDs are different even though names are the same
require.NotEqual(t, clientID1, clientID2, "Client IDs should be unique even with duplicate names")
// Verify both clients have the same name but unique IDs
name1, ok := regResponse1["client_name"].(string)
require.True(t, ok)
name2, ok := regResponse2["client_name"].(string)
require.True(t, ok)
require.Equal(t, clientName, name1, "First client should have the expected name")
require.Equal(t, clientName, name2, "Second client should have the same name")
require.Equal(t, name1, name2, "Both clients should have identical names")
t.Logf("Successfully registered two OAuth2 clients with duplicate name '%s' but unique IDs: %s, %s",
clientName, clientID1, clientID2)
})
}
func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) {
t.Parallel()
// Setup Coder server with authentication
coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
defer closer.Close()
user := coderdtest.CreateFirstUser(t, coderClient)
// Create template and workspace for testing search functionality
version := coderdtest.CreateTemplateVersion(t, coderClient, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, coderClient, version.ID)
template := coderdtest.CreateTemplate(t, coderClient, user.OrganizationID, version.ID)
// Create MCP client pointing to the ChatGPT endpoint
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint + "?toolset=chatgpt"
// Configure client with authentication headers using RFC 6750 Bearer token
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
t.Cleanup(func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
}
})
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
defer cancel()
// Start client
err := mcpClient.Start(ctx)
require.NoError(t, err)
// Initialize connection
initReq := mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "test-chatgpt-client",
Version: "1.0.0",
},
},
}
result, err := mcpClient.Initialize(ctx, initReq)
require.NoError(t, err)
require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name)
require.Equal(t, mcp.LATEST_PROTOCOL_VERSION, result.ProtocolVersion)
require.NotNil(t, result.Capabilities)
// Test tool listing - should only have search and fetch tools for ChatGPT
tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
require.NoError(t, err)
require.NotEmpty(t, tools.Tools)
// Verify we have exactly the ChatGPT tools and no others
var foundTools []string
for _, tool := range tools.Tools {
foundTools = append(foundTools, tool.Name)
}
// ChatGPT endpoint should only expose search and fetch tools
assert.Contains(t, foundTools, toolsdk.ToolNameChatGPTSearch, "Should have ChatGPT search tool")
assert.Contains(t, foundTools, toolsdk.ToolNameChatGPTFetch, "Should have ChatGPT fetch tool")
assert.Len(t, foundTools, 2, "ChatGPT endpoint should only expose search and fetch tools")
// Should NOT have other tools that are available in the standard endpoint
assert.NotContains(t, foundTools, toolsdk.ToolNameGetAuthenticatedUser, "Should not have authenticated user tool")
assert.NotContains(t, foundTools, toolsdk.ToolNameListWorkspaces, "Should not have list workspaces tool")
t.Logf("ChatGPT endpoint tools: %v", foundTools)
// Test search tool - search for templates
var searchTool *mcp.Tool
for _, tool := range tools.Tools {
if tool.Name == toolsdk.ToolNameChatGPTSearch {
searchTool = &tool
break
}
}
require.NotNil(t, searchTool, "Expected to find search tool")
// Execute search for templates
searchReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: searchTool.Name,
Arguments: map[string]any{
"query": "templates",
},
},
}
searchResult, err := mcpClient.CallTool(ctx, searchReq)
require.NoError(t, err)
require.NotEmpty(t, searchResult.Content)
// Verify the search result contains our template
assert.Len(t, searchResult.Content, 1)
if textContent, ok := searchResult.Content[0].(mcp.TextContent); ok {
assert.Equal(t, "text", textContent.Type)
assert.Contains(t, textContent.Text, template.ID.String(), "Search result should contain our test template")
t.Logf("Search result: %s", textContent.Text)
} else {
t.Errorf("Expected TextContent type, got %T", searchResult.Content[0])
}
// Test fetch tool
var fetchTool *mcp.Tool
for _, tool := range tools.Tools {
if tool.Name == toolsdk.ToolNameChatGPTFetch {
fetchTool = &tool
break
}
}
require.NotNil(t, fetchTool, "Expected to find fetch tool")
// Execute fetch for the template
fetchReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: fetchTool.Name,
Arguments: map[string]any{
"id": fmt.Sprintf("template:%s", template.ID.String()),
},
},
}
fetchResult, err := mcpClient.CallTool(ctx, fetchReq)
require.NoError(t, err)
require.NotEmpty(t, fetchResult.Content)
// Verify the fetch result contains template details
assert.Len(t, fetchResult.Content, 1)
if textContent, ok := fetchResult.Content[0].(mcp.TextContent); ok {
assert.Equal(t, "text", textContent.Type)
assert.Contains(t, textContent.Text, template.Name, "Fetch result should contain template name")
assert.Contains(t, textContent.Text, template.ID.String(), "Fetch result should contain template ID")
t.Logf("Fetch result contains template data")
} else {
t.Errorf("Expected TextContent type, got %T", fetchResult.Content[0])
}
t.Logf("ChatGPT endpoint E2E test successful: search and fetch tools working correctly")
}
// Helper function to parse URL safely in tests
// TestMCPHTTP_E2E_WorkspaceSSHAuthz verifies that users who can read
// a workspace but lack ActionSSH are denied when calling workspace
// tools through the MCP HTTP endpoint.
func TestMCPHTTP_E2E_WorkspaceSSHAuthz(t *testing.T) {
t.Parallel()
coderClient, closer, api := coderdtest.NewWithAPI(t, nil)
defer closer.Close()
admin := coderdtest.CreateFirstUser(t, coderClient)
// Create a workspace owned by the admin.
r := dbfake.WorkspaceBuild(t, api.Database, database.WorkspaceTable{
Name: "authz-test-ws",
OrganizationID: admin.OrganizationID,
OwnerID: admin.UserID,
}).WithAgent().Do()
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll("/tmp", 0o755))
require.NoError(t, afero.WriteFile(fs, "/tmp/secret.txt", []byte("secret-content"), 0o644))
_ = agenttest.New(t, coderClient.URL, r.AgentToken, func(opts *agent.Options) {
opts.Filesystem = fs
})
coderdtest.NewWorkspaceAgentWaiter(t, coderClient, r.Workspace.ID).Wait()
// Create a second user with template-admin role. This role grants
// ActionRead on workspaces but not ActionSSH.
tmplAdminClient, _ := coderdtest.CreateAnotherUser(
t, coderClient, admin.OrganizationID, rbac.RoleTemplateAdmin(),
)
// Connect with the template-admin user.
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + tmplAdminClient.SessionToken(),
}))
defer func() {
_ = mcpClient.Close()
}()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
require.NoError(t, mcpClient.Start(ctx))
_, err := mcpClient.Initialize(ctx, mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "test-client-authz",
Version: "1.0.0",
},
},
})
require.NoError(t, err)
// Calling a workspace tool that requires an agent connection
// should fail because the template-admin user lacks ActionSSH.
// Use owner/workspace format so the lookup resolves to the
// admin's workspace rather than defaulting to "me".
workspaceIdent := coderdtest.FirstUserParams.Username + "/" + r.Workspace.Name
toolResult, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: toolsdk.ToolNameWorkspaceReadFile,
Arguments: map[string]any{
"workspace": workspaceIdent,
"path": "/tmp/secret.txt",
},
},
})
// The MCP library may return the error in the tool result itself
// (isError=true) rather than as a Go error. Check both.
if err != nil {
require.ErrorContains(t, err, "unauthorized")
return
}
// If no Go error, the tool result must report failure.
require.True(t, toolResult.IsError, "expected tool call to fail for user without SSH access")
textContent, ok := toolResult.Content[0].(mcp.TextContent)
require.True(t, ok)
assert.Contains(t, textContent.Text, "unauthorized")
}
func mustParseURL(t *testing.T, rawURL string) *url.URL {
u, err := url.Parse(rawURL)
require.NoError(t, err, "Failed to parse URL %q", rawURL)
return u
}
// newIsolatedMCPClient creates a streamable HTTP MCP client that uses
// an isolated http.Transport cloned from http.DefaultTransport.
// This prevents httptest.Server.Close() (which calls
// http.DefaultTransport.CloseIdleConnections()) from disrupting the
// client's connections during parallel tests.
func newIsolatedMCPClient(t *testing.T, mcpURL string, opts ...transport.StreamableHTTPCOption) *mcpclient.Client {
t.Helper()
isolated := coderdtest.NewIsolatedHTTPClient(nil)
opts = append([]transport.StreamableHTTPCOption{transport.WithHTTPBasicClient(isolated)}, opts...)
client, err := mcpclient.NewStreamableHttpClient(mcpURL, opts...)
require.NoError(t, err)
return client
}
// sentinelTransport wraps an http.RoundTripper and counts how many
// requests flow through it. Used as a test sentinel to verify
// whether a client is (or is not) using http.DefaultTransport.
type sentinelTransport struct {
inner http.RoundTripper
hits atomic.Int64
}
func (s *sentinelTransport) RoundTrip(req *http.Request) (*http.Response, error) {
s.hits.Add(1)
return s.inner.RoundTrip(req)
}
// TestMCPHTTP_E2E_TransportIsolation verifies that the
// newIsolatedMCPClient helper creates clients that do NOT route
// requests through http.DefaultTransport, while raw
// mcpclient.NewStreamableHttpClient (without explicit
// WithHTTPBasicClient) does use it.
//
//nolint:paralleltest // Mutates http.DefaultTransport.
func TestMCPHTTP_E2E_TransportIsolation(t *testing.T) {
// Replace DefaultTransport with a counting sentinel.
original := http.DefaultTransport
sentinel := &sentinelTransport{inner: original}
http.DefaultTransport = sentinel
t.Cleanup(func() { http.DefaultTransport = original })
coderClient, closer, api := coderdtest.NewWithAPI(t, nil)
t.Cleanup(func() { closer.Close() })
_ = coderdtest.CreateFirstUser(t, coderClient)
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
authOpt := transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
initReq := mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{Name: "sentinel-test", Version: "1.0.0"},
},
}
t.Run("RawClientUsesDefaultTransport", func(t *testing.T) {
sentinel.hits.Store(0)
rawClient, err := mcpclient.NewStreamableHttpClient(mcpURL, authOpt)
require.NoError(t, err)
defer func() { _ = rawClient.Close() }()
require.NoError(t, rawClient.Start(ctx))
_, err = rawClient.Initialize(ctx, initReq)
require.NoError(t, err)
require.Greater(t, sentinel.hits.Load(), int64(0),
"raw client should route requests through http.DefaultTransport")
})
t.Run("IsolatedClientBypassesDefaultTransport", func(t *testing.T) {
sentinel.hits.Store(0)
isoClient := newIsolatedMCPClient(t, mcpURL, authOpt)
defer func() { _ = isoClient.Close() }()
require.NoError(t, isoClient.Start(ctx))
_, err := isoClient.Initialize(ctx, initReq)
require.NoError(t, err)
require.Equal(t, int64(0), sentinel.hits.Load(),
"isolated client must NOT route requests through http.DefaultTransport")
})
}