From 2e61d59308939c89d6a62f91d79321127bc5ec2f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 30 Jun 2025 17:45:24 +0200 Subject: [PATCH] feat(mcp): implement MCP HTTP server with toolsdk integration - Add MCP HTTP server with streamable transport support - Integrate with existing toolsdk for Coder workspace operations - Add comprehensive E2E tests with OAuth2 bearer token support - Register MCP endpoint at /api/experimental/mcp/http with authentication - Support RFC 6750 Bearer token authentication for MCP clients Change-Id: Ib9024569ae452729908797c42155006aa04330af Signed-off-by: Thomas Kosiewski --- coderd/apidoc/docs.go | 68 +- coderd/apidoc/swagger.json | 58 +- coderd/coderd.go | 4 + coderd/mcp/mcp.go | 135 ++++ coderd/mcp/mcp_e2e_test.go | 1223 +++++++++++++++++++++++++++++++++ coderd/mcp/mcp_test.go | 133 ++++ coderd/mcp_http.go | 39 ++ coderd/oauth2.go | 9 +- codersdk/toolsdk/toolsdk.go | 52 +- docs/reference/api/schemas.md | 44 +- 10 files changed, 1743 insertions(+), 22 deletions(-) create mode 100644 coderd/mcp/mcp.go create mode 100644 coderd/mcp/mcp_e2e_test.go create mode 100644 coderd/mcp/mcp_test.go create mode 100644 coderd/mcp_http.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 27a836c7776d5..8dcd7d36bdd30 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11711,7 +11711,73 @@ const docTemplate = `{ } }, "codersdk.CreateTestAuditLogRequest": { - "type": "object" + "type": "object", + "properties": { + "action": { + "enum": [ + "create", + "write", + "delete", + "start", + "stop" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.AuditAction" + } + ] + }, + "additional_fields": { + "type": "array", + "items": { + "type": "integer" + } + }, + "build_reason": { + "enum": [ + "autostart", + "autostop", + "initiator" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.BuildReason" + } + ] + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "request_id": { + "type": "string", + "format": "uuid" + }, + "resource_id": { + "type": "string", + "format": "uuid" + }, + "resource_type": { + "enum": [ + "template", + "template_version", + "user", + "workspace", + "workspace_build", + "git_ssh_key", + "auditable_group" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.ResourceType" + } + ] + }, + "time": { + "type": "string", + "format": "date-time" + } + } }, "codersdk.CreateTokenRequest": { "type": "object", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8b106a7e214e1..39c5b977f5b3b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10427,7 +10427,63 @@ } }, "codersdk.CreateTestAuditLogRequest": { - "type": "object" + "type": "object", + "properties": { + "action": { + "enum": ["create", "write", "delete", "start", "stop"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.AuditAction" + } + ] + }, + "additional_fields": { + "type": "array", + "items": { + "type": "integer" + } + }, + "build_reason": { + "enum": ["autostart", "autostop", "initiator"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.BuildReason" + } + ] + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "request_id": { + "type": "string", + "format": "uuid" + }, + "resource_id": { + "type": "string", + "format": "uuid" + }, + "resource_type": { + "enum": [ + "template", + "template_version", + "user", + "workspace", + "workspace_build", + "git_ssh_key", + "auditable_group" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.ResourceType" + } + ] + }, + "time": { + "type": "string", + "format": "date-time" + } + } }, "codersdk.CreateTokenRequest": { "type": "object", diff --git a/coderd/coderd.go b/coderd/coderd.go index dddd02eec7fbc..9a6255ca0ecb6 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -972,6 +972,10 @@ func New(options *Options) *API { r.Route("/aitasks", func(r chi.Router) { r.Get("/prompts", api.aiTasksPrompts) }) + r.Route("/mcp", func(r chi.Router) { + // MCP HTTP transport endpoint with mandatory authentication + r.Mount("/http", api.mcpHTTPHandler()) + }) }) r.Route("/api/v2", func(r chi.Router) { diff --git a/coderd/mcp/mcp.go b/coderd/mcp/mcp.go new file mode 100644 index 0000000000000..84cbfdda2cd9f --- /dev/null +++ b/coderd/mcp/mcp.go @@ -0,0 +1,135 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/toolsdk" +) + +const ( + // MCPServerName is the name used for the MCP server. + MCPServerName = "Coder" + // MCPServerInstructions is the instructions text for the MCP server. + MCPServerInstructions = "Coder MCP Server providing workspace and template management tools" +) + +// Server represents an MCP HTTP server instance +type Server struct { + Logger slog.Logger + + // mcpServer is the underlying MCP server + mcpServer *server.MCPServer + + // streamableServer handles HTTP transport + streamableServer *server.StreamableHTTPServer +} + +// NewServer creates a new MCP HTTP server +func NewServer(logger slog.Logger) (*Server, error) { + // Create the core MCP server + mcpSrv := server.NewMCPServer( + MCPServerName, + buildinfo.Version(), + server.WithInstructions(MCPServerInstructions), + ) + + // Create logger adapter for mcp-go + mcpLogger := &mcpLoggerAdapter{logger: logger} + + // Create streamable HTTP server with configuration + streamableServer := server.NewStreamableHTTPServer(mcpSrv, + server.WithHeartbeatInterval(30*time.Second), + server.WithLogger(mcpLogger), + ) + + return &Server{ + Logger: logger, + mcpServer: mcpSrv, + streamableServer: streamableServer, + }, nil +} + +// ServeHTTP implements http.Handler interface +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.streamableServer.ServeHTTP(w, r) +} + +// RegisterTools registers all available MCP tools with the server +func (s *Server) RegisterTools(client *codersdk.Client) error { + if client == nil { + return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client") + } + + // Create tool dependencies + toolDeps, err := toolsdk.NewDeps(client) + if err != nil { + return xerrors.Errorf("failed to initialize tool dependencies: %w", err) + } + + // Register all available tools + for _, tool := range toolsdk.All { + s.mcpServer.AddTools(mcpFromSDK(tool, toolDeps)) + } + + return nil +} + +// mcpFromSDK adapts a toolsdk.Tool to go-mcp's server.ServerTool +func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool { + if sdkTool.Schema.Properties == nil { + panic("developer error: schema properties cannot be nil") + } + + return server.ServerTool{ + Tool: mcp.Tool{ + Name: sdkTool.Name, + Description: sdkTool.Description, + InputSchema: mcp.ToolInputSchema{ + Type: "object", + Properties: sdkTool.Schema.Properties, + Required: sdkTool.Schema.Required, + }, + }, + Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(request.Params.Arguments); err != nil { + return nil, xerrors.Errorf("failed to encode request arguments: %w", err) + } + result, err := sdkTool.Handler(ctx, tb, buf.Bytes()) + if err != nil { + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(result)), + }, + }, nil + }, + } +} + +// mcpLoggerAdapter adapts slog.Logger to the mcp-go util.Logger interface +type mcpLoggerAdapter struct { + logger slog.Logger +} + +func (l *mcpLoggerAdapter) Infof(format string, v ...any) { + l.logger.Info(context.Background(), fmt.Sprintf(format, v...)) +} + +func (l *mcpLoggerAdapter) Errorf(format string, v ...any) { + l.logger.Error(context.Background(), fmt.Sprintf(format, v...)) +} diff --git a/coderd/mcp/mcp_e2e_test.go b/coderd/mcp/mcp_e2e_test.go new file mode 100644 index 0000000000000..248786405fda9 --- /dev/null +++ b/coderd/mcp/mcp_e2e_test.go @@ -0,0 +1,1223 @@ +package mcp_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + + 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" +) + +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() + "/api/experimental/mcp/http" + + // 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() + "/api/experimental/mcp/http" + + // 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") + + resp, err := http.DefaultClient.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() + "/api/experimental/mcp/http" + 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() + "/api/experimental/mcp/http" + 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() + "/api/experimental/mcp/http" + 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()+"/api/experimental/mcp/http"), + 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() + "/api/experimental/mcp/http" + 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) + authURL := fmt.Sprintf("%s/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&state=test_state", + api.AccessURL.String(), app.ID, "http://localhost:3000/callback") + + // 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"}, + } + + 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 := http.DefaultClient.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() + "/api/experimental/mcp/http" + 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 := http.DefaultClient.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()+"/api/experimental/mcp/http"), + 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() + "/api/experimental/mcp/http" + 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 := http.DefaultClient.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(®Response) + 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 + authURL := fmt.Sprintf("%s/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&state=dynamic_state", + api.AccessURL.String(), clientID, "http://localhost:3000/callback") + + // 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())) + + 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("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"}, + } + + 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 := http.DefaultClient.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 := http.DefaultClient.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") + + regResp1, err := http.DefaultClient.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(®Response1) + 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 := http.DefaultClient.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(®Response2) + 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) + }) +} + +// 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 +} diff --git a/coderd/mcp/mcp_test.go b/coderd/mcp/mcp_test.go new file mode 100644 index 0000000000000..0c53c899b9830 --- /dev/null +++ b/coderd/mcp/mcp_test.go @@ -0,0 +1,133 @@ +package mcp_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + 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" +) + +func TestMCPServer_Creation(t *testing.T) { + t.Parallel() + + logger := testutil.Logger(t) + + server, err := mcpserver.NewServer(logger) + require.NoError(t, err) + require.NotNil(t, server) +} + +func TestMCPServer_Handler(t *testing.T) { + t.Parallel() + + logger := testutil.Logger(t) + + server, err := mcpserver.NewServer(logger) + require.NoError(t, err) + + // Test that server implements http.Handler interface + var handler http.Handler = server + require.NotNil(t, handler) +} + +func TestMCPHTTP_InitializeRequest(t *testing.T) { + t.Parallel() + + logger := testutil.Logger(t) + + server, err := mcpserver.NewServer(logger) + require.NoError(t, err) + + // Use server directly as http.Handler + handler := server + + // Create initialize request + initRequest := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]any{ + "protocolVersion": mcp.LATEST_PROTOCOL_VERSION, + "capabilities": map[string]any{}, + "clientInfo": map[string]any{ + "name": "test-client", + "version": "1.0.0", + }, + }, + } + + body, err := json.Marshal(initRequest) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json,text/event-stream") + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Logf("Response body: %s", recorder.Body.String()) + } + assert.Equal(t, http.StatusOK, recorder.Code) + + // Check that a session ID was returned + sessionID := recorder.Header().Get("Mcp-Session-Id") + assert.NotEmpty(t, sessionID) + + // Parse response + var response map[string]any + err = json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "2.0", response["jsonrpc"]) + assert.Equal(t, float64(1), response["id"]) + + result, ok := response["result"].(map[string]any) + require.True(t, ok) + + assert.Equal(t, mcp.LATEST_PROTOCOL_VERSION, result["protocolVersion"]) + assert.Contains(t, result, "capabilities") + assert.Contains(t, result, "serverInfo") +} + +func TestMCPHTTP_ToolRegistration(t *testing.T) { + t.Parallel() + + logger := testutil.Logger(t) + + server, err := mcpserver.NewServer(logger) + require.NoError(t, err) + + // Test registering tools with nil client should return error + err = server.RegisterTools(nil) + require.Error(t, err) + require.Contains(t, err.Error(), "client cannot be nil", "Should reject nil client with appropriate error message") + + // Test registering tools with valid client should succeed + client := &codersdk.Client{} + err = server.RegisterTools(client) + require.NoError(t, err) + + // Verify that all expected tools are available in the toolsdk + expectedToolCount := len(toolsdk.All) + require.Greater(t, expectedToolCount, 0, "Should have some tools available") + + // Verify specific tools are present by checking tool names + toolNames := make([]string, len(toolsdk.All)) + for i, tool := range toolsdk.All { + toolNames[i] = tool.Name + } + require.Contains(t, toolNames, toolsdk.ToolNameReportTask, "Should include ReportTask (UserClientOptional)") + require.Contains(t, toolNames, toolsdk.ToolNameGetAuthenticatedUser, "Should include GetAuthenticatedUser (requires auth)") +} diff --git a/coderd/mcp_http.go b/coderd/mcp_http.go new file mode 100644 index 0000000000000..40aaaa1c40dd5 --- /dev/null +++ b/coderd/mcp_http.go @@ -0,0 +1,39 @@ +package coderd + +import ( + "net/http" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/mcp" + "github.com/coder/coder/v2/codersdk" +) + +// mcpHTTPHandler creates the MCP HTTP transport handler +func (api *API) mcpHTTPHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Create MCP server instance for each request + mcpServer, err := mcp.NewServer(api.Logger.Named("mcp")) + if err != nil { + api.Logger.Error(r.Context(), "failed to create MCP server", slog.Error(err)) + httpapi.Write(r.Context(), w, http.StatusInternalServerError, codersdk.Response{ + Message: "MCP server initialization failed", + }) + return + } + + authenticatedClient := codersdk.New(api.AccessURL) + // Extract the original session token from the request + authenticatedClient.SetSessionToken(httpmw.APITokenFromRequest(r)) + + // Register tools with authenticated client + if err := mcpServer.RegisterTools(authenticatedClient); err != nil { + api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err)) + } + + // Handle the MCP request + mcpServer.ServeHTTP(w, r) + }) +} diff --git a/coderd/oauth2.go b/coderd/oauth2.go index a96b694570869..e566fc1342837 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -536,12 +536,13 @@ func (api *API) postOAuth2ClientRegistration(rw http.ResponseWriter, r *http.Req // Store in database - use system context since this is a public endpoint now := dbtime.Now() + clientName := req.GenerateClientName() //nolint:gocritic // Dynamic client registration is a public endpoint, system access required app, err := api.Database.InsertOAuth2ProviderApp(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppParams{ ID: clientID, CreatedAt: now, UpdatedAt: now, - Name: req.GenerateClientName(), + Name: clientName, Icon: req.LogoURI, CallbackURL: req.RedirectURIs[0], // Primary redirect URI RedirectUris: req.RedirectURIs, @@ -566,7 +567,11 @@ func (api *API) postOAuth2ClientRegistration(rw http.ResponseWriter, r *http.Req RegistrationClientUri: sql.NullString{String: fmt.Sprintf("%s/oauth2/clients/%s", api.AccessURL.String(), clientID), Valid: true}, }) if err != nil { - api.Logger.Error(ctx, "failed to store oauth2 client registration", slog.Error(err)) + api.Logger.Error(ctx, "failed to store oauth2 client registration", + slog.Error(err), + slog.F("client_name", clientName), + slog.F("client_id", clientID.String()), + slog.F("redirect_uris", req.RedirectURIs)) writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, "server_error", "Failed to store client registration") return diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 24433c1b2a6da..4055674f6d2d3 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -15,6 +15,26 @@ import ( "github.com/coder/coder/v2/codersdk" ) +// Tool name constants to avoid hardcoded strings +const ( + ToolNameReportTask = "coder_report_task" + ToolNameGetWorkspace = "coder_get_workspace" + ToolNameCreateWorkspace = "coder_create_workspace" + ToolNameListWorkspaces = "coder_list_workspaces" + ToolNameListTemplates = "coder_list_templates" + ToolNameListTemplateVersionParams = "coder_template_version_parameters" + ToolNameGetAuthenticatedUser = "coder_get_authenticated_user" + ToolNameCreateWorkspaceBuild = "coder_create_workspace_build" + ToolNameCreateTemplateVersion = "coder_create_template_version" + ToolNameGetWorkspaceAgentLogs = "coder_get_workspace_agent_logs" + ToolNameGetWorkspaceBuildLogs = "coder_get_workspace_build_logs" + ToolNameGetTemplateVersionLogs = "coder_get_template_version_logs" + ToolNameUpdateTemplateActiveVersion = "coder_update_template_active_version" + ToolNameUploadTarFile = "coder_upload_tar_file" + ToolNameCreateTemplate = "coder_create_template" + ToolNameDeleteTemplate = "coder_delete_template" +) + func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) { d := Deps{ coderClient: client, @@ -173,7 +193,7 @@ type ReportTaskArgs struct { var ReportTask = Tool[ReportTaskArgs, codersdk.Response]{ Tool: aisdk.Tool{ - Name: "coder_report_task", + Name: ToolNameReportTask, Description: `Report progress on your work. The user observes your work through a Task UI. To keep them updated @@ -238,7 +258,7 @@ type GetWorkspaceArgs struct { var GetWorkspace = Tool[GetWorkspaceArgs, codersdk.Workspace]{ Tool: aisdk.Tool{ - Name: "coder_get_workspace", + Name: ToolNameGetWorkspace, Description: `Get a workspace by ID. This returns more data than list_workspaces to reduce token usage.`, @@ -269,7 +289,7 @@ type CreateWorkspaceArgs struct { var CreateWorkspace = Tool[CreateWorkspaceArgs, codersdk.Workspace]{ Tool: aisdk.Tool{ - Name: "coder_create_workspace", + Name: ToolNameCreateWorkspace, Description: `Create a new workspace in Coder. If a user is asking to "test a template", they are typically referring @@ -331,7 +351,7 @@ type ListWorkspacesArgs struct { var ListWorkspaces = Tool[ListWorkspacesArgs, []MinimalWorkspace]{ Tool: aisdk.Tool{ - Name: "coder_list_workspaces", + Name: ToolNameListWorkspaces, Description: "Lists workspaces for the authenticated user.", Schema: aisdk.Schema{ Properties: map[string]any{ @@ -373,7 +393,7 @@ var ListWorkspaces = Tool[ListWorkspacesArgs, []MinimalWorkspace]{ var ListTemplates = Tool[NoArgs, []MinimalTemplate]{ Tool: aisdk.Tool{ - Name: "coder_list_templates", + Name: ToolNameListTemplates, Description: "Lists templates for the authenticated user.", Schema: aisdk.Schema{ Properties: map[string]any{}, @@ -406,7 +426,7 @@ type ListTemplateVersionParametersArgs struct { var ListTemplateVersionParameters = Tool[ListTemplateVersionParametersArgs, []codersdk.TemplateVersionParameter]{ Tool: aisdk.Tool{ - Name: "coder_template_version_parameters", + Name: ToolNameListTemplateVersionParams, Description: "Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.", Schema: aisdk.Schema{ Properties: map[string]any{ @@ -432,7 +452,7 @@ var ListTemplateVersionParameters = Tool[ListTemplateVersionParametersArgs, []co var GetAuthenticatedUser = Tool[NoArgs, codersdk.User]{ Tool: aisdk.Tool{ - Name: "coder_get_authenticated_user", + Name: ToolNameGetAuthenticatedUser, Description: "Get the currently authenticated user, similar to the `whoami` command.", Schema: aisdk.Schema{ Properties: map[string]any{}, @@ -452,7 +472,7 @@ type CreateWorkspaceBuildArgs struct { var CreateWorkspaceBuild = Tool[CreateWorkspaceBuildArgs, codersdk.WorkspaceBuild]{ Tool: aisdk.Tool{ - Name: "coder_create_workspace_build", + Name: ToolNameCreateWorkspaceBuild, Description: "Create a new workspace build for an existing workspace. Use this to start, stop, or delete.", Schema: aisdk.Schema{ Properties: map[string]any{ @@ -502,7 +522,7 @@ type CreateTemplateVersionArgs struct { var CreateTemplateVersion = Tool[CreateTemplateVersionArgs, codersdk.TemplateVersion]{ Tool: aisdk.Tool{ - Name: "coder_create_template_version", + Name: ToolNameCreateTemplateVersion, Description: `Create a new template version. This is a precursor to creating a template, or you can update an existing template. Templates are Terraform defining a development environment. The provisioned infrastructure must run @@ -1002,7 +1022,7 @@ type GetWorkspaceAgentLogsArgs struct { var GetWorkspaceAgentLogs = Tool[GetWorkspaceAgentLogsArgs, []string]{ Tool: aisdk.Tool{ - Name: "coder_get_workspace_agent_logs", + Name: ToolNameGetWorkspaceAgentLogs, Description: `Get the logs of a workspace agent. More logs may appear after this call. It does not wait for the agent to finish.`, @@ -1041,7 +1061,7 @@ type GetWorkspaceBuildLogsArgs struct { var GetWorkspaceBuildLogs = Tool[GetWorkspaceBuildLogsArgs, []string]{ Tool: aisdk.Tool{ - Name: "coder_get_workspace_build_logs", + Name: ToolNameGetWorkspaceBuildLogs, Description: `Get the logs of a workspace build. Useful for checking whether a workspace builds successfully or not.`, @@ -1078,7 +1098,7 @@ type GetTemplateVersionLogsArgs struct { var GetTemplateVersionLogs = Tool[GetTemplateVersionLogsArgs, []string]{ Tool: aisdk.Tool{ - Name: "coder_get_template_version_logs", + Name: ToolNameGetTemplateVersionLogs, Description: "Get the logs of a template version. This is useful to check whether a template version successfully imports or not.", Schema: aisdk.Schema{ Properties: map[string]any{ @@ -1115,7 +1135,7 @@ type UpdateTemplateActiveVersionArgs struct { var UpdateTemplateActiveVersion = Tool[UpdateTemplateActiveVersionArgs, string]{ Tool: aisdk.Tool{ - Name: "coder_update_template_active_version", + Name: ToolNameUpdateTemplateActiveVersion, Description: "Update the active version of a template. This is helpful when iterating on templates.", Schema: aisdk.Schema{ Properties: map[string]any{ @@ -1154,7 +1174,7 @@ type UploadTarFileArgs struct { var UploadTarFile = Tool[UploadTarFileArgs, codersdk.UploadResponse]{ Tool: aisdk.Tool{ - Name: "coder_upload_tar_file", + Name: ToolNameUploadTarFile, Description: `Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of "create_template_version" to understand template requirements.`, Schema: aisdk.Schema{ Properties: map[string]any{ @@ -1216,7 +1236,7 @@ type CreateTemplateArgs struct { var CreateTemplate = Tool[CreateTemplateArgs, codersdk.Template]{ Tool: aisdk.Tool{ - Name: "coder_create_template", + Name: ToolNameCreateTemplate, Description: "Create a new template in Coder. First, you must create a template version.", Schema: aisdk.Schema{ Properties: map[string]any{ @@ -1269,7 +1289,7 @@ type DeleteTemplateArgs struct { var DeleteTemplate = Tool[DeleteTemplateArgs, codersdk.Response]{ Tool: aisdk.Tool{ - Name: "coder_delete_template", + Name: ToolNameDeleteTemplate, Description: "Delete a template. This is irreversible.", Schema: aisdk.Schema{ Properties: map[string]any{ diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index acb81e616e361..3611f391d99c1 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1366,12 +1366,52 @@ This is required on creation to enable a user-flow of validating a template work ## codersdk.CreateTestAuditLogRequest ```json -{} +{ + "action": "create", + "additional_fields": [ + 0 + ], + "build_reason": "autostart", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "request_id": "266ea41d-adf5-480b-af50-15b940c2b846", + "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", + "resource_type": "template", + "time": "2019-08-24T14:15:22Z" +} ``` ### Properties -None +| Name | Type | Required | Restrictions | Description | +|---------------------|------------------------------------------------|----------|--------------|-------------| +| `action` | [codersdk.AuditAction](#codersdkauditaction) | false | | | +| `additional_fields` | array of integer | false | | | +| `build_reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | | +| `organization_id` | string | false | | | +| `request_id` | string | false | | | +| `resource_id` | string | false | | | +| `resource_type` | [codersdk.ResourceType](#codersdkresourcetype) | false | | | +| `time` | string | false | | | + +#### Enumerated Values + +| Property | Value | +|-----------------|--------------------| +| `action` | `create` | +| `action` | `write` | +| `action` | `delete` | +| `action` | `start` | +| `action` | `stop` | +| `build_reason` | `autostart` | +| `build_reason` | `autostop` | +| `build_reason` | `initiator` | +| `resource_type` | `template` | +| `resource_type` | `template_version` | +| `resource_type` | `user` | +| `resource_type` | `workspace` | +| `resource_type` | `workspace_build` | +| `resource_type` | `git_ssh_key` | +| `resource_type` | `auditable_group` | ## codersdk.CreateTokenRequest pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy