Skip to content

Commit b8839e2

Browse files
committed
feat(mcp): implement MCP HTTP server with toolsdk integration
- Add MCP HTTP server with streamable transport support - Integrate with existing toolsdk for Coder workspace operations - Add comprehensive E2E tests with OAuth2 bearer token support - Register MCP endpoint at /api/experimental/mcp/http with authentication - Support RFC 6750 Bearer token authentication for MCP clients Change-Id: Ib9024569ae452729908797c42155006aa04330af Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 0ca3722 commit b8839e2

File tree

10 files changed

+1743
-22
lines changed

10 files changed

+1743
-22
lines changed

coderd/apidoc/docs.go

Lines changed: 67 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 57 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,10 @@ func New(options *Options) *API {
972972
r.Route("/aitasks", func(r chi.Router) {
973973
r.Get("/prompts", api.aiTasksPrompts)
974974
})
975+
r.Route("/mcp", func(r chi.Router) {
976+
// MCP HTTP transport endpoint with mandatory authentication
977+
r.Mount("/http", api.mcpHTTPHandler())
978+
})
975979
})
976980

977981
r.Route("/api/v2", func(r chi.Router) {

coderd/mcp/mcp.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package mcp
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
"time"
10+
11+
"github.com/mark3labs/mcp-go/mcp"
12+
"github.com/mark3labs/mcp-go/server"
13+
"golang.org/x/xerrors"
14+
15+
"cdr.dev/slog"
16+
17+
"github.com/coder/coder/v2/buildinfo"
18+
"github.com/coder/coder/v2/codersdk"
19+
"github.com/coder/coder/v2/codersdk/toolsdk"
20+
)
21+
22+
const (
23+
// MCPServerName is the name used for the MCP server.
24+
MCPServerName = "Coder"
25+
// MCPServerInstructions is the instructions text for the MCP server.
26+
MCPServerInstructions = "Coder MCP Server providing workspace and template management tools"
27+
)
28+
29+
// Server represents an MCP HTTP server instance
30+
type Server struct {
31+
Logger slog.Logger
32+
33+
// mcpServer is the underlying MCP server
34+
mcpServer *server.MCPServer
35+
36+
// streamableServer handles HTTP transport
37+
streamableServer *server.StreamableHTTPServer
38+
}
39+
40+
// NewServer creates a new MCP HTTP server
41+
func NewServer(logger slog.Logger) (*Server, error) {
42+
// Create the core MCP server
43+
mcpSrv := server.NewMCPServer(
44+
MCPServerName,
45+
buildinfo.Version(),
46+
server.WithInstructions(MCPServerInstructions),
47+
)
48+
49+
// Create logger adapter for mcp-go
50+
mcpLogger := &mcpLoggerAdapter{logger: logger}
51+
52+
// Create streamable HTTP server with configuration
53+
streamableServer := server.NewStreamableHTTPServer(mcpSrv,
54+
server.WithHeartbeatInterval(30*time.Second),
55+
server.WithLogger(mcpLogger),
56+
)
57+
58+
return &Server{
59+
Logger: logger,
60+
mcpServer: mcpSrv,
61+
streamableServer: streamableServer,
62+
}, nil
63+
}
64+
65+
// ServeHTTP implements http.Handler interface
66+
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
67+
s.streamableServer.ServeHTTP(w, r)
68+
}
69+
70+
// RegisterTools registers all available MCP tools with the server
71+
func (s *Server) RegisterTools(client *codersdk.Client) error {
72+
if client == nil {
73+
return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client")
74+
}
75+
76+
// Create tool dependencies
77+
toolDeps, err := toolsdk.NewDeps(client)
78+
if err != nil {
79+
return xerrors.Errorf("failed to initialize tool dependencies: %w", err)
80+
}
81+
82+
// Register all available tools
83+
for _, tool := range toolsdk.All {
84+
s.mcpServer.AddTools(mcpFromSDK(tool, toolDeps))
85+
}
86+
87+
return nil
88+
}
89+
90+
// mcpFromSDK adapts a toolsdk.Tool to go-mcp's server.ServerTool
91+
func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool {
92+
if sdkTool.Schema.Properties == nil {
93+
panic("developer error: schema properties cannot be nil")
94+
}
95+
96+
return server.ServerTool{
97+
Tool: mcp.Tool{
98+
Name: sdkTool.Name,
99+
Description: sdkTool.Description,
100+
InputSchema: mcp.ToolInputSchema{
101+
Type: "object",
102+
Properties: sdkTool.Schema.Properties,
103+
Required: sdkTool.Schema.Required,
104+
},
105+
},
106+
Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
107+
var buf bytes.Buffer
108+
if err := json.NewEncoder(&buf).Encode(request.Params.Arguments); err != nil {
109+
return nil, xerrors.Errorf("failed to encode request arguments: %w", err)
110+
}
111+
result, err := sdkTool.Handler(ctx, tb, buf.Bytes())
112+
if err != nil {
113+
return nil, err
114+
}
115+
return &mcp.CallToolResult{
116+
Content: []mcp.Content{
117+
mcp.NewTextContent(string(result)),
118+
},
119+
}, nil
120+
},
121+
}
122+
}
123+
124+
// mcpLoggerAdapter adapts slog.Logger to the mcp-go util.Logger interface
125+
type mcpLoggerAdapter struct {
126+
logger slog.Logger
127+
}
128+
129+
func (l *mcpLoggerAdapter) Infof(format string, v ...any) {
130+
l.logger.Info(context.Background(), fmt.Sprintf(format, v...))
131+
}
132+
133+
func (l *mcpLoggerAdapter) Errorf(format string, v ...any) {
134+
l.logger.Error(context.Background(), fmt.Sprintf(format, v...))
135+
}

0 commit comments

Comments
 (0)