fix: reuse shared tailnet for coderd-hosted MCP workspace tools (#24460)

## Problem

Coderd can expose an MCP server at `/api/experimental/mcp/http` (we have
this enabled on dogfood). Its workspace tools dialed agents through a
per-call client-side tailnet stack. Every tool call re-created a
WireGuard device, netstack, magicsock + UDP sockets, DERP connection,
coordinator websocket, and their goroutines — in a process that already
runs a long-lived shared tailnet. The duplicate stacks drove up resource
usage under load.

## Fix

Route this server's tool calls through the existing shared tailnet, so
none of those transports are reconstructed per call. Closing an
`AgentConn` now releases a tunnel reference instead of tearing down a
transport.

## Potential follow-up

`coder exp mcp server` still builds a fresh tailnet per call. It pays
per-call latency and causes coordinator/DERP churn. A shared CLI tailnet
is more involved — unlike coderd, the CLI has no existing shared tailnet
to reuse, so it would need a new long-lived client-side tailnet with
reconnect, sleep/wake, and idle-destination handling. There's less
motivation to optimize this, given the client-side MCP does not compete
for resources with coderd.

Closes CODAGT-199

> Generated by mux, but reviewed by a human
This commit is contained in:
Ethan
2026-04-21 11:37:10 +10:00
committed by GitHub
parent 1203f625b7
commit 181e103201
8 changed files with 283 additions and 99 deletions
+4 -4
View File
@@ -72,13 +72,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Register all available MCP tools with the server excluding:
// - ReportTask - which requires dependencies not available in the remote MCP context
// - ChatGPT search and fetch tools, which are redundant with the standard tools.
func (s *Server) RegisterTools(client *codersdk.Client) error {
func (s *Server) RegisterTools(client *codersdk.Client, opts ...func(*toolsdk.Deps)) error {
if client == nil {
return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client")
}
// Create tool dependencies
toolDeps, err := toolsdk.NewDeps(client)
toolDeps, err := toolsdk.NewDeps(client, opts...)
if err != nil {
return xerrors.Errorf("failed to initialize tool dependencies: %w", err)
}
@@ -100,13 +100,13 @@ func (s *Server) RegisterTools(client *codersdk.Client) error {
// We do not expose any extra ones because ChatGPT has an undocumented "Safety Scan" feature.
// In my experiments, if I included extra tools in the MCP server, ChatGPT would often - but not always -
// refuse to add Coder as a connector.
func (s *Server) RegisterChatGPTTools(client *codersdk.Client) error {
func (s *Server) RegisterChatGPTTools(client *codersdk.Client, opts ...func(*toolsdk.Deps)) error {
if client == nil {
return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client")
}
// Create tool dependencies
toolDeps, err := toolsdk.NewDeps(client)
toolDeps, err := toolsdk.NewDeps(client, opts...)
if err != nil {
return xerrors.Errorf("failed to initialize tool dependencies: %w", err)
}
+44 -52
View File
@@ -9,6 +9,8 @@ import (
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
@@ -16,11 +18,16 @@ import (
mcpclient "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
mcpserver "github.com/coder/coder/v2/coderd/mcp"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/toolsdk"
@@ -215,21 +222,27 @@ func TestMCPHTTP_E2E_UnauthenticatedAccess(t *testing.T) {
func TestMCPHTTP_E2E_ToolWithWorkspace(t *testing.T) {
t.Parallel()
// Setup Coder server with full workspace environment
coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
coderClient, closer, api := coderdtest.NewWithAPI(t, nil)
defer closer.Close()
user := coderdtest.CreateFirstUser(t, coderClient)
r := dbfake.WorkspaceBuild(t, api.Database, database.WorkspaceTable{
Name: "myworkspace",
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).WithAgent().Do()
// Create template and workspace for testing
version := coderdtest.CreateTemplateVersion(t, coderClient, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, coderClient, version.ID)
template := coderdtest.CreateTemplate(t, coderClient, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, coderClient, template.ID)
fs := afero.NewMemMapFs()
tmpdir := os.TempDir()
require.NoError(t, fs.MkdirAll(tmpdir, 0o755))
filePath := filepath.Join(tmpdir, "mcp-http-test.txt")
require.NoError(t, afero.WriteFile(fs, filePath, []byte("hello from mcp"), 0o644))
_ = agenttest.New(t, coderClient.URL, r.AgentToken, func(opts *agent.Options) {
opts.Filesystem = fs
})
coderdtest.NewWorkspaceAgentWaiter(t, coderClient, r.Workspace.ID).Wait()
// Create MCP client
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
transport.WithHTTPHeaders(map[string]string{
@@ -245,11 +258,8 @@ func TestMCPHTTP_E2E_ToolWithWorkspace(t *testing.T) {
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{
require.NoError(t, mcpClient.Start(ctx))
_, err = mcpClient.Initialize(ctx, mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
@@ -257,48 +267,30 @@ func TestMCPHTTP_E2E_ToolWithWorkspace(t *testing.T) {
Version: "1.0.0",
},
},
}
_, err = mcpClient.Initialize(ctx, initReq)
})
require.NoError(t, err)
// Test workspace-related tools
tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
require.NoError(t, err)
// Find workspace listing tool
var workspaceTool *mcp.Tool
for _, tool := range tools.Tools {
if tool.Name == toolsdk.ToolNameListWorkspaces {
workspaceTool = &tool
break
}
}
if workspaceTool != nil {
// Execute workspace listing tool
toolReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: workspaceTool.Name,
Arguments: map[string]any{},
toolResult, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: toolsdk.ToolNameWorkspaceLS,
Arguments: map[string]any{
"workspace": r.Workspace.Name,
"path": tmpdir,
},
}
},
})
require.NoError(t, err)
require.NotEmpty(t, toolResult.Content)
toolResult, err := mcpClient.CallTool(ctx, toolReq)
require.NoError(t, err)
require.NotEmpty(t, toolResult.Content)
textContent, ok := toolResult.Content[0].(mcp.TextContent)
require.True(t, ok, "expected TextContent type, got %T", toolResult.Content[0])
// Verify the result mentions our workspace
if textContent, ok := toolResult.Content[0].(mcp.TextContent); ok {
assert.Contains(t, textContent.Text, workspace.Name, "Workspace listing should include our test workspace")
} else {
t.Error("Expected TextContent type from workspace tool")
}
t.Logf("Workspace tool test successful: Found workspace %s in results", workspace.Name)
} else {
t.Skip("Workspace listing tool not available, skipping workspace-specific test")
}
var response toolsdk.WorkspaceLSResponse
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response))
assert.Contains(t, response.Contents, toolsdk.WorkspaceLSFile{
Path: filePath,
IsDir: false,
})
}
func TestMCPHTTP_E2E_ErrorHandling(t *testing.T) {
+4 -2
View File
@@ -9,6 +9,7 @@ import (
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/mcp"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/toolsdk"
)
type MCPToolset string
@@ -34,6 +35,7 @@ func (api *API) mcpHTTPHandler() http.Handler {
// Extract the original session token from the request
authenticatedClient := codersdk.New(api.AccessURL,
codersdk.WithSessionToken(httpmw.APITokenFromRequest(r)))
toolOpt := toolsdk.WithAgentConnFunc(api.agentProvider.AgentConn)
toolset := MCPToolset(r.URL.Query().Get("toolset"))
// Default to standard toolset if no toolset is specified.
if toolset == "" {
@@ -42,11 +44,11 @@ func (api *API) mcpHTTPHandler() http.Handler {
switch toolset {
case MCPToolsetStandard:
if err := mcpServer.RegisterTools(authenticatedClient); err != nil {
if err := mcpServer.RegisterTools(authenticatedClient, toolOpt); err != nil {
api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err))
}
case MCPToolsetChatGPT:
if err := mcpServer.RegisterChatGPTTools(authenticatedClient); err != nil {
if err := mcpServer.RegisterChatGPTTools(authenticatedClient, toolOpt); err != nil {
api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err))
}
default: