Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 22 additions & 53 deletions cli/exp_mcp.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package cli

import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/url"
"os"
"path/filepath"
Expand All @@ -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"

Expand All @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}()

Expand All @@ -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
Expand Down Expand Up @@ -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
},
}
}
14 changes: 10 additions & 4 deletions cli/exp_mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
Loading