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.
This commit is contained in:
Michael Suchacz
2026-08-13 10:47:14 +00:00
committed by GitHub
parent c8e8b21a88
commit e92fd8e96f
13 changed files with 503 additions and 835 deletions
+3 -3
View File
@@ -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)
+5 -2
View File
@@ -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 {
+177 -400
View File
@@ -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")
+2 -3
View File
@@ -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")
}
+104 -217
View File
@@ -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")
+3 -18
View File
@@ -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
}
+10 -11
View File
@@ -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
}
+188 -176
View File
@@ -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}),
},
},