From a34ad4128143a23b751a80a60ef5c581bfa7658b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:24:01 +0000 Subject: [PATCH] feat(cli): migrate exp mcp stdio server to official MCP Go SDK Replace mark3labs/mcp-go with the official SDK for coder exp mcp server. Tool registration reuses coderd/mcp's RegisterSDKTool adapter, preserving the allowlist, conditional registration of user-authenticated tools and coder_report_task, and stdout purity (protocol frames only, logs to stderr). The SDK enforces the MCP lifecycle, so test fixtures now send a spec-compliant initialize handshake before other requests. --- cli/exp_mcp.go | 75 +++++++++++++-------------------------------- cli/exp_mcp_test.go | 14 ++++++--- 2 files changed, 32 insertions(+), 57 deletions(-) diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index 6d72439b8cb..e26f6f1a373 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -1,10 +1,10 @@ package cli import ( - "bytes" "context" "encoding/json" "errors" + "io" "net/url" "os" "path/filepath" @@ -13,8 +13,7 @@ import ( "sync" "time" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/afero" "golang.org/x/xerrors" @@ -23,6 +22,7 @@ import ( "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/cli/cliutil" + coderdmcp "github.com/coder/coder/v2/coderd/mcp" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/toolsdk" @@ -692,11 +692,12 @@ func (s *mcpServer) startServer(ctx context.Context, inv *serpent.Invocation, in cliui.Infof(inv.Stderr, "Allowed Tools : %v", allowedTools) } - mcpSrv := server.NewMCPServer( - "Coder Agent", - buildinfo.Version(), - server.WithInstructions(instructions), - ) + mcpSrv := mcp.NewServer(&mcp.Implementation{ + Name: "Coder Agent", + Version: buildinfo.Version(), + }, &mcp.ServerOptions{ + Instructions: instructions, + }) // If neither the user client nor the agent socket is available, there // are no tools we can enable. @@ -751,14 +752,16 @@ func (s *mcpServer) startServer(ctx context.Context, inv *serpent.Invocation, in continue } - mcpSrv.AddTools(mcpFromSDK(tool, toolDeps)) + coderdmcp.RegisterSDKTool(mcpSrv, tool, toolDeps) } - srv := server.NewStdioServer(mcpSrv) done := make(chan error) go func() { defer close(done) - srvErr := srv.Listen(ctx, inv.Stdin, inv.Stdout) + srvErr := mcpSrv.Run(ctx, &mcp.IOTransport{ + Reader: io.NopCloser(inv.Stdin), + Writer: nopWriteCloser{inv.Stdout}, + }) done <- srvErr }() @@ -772,6 +775,14 @@ func (s *mcpServer) startServer(ctx context.Context, inv *serpent.Invocation, in return nil } +// nopWriteCloser adapts the invocation's stdout to the WriteCloser +// the SDK transport requires without closing the underlying stream. +type nopWriteCloser struct { + io.Writer +} + +func (nopWriteCloser) Close() error { return nil } + type ClaudeConfig struct { ConfigPath string ProjectDirectory string @@ -984,45 +995,3 @@ func indexOf(s, substr string) int { } return -1 } - -// mcpFromSDK adapts a toolsdk.Tool to go-mcp's server.ServerTool. -// It assumes that the tool responds with a valid JSON object. -func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool { - // NOTE: some clients will silently refuse to use tools if there is an issue - // with the tool's schema or configuration. - if sdkTool.Schema.Properties == nil { - panic("developer error: schema properties cannot be nil") - } - return server.ServerTool{ - Tool: mcp.Tool{ - Name: sdkTool.Tool.Name, - Description: sdkTool.Description, - InputSchema: mcp.ToolInputSchema{ - Type: "object", // Default of mcp.NewTool() - Properties: sdkTool.Schema.Properties, - Required: sdkTool.Schema.Required, - }, - Annotations: mcp.ToolAnnotation{ - ReadOnlyHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.ReadOnlyHint), - DestructiveHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.DestructiveHint), - IdempotentHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.IdempotentHint), - OpenWorldHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.OpenWorldHint), - }, - }, - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(request.Params.Arguments); err != nil { - return nil, xerrors.Errorf("failed to encode request arguments: %w", err) - } - result, err := sdkTool.Handler(ctx, tb, buf.Bytes()) - if err != nil { - return nil, err - } - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(string(result)), - }, - }, nil - }, - } -} diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go index 14989943d23..96acdd35fd4 100644 --- a/cli/exp_mcp_test.go +++ b/cli/exp_mcp_test.go @@ -63,6 +63,12 @@ func TestExpMcpServer(t *testing.T) { assert.NoError(t, err) }() + // The SDK server enforces the MCP lifecycle, so complete the + // initialize handshake before listing tools. + stdin.WriteLine(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + _ = stdout.ReadLine(ctx) + stdin.WriteLine(`{"jsonrpc":"2.0","method":"notifications/initialized"}`) + // When: we send a tools/list request toolsPayload := `{"jsonrpc":"2.0","id":2,"method":"tools/list"}` stdin.WriteLine(toolsPayload) @@ -140,7 +146,7 @@ func TestExpMcpServer(t *testing.T) { assert.NoError(t, err) }() - payload := `{"jsonrpc":"2.0","id":1,"method":"initialize"}` + payload := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}` stdin.WriteLine(payload) output := stdout.ReadLine(ctx) cancel() @@ -588,7 +594,7 @@ func TestExpMcpServerOptionalUserToken(t *testing.T) { }() // Verify server starts by checking for a successful initialization - payload := `{"jsonrpc":"2.0","id":1,"method":"initialize"}` + payload := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}` stdin.WriteLine(payload) output := stdout.ReadLine(ctx) @@ -1005,7 +1011,7 @@ func TestExpMcpReporter(t *testing.T) { }() // Initialize. - payload := `{"jsonrpc":"2.0","id":1,"method":"initialize"}` + payload := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}` stdin.WriteLine(payload) _ = stdout.ReadLine(ctx) // ignore init response @@ -1112,7 +1118,7 @@ func TestExpMcpReporter(t *testing.T) { clitest.Start(t, inv) // Initialize. - payload := `{"jsonrpc":"2.0","id":1,"method":"initialize"}` + payload := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}` stdin.WriteLine(payload) _ = stdout.ReadLine(ctx) // ignore init response