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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user