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:
Cian Johnston
2025-04-11 10:24:45 +01:00
committed by GitHub
co-authored by Kyle Carberry
parent 2c573dc023
commit 1235550637
9 changed files with 1774 additions and 1096 deletions
+65 -26
View File
@@ -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
View File
@@ -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) {
+29 -22
View File
@@ -287,23 +287,25 @@ type TemplateVersionResponse struct {
}
type TemplateVersionBuilder struct {
t testing.TB
db database.Store
seed database.TemplateVersion
fileID uuid.UUID
ps pubsub.Pubsub
resources []*sdkproto.Resource
params []database.TemplateVersionParameter
promote bool
t testing.TB
db database.Store
seed database.TemplateVersion
fileID uuid.UUID
ps pubsub.Pubsub
resources []*sdkproto.Resource
params []database.TemplateVersionParameter
promote bool
autoCreateTemplate bool
}
// TemplateVersion generates a template version and optionally a parent
// template if no template ID is set on the seed.
func TemplateVersion(t testing.TB, db database.Store) TemplateVersionBuilder {
return TemplateVersionBuilder{
t: t,
db: db,
promote: true,
t: t,
db: db,
promote: true,
autoCreateTemplate: true,
}
}
@@ -337,6 +339,13 @@ func (t TemplateVersionBuilder) Params(ps ...database.TemplateVersionParameter)
return t
}
func (t TemplateVersionBuilder) SkipCreateTemplate() TemplateVersionBuilder {
// nolint: revive // returns modified struct
t.autoCreateTemplate = false
t.promote = false
return t
}
func (t TemplateVersionBuilder) Do() TemplateVersionResponse {
t.t.Helper()
@@ -347,7 +356,7 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse {
t.fileID = takeFirst(t.fileID, uuid.New())
var resp TemplateVersionResponse
if t.seed.TemplateID.UUID == uuid.Nil {
if t.seed.TemplateID.UUID == uuid.Nil && t.autoCreateTemplate {
resp.Template = dbgen.Template(t.t, t.db, database.Template{
ActiveVersionID: t.seed.ID,
OrganizationID: t.seed.OrganizationID,
@@ -360,16 +369,14 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse {
}
version := dbgen.TemplateVersion(t.t, t.db, t.seed)
// Always make this version the active version. We can easily
// add a conditional to the builder to opt out of this when
// necessary.
err := t.db.UpdateTemplateActiveVersionByID(ownerCtx, database.UpdateTemplateActiveVersionByIDParams{
ID: t.seed.TemplateID.UUID,
ActiveVersionID: t.seed.ID,
UpdatedAt: dbtime.Now(),
})
require.NoError(t.t, err)
if t.promote {
err := t.db.UpdateTemplateActiveVersionByID(ownerCtx, database.UpdateTemplateActiveVersionByIDParams{
ID: t.seed.TemplateID.UUID,
ActiveVersionID: t.seed.ID,
UpdatedAt: dbtime.Now(),
})
require.NoError(t.t, err)
}
payload, err := json.Marshal(provisionerdserver.TemplateVersionImportJob{
TemplateVersionID: t.seed.ID,
File diff suppressed because it is too large Load Diff
+367
View File
@@ -0,0 +1,367 @@
package toolsdk_test
import (
"context"
"os"
"sort"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/toolsdk"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
)
// These tests are dependent on the state of the coder server.
// Running them in parallel is prone to racy behavior.
// nolint:tparallel,paralleltest
func TestTools(t *testing.T) {
// Given: a running coderd instance
setupCtx := testutil.Context(t, testutil.WaitShort)
client, store := coderdtest.NewWithDatabase(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
// Given: a member user with which to test the tools.
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// Given: a workspace with an agent.
// nolint:gocritic // This is in a test package and does not end up in the build
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
agents[0].Apps = []*proto.App{
{
Slug: "some-agent-app",
},
}
return agents
}).Do()
// Given: a client configured with the agent token.
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(r.AgentToken)
// Get the agent ID from the API. Overriding it in dbfake doesn't work.
ws, err := client.Workspace(setupCtx, r.Workspace.ID)
require.NoError(t, err)
require.NotEmpty(t, ws.LatestBuild.Resources)
require.NotEmpty(t, ws.LatestBuild.Resources[0].Agents)
agentID := ws.LatestBuild.Resources[0].Agents[0].ID
// Given: the workspace agent has written logs.
agentClient.PatchLogs(setupCtx, agentsdk.PatchLogs{
Logs: []agentsdk.Log{
{
CreatedAt: time.Now(),
Level: codersdk.LogLevelInfo,
Output: "test log message",
},
},
})
t.Run("ReportTask", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithAgentClient(ctx, agentClient)
ctx = toolsdk.WithWorkspaceAppStatusSlug(ctx, "some-agent-app")
_, err := testTool(ctx, t, toolsdk.ReportTask, map[string]any{
"summary": "test summary",
"state": "complete",
"link": "https://example.com",
"emoji": "✅",
})
require.NoError(t, err)
})
t.Run("ListTemplates", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, memberClient)
// Get the templates directly for comparison
expected, err := memberClient.Templates(context.Background(), codersdk.TemplateFilter{})
require.NoError(t, err)
result, err := testTool(ctx, t, toolsdk.ListTemplates, map[string]any{})
require.NoError(t, err)
require.Len(t, result, len(expected))
// Sort the results by name to ensure the order is consistent
sort.Slice(expected, func(a, b int) bool {
return expected[a].Name < expected[b].Name
})
sort.Slice(result, func(a, b int) bool {
return result[a].Name < result[b].Name
})
for i, template := range result {
require.Equal(t, expected[i].ID.String(), template.ID)
}
})
t.Run("Whoami", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, memberClient)
result, err := testTool(ctx, t, toolsdk.GetAuthenticatedUser, map[string]any{})
require.NoError(t, err)
require.Equal(t, member.ID, result.ID)
require.Equal(t, member.Username, result.Username)
})
t.Run("ListWorkspaces", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, memberClient)
result, err := testTool(ctx, t, toolsdk.ListWorkspaces, map[string]any{
"owner": "me",
})
require.NoError(t, err)
require.Len(t, result, 1, "expected 1 workspace")
workspace := result[0]
require.Equal(t, r.Workspace.ID.String(), workspace.ID, "expected the workspace to match the one we created")
})
t.Run("GetWorkspace", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, memberClient)
result, err := testTool(ctx, t, toolsdk.GetWorkspace, map[string]any{
"workspace_id": r.Workspace.ID.String(),
})
require.NoError(t, err)
require.Equal(t, r.Workspace.ID, result.ID, "expected the workspace ID to match")
})
t.Run("CreateWorkspaceBuild", func(t *testing.T) {
t.Run("Stop", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, memberClient)
result, err := testTool(ctx, t, toolsdk.CreateWorkspaceBuild, map[string]any{
"workspace_id": r.Workspace.ID.String(),
"transition": "stop",
})
require.NoError(t, err)
require.Equal(t, codersdk.WorkspaceTransitionStop, result.Transition)
require.Equal(t, r.Workspace.ID, result.WorkspaceID)
// Important: cancel the build. We don't run any provisioners, so this
// will remain in the 'pending' state indefinitely.
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID))
})
t.Run("Start", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, memberClient)
result, err := testTool(ctx, t, toolsdk.CreateWorkspaceBuild, map[string]any{
"workspace_id": r.Workspace.ID.String(),
"transition": "start",
})
require.NoError(t, err)
require.Equal(t, codersdk.WorkspaceTransitionStart, result.Transition)
require.Equal(t, r.Workspace.ID, result.WorkspaceID)
// Important: cancel the build. We don't run any provisioners, so this
// will remain in the 'pending' state indefinitely.
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID))
})
})
t.Run("ListTemplateVersionParameters", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, memberClient)
params, err := testTool(ctx, t, toolsdk.ListTemplateVersionParameters, map[string]any{
"template_version_id": r.TemplateVersion.ID.String(),
})
require.NoError(t, err)
require.Empty(t, params)
})
t.Run("GetWorkspaceAgentLogs", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, client)
logs, err := testTool(ctx, t, toolsdk.GetWorkspaceAgentLogs, map[string]any{
"workspace_agent_id": agentID.String(),
})
require.NoError(t, err)
require.NotEmpty(t, logs)
})
t.Run("GetWorkspaceBuildLogs", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, memberClient)
logs, err := testTool(ctx, t, toolsdk.GetWorkspaceBuildLogs, map[string]any{
"workspace_build_id": r.Build.ID.String(),
})
require.NoError(t, err)
_ = logs // The build may not have any logs yet, so we just check that the function returns successfully
})
t.Run("GetTemplateVersionLogs", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, memberClient)
logs, err := testTool(ctx, t, toolsdk.GetTemplateVersionLogs, map[string]any{
"template_version_id": r.TemplateVersion.ID.String(),
})
require.NoError(t, err)
_ = logs // Just ensuring the call succeeds
})
t.Run("UpdateTemplateActiveVersion", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, client) // Use owner client for permission
result, err := testTool(ctx, t, toolsdk.UpdateTemplateActiveVersion, map[string]any{
"template_id": r.Template.ID.String(),
"template_version_id": r.TemplateVersion.ID.String(),
})
require.NoError(t, err)
require.Contains(t, result, "Successfully updated")
})
t.Run("DeleteTemplate", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, client)
_, err := testTool(ctx, t, toolsdk.DeleteTemplate, map[string]any{
"template_id": r.Template.ID.String(),
})
// This will fail with because there already exists a workspace.
require.ErrorContains(t, err, "All workspaces must be deleted before a template can be removed")
})
t.Run("UploadTarFile", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, client)
files := map[string]any{
"main.tf": "resource \"null_resource\" \"example\" {}",
}
result, err := testTool(ctx, t, toolsdk.UploadTarFile, map[string]any{
"mime_type": string(codersdk.ContentTypeTar),
"files": files,
})
require.NoError(t, err)
require.NotEmpty(t, result.ID)
})
t.Run("CreateTemplateVersion", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, client)
// nolint:gocritic // This is in a test package and does not end up in the build
file := dbgen.File(t, store, database.File{})
tv, err := testTool(ctx, t, toolsdk.CreateTemplateVersion, map[string]any{
"file_id": file.ID.String(),
})
require.NoError(t, err)
require.NotEmpty(t, tv)
})
t.Run("CreateTemplate", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, client)
// Create a new template version for use here.
tv := dbfake.TemplateVersion(t, store).
// nolint:gocritic // This is in a test package and does not end up in the build
Seed(database.TemplateVersion{OrganizationID: owner.OrganizationID, CreatedBy: owner.UserID}).
SkipCreateTemplate().Do()
// We're going to re-use the pre-existing template version
_, err := testTool(ctx, t, toolsdk.CreateTemplate, map[string]any{
"name": testutil.GetRandomNameHyphenated(t),
"display_name": "Test Template",
"description": "This is a test template",
"version_id": tv.TemplateVersion.ID.String(),
})
require.NoError(t, err)
})
t.Run("CreateWorkspace", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
ctx = toolsdk.WithClient(ctx, memberClient)
// We need a template version ID to create a workspace
res, err := testTool(ctx, t, toolsdk.CreateWorkspace, map[string]any{
"user": "me",
"template_version_id": r.TemplateVersion.ID.String(),
"name": testutil.GetRandomNameHyphenated(t),
"rich_parameters": map[string]any{},
})
// The creation might fail for various reasons, but the important thing is
// to mark it as tested
require.NoError(t, err)
require.NotEmpty(t, res.ID, "expected a workspace ID")
})
}
// TestedTools keeps track of which tools have been tested.
var testedTools sync.Map
// testTool is a helper function to test a tool and mark it as tested.
func testTool[T any](ctx context.Context, t *testing.T, tool toolsdk.Tool[T], args map[string]any) (T, error) {
t.Helper()
testedTools.Store(tool.Tool.Name, true)
result, err := tool.Handler(ctx, args)
return result, err
}
// TestMain runs after all tests to ensure that all tools in this package have
// been tested once.
func TestMain(m *testing.M) {
// Initialize testedTools
for _, tool := range toolsdk.All {
testedTools.Store(tool.Tool.Name, false)
}
code := m.Run()
// Ensure all tools have been tested
var untested []string
for _, tool := range toolsdk.All {
if tested, ok := testedTools.Load(tool.Tool.Name); !ok || !tested.(bool) {
untested = append(untested, tool.Tool.Name)
}
}
if len(untested) > 0 && code == 0 {
println("The following tools were not tested:")
for _, tool := range untested {
println(" - " + tool)
}
println("Please ensure that all tools are tested using testTool().")
println("If you just added a new tool, please add a test for it.")
println("NOTE: if you just ran an individual test, this is expected.")
os.Exit(1)
}
os.Exit(code)
}
+20 -15
View File
@@ -222,8 +222,8 @@ require (
require (
cloud.google.com/go/auth v0.15.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/logging v1.12.0 // indirect
cloud.google.com/go/longrunning v0.6.2 // indirect
cloud.google.com/go/logging v1.13.0 // indirect
cloud.google.com/go/longrunning v0.6.4 // indirect
dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
@@ -465,9 +465,9 @@ require (
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
howett.net/plist v1.0.0 // indirect
@@ -489,38 +489,43 @@ require (
require (
github.com/coder/preview v0.0.0-20250409162646-62939c63c71a
github.com/mark3labs/mcp-go v0.19.0
github.com/kylecarbs/aisdk-go v0.0.5
github.com/mark3labs/mcp-go v0.17.0
)
require (
cel.dev/expr v0.19.1 // indirect
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/iam v1.2.2 // indirect
cloud.google.com/go/monitoring v1.21.2 // indirect
cloud.google.com/go/storage v1.49.0 // indirect
cel.dev/expr v0.19.2 // indirect
cloud.google.com/go v0.120.0 // indirect
cloud.google.com/go/iam v1.4.0 // indirect
cloud.google.com/go/monitoring v1.24.0 // indirect
cloud.google.com/go/storage v1.50.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 // indirect
github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 // indirect
github.com/aquasecurity/go-version v0.0.1 // indirect
github.com/aquasecurity/trivy v0.58.2 // indirect
github.com/aws/aws-sdk-go v1.55.6 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 // indirect
github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/go-getter v1.7.8 // indirect
github.com/hashicorp/go-safetemp v1.0.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/liamg/memoryfs v1.6.0 // indirect
github.com/moby/sys/user v0.3.0 // indirect
github.com/openai/openai-go v0.1.0-beta.6 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
google.golang.org/genai v0.7.0 // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
)
+45 -33
View File
@@ -1,7 +1,7 @@
cdr.dev/slog v1.6.2-0.20241112041820-0ec81e6e67bb h1:4MKA8lBQLnCqj2myJCb5Lzoa65y0tABO4gHrxuMdsCQ=
cdr.dev/slog v1.6.2-0.20241112041820-0ec81e6e67bb/go.mod h1:NaoTA7KwopCrnaSb0JXTC0PTp/O/Y83Lndnq0OEV3ZQ=
cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4=
cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4=
cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -38,8 +38,8 @@ cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRY
cloud.google.com/go v0.105.0/go.mod h1:PrLgOJNe5nfE9UMxKxgXj4mD3voiP+YQ6gdt6KMFOKM=
cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I=
cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q=
cloud.google.com/go/accessapproval v1.4.0/go.mod h1:zybIuC3KpDOvotz59lFe5qxRZx6C75OtwbisN56xYB4=
cloud.google.com/go/accessapproval v1.5.0/go.mod h1:HFy3tuiGvMdcd/u+Cu5b9NkO1pEICJ46IR82PoUdplw=
cloud.google.com/go/accessapproval v1.6.0/go.mod h1:R0EiYnwV5fsRFiKZkPHr6mwyk2wxUJ30nL4j2pcFY2E=
@@ -319,8 +319,8 @@ cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGE
cloud.google.com/go/iam v0.11.0/go.mod h1:9PiLDanza5D+oWFZiH1uG+RnRCfEGKoyl6yo4cgWZGY=
cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY=
cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0=
cloud.google.com/go/iam v1.2.2 h1:ozUSofHUGf/F4tCNy/mu9tHLTaxZFLOUiKzjcgWHGIA=
cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY=
cloud.google.com/go/iam v1.4.0 h1:ZNfy/TYfn2uh/ukvhp783WhnbVluqf/tzOaqVUPlIPA=
cloud.google.com/go/iam v1.4.0/go.mod h1:gMBgqPaERlriaOV0CUl//XUzDhSfXevn4OEUbg6VRs4=
cloud.google.com/go/iap v1.4.0/go.mod h1:RGFwRJdihTINIe4wZ2iCP0zF/qu18ZwyKxrhMhygBEc=
cloud.google.com/go/iap v1.5.0/go.mod h1:UH/CGgKd4KyohZL5Pt0jSKE4m3FR51qg6FKQ/z/Ix9A=
cloud.google.com/go/iap v1.6.0/go.mod h1:NSuvI9C/j7UdjGjIde7t7HBz+QTwBcapPE07+sSRcLk=
@@ -350,13 +350,13 @@ cloud.google.com/go/lifesciences v0.6.0/go.mod h1:ddj6tSX/7BOnhxCSd3ZcETvtNr8NZ6
cloud.google.com/go/lifesciences v0.8.0/go.mod h1:lFxiEOMqII6XggGbOnKiyZ7IBwoIqA84ClvoezaA/bo=
cloud.google.com/go/logging v1.6.1/go.mod h1:5ZO0mHHbvm8gEmeEUHrmDlTDSu5imF6MUP9OfilNXBw=
cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M=
cloud.google.com/go/logging v1.12.0 h1:ex1igYcGFd4S/RZWOCU51StlIEuey5bjqwH9ZYjHibk=
cloud.google.com/go/logging v1.12.0/go.mod h1:wwYBt5HlYP1InnrtYI0wtwttpVU1rifnMT7RejksUAM=
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
cloud.google.com/go/longrunning v0.1.1/go.mod h1:UUFxuDWkv22EuY93jjmDMFT5GPQKeFVJBIF6QlTqdsE=
cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc=
cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0MK+hc=
cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI=
cloud.google.com/go/longrunning v0.6.4 h1:3tyw9rO3E2XVXzSApn1gyEEnH2K9SynNQjMlBi3uHLg=
cloud.google.com/go/longrunning v0.6.4/go.mod h1:ttZpLCe6e7EXvn9OxpBRx7kZEB0efv8yBO6YnVMfhJs=
cloud.google.com/go/managedidentities v1.3.0/go.mod h1:UzlW3cBOiPrzucO5qWkNkh0w33KFtBJU281hacNvsdE=
cloud.google.com/go/managedidentities v1.4.0/go.mod h1:NWSBYbEMgqmbZsLIyKvxrYbtqOsxY1ZrGM+9RgDqInM=
cloud.google.com/go/managedidentities v1.5.0/go.mod h1:+dWcZ0JlUmpuxpIDfyP5pP5y0bLdRwOS4Lp7gMni/LA=
@@ -380,8 +380,8 @@ cloud.google.com/go/monitoring v1.7.0/go.mod h1:HpYse6kkGo//7p6sT0wsIC6IBDET0RhI
cloud.google.com/go/monitoring v1.8.0/go.mod h1:E7PtoMJ1kQXWxPjB6mv2fhC5/15jInuulFdYYtlcvT4=
cloud.google.com/go/monitoring v1.12.0/go.mod h1:yx8Jj2fZNEkL/GYZyTLS4ZtZEZN8WtDEiEqG4kLK50w=
cloud.google.com/go/monitoring v1.13.0/go.mod h1:k2yMBAB1H9JT/QETjNkgdCGD9bPF712XiLTVr+cBrpw=
cloud.google.com/go/monitoring v1.21.2 h1:FChwVtClH19E7pJ+e0xUhJPGksctZNVOk2UhMmblmdU=
cloud.google.com/go/monitoring v1.21.2/go.mod h1:hS3pXvaG8KgWTSz+dAdyzPrGUYmi2Q+WFX8g2hqVEZU=
cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM=
cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc=
cloud.google.com/go/networkconnectivity v1.4.0/go.mod h1:nOl7YL8odKyAOtzNX73/M5/mGZgqqMeryi6UPZTk/rA=
cloud.google.com/go/networkconnectivity v1.5.0/go.mod h1:3GzqJx7uhtlM3kln0+x5wyFvuVH1pIBJjhCpjzSt75o=
cloud.google.com/go/networkconnectivity v1.6.0/go.mod h1:OJOoEXW+0LAxHh89nXd64uGG+FbQoeH8DtxCHVOMlaM=
@@ -544,8 +544,8 @@ cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeL
cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s=
cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y=
cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4=
cloud.google.com/go/storage v1.49.0 h1:zenOPBOWHCnojRd9aJZAyQXBYqkJkdQS42dxL55CIMw=
cloud.google.com/go/storage v1.49.0/go.mod h1:k1eHhhpLvrPjVGfo0mOUPEJ4Y2+a/Hv5PiwehZI9qGU=
cloud.google.com/go/storage v1.50.0 h1:3TbVkzTooBvnZsk7WaAQfOsNrdoM8QHusXA1cpk6QJs=
cloud.google.com/go/storage v1.50.0/go.mod h1:l7XeiD//vx5lfqE3RavfmU9yvk5Pp0Zhcv482poyafY=
cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w=
cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I=
cloud.google.com/go/storagetransfer v1.7.0/go.mod h1:8Giuj1QNb1kfLAiWM1bN6dHzfdlDAVC9rv9abHot2W4=
@@ -565,8 +565,8 @@ cloud.google.com/go/trace v1.3.0/go.mod h1:FFUE83d9Ca57C+K8rDl/Ih8LwOzWIV1krKgxg
cloud.google.com/go/trace v1.4.0/go.mod h1:UG0v8UBqzusp+z63o7FK74SdFE+AXpCLdFb1rshXG+Y=
cloud.google.com/go/trace v1.8.0/go.mod h1:zH7vcsbAhklH8hWFig58HvxcxyQbaIqMarMg9hn5ECA=
cloud.google.com/go/trace v1.9.0/go.mod h1:lOQqpE5IaWY0Ixg7/r2SjixMuc6lfTFeO4QGM4dQWOk=
cloud.google.com/go/trace v1.11.2 h1:4ZmaBdL8Ng/ajrgKqY5jfvzqMXbrDcBsUGXOT9aqTtI=
cloud.google.com/go/trace v1.11.2/go.mod h1:bn7OwXd4pd5rFuAnTrzBuoZ4ax2XQeG3qNgYmfCy0Io=
cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE=
cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8=
cloud.google.com/go/translate v1.3.0/go.mod h1:gzMUwRjvOqj5i69y/LYLd8RrNQk+hOmIXTi9+nb3Djs=
cloud.google.com/go/translate v1.4.0/go.mod h1:06Dn/ppvLD6WvA5Rhdp029IX2Mi3Mn7fpMRLPvXT5Wg=
cloud.google.com/go/translate v1.5.0/go.mod h1:29YDSYveqqpA1CQFD7NQuP49xymq17RXNaUDdc0mNu0=
@@ -662,12 +662,12 @@ github.com/DataDog/sketches-go v1.4.5 h1:ki7VfeNz7IcNafq7yI/j5U/YCkO3LJiMDtXz9OM
github.com/DataDog/sketches-go v1.4.5/go.mod h1:7Y8GN8Jf66DLyDhc94zuWA3uHEt/7ttt8jHOBWWrSOg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 h1:UQ0AhxogsIRZDkElkblfnwjc3IaltCm2HUMvezQaL7s=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1/go.mod h1:jyqM3eLpJ3IbIFDTKVz2rF9T/xWGW0rIriGwnz8l9Tk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.48.1 h1:oTX4vsorBZo/Zdum6OKPA4o7544hm6smoRv1QjpTwGo=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.48.1/go.mod h1:0wEl7vrAD8mehJyohS9HZy+WyEOaQO2mJx86Cvh93kM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 h1:8nn+rsCvTq9axyEh382S0PFLBeaFwNsT43IrPWzctRU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1/go.mod h1:viRWSEhtMZqz1rhwmOVKkWl6SwmVowfL9O2YR5gI2PE=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 h1:5IT7xOdq17MtcdtL/vtl6mGfzhaq4m4vpollPRmlsBQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0/go.mod h1:ZV4VOm0/eHR06JLrXWe09068dHpr3TRpY9Uo7T+anuA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.50.0 h1:nNMpRpnkWDAaqcpxMJvxa/Ud98gjbYwayJY4/9bdjiU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.50.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 h1:ig/FpDD2JofP/NExKQUbn7uOSZzJAQqogfqluZK4ed4=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
@@ -713,6 +713,8 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7X
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 h1:b5t1ZJMvV/l99y4jbz7kRFdUp3BSDkI8EhSlHczivtw=
github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI=
@@ -884,8 +886,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 h1:boJj011Hh+874zpIySeApCX4GeOjPl9qhRF3QuIZq+Q=
github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk=
github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41 h1:SBN/DA63+ZHwuWwPHPYoCZ/KLAjHv5g4h2MS4f2/MTI=
github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41/go.mod h1:I9ULxr64UaOSUv7hcb3nX4kowodJCVS7vt7VVJk/kW4=
github.com/coder/clistat v1.0.0 h1:MjiS7qQ1IobuSSgDnxcCSyBPESs44hExnh2TEqMcGnA=
@@ -1314,6 +1316,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w=
@@ -1463,9 +1467,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylecarbs/aisdk-go v0.0.5 h1:e4HE/SMBUUZn7AS/luiIYbEtHbbtUBzJS95R6qHDYVE=
github.com/kylecarbs/aisdk-go v0.0.5/go.mod h1:3nAhClwRNo6ZfU44GrBZ8O2fCCrxJdaHb9JIz+P3LR8=
github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3 h1:Z9/bo5PSeMutpdiKYNt/TTSfGM1Ll0naj3QzYX9VxTc=
github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk=
github.com/kylecarbs/opencensus-go v0.23.1-0.20220307014935-4d0325a68f8b h1:1Y1X6aR78kMEQE1iCjQodB3lA7VO4jB88Wf8ZrzXSsA=
github.com/kylecarbs/opencensus-go v0.23.1-0.20220307014935-4d0325a68f8b/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
github.com/kylecarbs/readline v0.0.0-20220211054233-0d62993714c8/go.mod h1:n/KX1BZoN1m9EwoXkn/xAV4fd3k8c++gGBsgLONaPOY=
github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e h1:OP0ZMFeZkUnOzTFRfpuK3m7Kp4fNvC6qN+exwj7aI4M=
@@ -1496,8 +1501,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
github.com/mark3labs/mcp-go v0.19.0 h1:cYKBPFD+fge273/TV6f5+TZYBSTnxV6GCJAO08D2wvA=
github.com/mark3labs/mcp-go v0.19.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@@ -1604,6 +1609,8 @@ github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/open-policy-agent/opa v1.3.0 h1:zVvQvQg+9+FuSRBt4LgKNzJwsWl/c85kD5jPozJTydY=
github.com/open-policy-agent/opa v1.3.0/go.mod h1:t9iPNhaplD2qpiBqeudzJtEX3fKHK8zdA29oFvofAHo=
github.com/openai/openai-go v0.1.0-beta.6 h1:JquYDpprfrGnlKvQQg+apy9dQ8R9mIrm+wNvAPp6jCQ=
github.com/openai/openai-go v0.1.0-beta.6/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -1792,6 +1799,7 @@ github.com/testcontainers/testcontainers-go/modules/localstack v0.36.0 h1:zVwbe4
github.com/testcontainers/testcontainers-go/modules/localstack v0.36.0/go.mod h1:rxyzj5nX/OUn7QK5PVxKYHJg1eeNtNzWMX2hSbNNJk0=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -1799,6 +1807,8 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tinylib/msgp v1.2.1 h1:6ypy2qcCznxpP4hpORzhtXyTqrBs7cfM9MCCWY8zsmU=
github.com/tinylib/msgp v1.2.1/go.mod h1:2vIGs3lcUo8izAATNobrCHevYZC/LMsJtw4JPiYPHro=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
@@ -2474,6 +2484,8 @@ google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genai v0.7.0 h1:TINBYXnP+K+D8b16LfVyb6XR3kdtieXy6nJsGoEXcBc=
google.golang.org/genai v0.7.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@@ -2603,12 +2615,12 @@ google.golang.org/genproto v0.0.0-20230323212658-478b75c54725/go.mod h1:UUQDJDOl
google.golang.org/genproto v0.0.0-20230330154414-c0448cd141ea/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak=
google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak=
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk=
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE=
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE=
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-600
View File
@@ -1,600 +0,0 @@
package codermcp
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"slices"
"strings"
"time"
"github.com/google/uuid"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// allTools is the list of all available tools. When adding a new tool,
// make sure to update this list.
var allTools = ToolRegistry{
{
Tool: mcp.NewTool("coder_report_task",
mcp.WithDescription(`Report progress on a user task in Coder.
Use this tool to keep the user informed about your progress with their request.
For long-running operations, call this periodically to provide status updates.
This is especially useful when performing multi-step operations like workspace creation or deployment.`),
mcp.WithString("summary", mcp.Description(`A concise summary of your current progress on the task.
Good Summaries:
- "Taking a look at the login page..."
- "Found a bug! Fixing it now..."
- "Investigating the GitHub Issue..."
- "Waiting for workspace to start (1/3 resources ready)"
- "Downloading template files from repository"`), mcp.Required()),
mcp.WithString("link", mcp.Description(`A relevant URL related to your work, such as:
- GitHub issue link
- Pull request URL
- Documentation reference
- Workspace URL
Use complete URLs (including https://) when possible.`), mcp.Required()),
mcp.WithString("emoji", mcp.Description(`A relevant emoji that visually represents the current status:
- 🔍 for investigating/searching
- 🚀 for deploying/starting
- 🐛 for debugging
- ✅ for completion
- ⏳ for waiting
Choose an emoji that helps the user understand the current phase at a glance.`), mcp.Required()),
mcp.WithBoolean("done", mcp.Description(`Whether the overall task the user requested is complete.
Set to true only when the entire requested operation is finished successfully.
For multi-step processes, use false until all steps are complete.`), mcp.Required()),
mcp.WithBoolean("need_user_attention", mcp.Description(`Whether the user needs to take action on the task.
Set to true if the task is in a failed state or if the user needs to take action to continue.`), mcp.Required()),
),
MakeHandler: handleCoderReportTask,
},
{
Tool: mcp.NewTool("coder_whoami",
mcp.WithDescription(`Get information about the currently logged-in Coder user.
Returns JSON with the user's profile including fields: id, username, email, created_at, status, roles, etc.
Use this to identify the current user context before performing workspace operations.
This tool is useful for verifying permissions and checking the user's identity.
Common errors:
- Authentication failure: The session may have expired
- Server unavailable: The Coder deployment may be unreachable`),
),
MakeHandler: handleCoderWhoami,
},
{
Tool: mcp.NewTool("coder_list_templates",
mcp.WithDescription(`List all templates available on the Coder deployment.
Returns JSON with detailed information about each template, including:
- Template name, ID, and description
- Creation/modification timestamps
- Version information
- Associated organization
Use this tool to discover available templates before creating workspaces.
Templates define the infrastructure and configuration for workspaces.
Common errors:
- Authentication failure: Check user permissions
- No templates available: The deployment may not have any templates configured`),
),
MakeHandler: handleCoderListTemplates,
},
{
Tool: mcp.NewTool("coder_list_workspaces",
mcp.WithDescription(`List workspaces available on the Coder deployment.
Returns JSON with workspace metadata including status, resources, and configurations.
Use this before other workspace operations to find valid workspace names/IDs.
Results are paginated - use offset and limit parameters for large deployments.
Common errors:
- Authentication failure: Check user permissions
- Invalid owner parameter: Ensure the owner exists`),
mcp.WithString(`owner`, mcp.Description(`The username of the workspace owner to filter by.
Defaults to "me" which represents the currently authenticated user.
Use this to view workspaces belonging to other users (requires appropriate permissions).
Special value: "me" - List workspaces owned by the authenticated user.`), mcp.DefaultString(codersdk.Me)),
mcp.WithNumber(`offset`, mcp.Description(`Pagination offset - the starting index for listing workspaces.
Used with the 'limit' parameter to implement pagination.
For example, to get the second page of results with 10 items per page, use offset=10.
Defaults to 0 (first page).`), mcp.DefaultNumber(0)),
mcp.WithNumber(`limit`, mcp.Description(`Maximum number of workspaces to return in a single request.
Used with the 'offset' parameter to implement pagination.
Higher values return more results but may increase response time.
Valid range: 1-100. Defaults to 10.`), mcp.DefaultNumber(10)),
),
MakeHandler: handleCoderListWorkspaces,
},
{
Tool: mcp.NewTool("coder_get_workspace",
mcp.WithDescription(`Get detailed information about a specific Coder workspace.
Returns comprehensive JSON with the workspace's configuration, status, and resources.
Use this to check workspace status before performing operations like exec or start/stop.
The response includes the latest build status, agent connectivity, and resource details.
Common errors:
- Workspace not found: Check the workspace name or ID
- Permission denied: The user may not have access to this workspace`),
mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to retrieve.
Can be specified as either:
- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d"
- Workspace name: e.g., "dev", "python-project"
Use coder_list_workspaces first if you're not sure about available workspace names.`), mcp.Required()),
),
MakeHandler: handleCoderGetWorkspace,
},
{
Tool: mcp.NewTool("coder_workspace_exec",
mcp.WithDescription(`Execute a shell command in a remote Coder workspace.
Runs the specified command and returns the complete output (stdout/stderr).
Use this for file operations, running build commands, or checking workspace state.
The workspace must be running with a connected agent for this to succeed.
Before using this tool:
1. Verify the workspace is running using coder_get_workspace
2. Start the workspace if needed using coder_start_workspace
Common errors:
- Workspace not running: Start the workspace first
- Command not allowed: Check security restrictions
- Agent not connected: The workspace may still be starting up`),
mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name where the command will execute.
Can be specified as either:
- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d"
- Workspace name: e.g., "dev", "python-project"
The workspace must be running with a connected agent.
Use coder_get_workspace first to check the workspace status.`), mcp.Required()),
mcp.WithString("command", mcp.Description(`The shell command to execute in the workspace.
Commands are executed in the default shell of the workspace.
Examples:
- "ls -la" - List files with details
- "cd /path/to/directory && command" - Execute in specific directory
- "cat ~/.bashrc" - View a file's contents
- "python -m pip list" - List installed Python packages
Note: Very long-running commands may time out.`), mcp.Required()),
),
MakeHandler: handleCoderWorkspaceExec,
},
{
Tool: mcp.NewTool("coder_workspace_transition",
mcp.WithDescription(`Start or stop a running Coder workspace.
If stopping, initiates the workspace stop transition.
Only works on workspaces that are currently running or failed.
If starting, initiates the workspace start transition.
Only works on workspaces that are currently stopped or failed.
Stopping or starting a workspace is an asynchronous operation - it may take several minutes to complete.
After calling this tool:
1. Use coder_report_task to inform the user that the workspace is stopping or starting
2. Use coder_get_workspace periodically to check for completion
Common errors:
- Workspace already started/starting/stopped/stopping: No action needed
- Cancellation failed: There may be issues with the underlying infrastructure
- User doesn't own workspace: Permission issues`),
mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to start or stop.
Can be specified as either:
- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d"
- Workspace name: e.g., "dev", "python-project"
The workspace must be in a running state to be stopped, or in a stopped or failed state to be started.
Use coder_get_workspace first to check the current workspace status.`), mcp.Required()),
mcp.WithString("transition", mcp.Description(`The transition to apply to the workspace.
Can be either "start" or "stop".`)),
),
MakeHandler: handleCoderWorkspaceTransition,
},
}
// ToolDeps contains all dependencies needed by tool handlers
type ToolDeps struct {
Client *codersdk.Client
AgentClient *agentsdk.Client
Logger *slog.Logger
AppStatusSlug string
}
// ToolHandler associates a tool with its handler creation function
type ToolHandler struct {
Tool mcp.Tool
MakeHandler func(ToolDeps) server.ToolHandlerFunc
}
// ToolRegistry is a map of available tools with their handler creation
// functions
type ToolRegistry []ToolHandler
// WithOnlyAllowed returns a new ToolRegistry containing only the tools
// specified in the allowed list.
func (r ToolRegistry) WithOnlyAllowed(allowed ...string) ToolRegistry {
if len(allowed) == 0 {
return []ToolHandler{}
}
filtered := make(ToolRegistry, 0, len(r))
// The overhead of a map lookup is likely higher than a linear scan
// for a small number of tools.
for _, entry := range r {
if slices.Contains(allowed, entry.Tool.Name) {
filtered = append(filtered, entry)
}
}
return filtered
}
// Register registers all tools in the registry with the given tool adder
// and dependencies.
func (r ToolRegistry) Register(srv *server.MCPServer, deps ToolDeps) {
for _, entry := range r {
srv.AddTool(entry.Tool, entry.MakeHandler(deps))
}
}
// AllTools returns all available tools.
func AllTools() ToolRegistry {
// return a copy of allTools to avoid mutating the original
return slices.Clone(allTools)
}
type handleCoderReportTaskArgs struct {
Summary string `json:"summary"`
Link string `json:"link"`
Emoji string `json:"emoji"`
Done bool `json:"done"`
NeedUserAttention bool `json:"need_user_attention"`
}
// Example payload:
// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_report_task", "arguments": {"summary": "I need help with the login page.", "link": "https://github.com/coder/coder/pull/1234", "emoji": "🔍", "done": false, "need_user_attention": true}}}
func handleCoderReportTask(deps ToolDeps) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if deps.AgentClient == nil {
return nil, xerrors.New("developer error: agent client is required")
}
if deps.AppStatusSlug == "" {
return nil, xerrors.New("No app status slug provided, set CODER_MCP_APP_STATUS_SLUG when running the MCP server to report tasks.")
}
// Convert the request parameters to a json.RawMessage so we can unmarshal
// them into the correct struct.
args, err := unmarshalArgs[handleCoderReportTaskArgs](request.Params.Arguments)
if err != nil {
return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err)
}
deps.Logger.Info(ctx, "report task tool called",
slog.F("summary", args.Summary),
slog.F("link", args.Link),
slog.F("emoji", args.Emoji),
slog.F("done", args.Done),
slog.F("need_user_attention", args.NeedUserAttention),
)
newStatus := agentsdk.PatchAppStatus{
AppSlug: deps.AppStatusSlug,
Message: args.Summary,
URI: args.Link,
Icon: args.Emoji,
NeedsUserAttention: args.NeedUserAttention,
State: codersdk.WorkspaceAppStatusStateWorking,
}
if args.Done {
newStatus.State = codersdk.WorkspaceAppStatusStateComplete
}
if args.NeedUserAttention {
newStatus.State = codersdk.WorkspaceAppStatusStateFailure
}
if err := deps.AgentClient.PatchAppStatus(ctx, newStatus); err != nil {
return nil, xerrors.Errorf("failed to patch app status: %w", err)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.NewTextContent("Thanks for reporting!"),
},
}, nil
}
}
// Example payload:
// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_whoami", "arguments": {}}}
func handleCoderWhoami(deps ToolDeps) server.ToolHandlerFunc {
return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if deps.Client == nil {
return nil, xerrors.New("developer error: client is required")
}
me, err := deps.Client.User(ctx, codersdk.Me)
if err != nil {
return nil, xerrors.Errorf("Failed to fetch the current user: %s", err.Error())
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(me); err != nil {
return nil, xerrors.Errorf("Failed to encode the current user: %s", err.Error())
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.NewTextContent(strings.TrimSpace(buf.String())),
},
}, nil
}
}
type handleCoderListWorkspacesArgs struct {
Owner string `json:"owner"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
// Example payload:
// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_list_workspaces", "arguments": {"owner": "me", "offset": 0, "limit": 10}}}
func handleCoderListWorkspaces(deps ToolDeps) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if deps.Client == nil {
return nil, xerrors.New("developer error: client is required")
}
args, err := unmarshalArgs[handleCoderListWorkspacesArgs](request.Params.Arguments)
if err != nil {
return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err)
}
workspaces, err := deps.Client.Workspaces(ctx, codersdk.WorkspaceFilter{
Owner: args.Owner,
Offset: args.Offset,
Limit: args.Limit,
})
if err != nil {
return nil, xerrors.Errorf("failed to fetch workspaces: %w", err)
}
// Encode it as JSON. TODO: It might be nicer for the agent to have a tabulated response.
data, err := json.Marshal(workspaces)
if err != nil {
return nil, xerrors.Errorf("failed to encode workspaces: %s", err.Error())
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.NewTextContent(string(data)),
},
}, nil
}
}
type handleCoderGetWorkspaceArgs struct {
Workspace string `json:"workspace"`
}
// Example payload:
// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_get_workspace", "arguments": {"workspace": "dev"}}}
func handleCoderGetWorkspace(deps ToolDeps) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if deps.Client == nil {
return nil, xerrors.New("developer error: client is required")
}
args, err := unmarshalArgs[handleCoderGetWorkspaceArgs](request.Params.Arguments)
if err != nil {
return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err)
}
workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace)
if err != nil {
return nil, xerrors.Errorf("failed to fetch workspace: %w", err)
}
workspaceJSON, err := json.Marshal(workspace)
if err != nil {
return nil, xerrors.Errorf("failed to encode workspace: %w", err)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.NewTextContent(string(workspaceJSON)),
},
}, nil
}
}
type handleCoderWorkspaceExecArgs struct {
Workspace string `json:"workspace"`
Command string `json:"command"`
}
// Example payload:
// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_workspace_exec", "arguments": {"workspace": "dev", "command": "ps -ef"}}}
func handleCoderWorkspaceExec(deps ToolDeps) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if deps.Client == nil {
return nil, xerrors.New("developer error: client is required")
}
args, err := unmarshalArgs[handleCoderWorkspaceExecArgs](request.Params.Arguments)
if err != nil {
return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err)
}
// Attempt to fetch the workspace. We may get a UUID or a name, so try to
// handle both.
ws, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace)
if err != nil {
return nil, xerrors.Errorf("failed to fetch workspace: %w", err)
}
// Ensure the workspace is started.
// Select the first agent of the workspace.
var agt *codersdk.WorkspaceAgent
for _, r := range ws.LatestBuild.Resources {
for _, a := range r.Agents {
if a.Status != codersdk.WorkspaceAgentConnected {
continue
}
agt = ptr.Ref(a)
break
}
}
if agt == nil {
return nil, xerrors.Errorf("no connected agents for workspace %s", ws.ID)
}
startedAt := time.Now()
conn, err := workspacesdk.New(deps.Client).AgentReconnectingPTY(ctx, workspacesdk.WorkspaceAgentReconnectingPTYOpts{
AgentID: agt.ID,
Reconnect: uuid.New(),
Width: 80,
Height: 24,
Command: args.Command,
BackendType: "buffered", // the screen backend is annoying to use here.
})
if err != nil {
return nil, xerrors.Errorf("failed to open reconnecting PTY: %w", err)
}
defer conn.Close()
connectedAt := time.Now()
var buf bytes.Buffer
if _, err := io.Copy(&buf, conn); err != nil {
// EOF is expected when the connection is closed.
// We can ignore this error.
if !errors.Is(err, io.EOF) {
return nil, xerrors.Errorf("failed to read from reconnecting PTY: %w", err)
}
}
completedAt := time.Now()
connectionTime := connectedAt.Sub(startedAt)
executionTime := completedAt.Sub(connectedAt)
resp := map[string]string{
"connection_time": connectionTime.String(),
"execution_time": executionTime.String(),
"output": buf.String(),
}
respJSON, err := json.Marshal(resp)
if err != nil {
return nil, xerrors.Errorf("failed to encode workspace build: %w", err)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.NewTextContent(string(respJSON)),
},
}, nil
}
}
// Example payload:
// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_list_templates", "arguments": {}}}
func handleCoderListTemplates(deps ToolDeps) server.ToolHandlerFunc {
return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if deps.Client == nil {
return nil, xerrors.New("developer error: client is required")
}
templates, err := deps.Client.Templates(ctx, codersdk.TemplateFilter{})
if err != nil {
return nil, xerrors.Errorf("failed to fetch templates: %w", err)
}
templateJSON, err := json.Marshal(templates)
if err != nil {
return nil, xerrors.Errorf("failed to encode templates: %w", err)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.NewTextContent(string(templateJSON)),
},
}, nil
}
}
type handleCoderWorkspaceTransitionArgs struct {
Workspace string `json:"workspace"`
Transition string `json:"transition"`
}
// Example payload:
// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name":
// "coder_workspace_transition", "arguments": {"workspace": "dev", "transition": "stop"}}}
func handleCoderWorkspaceTransition(deps ToolDeps) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if deps.Client == nil {
return nil, xerrors.New("developer error: client is required")
}
args, err := unmarshalArgs[handleCoderWorkspaceTransitionArgs](request.Params.Arguments)
if err != nil {
return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err)
}
workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace)
if err != nil {
return nil, xerrors.Errorf("failed to fetch workspace: %w", err)
}
wsTransition := codersdk.WorkspaceTransition(args.Transition)
switch wsTransition {
case codersdk.WorkspaceTransitionStart:
case codersdk.WorkspaceTransitionStop:
default:
return nil, xerrors.New("invalid transition")
}
// We're not going to check the workspace status here as it is checked on the
// server side.
wb, err := deps.Client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: wsTransition,
})
if err != nil {
return nil, xerrors.Errorf("failed to stop workspace: %w", err)
}
resp := map[string]any{"status": wb.Status, "transition": wb.Transition}
respJSON, err := json.Marshal(resp)
if err != nil {
return nil, xerrors.Errorf("failed to encode workspace build: %w", err)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.NewTextContent(string(respJSON)),
},
}, nil
}
}
func getWorkspaceByIDOrOwnerName(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) {
if wsid, err := uuid.Parse(identifier); err == nil {
return client.Workspace(ctx, wsid)
}
return client.WorkspaceByOwnerAndName(ctx, codersdk.Me, identifier, codersdk.WorkspaceOptions{})
}
// unmarshalArgs is a helper function to convert the map[string]any we get from
// the MCP server into a typed struct. It does this by marshaling and unmarshalling
// the arguments.
func unmarshalArgs[T any](args map[string]interface{}) (t T, err error) {
argsJSON, err := json.Marshal(args)
if err != nil {
return t, xerrors.Errorf("failed to marshal arguments: %w", err)
}
if err := json.Unmarshal(argsJSON, &t); err != nil {
return t, xerrors.Errorf("failed to unmarshal arguments: %w", err)
}
return t, nil
}
-397
View File
@@ -1,397 +0,0 @@
package codermcp_test
import (
"context"
"encoding/json"
"io"
"runtime"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/stretchr/testify/require"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"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/provisionersdk/proto"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
// These tests are dependent on the state of the coder server.
// Running them in parallel is prone to racy behavior.
// nolint:tparallel,paralleltest
func TestCoderTools(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("skipping on non-linux due to pty issues")
}
ctx := testutil.Context(t, testutil.WaitLong)
// Given: a coder server, workspace, and agent.
client, store := coderdtest.NewWithDatabase(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
// Given: a member user with which to test the tools.
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// Given: a workspace with an agent.
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
agents[0].Apps = []*proto.App{
{
Slug: "some-agent-app",
},
}
return agents
}).Do()
// Note: we want to test the list_workspaces tool before starting the
// workspace agent. Starting the workspace agent will modify the workspace
// state, which will affect the results of the list_workspaces tool.
listWorkspacesDone := make(chan struct{})
agentStarted := make(chan struct{})
go func() {
defer close(agentStarted)
<-listWorkspacesDone
agt := agenttest.New(t, client.URL, r.AgentToken)
t.Cleanup(func() {
_ = agt.Close()
})
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait()
}()
// Given: a MCP server listening on a pty.
pty := ptytest.New(t)
mcpSrv, closeSrv := startTestMCPServer(ctx, t, pty.Input(), pty.Output())
t.Cleanup(func() {
_ = closeSrv()
})
// Register tools using our registry
logger := slogtest.Make(t, nil)
agentClient := agentsdk.New(memberClient.URL)
codermcp.AllTools().Register(mcpSrv, codermcp.ToolDeps{
Client: memberClient,
Logger: &logger,
AppStatusSlug: "some-agent-app",
AgentClient: agentClient,
})
t.Run("coder_list_templates", func(t *testing.T) {
// When: the coder_list_templates tool is called
ctr := makeJSONRPCRequest(t, "tools/call", "coder_list_templates", map[string]any{})
pty.WriteLine(ctr)
_ = pty.ReadLine(ctx) // skip the echo
// Then: the response is a list of expected visible to the user.
expected, err := memberClient.Templates(ctx, codersdk.TemplateFilter{})
require.NoError(t, err)
actual := unmarshalFromCallToolResult[[]codersdk.Template](t, pty.ReadLine(ctx))
require.Len(t, actual, 1)
require.Equal(t, expected[0].ID, actual[0].ID)
})
t.Run("coder_report_task", func(t *testing.T) {
// Given: the MCP server has an agent token.
oldAgentToken := agentClient.SDK.SessionToken()
agentClient.SetSessionToken(r.AgentToken)
t.Cleanup(func() {
agentClient.SDK.SetSessionToken(oldAgentToken)
})
// When: the coder_report_task tool is called
ctr := makeJSONRPCRequest(t, "tools/call", "coder_report_task", map[string]any{
"summary": "Test summary",
"link": "https://example.com",
"emoji": "🔍",
"done": false,
"need_user_attention": true,
})
pty.WriteLine(ctr)
_ = pty.ReadLine(ctx) // skip the echo
// Then: positive feedback is given to the reporting agent.
actual := pty.ReadLine(ctx)
require.Contains(t, actual, "Thanks for reporting!")
// Then: the response is a success message.
ws, err := memberClient.Workspace(ctx, r.Workspace.ID)
require.NoError(t, err, "failed to get workspace")
agt, err := memberClient.WorkspaceAgent(ctx, ws.LatestBuild.Resources[0].Agents[0].ID)
require.NoError(t, err, "failed to get workspace agent")
require.NotEmpty(t, agt.Apps, "workspace agent should have an app")
require.NotEmpty(t, agt.Apps[0].Statuses, "workspace agent app should have a status")
st := agt.Apps[0].Statuses[0]
// require.Equal(t, ws.ID, st.WorkspaceID, "workspace app status should have the correct workspace id")
require.Equal(t, agt.ID, st.AgentID, "workspace app status should have the correct agent id")
require.Equal(t, agt.Apps[0].ID, st.AppID, "workspace app status should have the correct app id")
require.Equal(t, codersdk.WorkspaceAppStatusStateFailure, st.State, "workspace app status should be in the failure state")
require.Equal(t, "Test summary", st.Message, "workspace app status should have the correct message")
require.Equal(t, "https://example.com", st.URI, "workspace app status should have the correct uri")
require.Equal(t, "🔍", st.Icon, "workspace app status should have the correct icon")
require.True(t, st.NeedsUserAttention, "workspace app status should need user attention")
})
t.Run("coder_whoami", func(t *testing.T) {
// When: the coder_whoami tool is called
ctr := makeJSONRPCRequest(t, "tools/call", "coder_whoami", map[string]any{})
pty.WriteLine(ctr)
_ = pty.ReadLine(ctx) // skip the echo
// Then: the response is a valid JSON respresentation of the calling user.
expected, err := memberClient.User(ctx, codersdk.Me)
require.NoError(t, err)
actual := unmarshalFromCallToolResult[codersdk.User](t, pty.ReadLine(ctx))
require.Equal(t, expected.ID, actual.ID)
})
t.Run("coder_list_workspaces", func(t *testing.T) {
defer close(listWorkspacesDone)
// When: the coder_list_workspaces tool is called
ctr := makeJSONRPCRequest(t, "tools/call", "coder_list_workspaces", map[string]any{
"coder_url": client.URL.String(),
"coder_session_token": client.SessionToken(),
})
pty.WriteLine(ctr)
_ = pty.ReadLine(ctx) // skip the echo
// Then: the response is a valid JSON respresentation of the calling user's workspaces.
actual := unmarshalFromCallToolResult[codersdk.WorkspacesResponse](t, pty.ReadLine(ctx))
require.Len(t, actual.Workspaces, 1, "expected 1 workspace")
require.Equal(t, r.Workspace.ID, actual.Workspaces[0].ID, "expected the workspace to be the one we created in setup")
})
t.Run("coder_get_workspace", func(t *testing.T) {
// Given: the workspace agent is connected.
// The act of starting the agent will modify the workspace state.
<-agentStarted
// When: the coder_get_workspace tool is called
ctr := makeJSONRPCRequest(t, "tools/call", "coder_get_workspace", map[string]any{
"workspace": r.Workspace.ID.String(),
})
pty.WriteLine(ctr)
_ = pty.ReadLine(ctx) // skip the echo
expected, err := memberClient.Workspace(ctx, r.Workspace.ID)
require.NoError(t, err)
// Then: the response is a valid JSON respresentation of the workspace.
actual := unmarshalFromCallToolResult[codersdk.Workspace](t, pty.ReadLine(ctx))
require.Equal(t, expected.ID, actual.ID)
})
// NOTE: this test runs after the list_workspaces tool is called.
t.Run("coder_workspace_exec", func(t *testing.T) {
// Given: the workspace agent is connected
<-agentStarted
// When: the coder_workspace_exec tools is called with a command
randString := testutil.GetRandomName(t)
ctr := makeJSONRPCRequest(t, "tools/call", "coder_workspace_exec", map[string]any{
"workspace": r.Workspace.ID.String(),
"command": "echo " + randString,
"coder_url": client.URL.String(),
"coder_session_token": client.SessionToken(),
})
pty.WriteLine(ctr)
_ = pty.ReadLine(ctx) // skip the echo
// Then: the response is the output of the command.
actual := pty.ReadLine(ctx)
require.Contains(t, actual, randString)
})
// NOTE: this test runs after the list_workspaces tool is called.
t.Run("tool_restrictions", func(t *testing.T) {
// Given: the workspace agent is connected
<-agentStarted
// Given: a restricted MCP server with only allowed tools and commands
restrictedPty := ptytest.New(t)
allowedTools := []string{"coder_workspace_exec"}
restrictedMCPSrv, closeRestrictedSrv := startTestMCPServer(ctx, t, restrictedPty.Input(), restrictedPty.Output())
t.Cleanup(func() {
_ = closeRestrictedSrv()
})
codermcp.AllTools().
WithOnlyAllowed(allowedTools...).
Register(restrictedMCPSrv, codermcp.ToolDeps{
Client: memberClient,
Logger: &logger,
})
// When: the tools/list command is called
toolsListCmd := makeJSONRPCRequest(t, "tools/list", "", nil)
restrictedPty.WriteLine(toolsListCmd)
_ = restrictedPty.ReadLine(ctx) // skip the echo
// Then: the response is a list of only the allowed tools.
toolsListResponse := restrictedPty.ReadLine(ctx)
require.Contains(t, toolsListResponse, "coder_workspace_exec")
require.NotContains(t, toolsListResponse, "coder_whoami")
// When: a disallowed tool is called
disallowedToolCmd := makeJSONRPCRequest(t, "tools/call", "coder_whoami", map[string]any{})
restrictedPty.WriteLine(disallowedToolCmd)
_ = restrictedPty.ReadLine(ctx) // skip the echo
// Then: the response is an error indicating the tool is not available.
disallowedToolResponse := restrictedPty.ReadLine(ctx)
require.Contains(t, disallowedToolResponse, "error")
require.Contains(t, disallowedToolResponse, "not found")
})
t.Run("coder_workspace_transition_stop", func(t *testing.T) {
// Given: a separate workspace in the running state
stopWs := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
}).WithAgent().Do()
// When: the coder_workspace_transition tool is called with a stop transition
ctr := makeJSONRPCRequest(t, "tools/call", "coder_workspace_transition", map[string]any{
"workspace": stopWs.Workspace.ID.String(),
"transition": "stop",
})
pty.WriteLine(ctr)
_ = pty.ReadLine(ctx) // skip the echo
// Then: the response is as expected.
expected := makeJSONRPCTextResponse(t, `{"status":"pending","transition":"stop"}`) // no provisionerd yet
actual := pty.ReadLine(ctx)
testutil.RequireJSONEq(t, expected, actual)
})
t.Run("coder_workspace_transition_start", func(t *testing.T) {
// Given: a separate workspace in the stopped state
stopWs := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
}).Seed(database.WorkspaceBuild{
Transition: database.WorkspaceTransitionStop,
}).Do()
// When: the coder_workspace_transition tool is called with a start transition
ctr := makeJSONRPCRequest(t, "tools/call", "coder_workspace_transition", map[string]any{
"workspace": stopWs.Workspace.ID.String(),
"transition": "start",
})
pty.WriteLine(ctr)
_ = pty.ReadLine(ctx) // skip the echo
// Then: the response is as expected
expected := makeJSONRPCTextResponse(t, `{"status":"pending","transition":"start"}`) // no provisionerd yet
actual := pty.ReadLine(ctx)
testutil.RequireJSONEq(t, expected, actual)
})
}
// makeJSONRPCRequest is a helper function that makes a JSON RPC request.
func makeJSONRPCRequest(t *testing.T, method, name string, args map[string]any) string {
t.Helper()
req := mcp.JSONRPCRequest{
ID: "1",
JSONRPC: "2.0",
Request: mcp.Request{Method: method},
Params: struct { // Unfortunately, there is no type for this yet.
Name string `json:"name"`
Arguments map[string]any `json:"arguments,omitempty"`
Meta *struct {
ProgressToken mcp.ProgressToken `json:"progressToken,omitempty"`
} `json:"_meta,omitempty"`
}{
Name: name,
Arguments: args,
},
}
bs, err := json.Marshal(req)
require.NoError(t, err, "failed to marshal JSON RPC request")
return string(bs)
}
// makeJSONRPCTextResponse is a helper function that makes a JSON RPC text response
func makeJSONRPCTextResponse(t *testing.T, text string) string {
t.Helper()
resp := mcp.JSONRPCResponse{
ID: "1",
JSONRPC: "2.0",
Result: mcp.CallToolResult{
Content: []mcp.Content{
mcp.NewTextContent(text),
},
},
}
bs, err := json.Marshal(resp)
require.NoError(t, err, "failed to marshal JSON RPC response")
return string(bs)
}
func unmarshalFromCallToolResult[T any](t *testing.T, raw string) T {
t.Helper()
var resp map[string]any
require.NoError(t, json.Unmarshal([]byte(raw), &resp), "failed to unmarshal JSON RPC response")
res, ok := resp["result"].(map[string]any)
require.True(t, ok, "expected a result field in the response")
ct, ok := res["content"].([]any)
require.True(t, ok, "expected a content field in the result")
require.Len(t, ct, 1, "expected a single content item in the result")
ct0, ok := ct[0].(map[string]any)
require.True(t, ok, "expected a content item in the result")
txt, ok := ct0["text"].(string)
require.True(t, ok, "expected a text field in the content item")
var actual T
require.NoError(t, json.Unmarshal([]byte(txt), &actual), "failed to unmarshal content")
return actual
}
// startTestMCPServer is a helper function that starts a MCP server listening on
// a pty. It is the responsibility of the caller to close the server.
func startTestMCPServer(ctx context.Context, t testing.TB, stdin io.Reader, stdout io.Writer) (*server.MCPServer, func() error) {
t.Helper()
mcpSrv := server.NewMCPServer(
"Test Server",
"0.0.0",
server.WithInstructions(""),
server.WithLogging(),
)
stdioSrv := server.NewStdioServer(mcpSrv)
cancelCtx, cancel := context.WithCancel(ctx)
closeCh := make(chan struct{})
done := make(chan error)
go func() {
defer close(done)
srvErr := stdioSrv.Listen(cancelCtx, stdin, stdout)
done <- srvErr
}()
go func() {
select {
case <-closeCh:
cancel()
case <-done:
cancel()
}
}()
return mcpSrv, func() error {
close(closeCh)
return <-done
}
}