mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+22
-53
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user