Skip to content

fix(mcp): report task status correctly #17187

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 57 additions & 15 deletions cli/exp_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -191,14 +195,16 @@ func (*RootCmd) mcpConfigureCursor() *serpent.Command {

func (r *RootCmd) mcpServer() *serpent.Command {
var (
client = new(codersdk.Client)
instructions string
allowedTools []string
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(
Expand All @@ -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.")
Expand All @@ -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),
}

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)
}

// Add allowed tools option if specified
// 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)
Expand Down
135 changes: 46 additions & 89 deletions mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"io"
"os"
"slices"
"strings"
"time"
Expand All @@ -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{
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -265,8 +202,10 @@ Can be either "start" or "stop".`)),

// ToolDeps contains all dependencies needed by tool handlers
type ToolDeps struct {
Client *codersdk.Client
Logger *slog.Logger
Client *codersdk.Client
AgentClient *agentsdk.Client
Logger *slog.Logger
AppStatusSlug string
}

// ToolHandler associates a tool with its handler creation function
Expand Down Expand Up @@ -313,18 +252,23 @@ func AllTools() ToolRegistry {
}

type handleCoderReportTaskArgs struct {
Summary string `json:"summary"`
Link string `json:"link"`
Emoji string `json:"emoji"`
Done bool `json:"done"`
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'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
Expand All @@ -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{
Expand Down
Loading
Loading