mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(codersdk): add toolsdk and replace existing mcp server tool impl (#17343)
- Refactors existing `mcp` package to use `kylecarbs/aisdk-go` and moves to `codersdk/toolsdk` package. - Updates existing MCP server implementation to use `codersdk/toolsdk` Co-authored-by: Kyle Carberry <kyle@coder.com>
This commit is contained in:
co-authored by
Kyle Carberry
parent
2c573dc023
commit
1235550637
+65
-26
@@ -6,19 +6,19 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/spf13/afero"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
codermcp "github.com/coder/coder/v2/mcp"
|
||||
"github.com/coder/coder/v2/codersdk/toolsdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
@@ -365,6 +365,8 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
defer cancel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
if err != nil {
|
||||
cliui.Errorf(inv.Stderr, "Failed to log in to the Coder deployment.")
|
||||
@@ -397,40 +399,36 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct
|
||||
server.WithInstructions(instructions),
|
||||
)
|
||||
|
||||
// Create a separate logger for the tools.
|
||||
toolLogger := slog.Make(sloghuman.Sink(invStderr))
|
||||
|
||||
toolDeps := codermcp.ToolDeps{
|
||||
Client: client,
|
||||
Logger: &toolLogger,
|
||||
AppStatusSlug: appStatusSlug,
|
||||
AgentClient: agentsdk.New(client.URL),
|
||||
}
|
||||
|
||||
// Create a new context for the tools with all relevant information.
|
||||
clientCtx := toolsdk.WithClient(ctx, client)
|
||||
// Get the workspace agent token from the environment.
|
||||
agentToken, ok := os.LookupEnv("CODER_AGENT_TOKEN")
|
||||
if ok && agentToken != "" {
|
||||
toolDeps.AgentClient.SetSessionToken(agentToken)
|
||||
if agentToken, err := getAgentToken(fs); err == nil && agentToken != "" {
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
clientCtx = toolsdk.WithAgentClient(clientCtx, agentClient)
|
||||
} else {
|
||||
cliui.Warnf(inv.Stderr, "CODER_AGENT_TOKEN is not set, task reporting will not be available")
|
||||
}
|
||||
if appStatusSlug == "" {
|
||||
if appStatusSlug != "" {
|
||||
cliui.Warnf(inv.Stderr, "CODER_MCP_APP_STATUS_SLUG is not set, task reporting will not be available.")
|
||||
} else {
|
||||
clientCtx = toolsdk.WithWorkspaceAppStatusSlug(clientCtx, appStatusSlug)
|
||||
}
|
||||
|
||||
// Register tools based on the allowlist (if specified)
|
||||
reg := codermcp.AllTools()
|
||||
if len(allowedTools) > 0 {
|
||||
reg = reg.WithOnlyAllowed(allowedTools...)
|
||||
for _, tool := range toolsdk.All {
|
||||
if len(allowedTools) == 0 || slices.ContainsFunc(allowedTools, func(t string) bool {
|
||||
return t == tool.Tool.Name
|
||||
}) {
|
||||
mcpSrv.AddTools(mcpFromSDK(tool))
|
||||
}
|
||||
}
|
||||
|
||||
reg.Register(mcpSrv, toolDeps)
|
||||
|
||||
srv := server.NewStdioServer(mcpSrv)
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
defer close(done)
|
||||
srvErr := srv.Listen(ctx, invStdin, invStdout)
|
||||
srvErr := srv.Listen(clientCtx, invStdin, invStdout)
|
||||
done <- srvErr
|
||||
}()
|
||||
|
||||
@@ -527,8 +525,8 @@ func configureClaude(fs afero.Fs, cfg ClaudeConfig) error {
|
||||
if !ok {
|
||||
mcpServers = make(map[string]any)
|
||||
}
|
||||
for name, mcp := range cfg.MCPServers {
|
||||
mcpServers[name] = mcp
|
||||
for name, cfgmcp := range cfg.MCPServers {
|
||||
mcpServers[name] = cfgmcp
|
||||
}
|
||||
project["mcpServers"] = mcpServers
|
||||
// Prevents Claude from asking the user to complete the project onboarding.
|
||||
@@ -674,7 +672,7 @@ func indexOf(s, substr string) int {
|
||||
|
||||
func getAgentToken(fs afero.Fs) (string, error) {
|
||||
token, ok := os.LookupEnv("CODER_AGENT_TOKEN")
|
||||
if ok {
|
||||
if ok && token != "" {
|
||||
return token, nil
|
||||
}
|
||||
tokenFile, ok := os.LookupEnv("CODER_AGENT_TOKEN_FILE")
|
||||
@@ -687,3 +685,44 @@ func getAgentToken(fs afero.Fs) (string, error) {
|
||||
}
|
||||
return string(bs), nil
|
||||
}
|
||||
|
||||
// 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.Tool[any]) server.ServerTool {
|
||||
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,
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
result, err := sdkTool.Handler(ctx, request.Params.Arguments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sb strings.Builder
|
||||
if err := json.NewEncoder(&sb).Encode(result); err == nil {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
mcp.NewTextContent(sb.String()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
// If the result is not JSON, return it as a string.
|
||||
// This is a fallback for tools that return non-JSON data.
|
||||
resultStr, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("tool call result is neither valid JSON or a string, got: %T", result)
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
mcp.NewTextContent(resultStr),
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -39,12 +39,13 @@ func TestExpMcpServer(t *testing.T) {
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Given: we run the exp mcp command with allowed tools set
|
||||
inv, root := clitest.New(t, "exp", "mcp", "server", "--allowed-tools=coder_whoami,coder_list_templates")
|
||||
inv, root := clitest.New(t, "exp", "mcp", "server", "--allowed-tools=coder_get_authenticated_user")
|
||||
inv = inv.WithContext(cancelCtx)
|
||||
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
// nolint: gocritic // not the focus of this test
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
cmdDone := make(chan struct{})
|
||||
@@ -73,13 +74,13 @@ func TestExpMcpServer(t *testing.T) {
|
||||
}
|
||||
err := json.Unmarshal([]byte(output), &toolsResponse)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, toolsResponse.Result.Tools, 2, "should have exactly 2 tools")
|
||||
require.Len(t, toolsResponse.Result.Tools, 1, "should have exactly 1 tool")
|
||||
foundTools := make([]string, 0, 2)
|
||||
for _, tool := range toolsResponse.Result.Tools {
|
||||
foundTools = append(foundTools, tool.Name)
|
||||
}
|
||||
slices.Sort(foundTools)
|
||||
require.Equal(t, []string{"coder_list_templates", "coder_whoami"}, foundTools)
|
||||
require.Equal(t, []string{"coder_get_authenticated_user"}, foundTools)
|
||||
})
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user