Files
coder/coderd/mcp/mcp_e2e_test.go
T
Thomas Kosiewski b776a14b46 fix(coderd): harden OAuth2 provider security (#22194)
## Summary

Harden the OAuth2 provider with multiple security fixes addressing
`coder/security#121` (CSRF session takeover) and converge on OAuth 2.1
compliance.

### Security Fixes

| Fix | Description | Commits |
|-----|-------------|---------|
| **CSRF on `/oauth2/authorize`** | Enforce CSRF protection on the
authorize endpoint POST (consent form submission) | `ba7d646`, `b94a64e`
|
| **Clickjacking: `frame-ancestors` CSP** | Prevent consent page from
being iframed (`Content-Security-Policy: frame-ancestors 'none'` +
`X-Frame-Options: DENY`) | `597aeb2` |
| **Exact redirect URI matching** | Changed from prefix matching to full
string exact matching per OAuth 2.1 §4.1.2.1 | `73d64b1`, `93897f1` |
| **Store & verify `redirect_uri`** | Store redirect_uri with auth code
in DB, verify at token exchange matches exactly (RFC 6749 §4.1.3) |
`50569b9`, `d7ca315` |
| **Mandatory PKCE** | Require `code_challenge` at authorization (for
`response_type=code`) + unconditional `code_verifier` verification at
token exchange | `d7ca315`, `1cda1a9` |
| **Reject implicit grant** | `response_type=token` now returns
`unsupported_response_type` error page (OAuth 2.1 removes implicit flow)
| `d7ca315`, `91b8863` |

### Changes by File

**`coderd/httpmw/csrf.go`** — Extended the CSRF `ExemptFunc` to enforce
CSRF on `/oauth2/authorize` in addition to `/api` routes. The consent
form POST is now CSRF-protected to prevent cross-site authorization code
theft.

**`site/site.go`** — Added `Content-Security-Policy: frame-ancestors
'none'` and `X-Frame-Options: DENY` headers to `RenderOAuthAllowPage`
(consent page only — does not affect the SPA/global CSP used by AI
tasks).

**`coderd/httpapi/queryparams.go`** — Changed `RedirectURL` from prefix
matching (`strings.HasPrefix(v.Path, base.Path)`) to full URI exact
matching (`v.String() != base.String()`), comparing scheme, host, path,
and query.

**`coderd/oauth2provider/authorize.go`** — Added PKCE enforcement:
`code_challenge` is required when `response_type=code` (via a
conditional check, not `RequiredNotEmpty`, so `response_type=token` can
reach the explicit rejection path). `ShowAuthorizePage` (GET) validates
`response_type` before rendering and returns a 400 error page for
unsupported types. `ProcessAuthorize` (POST) stores the `redirect_uri`
with the auth code when explicitly provided.

**`coderd/oauth2provider/tokens.go`** — PKCE verification is now
unconditional (not gated on `code_challenge` being present in DB). If
the stored code has a `redirect_uri`, the token endpoint verifies it
matches exactly — mismatch returns `errBadCode` → `invalid_grant`.
Missing `code_verifier` returns `invalid_grant`.

**`codersdk/oauth2.go`** — `OAuth2ProviderResponseTypeToken` constant
and `Valid()` acceptance are **kept** so the authorize handler can parse
`response_type=token` and return the proper `unsupported_response_type`
error rather than failing at parameter validation.

**`coderd/database/migrations/000421_*`** — Added `redirect_uri text`
column to `oauth2_provider_app_codes`.

### Design Decisions

**`state` parameter remains optional** — The plan initially required
`state` via `RequiredNotEmpty`, but this was reverted in `376a753` to
avoid breaking existing clients. The `state` is still hashed and stored
when provided (via `state_hash` column), securing clients that opt in.

**`response_type=token` kept in `Valid()`** — Removing it from `Valid()`
would cause the parameter parser to reject the request before the
authorize handler can return the proper `unsupported_response_type`
error. The constant is kept for correct error handling flow.

**CSP scoped to consent page only** — `frame-ancestors 'none'` is set
only on the OAuth consent page renderer, not globally. The SPA/global
CSP was previously changed to allow framing for AI tasks
([#18102](https://github.com/coder/coder/pull/18102)); this change does
not regress that.

### Out of Scope (follow-up PRs)

- Bearer tokens in query strings (needs internal caller audit)
- Scope enforcement on OAuth2 tokens
- Rate limiting on dynamic client registration


---

<details>
<summary>📋 Implementation Plan</summary>

# Plan: Harden OAuth2 Provider — Security Fixes + OAuth 2.1 Compliance

## Context & Why

Security issue `coder/security#121` reports a critical session takeover
via CSRF on the OAuth2 provider. This plan covers all remaining security
fixes from that issue **plus** convergence on OAuth 2.1 requirements.
The goal is a single PR that closes all actionable gaps.

## Current State (already committed on branch `csrf-sjx1`)

| Fix | Status | Commits |
|-----|--------|---------|
| Fix 1: CSRF on `/oauth2/authorize` |  Done | `ba7d646`, `b94a64e` |
| CSRF token in consent form HTML |  Done | `b94a64e` |
| `state_hash` column + storage |  Done (hash stored, but state still
optional) | `9167d83`, `b94a64e` |
| Tests for CSRF + state hash |  Done | `e4119b5` |

## Remaining Work

### ~~Fix 2 — Require `state` parameter~~ (DROPPED)

> **Decision:** Do not enforce `state` as required. The `state`
parameter is still hashed and stored when provided (via
`hashOAuth2State` / `state_hash` column from prior commits), but clients
are not forced to supply it. This avoids breaking existing integrations
that omit state.

**Rollback:** Remove `"state"` from the `RequiredNotEmpty` call in
`coderd/oauth2provider/authorize.go:42`:

```go
// BEFORE (current on branch)
p.RequiredNotEmpty("response_type", "client_id", "state", "code_challenge")

// AFTER
p.RequiredNotEmpty("response_type", "client_id", "code_challenge")
```

No test changes needed — tests already pass `state` voluntarily.

### Fix 4 — Exact redirect URI matching

Currently `coderd/httpapi/queryparams.go:233` uses prefix matching:

```go
// CURRENT — prefix match
if v.Host != base.Host || !strings.HasPrefix(v.Path, base.Path) {
```

OAuth 2.1 requires **exact string matching**. Change to:

```go
// AFTER — exact match (OAuth 2.1 §4.1.2.1)
if v.Host != base.Host || v.Path != base.Path {
```

**File: `coderd/httpapi/queryparams.go` — `RedirectURL` method**

Also update the error message from "must be a subset of" to "must
exactly match".

**Additionally**, store `redirect_uri` with the auth code and verify at
the token endpoint (RFC 6749 §4.1.3):

1. **New migration** (same migration file or a new `000421`): Add
`redirect_uri text` column to `oauth2_provider_app_codes`
2. **Update INSERT query** in `coderd/database/queries/oauth2.sql` to
include `redirect_uri`
3. **`coderd/oauth2provider/authorize.go`**: Store
`params.redirectURL.String()` when inserting the code
4. **`coderd/oauth2provider/tokens.go`**: After retrieving the code from
DB, verify that `redirect_uri` from the token request matches the stored
value exactly. Currently `tokens.go:103` calls `p.RedirectURL(vals,
callbackURL, "redirect_uri")` for prefix validation only — it must
compare against the stored redirect_uri from the code, not just the
app's callback URL.

<details>
<summary>Why both exact match AND store+verify?</summary>

Exact matching at the authorize endpoint prevents open redirectors
(attacker can't use a sub-path).
Storing and verifying at the token endpoint prevents code injection — an
attacker who steals a code can't exchange it with a different
redirect_uri than was originally authorized. This is required by RFC
6749 §4.1.3 and OAuth 2.1.
</details>

### Fix 7 — `frame-ancestors` CSP on consent page

The consent page can be iframed by a workspace app (same-site), which is
the attack vector. Add a `Content-Security-Policy` header to prevent
framing.

**File: `site/site.go` — `RenderOAuthAllowPage` function (~line 731)**

Before writing the response, add:

```go
func RenderOAuthAllowPage(rw http.ResponseWriter, r *http.Request, data RenderOAuthAllowData) {
    rw.Header().Set("Content-Type", "text/html; charset=utf-8")
    // Prevent the consent page from being framed to mitigate
    // clickjacking attacks (coder/security#121).
    rw.Header().Set("Content-Security-Policy", "frame-ancestors 'none'")
    rw.Header().Set("X-Frame-Options", "DENY")
    ...
```

Both headers for defense-in-depth (CSP for modern browsers,
X-Frame-Options for legacy).

### OAuth 2.1 — Mandatory PKCE

Currently PKCE is checked only when `code_challenge` was provided during
authorization (`tokens.go:258`):

```go
// CURRENT — conditional check
if dbCode.CodeChallenge.Valid && dbCode.CodeChallenge.String != "" {
    // verify PKCE
}
```

OAuth 2.1 requires PKCE for ALL authorization code flows. Change to:

**File: `coderd/oauth2provider/authorize.go`** — Add `"code_challenge"`
to required params:

```go
p.RequiredNotEmpty("response_type", "client_id", "code_challenge")
```

**File: `coderd/oauth2provider/tokens.go:257-265`** — Make PKCE
verification unconditional:

```go
// AFTER — PKCE always required (OAuth 2.1)
if req.CodeVerifier == "" {
    return codersdk.OAuth2TokenResponse{}, errInvalidPKCE
}
if !dbCode.CodeChallenge.Valid || dbCode.CodeChallenge.String == "" {
    // Code was issued without a challenge — should not happen
    // with the authorize endpoint enforcement, but defend in
    // depth.
    return codersdk.OAuth2TokenResponse{}, errInvalidPKCE
}
if !VerifyPKCE(dbCode.CodeChallenge.String, req.CodeVerifier) {
    return codersdk.OAuth2TokenResponse{}, errInvalidPKCE
}
```

**File: `codersdk/oauth2.go`** — Remove
`OAuth2ProviderResponseTypeToken` from the enum or reject it explicitly
in the authorize handler. Currently it's defined at line 216 but the
handler ignores `response_type` and always issues a code. We should
either:
- (a) Remove the `"token"` variant from the enum and reject it with
`unsupported_response_type`, OR
- (b) Add an explicit check in `ProcessAuthorize` that rejects
`response_type=token`

Option (b) is simpler and more backwards-compatible:

```go
// In ProcessAuthorize, after extracting params:
if params.responseType != codersdk.OAuth2ProviderResponseTypeCode {
    httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest,
        codersdk.OAuth2ErrorCodeUnsupportedResponseType,
        "Only response_type=code is supported")
    return
}
```

### OAuth 2.1 — Bearer tokens in query strings

`coderd/httpmw/apikey.go:743` accepts `access_token` from URL query
parameters. OAuth 2.1 prohibits this. However, this may be used
internally (e.g., workspace apps, DERP). Need to audit callers before
removing.

**Approach:** This is a larger change with potential breakage. Mark as a
**separate follow-up issue** rather than including in this PR. Document
the finding.

### OAuth 2.1 — Removed flows

 **Already compliant.** `tokens.go` only supports `authorization_code`
and `refresh_token` grant types. The implicit grant
(`response_type=token`) will be explicitly rejected per the PKCE section
above.

### OAuth 2.1 — Refresh token rotation

 **Already compliant.** `tokens.go:442` deletes the old API key when a
refresh token is used.

## Migration Plan

All DB changes can go in a single new migration (or extend 000420 if the
branch is rebased before merge). Columns to add:
- `redirect_uri text` on `oauth2_provider_app_codes`

The `state_hash` column is already added by migration 000420.

## Implementation Order

1. **Fix 7** — CSP headers on consent page (isolated, no deps)
2. ~~**Fix 2** — Require `state` parameter~~ (DROPPED — state stays
optional)
3. **Fix 4** — Exact redirect URI matching + store/verify redirect_uri
4. **PKCE mandatory** — Require `code_challenge` + reject
`response_type=token`
5. **Rollback** — Remove `"state"` from `RequiredNotEmpty` in
`authorize.go`
6. **Tests** — Update/add tests for all changes
7. **`make gen`** after DB changes

## Out of Scope (separate PRs)

- Bearer tokens in query strings (needs internal caller audit)
- Scope enforcement on OAuth2 tokens
- Rate limiting / quota on dynamic client registration

</details>

---
_Generated with [`mux`](https://github.com/coder/mux) • Model:
`anthropic:claude-opus-4-6` • Thinking: `xhigh`_
2026-02-23 12:18:44 +01:00

1393 lines
47 KiB
Go

package mcp_test
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"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/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"github.com/coder/coder/v2/coderd/coderdtest"
mcpserver "github.com/coder/coder/v2/coderd/mcp"
"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, err := mcpclient.NewStreamableHttpClient(mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
require.NoError(t, err)
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
for _, tool := range tools.Tools {
foundTools = append(foundTools, tool.Name)
}
// Check for some basic tools that should be available
assert.Contains(t, foundTools, toolsdk.ToolNameGetAuthenticatedUser, "Should have authenticated user tool")
// 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")
// 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, err := mcpclient.NewStreamableHttpClient(mcpURL)
require.NoError(t, err, "Should be able to create MCP client without authentication")
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()
// Setup Coder server with full workspace environment
coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
defer closer.Close()
user := coderdtest.CreateFirstUser(t, coderClient)
// Create template and workspace for testing
version := coderdtest.CreateTemplateVersion(t, coderClient, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, coderClient, version.ID)
template := coderdtest.CreateTemplate(t, coderClient, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, coderClient, template.ID)
// Create MCP client
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
require.NoError(t, err)
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-workspace",
Version: "1.0.0",
},
},
}
_, err = mcpClient.Initialize(ctx, initReq)
require.NoError(t, err)
// Test workspace-related tools
tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
require.NoError(t, err)
// Find workspace listing tool
var workspaceTool *mcp.Tool
for _, tool := range tools.Tools {
if tool.Name == toolsdk.ToolNameListWorkspaces {
workspaceTool = &tool
break
}
}
if workspaceTool != nil {
// Execute workspace listing tool
toolReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: workspaceTool.Name,
Arguments: map[string]any{},
},
}
toolResult, err := mcpClient.CallTool(ctx, toolReq)
require.NoError(t, err)
require.NotEmpty(t, toolResult.Content)
// Verify the result mentions our workspace
if textContent, ok := toolResult.Content[0].(mcp.TextContent); ok {
assert.Contains(t, textContent.Text, workspace.Name, "Workspace listing should include our test workspace")
} else {
t.Error("Expected TextContent type from workspace tool")
}
t.Logf("Workspace tool test successful: Found workspace %s in results", workspace.Name)
} else {
t.Skip("Workspace listing tool not available, skipping workspace-specific test")
}
}
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, err := mcpclient.NewStreamableHttpClient(mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
require.NoError(t, err)
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, err := mcpclient.NewStreamableHttpClient(mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
require.NoError(t, err)
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)
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, err := mcpclient.NewStreamableHttpClient(mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + sessionToken,
}))
require.NoError(t, err)
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, err := mcpclient.NewStreamableHttpClient(mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + accessToken,
}))
require.NoError(t, err)
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, err := mcpclient.NewStreamableHttpClient(mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + newAccessToken,
}))
require.NoError(t, err)
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, err := mcpclient.NewStreamableHttpClient(mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + accessToken,
}))
require.NoError(t, err)
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, err := mcpclient.NewStreamableHttpClient(mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + newAccessToken,
}))
require.NoError(t, err)
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, err := mcpclient.NewStreamableHttpClient(mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
require.NoError(t, err)
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
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
}