feat(cli): migrate exp mcp stdio server to official MCP Go SDK (#28057)

## Stack Context

PR 2 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

`coder exp mcp server` (stdio) now uses the official SDK server with
`mcp.IOTransport` over the invocation's stdin/stdout, and reuses the
shared `coderd/mcp.RegisterSDKTool` helper from PR #28056 so both
servers register tools identically.

- A `nopWriteCloser` prevents the SDK from closing the invocation's
stdout.
- Tests send spec-compliant initialize params and
`notifications/initialized` before `tools/list` because the official SDK
enforces the protocol lifecycle.

> Mux created this PR on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-08-13 10:02:47 +00:00
committed by GitHub
parent 08a1525f78
commit 26fe3f3185
2 changed files with 32 additions and 57 deletions
+22 -53
View File
@@ -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
},
}
}
+10 -4
View File
@@ -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