mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
fix(mcp): report task status correctly (#17187)
This commit is contained in:
@ -4,14 +4,18 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"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/serpent"
|
||||
)
|
||||
@ -194,11 +198,13 @@ func (r *RootCmd) mcpServer() *serpent.Command {
|
||||
client = new(codersdk.Client)
|
||||
instructions string
|
||||
allowedTools []string
|
||||
appStatusSlug string
|
||||
mcpServerAgent bool
|
||||
)
|
||||
return &serpent.Command{
|
||||
Use: "server",
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return mcpServerHandler(inv, client, instructions, allowedTools)
|
||||
return mcpServerHandler(inv, client, instructions, allowedTools, appStatusSlug, mcpServerAgent)
|
||||
},
|
||||
Short: "Start the Coder MCP server.",
|
||||
Middleware: serpent.Chain(
|
||||
@ -209,24 +215,39 @@ func (r *RootCmd) mcpServer() *serpent.Command {
|
||||
Name: "instructions",
|
||||
Description: "The instructions to pass to the MCP server.",
|
||||
Flag: "instructions",
|
||||
Env: "CODER_MCP_INSTRUCTIONS",
|
||||
Value: serpent.StringOf(&instructions),
|
||||
},
|
||||
{
|
||||
Name: "allowed-tools",
|
||||
Description: "Comma-separated list of allowed tools. If not specified, all tools are allowed.",
|
||||
Flag: "allowed-tools",
|
||||
Env: "CODER_MCP_ALLOWED_TOOLS",
|
||||
Value: serpent.StringArrayOf(&allowedTools),
|
||||
},
|
||||
{
|
||||
Name: "app-status-slug",
|
||||
Description: "When reporting a task, the coder_app slug under which to report the task.",
|
||||
Flag: "app-status-slug",
|
||||
Env: "CODER_MCP_APP_STATUS_SLUG",
|
||||
Value: serpent.StringOf(&appStatusSlug),
|
||||
Default: "",
|
||||
},
|
||||
{
|
||||
Flag: "agent",
|
||||
Env: "CODER_MCP_SERVER_AGENT",
|
||||
Description: "Start the MCP server in agent mode, with a different set of tools.",
|
||||
Value: serpent.BoolOf(&mcpServerAgent),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instructions string, allowedTools []string) error {
|
||||
//nolint:revive // control coupling
|
||||
func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instructions string, allowedTools []string, appStatusSlug string, mcpServerAgent bool) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
defer cancel()
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stdout))
|
||||
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
if err != nil {
|
||||
cliui.Errorf(inv.Stderr, "Failed to log in to the Coder deployment.")
|
||||
@ -253,19 +274,40 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct
|
||||
inv.Stderr = invStderr
|
||||
}()
|
||||
|
||||
options := []codermcp.Option{
|
||||
codermcp.WithInstructions(instructions),
|
||||
codermcp.WithLogger(&logger),
|
||||
mcpSrv := server.NewMCPServer(
|
||||
"Coder Agent",
|
||||
buildinfo.Version(),
|
||||
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),
|
||||
}
|
||||
|
||||
// Add allowed tools option if specified
|
||||
if mcpServerAgent {
|
||||
// Get the workspace agent token from the environment.
|
||||
agentToken, ok := os.LookupEnv("CODER_AGENT_TOKEN")
|
||||
if !ok || agentToken == "" {
|
||||
return xerrors.New("CODER_AGENT_TOKEN is not set")
|
||||
}
|
||||
toolDeps.AgentClient.SetSessionToken(agentToken)
|
||||
}
|
||||
|
||||
// Register tools based on the allowlist (if specified)
|
||||
reg := codermcp.AllTools()
|
||||
if len(allowedTools) > 0 {
|
||||
options = append(options, codermcp.WithAllowedTools(allowedTools))
|
||||
reg = reg.WithOnlyAllowed(allowedTools...)
|
||||
}
|
||||
|
||||
srv := codermcp.NewStdio(client, options...)
|
||||
srv.SetErrorLogger(log.New(invStderr, "", log.LstdFlags))
|
||||
reg.Register(mcpSrv, toolDeps)
|
||||
|
||||
srv := server.NewStdioServer(mcpSrv)
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
121
mcp/mcp.go
121
mcp/mcp.go
@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@ -17,76 +16,12 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"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"
|
||||
)
|
||||
|
||||
type mcpOptions struct {
|
||||
instructions string
|
||||
logger *slog.Logger
|
||||
allowedTools []string
|
||||
}
|
||||
|
||||
// Option is a function that configures the MCP server.
|
||||
type Option func(*mcpOptions)
|
||||
|
||||
// WithInstructions sets the instructions for the MCP server.
|
||||
func WithInstructions(instructions string) Option {
|
||||
return func(o *mcpOptions) {
|
||||
o.instructions = instructions
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets the logger for the MCP server.
|
||||
func WithLogger(logger *slog.Logger) Option {
|
||||
return func(o *mcpOptions) {
|
||||
o.logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
// WithAllowedTools sets the allowed tools for the MCP server.
|
||||
func WithAllowedTools(tools []string) Option {
|
||||
return func(o *mcpOptions) {
|
||||
o.allowedTools = tools
|
||||
}
|
||||
}
|
||||
|
||||
// NewStdio creates a new MCP stdio server with the given client and options.
|
||||
// It is the responsibility of the caller to start and stop the server.
|
||||
func NewStdio(client *codersdk.Client, opts ...Option) *server.StdioServer {
|
||||
options := &mcpOptions{
|
||||
instructions: ``,
|
||||
logger: ptr.Ref(slog.Make(sloghuman.Sink(os.Stdout))),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
mcpSrv := server.NewMCPServer(
|
||||
"Coder Agent",
|
||||
buildinfo.Version(),
|
||||
server.WithInstructions(options.instructions),
|
||||
)
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(os.Stdout))
|
||||
|
||||
// Register tools based on the allowed list (if specified)
|
||||
reg := AllTools()
|
||||
if len(options.allowedTools) > 0 {
|
||||
reg = reg.WithOnlyAllowed(options.allowedTools...)
|
||||
}
|
||||
reg.Register(mcpSrv, ToolDeps{
|
||||
Client: client,
|
||||
Logger: &logger,
|
||||
})
|
||||
|
||||
srv := server.NewStdioServer(mcpSrv)
|
||||
return srv
|
||||
}
|
||||
|
||||
// allTools is the list of all available tools. When adding a new tool,
|
||||
// make sure to update this list.
|
||||
var allTools = ToolRegistry{
|
||||
@ -120,6 +55,8 @@ Choose an emoji that helps the user understand the current phase at a glance.`),
|
||||
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,
|
||||
},
|
||||
@ -266,7 +203,9 @@ Can be either "start" or "stop".`)),
|
||||
// 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
|
||||
@ -317,14 +256,19 @@ type handleCoderReportTaskArgs struct {
|
||||
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'm working on the login page.", "link": "https://github.com/coder/coder/pull/1234", "emoji": "🔍", "done": false}}}
|
||||
// {"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.Client == nil {
|
||||
return nil, xerrors.New("developer error: client is required")
|
||||
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
|
||||
@ -334,20 +278,33 @@ func handleCoderReportTask(deps ToolDeps) server.ToolHandlerFunc {
|
||||
return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Waiting on support for tasks.
|
||||
deps.Logger.Info(ctx, "report task tool called", slog.F("summary", args.Summary), slog.F("link", args.Link), slog.F("done", args.Done), slog.F("emoji", args.Emoji))
|
||||
/*
|
||||
err := sdk.PostTask(ctx, agentsdk.PostTaskRequest{
|
||||
Reporter: "claude",
|
||||
Summary: summary,
|
||||
URL: link,
|
||||
Completion: done,
|
||||
Icon: emoji,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 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{
|
||||
|
@ -17,7 +17,9 @@ import (
|
||||
"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"
|
||||
)
|
||||
@ -39,7 +41,14 @@ func TestCoderTools(t *testing.T) {
|
||||
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: member.ID,
|
||||
}).WithAgent().Do()
|
||||
}).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
|
||||
@ -65,9 +74,12 @@ func TestCoderTools(t *testing.T) {
|
||||
|
||||
// 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) {
|
||||
@ -86,24 +98,44 @@ func TestCoderTools(t *testing.T) {
|
||||
})
|
||||
|
||||
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,
|
||||
"coder_url": client.URL.String(),
|
||||
"coder_session_token": client.SessionToken(),
|
||||
"need_user_attention": true,
|
||||
})
|
||||
|
||||
pty.WriteLine(ctr)
|
||||
_ = pty.ReadLine(ctx) // skip the echo
|
||||
|
||||
// Then: the response is a success message.
|
||||
// TODO: check the task was created. This functionality is not yet implemented.
|
||||
expected := makeJSONRPCTextResponse(t, "Thanks for reporting!")
|
||||
// Then: positive feedback is given to the reporting agent.
|
||||
actual := pty.ReadLine(ctx)
|
||||
testutil.RequireJSONEq(t, expected, actual)
|
||||
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) {
|
||||
|
Reference in New Issue
Block a user