From e92fd8e96fb85dfc0e66fbbf09cf0b12e073ec04 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:47:14 +0200 Subject: [PATCH] chore: retire mark3labs/mcp-go dependency (#28061) ## Stack Context PR 6 of 6 in a stack that migrates every Coder MCP surface from the archived `github.com/mark3labs/mcp-go` library to the official `github.com/modelcontextprotocol/go-sdk` v1.7.0. Stack: #28056 -> #28057 -> #28058 -> #28059 -> #28060 -> #28061 ## Why With every production surface migrated, this PR removes the mark3labs dependency entirely and converts the remaining test fixtures. - Migrates the remaining mark3labs test fixtures (coderd MCP e2e tests, chatd fixtures, mcpclient fixtures, and the Force On MCP policy tests) to official stateless SDK servers. - Removes `github.com/mark3labs/mcp-go` from `go.mod` and drops the corresponding dependabot ignore entry. Zero references remain repo-wide. - Updates the MCP docs for the 2026-07-28 protocol: stateless Streamable HTTP behavior, the supported 2024-11-05 through 2026-07-28 protocol range, and explicit non-features (resources, prompts, structured output, elicitation, MCP Tasks). - The e2e ping assertion is removed because MCP 2026-07-28 removed the ping method. > Mux created this PR on Mike's behalf. --- .github/dependabot.yaml | 1 - coderd/exp_chats_test.go | 6 +- coderd/mcp.go | 7 +- coderd/mcp/mcp_e2e_test.go | 577 ++++++------------ coderd/mcp/mcp_test.go | 5 +- coderd/x/chatd/chatd_test.go | 321 ++++------ coderd/x/chatd/forced_mcp_test.go | 21 +- .../x/chatd/mcpclient/coder_headers_test.go | 21 +- coderd/x/chatd/mcpclient/mcpclient_test.go | 364 +++++------ docs/ai-coder/ai-gateway/mcp.md | 2 +- docs/ai-coder/mcp-server.md | 10 + go.mod | 1 - go.sum | 2 - 13 files changed, 503 insertions(+), 835 deletions(-) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index d4ad58b2d4..e8f157b0ef 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -35,7 +35,6 @@ updates: - dependency-name: "*" update-types: - version-update:semver-patch - - dependency-name: "github.com/mark3labs/mcp-go" # Update our Dockerfile. - package-ecosystem: "docker" diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 6c5383b024..e32fae2a70 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -19,7 +19,7 @@ import ( "time" "github.com/google/uuid" - "github.com/mark3labs/mcp-go/mcp" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" @@ -10612,7 +10612,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) { dynamicTools := []mcp.Tool{{ Name: dynamicToolName, Description: "a test dynamic tool", - InputSchema: mcp.ToolInputSchema{Type: "object"}, + InputSchema: map[string]any{"type": "object"}, }} dtJSON, err := json.Marshal(dynamicTools) require.NoError(t, err) @@ -15237,7 +15237,7 @@ func TestSubmitToolResults(t *testing.T) { dynamicTools := []mcp.Tool{{ Name: dynamicToolName, Description: "a test dynamic tool", - InputSchema: mcp.ToolInputSchema{Type: "object"}, + InputSchema: map[string]any{"type": "object"}, }} dtJSON, err := json.Marshal(dynamicTools) require.NoError(t, err) diff --git a/coderd/mcp.go b/coderd/mcp.go index 4c7ebb918c..f9c90b01b6 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -15,7 +15,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" - "github.com/mark3labs/mcp-go/mcp" "golang.org/x/oauth2" "golang.org/x/xerrors" @@ -32,6 +31,10 @@ import ( "github.com/coder/coder/v2/codersdk" ) +// mcpProtocolVersion is copied from the official SDK, which does not export +// protocol version constants. +const mcpProtocolVersion = "2026-07-28" + // oidcMCPTokenSource implements mcpclient.UserOIDCTokenSource using // the same refresh strategy as provisionerdserver.ObtainOIDCAccessToken. // The logic is duplicated to avoid importing provisionerdserver from @@ -1570,7 +1573,7 @@ func fetchJSON(ctx context.Context, httpClient *http.Client, rawURL string, dest return xerrors.Errorf("create request for %s: %w", rawURL, err) } req.Header.Set("Accept", "application/json") - req.Header.Set("MCP-Protocol-Version", mcp.LATEST_PROTOCOL_VERSION) + req.Header.Set("MCP-Protocol-Version", mcpProtocolVersion) resp, err := httpClient.Do(req) if err != nil { diff --git a/coderd/mcp/mcp_e2e_test.go b/coderd/mcp/mcp_e2e_test.go index ab48450a07..7c44f99592 100644 --- a/coderd/mcp/mcp_e2e_test.go +++ b/coderd/mcp/mcp_e2e_test.go @@ -16,9 +16,7 @@ import ( "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/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -58,43 +56,27 @@ func TestMCPHTTP_E2E_ClientIntegration(t *testing.T) { // Create MCP client pointing to our endpoint mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + // Configure client with authentication headers using RFC 6750 Bearer token - mcpClient := newIsolatedMCPClient(t, mcpURL, - transport.WithHTTPHeaders(map[string]string{ - "Authorization": "Bearer " + coderClient.SessionToken(), - })) + mcpClient, err := newIsolatedMCPClient(ctx, mcpURL, "test-client", 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) + result := mcpClient.InitializeResult() require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name) - require.Equal(t, mcp.LATEST_PROTOCOL_VERSION, result.ProtocolVersion) + require.Equal(t, "2026-07-28", result.ProtocolVersion) require.NotNil(t, result.Capabilities) // Test tool listing - tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{}) + tools, err := mcpClient.ListTools(ctx, nil) require.NoError(t, err) require.NotEmpty(t, tools.Tools) @@ -102,14 +84,13 @@ func TestMCPHTTP_E2E_ClientIntegration(t *testing.T) { var foundTools []string var userTool *mcp.Tool var writeFileTool *mcp.Tool - for i := range tools.Tools { - tool := tools.Tools[i] + for _, tool := range tools.Tools { foundTools = append(foundTools, tool.Name) switch tool.Name { case toolsdk.ToolNameGetAuthenticatedUser: - userTool = &tools.Tools[i] + userTool = tool case toolsdk.ToolNameWorkspaceWriteFile: - writeFileTool = &tools.Tools[i] + writeFileTool = tool } } @@ -117,50 +98,39 @@ func TestMCPHTTP_E2E_ClientIntegration(t *testing.T) { assert.Contains(t, foundTools, toolsdk.ToolNameGetAuthenticatedUser, "Should have authenticated user tool") require.NotNil(t, userTool) require.NotNil(t, writeFileTool) - require.NotNil(t, userTool.Annotations.ReadOnlyHint) + require.NotNil(t, userTool.Annotations) require.NotNil(t, userTool.Annotations.DestructiveHint) - require.NotNil(t, userTool.Annotations.IdempotentHint) require.NotNil(t, userTool.Annotations.OpenWorldHint) - assert.True(t, *userTool.Annotations.ReadOnlyHint) + assert.True(t, userTool.Annotations.ReadOnlyHint) assert.False(t, *userTool.Annotations.DestructiveHint) - assert.True(t, *userTool.Annotations.IdempotentHint) + assert.True(t, userTool.Annotations.IdempotentHint) assert.False(t, *userTool.Annotations.OpenWorldHint) - require.NotNil(t, writeFileTool.Annotations.ReadOnlyHint) + require.NotNil(t, writeFileTool.Annotations) require.NotNil(t, writeFileTool.Annotations.DestructiveHint) - require.NotNil(t, writeFileTool.Annotations.IdempotentHint) require.NotNil(t, writeFileTool.Annotations.OpenWorldHint) - assert.False(t, *writeFileTool.Annotations.ReadOnlyHint) + assert.False(t, writeFileTool.Annotations.ReadOnlyHint) assert.True(t, *writeFileTool.Annotations.DestructiveHint) - assert.False(t, *writeFileTool.Annotations.IdempotentHint) + assert.False(t, writeFileTool.Annotations.IdempotentHint) assert.False(t, *writeFileTool.Annotations.OpenWorldHint) // Execute the authenticated user tool. require.NotNil(t, userTool, "Expected to find "+toolsdk.ToolNameGetAuthenticatedUser+" tool") // Execute the tool - toolReq := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: userTool.Name, - Arguments: map[string]any{}, - }, - } - - toolResult, err := mcpClient.CallTool(ctx, toolReq) + toolResult, err := mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: userTool.Name, + Arguments: map[string]any{}, + }) 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) + if textContent, ok := toolResult.Content[0].(*mcp.TextContent); ok { 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) { @@ -191,32 +161,7 @@ func TestMCPHTTP_E2E_UnauthenticatedAccess(t *testing.T) { require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Should get HTTP 401 for unauthenticated access") // Also test with MCP client to ensure it handles the error gracefully - mcpClient := newIsolatedMCPClient(t, mcpURL) - defer func() { - if closeErr := mcpClient.Close(); closeErr != nil { - t.Logf("Failed to close MCP client: %v", closeErr) - } - }() - - // Start client and try to initialize - this should fail due to authentication - err = mcpClient.Start(ctx) - if err != nil { - // Authentication failed at transport level - this is expected - t.Logf("Unauthenticated access test successful: Transport-level authentication error: %v", err) - return - } - - initReq := mcp.InitializeRequest{ - Params: mcp.InitializeParams{ - ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION, - ClientInfo: mcp.Implementation{ - Name: "test-client-unauth", - Version: "1.0.0", - }, - }, - } - - _, err = mcpClient.Initialize(ctx, initReq) + _, err = newIsolatedMCPClient(ctx, mcpURL, "test-client-unauth", nil) require.Error(t, err, "Should fail during MCP initialization without authentication") } @@ -245,44 +190,30 @@ func TestMCPHTTP_E2E_ToolWithWorkspace(t *testing.T) { coderdtest.NewWorkspaceAgentWaiter(t, coderClient, r.Workspace.ID).Wait() mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint - mcpClient := newIsolatedMCPClient(t, mcpURL, - transport.WithHTTPHeaders(map[string]string{ - "Authorization": "Bearer " + coderClient.SessionToken(), - })) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + mcpClient, err := newIsolatedMCPClient(ctx, mcpURL, "test-client-workspace", 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() - - require.NoError(t, mcpClient.Start(ctx)) - _, err := mcpClient.Initialize(ctx, mcp.InitializeRequest{ - Params: mcp.InitializeParams{ - ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION, - ClientInfo: mcp.Implementation{ - Name: "test-client-workspace", - Version: "1.0.0", - }, - }, - }) - require.NoError(t, err) - - toolResult, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: toolsdk.ToolNameWorkspaceLS, - Arguments: map[string]any{ - "workspace": r.Workspace.Name, - "path": tmpdir, - }, + toolResult, err := mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: toolsdk.ToolNameWorkspaceLS, + Arguments: map[string]any{ + "workspace": r.Workspace.Name, + "path": tmpdir, }, }) require.NoError(t, err) require.NotEmpty(t, toolResult.Content) - textContent, ok := toolResult.Content[0].(mcp.TextContent) + textContent, ok := toolResult.Content[0].(*mcp.TextContent) require.True(t, ok, "expected TextContent type, got %T", toolResult.Content[0]) var response toolsdk.WorkspaceLSResponse @@ -306,45 +237,24 @@ func TestMCPHTTP_E2E_ErrorHandling(t *testing.T) { // Create MCP client mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint - mcpClient := newIsolatedMCPClient(t, mcpURL, - transport.WithHTTPHeaders(map[string]string{ - "Authorization": "Bearer " + coderClient.SessionToken(), - })) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + mcpClient, err := newIsolatedMCPClient(ctx, mcpURL, "test-client-errors", 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) + _, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: "nonexistent_tool", + Arguments: map[string]any{}, + }) 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") @@ -364,36 +274,19 @@ func TestMCPHTTP_E2E_ConcurrentRequests(t *testing.T) { // Create MCP client mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint - mcpClient := newIsolatedMCPClient(t, mcpURL, - transport.WithHTTPHeaders(map[string]string{ - "Authorization": "Bearer " + coderClient.SessionToken(), - })) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + mcpClient, err := newIsolatedMCPClient(ctx, mcpURL, "test-client-concurrent", 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) @@ -403,7 +296,7 @@ func TestMCPHTTP_E2E_ConcurrentRequests(t *testing.T) { reqCtx, reqCancel := context.WithTimeout(egCtx, testutil.WaitLong) defer reqCancel() - tools, err := mcpClient.ListTools(reqCtx, mcp.ListToolsRequest{}) + tools, err := mcpClient.ListTools(reqCtx, nil) if err != nil { return err } @@ -518,39 +411,23 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) { sessionToken := coderClient.SessionToken() mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint - mcpClient := newIsolatedMCPClient(t, mcpURL, - transport.WithHTTPHeaders(map[string]string{ - "Authorization": "Bearer " + sessionToken, - })) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + mcpClient, err := newIsolatedMCPClient(ctx, mcpURL, "test-oauth2-client", 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) + require.Equal(t, mcpserver.MCPServerName, mcpClient.InitializeResult().ServerInfo.Name) // Test tool listing with OAuth2 Bearer token - tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{}) + tools, err := mcpClient.ListTools(ctx, nil) require.NoError(t, err) require.NotEmpty(t, tools.Tools) @@ -666,36 +543,20 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) { // Step 3: Use access token to authenticate with MCP endpoint mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint - mcpClient := newIsolatedMCPClient(t, mcpURL, - transport.WithHTTPHeaders(map[string]string{ - "Authorization": "Bearer " + accessToken, - })) + mcpClient, err := newIsolatedMCPClient(ctx, mcpURL, "test-oauth2-flow-client", 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) + require.Equal(t, mcpserver.MCPServerName, mcpClient.InitializeResult().ServerInfo.Name) // Test tool execution with OAuth2 access token - tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{}) + tools, err := mcpClient.ListTools(ctx, nil) require.NoError(t, err) require.NotEmpty(t, tools.Tools) @@ -703,17 +564,15 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) { var userTool *mcp.Tool for _, tool := range tools.Tools { if tool.Name == toolsdk.ToolNameGetAuthenticatedUser { - userTool = &tool + 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{}, - }, + toolReq := &mcp.CallToolParams{ + Name: userTool.Name, + Arguments: map[string]any{}, } toolResult, err := mcpClient.CallTool(ctx, toolReq) @@ -758,36 +617,20 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) { t.Logf("Successfully refreshed token: %s...", newAccessToken[:10]) // Step 5: Use new access token to create another MCP connection - newMcpClient := newIsolatedMCPClient(t, mcpURL, - transport.WithHTTPHeaders(map[string]string{ - "Authorization": "Bearer " + newAccessToken, - })) + newMcpClient, err := newIsolatedMCPClient(ctx, mcpURL, "test-refreshed-token-client", 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) + t.Logf("Failed to close 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) + require.Equal(t, mcpserver.MCPServerName, newMcpClient.InitializeResult().ServerInfo.Name) // Verify we can still execute tools with the refreshed token - newTools, err := newMcpClient.ListTools(ctx, mcp.ListToolsRequest{}) + newTools, err := newMcpClient.ListTools(ctx, nil) require.NoError(t, err) require.NotEmpty(t, newTools.Tools) @@ -985,36 +828,20 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) { t.Logf("Successfully obtained access token: %s...", accessToken[:10]) // Step 5: Use access token to get user information via MCP - mcpClient := newIsolatedMCPClient(t, mcpURL, - transport.WithHTTPHeaders(map[string]string{ - "Authorization": "Bearer " + accessToken, - })) + mcpClient, err := newIsolatedMCPClient(ctx, mcpURL, "test-dynamic-client", 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) + require.Equal(t, mcpserver.MCPServerName, mcpClient.InitializeResult().ServerInfo.Name) // Get user information - tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{}) + tools, err := mcpClient.ListTools(ctx, nil) require.NoError(t, err) require.NotEmpty(t, tools.Tools) @@ -1022,17 +849,15 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) { var userTool *mcp.Tool for _, tool := range tools.Tools { if tool.Name == toolsdk.ToolNameGetAuthenticatedUser { - userTool = &tool + 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{}, - }, + toolReq := &mcp.CallToolParams{ + Name: userTool.Name, + Arguments: map[string]any{}, } toolResult, err := mcpClient.CallTool(ctx, toolReq) @@ -1041,7 +866,7 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) { // Extract user info from first token var firstUserInfo string - if textContent, ok := toolResult.Content[0].(mcp.TextContent); ok { + if textContent, ok := toolResult.Content[0].(*mcp.TextContent); ok { firstUserInfo = textContent.Text } else { t.Errorf("Expected TextContent type, got %T", toolResult.Content[0]) @@ -1082,36 +907,20 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) { t.Logf("Successfully refreshed token: %s...", newAccessToken[:10]) // Step 7: Use refreshed token to get user information again via MCP - newMcpClient := newIsolatedMCPClient(t, mcpURL, - transport.WithHTTPHeaders(map[string]string{ - "Authorization": "Bearer " + newAccessToken, - })) + newMcpClient, err := newIsolatedMCPClient(ctx, mcpURL, "test-dynamic-client-refreshed", 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) + t.Logf("Failed to close 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) + require.Equal(t, mcpserver.MCPServerName, newMcpClient.InitializeResult().ServerInfo.Name) // Get user information with refreshed token - newTools, err := newMcpClient.ListTools(ctx, mcp.ListToolsRequest{}) + newTools, err := newMcpClient.ListTools(ctx, nil) require.NoError(t, err) require.NotEmpty(t, newTools.Tools) @@ -1122,7 +931,7 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) { // Extract user info from refreshed token var secondUserInfo string - if textContent, ok := newToolResult.Content[0].(mcp.TextContent); ok { + if textContent, ok := newToolResult.Content[0].(*mcp.TextContent); ok { secondUserInfo = textContent.Text } else { t.Errorf("Expected TextContent type, got %T", newToolResult.Content[0]) @@ -1260,43 +1069,27 @@ func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) { // Create MCP client pointing to the ChatGPT endpoint mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint + "?toolset=chatgpt" + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + defer cancel() + // Configure client with authentication headers using RFC 6750 Bearer token - mcpClient := newIsolatedMCPClient(t, mcpURL, - transport.WithHTTPHeaders(map[string]string{ - "Authorization": "Bearer " + coderClient.SessionToken(), - })) + mcpClient, err := newIsolatedMCPClient(ctx, mcpURL, "test-chatgpt-client", 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) + result := mcpClient.InitializeResult() require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name) - require.Equal(t, mcp.LATEST_PROTOCOL_VERSION, result.ProtocolVersion) + require.Equal(t, "2026-07-28", 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{}) + tools, err := mcpClient.ListTools(ctx, nil) require.NoError(t, err) require.NotEmpty(t, tools.Tools) @@ -1321,19 +1114,17 @@ func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) { var searchTool *mcp.Tool for _, tool := range tools.Tools { if tool.Name == toolsdk.ToolNameChatGPTSearch { - searchTool = &tool + 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", - }, + searchReq := &mcp.CallToolParams{ + Name: searchTool.Name, + Arguments: map[string]any{ + "query": "templates", }, } @@ -1343,8 +1134,7 @@ func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) { // 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) + if textContent, ok := searchResult.Content[0].(*mcp.TextContent); ok { assert.Contains(t, textContent.Text, template.ID.String(), "Search result should contain our test template") t.Logf("Search result: %s", textContent.Text) } else { @@ -1355,19 +1145,17 @@ func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) { var fetchTool *mcp.Tool for _, tool := range tools.Tools { if tool.Name == toolsdk.ToolNameChatGPTFetch { - fetchTool = &tool + 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()), - }, + fetchReq := &mcp.CallToolParams{ + Name: fetchTool.Name, + Arguments: map[string]any{ + "id": fmt.Sprintf("template:%s", template.ID.String()), }, } @@ -1377,8 +1165,7 @@ func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) { // 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) + if textContent, ok := fetchResult.Content[0].(*mcp.TextContent); ok { 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") @@ -1425,41 +1212,27 @@ func TestMCPHTTP_E2E_WorkspaceSSHAuthz(t *testing.T) { // Connect with the template-admin user. mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint - mcpClient := newIsolatedMCPClient(t, mcpURL, - transport.WithHTTPHeaders(map[string]string{ - "Authorization": "Bearer " + tmplAdminClient.SessionToken(), - })) - defer func() { - _ = mcpClient.Close() - }() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - require.NoError(t, mcpClient.Start(ctx)) - _, err := mcpClient.Initialize(ctx, mcp.InitializeRequest{ - Params: mcp.InitializeParams{ - ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION, - ClientInfo: mcp.Implementation{ - Name: "test-client-authz", - Version: "1.0.0", - }, - }, + mcpClient, err := newIsolatedMCPClient(ctx, mcpURL, "test-client-authz", map[string]string{ + "Authorization": "Bearer " + tmplAdminClient.SessionToken(), }) require.NoError(t, err) + defer func() { + _ = mcpClient.Close() + }() // Calling a workspace tool that requires an agent connection // should fail because the template-admin user lacks ActionSSH. // Use owner/workspace format so the lookup resolves to the // admin's workspace rather than defaulting to "me". workspaceIdent := coderdtest.FirstUserParams.Username + "/" + r.Workspace.Name - toolResult, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: toolsdk.ToolNameWorkspaceReadFile, - Arguments: map[string]any{ - "workspace": workspaceIdent, - "path": "/tmp/secret.txt", - }, + toolResult, err := mcpClient.CallTool(ctx, &mcp.CallToolParams{ + Name: toolsdk.ToolNameWorkspaceReadFile, + Arguments: map[string]any{ + "workspace": workspaceIdent, + "path": "/tmp/secret.txt", }, }) // The MCP library may return the error in the tool result itself @@ -1470,7 +1243,7 @@ func TestMCPHTTP_E2E_WorkspaceSSHAuthz(t *testing.T) { } // If no Go error, the tool result must report failure. require.True(t, toolResult.IsError, "expected tool call to fail for user without SSH access") - textContent, ok := toolResult.Content[0].(mcp.TextContent) + textContent, ok := toolResult.Content[0].(*mcp.TextContent) require.True(t, ok) assert.Contains(t, textContent.Text, "unauthorized") } @@ -1481,18 +1254,35 @@ func mustParseURL(t *testing.T, rawURL string) *url.URL { return u } -// newIsolatedMCPClient creates a streamable HTTP MCP client that uses -// an isolated http.Transport cloned from http.DefaultTransport. -// This prevents httptest.Server.Close() (which calls -// http.DefaultTransport.CloseIdleConnections()) from disrupting the -// client's connections during parallel tests. -func newIsolatedMCPClient(t *testing.T, mcpURL string, opts ...transport.StreamableHTTPCOption) *mcpclient.Client { - t.Helper() +// newIsolatedMCPClient connects through a transport isolated from +// http.DefaultTransport, preventing parallel httptest cleanup from closing +// the client's idle connections. +func newIsolatedMCPClient(ctx context.Context, mcpURL, name string, headers map[string]string) (*mcp.ClientSession, error) { isolated := coderdtest.NewIsolatedHTTPClient(nil) - opts = append([]transport.StreamableHTTPCOption{transport.WithHTTPBasicClient(isolated)}, opts...) - client, err := mcpclient.NewStreamableHttpClient(mcpURL, opts...) - require.NoError(t, err) - return client + if len(headers) > 0 { + isolated.Transport = &headerRoundTripper{ + base: isolated.Transport, + headers: headers, + } + } + client := mcp.NewClient(&mcp.Implementation{Name: name, Version: "1.0.0"}, nil) + return client.Connect(ctx, &mcp.StreamableClientTransport{ + Endpoint: mcpURL, + HTTPClient: isolated, + }, nil) +} + +type headerRoundTripper struct { + base http.RoundTripper + headers map[string]string +} + +func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + for key, value := range h.headers { + clone.Header.Set(key, value) + } + return h.base.RoundTrip(clone) } // sentinelTransport wraps an http.RoundTripper and counts how many @@ -1508,12 +1298,6 @@ func (s *sentinelTransport) RoundTrip(req *http.Request) (*http.Response, error) return s.inner.RoundTrip(req) } -// TestMCPHTTP_E2E_TransportIsolation verifies that the -// newIsolatedMCPClient helper creates clients that do NOT route -// requests through http.DefaultTransport, while raw -// mcpclient.NewStreamableHttpClient (without explicit -// WithHTTPBasicClient) does use it. -// //nolint:paralleltest // Mutates http.DefaultTransport. func TestMCPHTTP_E2E_TransportIsolation(t *testing.T) { // Replace DefaultTransport with a counting sentinel. @@ -1527,29 +1311,25 @@ func TestMCPHTTP_E2E_TransportIsolation(t *testing.T) { _ = coderdtest.CreateFirstUser(t, coderClient) mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint - authOpt := transport.WithHTTPHeaders(map[string]string{ + authHeaders := map[string]string{ "Authorization": "Bearer " + coderClient.SessionToken(), - }) + } ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - initReq := mcp.InitializeRequest{ - Params: mcp.InitializeParams{ - ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION, - ClientInfo: mcp.Implementation{Name: "sentinel-test", Version: "1.0.0"}, - }, - } - t.Run("RawClientUsesDefaultTransport", func(t *testing.T) { sentinel.hits.Store(0) - rawClient, err := mcpclient.NewStreamableHttpClient(mcpURL, authOpt) - require.NoError(t, err) - defer func() { _ = rawClient.Close() }() - - require.NoError(t, rawClient.Start(ctx)) - _, err = rawClient.Initialize(ctx, initReq) + rawClient := mcp.NewClient(&mcp.Implementation{Name: "sentinel-test", Version: "1.0.0"}, nil) + rawSession, err := rawClient.Connect(ctx, &mcp.StreamableClientTransport{ + Endpoint: mcpURL, + HTTPClient: &http.Client{Transport: &headerRoundTripper{ + base: http.DefaultTransport, + headers: authHeaders, + }}, + }, nil) require.NoError(t, err) + defer func() { _ = rawSession.Close() }() require.Greater(t, sentinel.hits.Load(), int64(0), "raw client should route requests through http.DefaultTransport") @@ -1557,12 +1337,9 @@ func TestMCPHTTP_E2E_TransportIsolation(t *testing.T) { t.Run("IsolatedClientBypassesDefaultTransport", func(t *testing.T) { sentinel.hits.Store(0) - isoClient := newIsolatedMCPClient(t, mcpURL, authOpt) - defer func() { _ = isoClient.Close() }() - - require.NoError(t, isoClient.Start(ctx)) - _, err := isoClient.Initialize(ctx, initReq) + isoClient, err := newIsolatedMCPClient(ctx, mcpURL, "sentinel-test", authHeaders) require.NoError(t, err) + defer func() { _ = isoClient.Close() }() require.Equal(t, int64(0), sentinel.hits.Load(), "isolated client must NOT route requests through http.DefaultTransport") diff --git a/coderd/mcp/mcp_test.go b/coderd/mcp/mcp_test.go index 754f1e408c..b7bf52f52b 100644 --- a/coderd/mcp/mcp_test.go +++ b/coderd/mcp/mcp_test.go @@ -8,7 +8,6 @@ import ( "strings" "testing" - "github.com/mark3labs/mcp-go/mcp" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -59,7 +58,7 @@ func TestMCPHTTP_InitializeRequest(t *testing.T) { "id": 1, "method": "initialize", "params": map[string]any{ - "protocolVersion": mcp.LATEST_PROTOCOL_VERSION, + "protocolVersion": "2025-06-18", "capabilities": map[string]any{}, "clientInfo": map[string]any{ "name": "test-client", @@ -97,7 +96,7 @@ func TestMCPHTTP_InitializeRequest(t *testing.T) { result, ok := response["result"].(map[string]any) require.True(t, ok) - assert.Equal(t, mcp.LATEST_PROTOCOL_VERSION, result["protocolVersion"]) + assert.Equal(t, "2025-06-18", result["protocolVersion"]) assert.Contains(t, result, "capabilities") assert.Contains(t, result, "serverInfo") } diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 8e401051b3..53e4a4fd08 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -23,8 +23,7 @@ import ( "charm.land/fantasy" fantasyanthropic "charm.land/fantasy/providers/anthropic" "github.com/google/uuid" - mcpgo "github.com/mark3labs/mcp-go/mcp" - mcpserver "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/prometheus/client_golang/prometheus" io_prometheus_client "github.com/prometheus/client_model/go" "github.com/sqlc-dev/pqtype" @@ -83,6 +82,40 @@ func openAIToolName(tool chattest.OpenAITool) string { return cmp.Or(tool.Function.Name, tool.Name, tool.Type) } +func newTestMCPServer(name string) *mcp.Server { + return mcp.NewServer(&mcp.Implementation{Name: name, Version: "1.0.0"}, nil) +} + +func addTestMCPTextTool(server *mcp.Server, name, description, outputPrefix string) { + server.AddTool(&mcp.Tool{ + Name: name, + Description: description, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "input": map[string]any{ + "type": "string", + "description": "The input string", + }, + }, + "required": []string{"input"}, + }, + }, func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var arguments map[string]any + _ = json.Unmarshal(req.Params.Arguments, &arguments) + input, _ := arguments["input"].(string) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: outputPrefix + input}}, + }, nil + }) +} + +func testMCPHTTPHandler(server *mcp.Server) http.Handler { + return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{Stateless: true}) +} + func mustChatLastErrorRawMessage(t testing.TB, payload codersdk.ChatError) pqtype.NullRawMessage { t.Helper() @@ -366,21 +399,9 @@ func TestPlanModeSubagentChatExcludesAskUserQuestion(t *testing.T) { // Start an external MCP server whose tools should remain available to the // root plan-mode chat but stay hidden from plan-mode subagents. - mcpSrv := mcpserver.NewMCPServer("plan-root-mcp", "1.0.0") - mcpSrv.AddTools(mcpserver.ServerTool{ - Tool: mcpgo.NewTool("echo", - mcpgo.WithDescription("Echoes the input"), - mcpgo.WithString("input", - mcpgo.Description("The input string"), - mcpgo.Required(), - ), - ), - Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcpgo.NewToolResultText("echo: " + input), nil - }, - }) - mcpTS := httptest.NewServer(mcpserver.NewStreamableHTTPServer(mcpSrv)) + mcpSrv := newTestMCPServer("plan-root-mcp") + addTestMCPTextTool(mcpSrv, "echo", "Echoes the input", "echo: ") + mcpTS := httptest.NewServer(testMCPHTTPHandler(mcpSrv)) t.Cleanup(mcpTS.Close) mcpConfig, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ @@ -661,38 +682,14 @@ func TestExploreChatUsesPersistedMCPSnapshot(t *testing.T) { db, ps := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitLong) - externalMCP := mcpserver.NewMCPServer("external-snapshot-mcp", "1.0.0") - externalMCP.AddTools(mcpserver.ServerTool{ - Tool: mcpgo.NewTool("echo", - mcpgo.WithDescription("Echoes the input"), - mcpgo.WithString("input", - mcpgo.Description("The input string"), - mcpgo.Required(), - ), - ), - Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcpgo.NewToolResultText("echo: " + input), nil - }, - }) - externalMCPServer := httptest.NewServer(mcpserver.NewStreamableHTTPServer(externalMCP)) + externalMCP := newTestMCPServer("external-snapshot-mcp") + addTestMCPTextTool(externalMCP, "echo", "Echoes the input", "echo: ") + externalMCPServer := httptest.NewServer(testMCPHTTPHandler(externalMCP)) defer externalMCPServer.Close() - secondMCP := mcpserver.NewMCPServer("second-mcp", "1.0.0") - secondMCP.AddTools(mcpserver.ServerTool{ - Tool: mcpgo.NewTool("echo", - mcpgo.WithDescription("Echoes the input"), - mcpgo.WithString("input", - mcpgo.Description("The input string"), - mcpgo.Required(), - ), - ), - Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcpgo.NewToolResultText("echo: " + input), nil - }, - }) - secondMCPServer := httptest.NewServer(mcpserver.NewStreamableHTTPServer(secondMCP)) + secondMCP := newTestMCPServer("second-mcp") + addTestMCPTextTool(secondMCP, "echo", "Echoes the input", "echo: ") + secondMCPServer := httptest.NewServer(testMCPHTTPHandler(secondMCP)) defer secondMCPServer.Close() var ( @@ -842,21 +839,9 @@ func TestRootExploreChatStaysBuiltinOnlyAtRuntime(t *testing.T) { db, ps := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitLong) - externalMCP := mcpserver.NewMCPServer("root-explore-runtime-mcp", "1.0.0") - externalMCP.AddTools(mcpserver.ServerTool{ - Tool: mcpgo.NewTool("echo", - mcpgo.WithDescription("Echoes the input"), - mcpgo.WithString("input", - mcpgo.Description("The input string"), - mcpgo.Required(), - ), - ), - Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcpgo.NewToolResultText("echo: " + input), nil - }, - }) - externalMCPServer := httptest.NewServer(mcpserver.NewStreamableHTTPServer(externalMCP)) + externalMCP := newTestMCPServer("root-explore-runtime-mcp") + addTestMCPTextTool(externalMCP, "echo", "Echoes the input", "echo: ") + externalMCPServer := httptest.NewServer(testMCPHTTPHandler(externalMCP)) defer externalMCPServer.Close() var ( @@ -1024,21 +1009,9 @@ func TestExploreChatSendMessageCannotMutateMCPSnapshot(t *testing.T) { newEchoMCPServer := func(name string) *httptest.Server { t.Helper() - mcpSrv := mcpserver.NewMCPServer(name, "1.0.0") - mcpSrv.AddTools(mcpserver.ServerTool{ - Tool: mcpgo.NewTool("echo", - mcpgo.WithDescription("Echoes the input"), - mcpgo.WithString("input", - mcpgo.Description("The input string"), - mcpgo.Required(), - ), - ), - Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcpgo.NewToolResultText("echo: " + input), nil - }, - }) - mcpTS := httptest.NewServer(mcpserver.NewStreamableHTTPServer(mcpSrv)) + mcpSrv := newTestMCPServer(name) + addTestMCPTextTool(mcpSrv, "echo", "Echoes the input", "echo: ") + mcpTS := httptest.NewServer(testMCPHTTPHandler(mcpSrv)) t.Cleanup(mcpTS.Close) return mcpTS } @@ -1183,53 +1156,15 @@ func TestPlanModeRootChatAllowsApprovedExternalMCPTools(t *testing.T) { db, ps := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitLong) - echoMCP := mcpserver.NewMCPServer("plan-visibility-echo", "1.0.0") - echoMCP.AddTools(mcpserver.ServerTool{ - Tool: mcpgo.NewTool("echo", - mcpgo.WithDescription("Echoes the input"), - mcpgo.WithString("input", - mcpgo.Description("The input string"), - mcpgo.Required(), - ), - ), - Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcpgo.NewToolResultText("echo: " + input), nil - }, - }) - echoTS := httptest.NewServer(mcpserver.NewStreamableHTTPServer(echoMCP)) + echoMCP := newTestMCPServer("plan-visibility-echo") + addTestMCPTextTool(echoMCP, "echo", "Echoes the input", "echo: ") + echoTS := httptest.NewServer(testMCPHTTPHandler(echoMCP)) t.Cleanup(echoTS.Close) - filteredMCP := mcpserver.NewMCPServer("plan-visibility-filtered", "1.0.0") - filteredMCP.AddTools( - mcpserver.ServerTool{ - Tool: mcpgo.NewTool("visible", - mcpgo.WithDescription("Visible tool"), - mcpgo.WithString("input", - mcpgo.Description("The input string"), - mcpgo.Required(), - ), - ), - Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcpgo.NewToolResultText("visible: " + input), nil - }, - }, - mcpserver.ServerTool{ - Tool: mcpgo.NewTool("hidden", - mcpgo.WithDescription("Hidden tool"), - mcpgo.WithString("input", - mcpgo.Description("The input string"), - mcpgo.Required(), - ), - ), - Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcpgo.NewToolResultText("hidden: " + input), nil - }, - }, - ) - filteredTS := httptest.NewServer(mcpserver.NewStreamableHTTPServer(filteredMCP)) + filteredMCP := newTestMCPServer("plan-visibility-filtered") + addTestMCPTextTool(filteredMCP, "visible", "Visible tool", "visible: ") + addTestMCPTextTool(filteredMCP, "hidden", "Hidden tool", "hidden: ") + filteredTS := httptest.NewServer(testMCPHTTPHandler(filteredMCP)) t.Cleanup(filteredTS.Close) var ( @@ -2459,12 +2394,12 @@ func TestRecoverStaleRequiresActionChat(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai", openAIURL) toolName := "my_dynamic_tool" - dynamicToolsJSON, err := json.Marshal([]mcpgo.Tool{{ + dynamicToolsJSON, err := json.Marshal([]mcp.Tool{{ Name: toolName, Description: "A test dynamic tool.", - InputSchema: mcpgo.ToolInputSchema{ - Type: "object", - Properties: map[string]any{}, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{}, }, }}) require.NoError(t, err) @@ -2969,15 +2904,15 @@ func TestRequiresActionChatPersistsWaitingStatusLabel(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) - dynamicToolsJSON, err := json.Marshal([]mcpgo.Tool{{ + dynamicToolsJSON, err := json.Marshal([]mcp.Tool{{ Name: "my_dynamic_tool", Description: "A test dynamic tool.", - InputSchema: mcpgo.ToolInputSchema{ - Type: "object", - Properties: map[string]any{ + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ "input": map[string]any{"type": "string"}, }, - Required: []string{"input"}, + "required": []string{"input"}, }, }}) require.NoError(t, err) @@ -3765,15 +3700,15 @@ func TestDynamicToolCallPausesAndResumes(t *testing.T) { }) // Create a chat with a dynamic tool. - dynamicToolsJSON, err := json.Marshal([]mcpgo.Tool{{ + dynamicToolsJSON, err := json.Marshal([]mcp.Tool{{ Name: "my_dynamic_tool", Description: "A test dynamic tool.", - InputSchema: mcpgo.ToolInputSchema{ - Type: "object", - Properties: map[string]any{ + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ "input": map[string]any{"type": "string"}, }, - Required: []string{"input"}, + "required": []string{"input"}, }, }}) require.NoError(t, err) @@ -3935,15 +3870,15 @@ func TestDynamicToolNamedProposePlanRemainsAvailableOutsidePlanMode(t *testing.T cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(factory) }) - dynamicToolsJSON, err := json.Marshal([]mcpgo.Tool{{ + dynamicToolsJSON, err := json.Marshal([]mcp.Tool{{ Name: "propose_plan", Description: "A dynamic tool whose name collides with the hidden built-in.", - InputSchema: mcpgo.ToolInputSchema{ - Type: "object", - Properties: map[string]any{ + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ "input": map[string]any{"type": "string"}, }, - Required: []string{"input"}, + "required": []string{"input"}, }, }}) require.NoError(t, err) @@ -4052,15 +3987,15 @@ func TestDynamicToolCallMixedWithBuiltIn(t *testing.T) { }) // Create a chat with a dynamic tool. - dynamicToolsJSON, err := json.Marshal([]mcpgo.Tool{{ + dynamicToolsJSON, err := json.Marshal([]mcp.Tool{{ Name: "my_dynamic_tool", Description: "A test dynamic tool.", - InputSchema: mcpgo.ToolInputSchema{ - Type: "object", - Properties: map[string]any{ + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ "input": map[string]any{"type": "string"}, }, - Required: []string{"input"}, + "required": []string{"input"}, }, }}) require.NoError(t, err) @@ -4194,15 +4129,15 @@ func TestSubmitToolResultsConcurrency(t *testing.T) { }) // Create a chat with a dynamic tool. - dynamicToolsJSON, err := json.Marshal([]mcpgo.Tool{{ + dynamicToolsJSON, err := json.Marshal([]mcp.Tool{{ Name: "my_dynamic_tool", Description: "A test dynamic tool.", - InputSchema: mcpgo.ToolInputSchema{ - Type: "object", - Properties: map[string]any{ + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ "input": map[string]any{"type": "string"}, }, - Required: []string{"input"}, + "required": []string{"input"}, }, }}) require.NoError(t, err) @@ -6734,12 +6669,12 @@ func setupToolExecutionAgentConn( func dynamicToolJSON(t *testing.T, name string) []byte { t.Helper() - encoded, err := json.Marshal([]mcpgo.Tool{{ + encoded, err := json.Marshal([]mcp.Tool{{ Name: name, Description: "A test dynamic tool.", - InputSchema: mcpgo.ToolInputSchema{ - Type: "object", - Properties: map[string]any{ + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ "query": map[string]any{"type": "string"}, }, }, @@ -7691,10 +7626,10 @@ func TestActiveServer_ExclusiveToolPolicy(t *testing.T) { }) user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) seedAdvisorConfig(ctx, t, db, codersdk.AdvisorConfig{Enabled: true, MaxUsesPerRun: 3, MaxOutputTokens: 1024}) - dynamicToolsJSON, err := json.Marshal([]mcpgo.Tool{{ + dynamicToolsJSON, err := json.Marshal([]mcp.Tool{{ Name: "mcp_tool", Description: "dynamic test tool", - InputSchema: mcpgo.ToolInputSchema{Type: "object", Properties: map[string]any{"q": map[string]any{"type": "string"}}}, + InputSchema: map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}}}, }}) require.NoError(t, err) @@ -10490,21 +10425,9 @@ func TestMCPServerToolInvocation(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) // Start a real MCP server that exposes an "echo" tool. - mcpSrv := mcpserver.NewMCPServer("test-mcp", "1.0.0") - mcpSrv.AddTools(mcpserver.ServerTool{ - Tool: mcpgo.NewTool("echo", - mcpgo.WithDescription("Echoes the input"), - mcpgo.WithString("input", - mcpgo.Description("The input string"), - mcpgo.Required(), - ), - ), - Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcpgo.NewToolResultText("echo: " + input), nil - }, - }) - mcpHTTP := mcpserver.NewStreamableHTTPServer(mcpSrv) + mcpSrv := newTestMCPServer("test-mcp") + addTestMCPTextTool(mcpSrv, "echo", "Echoes the input", "echo: ") + mcpHTTP := testMCPHTTPHandler(mcpSrv) mcpTS := httptest.NewServer(mcpHTTP) t.Cleanup(mcpTS.Close) @@ -10672,21 +10595,9 @@ func TestPlanModeRootChatApprovedExternalMCPToolInvocation(t *testing.T) { db, ps := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitLong) - mcpSrv := mcpserver.NewMCPServer("plan-mode-mcp", "1.0.0") - mcpSrv.AddTools(mcpserver.ServerTool{ - Tool: mcpgo.NewTool("echo", - mcpgo.WithDescription("Echoes the input"), - mcpgo.WithString("input", - mcpgo.Description("The input string"), - mcpgo.Required(), - ), - ), - Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcpgo.NewToolResultText("echo: " + input), nil - }, - }) - mcpTS := httptest.NewServer(mcpserver.NewStreamableHTTPServer(mcpSrv)) + mcpSrv := newTestMCPServer("plan-mode-mcp") + addTestMCPTextTool(mcpSrv, "echo", "Echoes the input", "echo: ") + mcpTS := httptest.NewServer(testMCPHTTPHandler(mcpSrv)) t.Cleanup(mcpTS.Close) var ( @@ -10777,21 +10688,9 @@ func TestPlanModeRootChatApprovedExternalMCPWorkflowCanReachProposePlan(t *testi db, ps := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitLong) - mcpSrv := mcpserver.NewMCPServer("plan-workflow-mcp", "1.0.0") - mcpSrv.AddTools(mcpserver.ServerTool{ - Tool: mcpgo.NewTool("echo", - mcpgo.WithDescription("Echoes the input"), - mcpgo.WithString("input", - mcpgo.Description("The input string"), - mcpgo.Required(), - ), - ), - Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcpgo.NewToolResultText("echo: " + input), nil - }, - }) - mcpTS := httptest.NewServer(mcpserver.NewStreamableHTTPServer(mcpSrv)) + mcpSrv := newTestMCPServer("plan-workflow-mcp") + addTestMCPTextTool(mcpSrv, "echo", "Echoes the input", "echo: ") + mcpTS := httptest.NewServer(testMCPHTTPHandler(mcpSrv)) t.Cleanup(mcpTS.Close) var ( @@ -10977,21 +10876,9 @@ func TestMCPServerOAuth2TokenRefresh(t *testing.T) { // Start a real MCP server with an auth middleware that only // accepts the fresh access token. An expired token (or any // other value) gets a 401. - mcpSrv := mcpserver.NewMCPServer("authed-mcp", "1.0.0") - mcpSrv.AddTools(mcpserver.ServerTool{ - Tool: mcpgo.NewTool("echo", - mcpgo.WithDescription("Echoes the input"), - mcpgo.WithString("input", - mcpgo.Description("The input string"), - mcpgo.Required(), - ), - ), - Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcpgo.NewToolResultText("echo: " + input), nil - }, - }) - mcpHTTP := mcpserver.NewStreamableHTTPServer(mcpSrv) + mcpSrv := newTestMCPServer("authed-mcp") + addTestMCPTextTool(mcpSrv, "echo", "Echoes the input", "echo: ") + mcpHTTP := testMCPHTTPHandler(mcpSrv) // Wrap with auth check. authMux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") diff --git a/coderd/x/chatd/forced_mcp_test.go b/coderd/x/chatd/forced_mcp_test.go index c72e29f0a9..2447b20320 100644 --- a/coderd/x/chatd/forced_mcp_test.go +++ b/coderd/x/chatd/forced_mcp_test.go @@ -7,14 +7,11 @@ package chatd_test // the conversation. import ( - "context" "net/http/httptest" "sync" "testing" "github.com/google/uuid" - mcpgo "github.com/mark3labs/mcp-go/mcp" - mcpserver "github.com/mark3labs/mcp-go/server" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/database" @@ -30,21 +27,9 @@ import ( // tool and returns its base URL. func newEchoMCPTestServer(t *testing.T, name string) string { t.Helper() - srv := mcpserver.NewMCPServer(name, "1.0.0") - srv.AddTools(mcpserver.ServerTool{ - Tool: mcpgo.NewTool("echo", - mcpgo.WithDescription("Echoes the input"), - mcpgo.WithString("input", - mcpgo.Description("The input string"), - mcpgo.Required(), - ), - ), - Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcpgo.NewToolResultText("echo: " + input), nil - }, - }) - ts := httptest.NewServer(mcpserver.NewStreamableHTTPServer(srv)) + srv := newTestMCPServer(name) + addTestMCPTextTool(srv, "echo", "Echoes the input", "echo: ") + ts := httptest.NewServer(testMCPHTTPHandler(srv)) t.Cleanup(ts.Close) return ts.URL } diff --git a/coderd/x/chatd/mcpclient/coder_headers_test.go b/coderd/x/chatd/mcpclient/coder_headers_test.go index f90a031d5a..a034c38bdb 100644 --- a/coderd/x/chatd/mcpclient/coder_headers_test.go +++ b/coderd/x/chatd/mcpclient/coder_headers_test.go @@ -9,8 +9,7 @@ import ( "charm.land/fantasy" "github.com/google/uuid" - "github.com/mark3labs/mcp-go/mcp" - mcpserver "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -29,19 +28,19 @@ func newHeaderRecordingServer(t *testing.T) (*httptest.Server, *sync.Mutex, *[]h mu sync.Mutex headers []http.Header ) - srv := mcpserver.NewMCPServer("hdr-server", "1.0.0") - srv.AddTools(mcpserver.ServerTool{ - Tool: mcp.NewTool("ping", mcp.WithDescription("records the request headers")), - Handler: func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ts := newTestMCPServer(t, testTool{ + tool: &mcp.Tool{ + Name: "ping", + Description: "records the request headers", + InputSchema: map[string]any{"type": "object"}, + }, + handler: func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { mu.Lock() - headers = append(headers, req.Header.Clone()) + headers = append(headers, req.Extra.Header.Clone()) mu.Unlock() - return mcp.NewToolResultText("ok"), nil + return textToolResult("ok"), nil }, }) - httpSrv := mcpserver.NewStreamableHTTPServer(srv) - ts := httptest.NewServer(httpSrv) - t.Cleanup(ts.Close) return ts, &mu, &headers } diff --git a/coderd/x/chatd/mcpclient/mcpclient_test.go b/coderd/x/chatd/mcpclient/mcpclient_test.go index a5e6fedfbf..c844b68b19 100644 --- a/coderd/x/chatd/mcpclient/mcpclient_test.go +++ b/coderd/x/chatd/mcpclient/mcpclient_test.go @@ -3,8 +3,8 @@ package mcpclient_test import ( "context" "database/sql" - "encoding/base64" "encoding/json" + "net/http" "net/http/httptest" "strings" "sync" @@ -14,9 +14,7 @@ import ( "charm.land/fantasy" "github.com/google/uuid" - "github.com/mark3labs/mcp-go/mcp" - mcpserver "github.com/mark3labs/mcp-go/server" - sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -25,54 +23,92 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/mcpclient" ) -// newTestMCPServer creates a streamable HTTP MCP server with the -// given tools. The caller must close the returned *httptest.Server. -func newTestMCPServer(t *testing.T, tools ...mcpserver.ServerTool) *httptest.Server { +type testTool struct { + tool *mcp.Tool + handler mcp.ToolHandler +} + +func newTestMCPServer(t *testing.T, tools ...testTool) *httptest.Server { t.Helper() - srv := mcpserver.NewMCPServer("test-server", "1.0.0") - srv.AddTools(tools...) - httpSrv := mcpserver.NewStreamableHTTPServer(srv) + srv := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "1.0.0"}, nil) + for _, tool := range tools { + srv.AddTool(tool.tool, tool.handler) + } + httpSrv := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return srv }, + &mcp.StreamableHTTPOptions{Stateless: true}, + ) ts := httptest.NewServer(httpSrv) t.Cleanup(ts.Close) return ts } -// echoTool returns a ServerTool that echoes its "input" argument -// prefixed with "echo: ". -func echoTool() mcpserver.ServerTool { - return mcpserver.ServerTool{ - Tool: mcp.NewTool("echo", - mcp.WithDescription("Echoes the input"), - mcp.WithString("input", mcp.Description("The input"), mcp.Required()), - ), - Handler: func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - input, _ := req.GetArguments()["input"].(string) - return mcp.NewToolResultText("echo: " + input), nil +func textToolResult(text string) *mcp.CallToolResult { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}} +} + +func echoTool() testTool { + return testTool{ + tool: &mcp.Tool{ + Name: "echo", + Description: "Echoes the input", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "input": map[string]any{ + "type": "string", + "description": "The input", + }, + }, + "required": []string{"input"}, + }, + }, + handler: func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args map[string]any + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return nil, err + } + input, _ := args["input"].(string) + return textToolResult("echo: " + input), nil }, } } -// greetTool returns a ServerTool that greets by name. -func greetTool() mcpserver.ServerTool { - return mcpserver.ServerTool{ - Tool: mcp.NewTool("greet", - mcp.WithDescription("Greets the user"), - mcp.WithString("name", mcp.Description("Name to greet"), mcp.Required()), - ), - Handler: func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - name, _ := req.GetArguments()["name"].(string) - return mcp.NewToolResultText("hello " + name), nil +func greetTool() testTool { + return testTool{ + tool: &mcp.Tool{ + Name: "greet", + Description: "Greets the user", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "description": "Name to greet", + }, + }, + "required": []string{"name"}, + }, + }, + handler: func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args map[string]any + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return nil, err + } + name, _ := args["name"].(string) + return textToolResult("hello " + name), nil }, } } -// makeTool returns a ServerTool with the given name and a -// no-op handler that always returns "ok". -func makeTool(name string) mcpserver.ServerTool { - return mcpserver.ServerTool{ - Tool: mcp.NewTool(name), - Handler: func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return mcp.NewToolResultText("ok"), nil +func makeTool(name string) testTool { + return testTool{ + tool: &mcp.Tool{ + Name: name, + InputSchema: map[string]any{"type": "object"}, + }, + handler: func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return textToolResult("ok"), nil }, } } @@ -412,24 +448,21 @@ func TestConnectAll_AuthHeaders(t *testing.T) { seenHeaders []string ) - srv := mcpserver.NewMCPServer("auth-server", "1.0.0") - srv.AddTools(mcpserver.ServerTool{ - Tool: mcp.NewTool("whoami", - mcp.WithDescription("Returns the auth header"), - ), - Handler: func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - auth := req.Header.Get("Authorization") + ts := newTestMCPServer(t, testTool{ + tool: &mcp.Tool{ + Name: "whoami", + Description: "Returns the auth header", + InputSchema: map[string]any{"type": "object"}, + }, + handler: func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + auth := req.Extra.Header.Get("Authorization") mu.Lock() seenHeaders = append(seenHeaders, auth) mu.Unlock() - return mcp.NewToolResultText("auth:" + auth), nil + return textToolResult("auth:" + auth), nil }, }) - httpSrv := mcpserver.NewStreamableHTTPServer(srv) - ts := httptest.NewServer(httpSrv) - t.Cleanup(ts.Close) - configID := uuid.New() cfg := database.MCPServerConfig{ ID: configID, @@ -576,13 +609,22 @@ func TestConnectAll_NilRequiredBecomesEmptySlice(t *testing.T) { logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) // noRequiredTool defines a tool with no required parameters. - noRequiredTool := mcpserver.ServerTool{ - Tool: mcp.NewTool("optional_only", - mcp.WithDescription("A tool with no required fields"), - mcp.WithString("note", mcp.Description("An optional note")), - ), - Handler: func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return mcp.NewToolResultText("ok"), nil + noRequiredTool := testTool{ + tool: &mcp.Tool{ + Name: "optional_only", + Description: "A tool with no required fields", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "note": map[string]any{ + "type": "string", + "description": "An optional note", + }, + }, + }, + }, + handler: func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return textToolResult("ok"), nil }, } @@ -615,24 +657,21 @@ func TestConnectAll_APIKeyAuth(t *testing.T) { seenHeaders []string ) - srv := mcpserver.NewMCPServer("apikey-server", "1.0.0") - srv.AddTools(mcpserver.ServerTool{ - Tool: mcp.NewTool("check", - mcp.WithDescription("Returns the API key header"), - ), - Handler: func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - val := req.Header.Get("X-API-Key") + ts := newTestMCPServer(t, testTool{ + tool: &mcp.Tool{ + Name: "check", + Description: "Returns the API key header", + InputSchema: map[string]any{"type": "object"}, + }, + handler: func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + val := req.Extra.Header.Get("X-API-Key") mu.Lock() seenHeaders = append(seenHeaders, val) mu.Unlock() - return mcp.NewToolResultText("key:" + val), nil + return textToolResult("key:" + val), nil }, }) - httpSrv := mcpserver.NewStreamableHTTPServer(srv) - ts := httptest.NewServer(httpSrv) - t.Cleanup(ts.Close) - cfg := makeConfig("apikey", ts.URL) cfg.AuthType = "api_key" cfg.APIKeyHeader = "X-API-Key" @@ -674,24 +713,21 @@ func TestConnectAll_CustomHeadersAuth(t *testing.T) { seenHeaders []string ) - srv := mcpserver.NewMCPServer("custom-server", "1.0.0") - srv.AddTools(mcpserver.ServerTool{ - Tool: mcp.NewTool("check", - mcp.WithDescription("Returns the custom auth header"), - ), - Handler: func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - val := req.Header.Get("X-Custom-Auth") + ts := newTestMCPServer(t, testTool{ + tool: &mcp.Tool{ + Name: "check", + Description: "Returns the custom auth header", + InputSchema: map[string]any{"type": "object"}, + }, + handler: func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + val := req.Extra.Header.Get("X-Custom-Auth") mu.Lock() seenHeaders = append(seenHeaders, val) mu.Unlock() - return mcp.NewToolResultText("custom:" + val), nil + return textToolResult("custom:" + val), nil }, }) - httpSrv := mcpserver.NewStreamableHTTPServer(srv) - ts := httptest.NewServer(httpSrv) - t.Cleanup(ts.Close) - cfg := makeConfig("custom", ts.URL) cfg.AuthType = "custom_headers" cfg.CustomHeaders = `{"X-Custom-Auth":"custom-val"}` @@ -771,24 +807,21 @@ func TestConnectAll_UserOIDCAuth(t *testing.T) { seenHeaders []string ) - srv := mcpserver.NewMCPServer("oidc-server", "1.0.0") - srv.AddTools(mcpserver.ServerTool{ - Tool: mcp.NewTool("whoami", - mcp.WithDescription("Returns the auth header"), - ), - Handler: func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - auth := req.Header.Get("Authorization") + ts := newTestMCPServer(t, testTool{ + tool: &mcp.Tool{ + Name: "whoami", + Description: "Returns the auth header", + InputSchema: map[string]any{"type": "object"}, + }, + handler: func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + auth := req.Extra.Header.Get("Authorization") mu.Lock() seenHeaders = append(seenHeaders, auth) mu.Unlock() - return mcp.NewToolResultText("auth:" + auth), nil + return textToolResult("auth:" + auth), nil }, }) - httpSrv := mcpserver.NewStreamableHTTPServer(srv) - ts := httptest.NewServer(httpSrv) - t.Cleanup(ts.Close) - cfg := makeConfig("oidc-srv", ts.URL) cfg.AuthType = "user_oidc" userID := uuid.New() @@ -831,24 +864,21 @@ func TestConnectAll_UserOIDCAuth_NoLink(t *testing.T) { seenHeaders []string ) - srv := mcpserver.NewMCPServer("oidc-server-nolink", "1.0.0") - srv.AddTools(mcpserver.ServerTool{ - Tool: mcp.NewTool("whoami", - mcp.WithDescription("Returns the auth header"), - ), - Handler: func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - auth := req.Header.Get("Authorization") + ts := newTestMCPServer(t, testTool{ + tool: &mcp.Tool{ + Name: "whoami", + Description: "Returns the auth header", + InputSchema: map[string]any{"type": "object"}, + }, + handler: func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + auth := req.Extra.Header.Get("Authorization") mu.Lock() seenHeaders = append(seenHeaders, auth) mu.Unlock() - return mcp.NewToolResultText("auth:" + auth), nil + return textToolResult("auth:" + auth), nil }, }) - httpSrv := mcpserver.NewStreamableHTTPServer(srv) - ts := httptest.NewServer(httpSrv) - t.Cleanup(ts.Close) - cfg := makeConfig("oidc-nolink", ts.URL) cfg.AuthType = "user_oidc" src := staticOIDCSource{token: "", err: nil} @@ -1118,21 +1148,18 @@ func TestConnectAll_EmbeddedResourceText(t *testing.T) { ctx := context.Background() logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - srv := mcpserver.NewMCPServer("embedded-text-server", "1.0.0") - srv.AddTools(mcpserver.ServerTool{ - Tool: mcp.NewTool("fetch_doc", - mcp.WithDescription("Returns an embedded text resource"), - ), - Handler: func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ts := newTestMCPServer(t, testTool{ + tool: &mcp.Tool{ + Name: "fetch_doc", + Description: "Returns an embedded text resource", + InputSchema: map[string]any{"type": "object"}, + }, + handler: func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: "successfully downloaded text file", - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.TextResourceContents{ + &mcp.TextContent{Text: "successfully downloaded text file"}, + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ URI: "file:///example.txt", MIMEType: "text/plain", Text: "Hello from embedded resource", @@ -1143,10 +1170,6 @@ func TestConnectAll_EmbeddedResourceText(t *testing.T) { }, }) - httpSrv := mcpserver.NewStreamableHTTPServer(srv) - ts := httptest.NewServer(httpSrv) - t.Cleanup(ts.Close) - cfg := makeConfig("embed-txt", ts.URL) tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) @@ -1186,20 +1209,20 @@ func TestConnectAll_EmbeddedResourceBlob(t *testing.T) { ctx := context.Background() logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - blobData := base64.StdEncoding.EncodeToString([]byte("binary-content")) + blobData := []byte("binary-content") mime := tt.mimeType - srv := mcpserver.NewMCPServer("embedded-blob-server", "1.0.0") - srv.AddTools(mcpserver.ServerTool{ - Tool: mcp.NewTool("fetch_blob", - mcp.WithDescription("Returns an embedded blob resource"), - ), - Handler: func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ts := newTestMCPServer(t, testTool{ + tool: &mcp.Tool{ + Name: "fetch_blob", + Description: "Returns an embedded blob resource", + InputSchema: map[string]any{"type": "object"}, + }, + handler: func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ Content: []mcp.Content{ - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.BlobResourceContents{ + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ URI: "file:///blob", MIMEType: mime, Blob: blobData, @@ -1210,10 +1233,6 @@ func TestConnectAll_EmbeddedResourceBlob(t *testing.T) { }, }) - httpSrv := mcpserver.NewStreamableHTTPServer(srv) - ts := httptest.NewServer(httpSrv) - t.Cleanup(ts.Close) - cfg := makeConfig("embed-blob", ts.URL) tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) @@ -1245,14 +1264,13 @@ func TestConnectAll_ResourceLink(t *testing.T) { tests := []struct { name string - link mcp.ResourceLink + link *mcp.ResourceLink contains []string notContains []string }{ { name: "with_name", - link: mcp.ResourceLink{ - Type: "resource_link", + link: &mcp.ResourceLink{ Name: "Example Resource", URI: "https://example.com/resource", }, @@ -1261,8 +1279,7 @@ func TestConnectAll_ResourceLink(t *testing.T) { }, { name: "with_description", - link: mcp.ResourceLink{ - Type: "resource_link", + link: &mcp.ResourceLink{ Name: "Deploy Log", URI: "file:///var/log/deploy.log", Description: "Latest deployment log", @@ -1278,22 +1295,19 @@ func TestConnectAll_ResourceLink(t *testing.T) { logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) link := tt.link - srv := mcpserver.NewMCPServer("resource-link-server", "1.0.0") - srv.AddTools(mcpserver.ServerTool{ - Tool: mcp.NewTool("get_link", - mcp.WithDescription("Returns a resource link"), - ), - Handler: func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ts := newTestMCPServer(t, testTool{ + tool: &mcp.Tool{ + Name: "get_link", + Description: "Returns a resource link", + InputSchema: map[string]any{"type": "object"}, + }, + handler: func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ Content: []mcp.Content{link}, }, nil }, }) - httpSrv := mcpserver.NewStreamableHTTPServer(srv) - ts := httptest.NewServer(httpSrv) - t.Cleanup(ts.Close) - cfg := makeConfig("res-link", ts.URL) tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) t.Cleanup(cleanup) @@ -1322,21 +1336,19 @@ func TestConnectAll_CallToolError(t *testing.T) { logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) // Server with a tool that always returns an error result. - srv := mcpserver.NewMCPServer("error-server", "1.0.0") - srv.AddTools(mcpserver.ServerTool{ - Tool: mcp.NewTool("fail_tool", - mcp.WithDescription("Always fails"), - ), - Handler: func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ts := newTestMCPServer(t, testTool{ + tool: &mcp.Tool{ + Name: "fail_tool", + Description: "Always fails", + InputSchema: map[string]any{"type": "object"}, + }, + handler: func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ - Content: []mcp.Content{mcp.NewTextContent("something broke")}, + Content: []mcp.Content{&mcp.TextContent{Text: "something broke"}}, IsError: true, }, nil }, }) - httpSrv := mcpserver.NewStreamableHTTPServer(srv) - ts := httptest.NewServer(httpSrv) - t.Cleanup(ts.Close) cfg := makeConfig("err-srv", ts.URL) tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil, uuid.Nil, nil, nil) @@ -1517,14 +1529,14 @@ func TestConvertCallResult_UTF8Sanitization(t *testing.T) { tests := []struct { name string - result *sdkmcp.CallToolResult + result *mcp.CallToolResult wantContains []string }{ { name: "InvalidUTF8InTextContent", - result: &sdkmcp.CallToolResult{ - Content: []sdkmcp.Content{ - &sdkmcp.TextContent{ + result: &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ Text: "Hello" + string([]byte{0xFF, 0xFE, 0x80}) + "World", }, }, @@ -1533,10 +1545,10 @@ func TestConvertCallResult_UTF8Sanitization(t *testing.T) { }, { name: "InvalidUTF8InEmbeddedResourceText", - result: &sdkmcp.CallToolResult{ - Content: []sdkmcp.Content{ - &sdkmcp.EmbeddedResource{ - Resource: &sdkmcp.ResourceContents{ + result: &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ Text: "Content" + string([]byte{0x80, 0x81, 0x82}), }, }, @@ -1546,9 +1558,9 @@ func TestConvertCallResult_UTF8Sanitization(t *testing.T) { }, { name: "ValidUTF8PassesThrough", - result: &sdkmcp.CallToolResult{ - Content: []sdkmcp.Content{ - &sdkmcp.TextContent{ + result: &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ Text: "Hello, δΈ–η•Œ! 🌍", }, }, @@ -1557,12 +1569,12 @@ func TestConvertCallResult_UTF8Sanitization(t *testing.T) { }, { name: "MultipleTextPartsAllSanitized", - result: &sdkmcp.CallToolResult{ - Content: []sdkmcp.Content{ - &sdkmcp.TextContent{ + result: &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ Text: "Part1" + string([]byte{0xFF}), }, - &sdkmcp.TextContent{ + &mcp.TextContent{ Text: "Part2" + string([]byte{0xFE}), }, }, diff --git a/docs/ai-coder/ai-gateway/mcp.md b/docs/ai-coder/ai-gateway/mcp.md index 5ea0531c5b..924e059638 100644 --- a/docs/ai-coder/ai-gateway/mcp.md +++ b/docs/ai-coder/ai-gateway/mcp.md @@ -19,7 +19,7 @@ AI Gateway can connect to MCP servers and inject tools automatically, enabling y > [!NOTE] > Only MCP servers which support OAuth2 Authorization are supported currently. > -> [_Streamable HTTP_](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) is the only supported transport currently. In future releases we will support the (now deprecated) [_Server-Sent Events_](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#backwards-compatibility) transport. +> [_Streamable HTTP_](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports) is the only supported transport currently. In future releases we will support the (now deprecated) [_Server-Sent Events_](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#backwards-compatibility) transport. AI Gateway makes use of [External Auth](../../admin/external-auth/index.md) applications, as they define OAuth2 connections to upstream services. If your External Auth application hosts a remote MCP server, you can configure AI Gateway to connect to it, retrieve its tools and inject them into requests automatically - all while using each individual user's access token. diff --git a/docs/ai-coder/mcp-server.md b/docs/ai-coder/mcp-server.md index 8b0b9194be..32875be261 100644 --- a/docs/ai-coder/mcp-server.md +++ b/docs/ai-coder/mcp-server.md @@ -87,6 +87,16 @@ The remote MCP server is an HTTP endpoint exposed by your Coder deployment at `/api/experimental/mcp/http`. This enables MCP clients to connect to Coder without running the CLI locally. +The endpoint implements the +[Streamable HTTP transport](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports) +in stateless mode: it supports MCP specification versions from `2024-11-05` +through `2026-07-28`, does not issue `Mcp-Session-Id` headers, and answers +`GET` and `DELETE` with `405 Method Not Allowed` (there is no standalone +server-event stream or explicit session termination, both permitted by the +specification). The server exposes tools only; MCP resources, prompts, +structured tool output, elicitation, and the MCP Tasks extension (which is +unrelated to Coder's task tools) are not implemented. + ### Prerequisites The remote MCP HTTP endpoint requires both the `oauth2` and `mcp-server-http` diff --git a/go.mod b/go.mod index 9f2df389ff..91e311f01c 100644 --- a/go.mod +++ b/go.mod @@ -542,7 +542,6 @@ require ( github.com/fsnotify/fsnotify v1.10.1 github.com/go-git/go-git/v5 v5.19.2 github.com/invopop/jsonschema v0.14.0 - github.com/mark3labs/mcp-go v0.38.0 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/nats-io/nats-server/v2 v2.14.2 github.com/nats-io/nats.go v1.52.0 diff --git a/go.sum b/go.sum index 96fbcdf703..33de7804ee 100644 --- a/go.sum +++ b/go.sum @@ -860,8 +860,6 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc= github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0= github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA= -github.com/mark3labs/mcp-go v0.38.0 h1:E5tmJiIXkhwlV0pLAwAT0O5ZjUZSISE/2Jxg+6vpq4I= -github.com/mark3labs/mcp-go v0.38.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=