Skip to content

feat: add status watcher to MCP server #18320

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 15 commits into from
Jun 13, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Rename LLM to AI
Also, any references to the API are renamed as "AgentAPI" rather than
just "agent".
  • Loading branch information
code-asher committed Jun 13, 2025
commit a79ee722be4497ef3adf7a021eb992b81e9effe2
81 changes: 41 additions & 40 deletions cli/exp_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import (

const (
envAppStatusSlug = "CODER_MCP_APP_STATUS_SLUG"
envLLMAgentURL = "CODER_MCP_LLM_AGENT_URL"
envAIAgentAPIURL = "CODER_MCP_AI_AGENTAPI_URL"
)

func (r *RootCmd) mcpCommand() *serpent.Command {
Expand Down Expand Up @@ -126,7 +126,7 @@ func (r *RootCmd) mcpConfigureClaudeCode() *serpent.Command {
coderPrompt string
appStatusSlug string
testBinaryName string
llmAgentURL url.URL
aiAgentAPIURL url.URL

deprecatedCoderMCPClaudeAPIKey string
)
Expand Down Expand Up @@ -165,8 +165,8 @@ func (r *RootCmd) mcpConfigureClaudeCode() *serpent.Command {
if appStatusSlug != "" {
configureClaudeEnv[envAppStatusSlug] = appStatusSlug
}
if llmAgentURL.String() != "" {
configureClaudeEnv[envLLMAgentURL] = llmAgentURL.String()
if aiAgentAPIURL.String() != "" {
configureClaudeEnv[envAIAgentAPIURL] = aiAgentAPIURL.String()
}
if deprecatedSystemPromptEnv, ok := os.LookupEnv("SYSTEM_PROMPT"); ok {
cliui.Warnf(inv.Stderr, "SYSTEM_PROMPT is deprecated, use CODER_MCP_CLAUDE_SYSTEM_PROMPT instead")
Expand Down Expand Up @@ -267,10 +267,10 @@ func (r *RootCmd) mcpConfigureClaudeCode() *serpent.Command {
Value: serpent.StringOf(&appStatusSlug),
},
{
Flag: "llm-agent-url",
Description: "The URL of the LLM agent API, used to listen for status updates.",
Env: envLLMAgentURL,
Value: serpent.URLOf(&llmAgentURL),
Flag: "ai-agentapi-url",
Description: "The URL of the AI AgentAPI, used to listen for status updates.",
Env: envAIAgentAPIURL,
Value: serpent.URLOf(&aiAgentAPIURL),
},
{
Name: "test-binary-name",
Expand Down Expand Up @@ -370,11 +370,11 @@ type taskReport struct {
}

type mcpServer struct {
agentClient *agentsdk.Client
appStatusSlug string
client *codersdk.Client
llmClient *agentapi.Client
queue *cliutil.Queue[taskReport]
agentClient *agentsdk.Client
appStatusSlug string
client *codersdk.Client
aiAgentAPIClient *agentapi.Client
queue *cliutil.Queue[taskReport]
}

func (r *RootCmd) mcpServer() *serpent.Command {
Expand All @@ -383,13 +383,13 @@ func (r *RootCmd) mcpServer() *serpent.Command {
instructions string
allowedTools []string
appStatusSlug string
llmAgentURL url.URL
aiAgentAPIURL url.URL
)
return &serpent.Command{
Use: "server",
Handler: func(inv *serpent.Invocation) error {
// lastUserMessageID is the ID of the last *user* message that we saw. A
// user message only happens when interacting via the LLM agent API (as
// user message only happens when interacting via the AI AgentAPI (as
// opposed to interacting with the terminal directly).
var lastUserMessageID int64
var lastReport taskReport
Expand All @@ -399,14 +399,15 @@ func (r *RootCmd) mcpServer() *serpent.Command {
// new user message, and the status is "working" and not self-reported
// (meaning it came from the screen watcher), then it means one of two
// things:
// 1. The LLM is still working, so there is nothing to update.
// 2. The LLM stopped working, then the user has interacted with the
// terminal directly. For now, we are ignoring these updates. This
// risks missing cases where the user manually submits a new prompt
// and the LLM becomes active and does not update itself, but it
// avoids spamming useless status updates as the user is typing, so
// the tradeoff is worth it. In the future, if we can reliably
// distinguish between user and LLM activity, we can change this.
// 1. The AI agent is still working, so there is nothing to update.
// 2. The AI agent stopped working, then the user has interacted with
// the terminal directly. For now, we are ignoring these updates.
// This risks missing cases where the user manually submits a new
// prompt and the AI agent becomes active and does not update itself,
// but it avoids spamming useless status updates as the user is
// typing, so the tradeoff is worth it. In the future, if we can
// reliably distinguish between user and AI agent activity, we can
// change this.
if report.messageID > lastUserMessageID {
report.state = codersdk.WorkspaceAppStatusStateWorking
} else if report.state == codersdk.WorkspaceAppStatusStateWorking && !report.selfReported {
Expand Down Expand Up @@ -475,20 +476,20 @@ func (r *RootCmd) mcpServer() *serpent.Command {
cliui.Infof(inv.Stderr, "Task reporter : Enabled")
}

// Try to create a client for the LLM agent API, which is used to get the
// Try to create a client for the AI AgentAPI, which is used to get the
// screen status to make the status reporting more robust. No auth
// needed, so no validation.
if llmAgentURL.String() == "" {
cliui.Infof(inv.Stderr, "LLM agent URL : Not configured")
if aiAgentAPIURL.String() == "" {
cliui.Infof(inv.Stderr, "AI AgentAPI URL : Not configured")
} else {
cliui.Infof(inv.Stderr, "LLM agent URL : %s", llmAgentURL.String())
llmClient, err := agentapi.NewClient(llmAgentURL.String())
cliui.Infof(inv.Stderr, "AI AgentAPI URL : %s", aiAgentAPIURL.String())
aiAgentAPIClient, err := agentapi.NewClient(aiAgentAPIURL.String())
if err != nil {
cliui.Infof(inv.Stderr, "Screen events : Disabled")
cliui.Warnf(inv.Stderr, "%s must be set", envLLMAgentURL)
cliui.Warnf(inv.Stderr, "%s must be set", envAIAgentAPIURL)
} else {
cliui.Infof(inv.Stderr, "Screen events : Enabled")
srv.llmClient = llmClient
srv.aiAgentAPIClient = aiAgentAPIClient
}
}

Expand All @@ -499,10 +500,10 @@ func (r *RootCmd) mcpServer() *serpent.Command {
cliui.Infof(inv.Stderr, "Failed to watch screen events")
// Start the reporter, watcher, and server. These are all tied to the
// lifetime of the MCP server, which is itself tied to the lifetime of the
// LLM agent.
// AI agent.
if srv.agentClient != nil && appStatusSlug != "" {
srv.startReporter(ctx, inv)
if srv.llmClient != nil {
if srv.aiAgentAPIClient != nil {
srv.startWatcher(ctx, inv)
}
}
Expand Down Expand Up @@ -536,10 +537,10 @@ func (r *RootCmd) mcpServer() *serpent.Command {
Default: "",
},
{
Flag: "llm-agent-url",
Description: "The URL of the LLM agent API, used to listen for status updates.",
Env: envLLMAgentURL,
Value: serpent.URLOf(&llmAgentURL),
Flag: "ai-agentapi-url",
Description: "The URL of the AI AgentAPI, used to listen for status updates.",
Env: envAIAgentAPIURL,
Value: serpent.URLOf(&aiAgentAPIURL),
},
},
}
Expand All @@ -549,9 +550,9 @@ func (s *mcpServer) startReporter(ctx context.Context, inv *serpent.Invocation)
go func() {
for {
// TODO: Even with the queue, there is still the potential that a message
// from the screen watcher and a message from the LLM could arrive out of
// order if the timing is just right. We might want to wait a bit, then
// check if the status has changed before committing.
// from the screen watcher and a message from the AI agent could arrive
// out of order if the timing is just right. We might want to wait a bit,
// then check if the status has changed before committing.
item, ok := s.queue.Pop()
if !ok {
return
Expand All @@ -571,7 +572,7 @@ func (s *mcpServer) startReporter(ctx context.Context, inv *serpent.Invocation)
}

func (s *mcpServer) startWatcher(ctx context.Context, inv *serpent.Invocation) {
eventsCh, errCh, err := s.llmClient.SubscribeEvents(ctx)
eventsCh, errCh, err := s.aiAgentAPIClient.SubscribeEvents(ctx)
if err != nil {
cliui.Warnf(inv.Stderr, "Failed to watch screen events: %s", err)
return
Expand Down
34 changes: 17 additions & 17 deletions cli/exp_mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ test-system-prompt
"CODER_AGENT_URL": "%s",
"CODER_AGENT_TOKEN": "test-agent-token",
"CODER_MCP_APP_STATUS_SLUG": "some-app-name",
"CODER_MCP_LLM_AGENT_URL": "http://localhost:3284"
"CODER_MCP_AI_AGENTAPI_URL": "http://localhost:3284"
}
}
}
Expand All @@ -391,7 +391,7 @@ test-system-prompt
"--claude-test-binary-name=pathtothecoderbinary",
"--agent-url", client.URL.String(),
"--agent-token", "test-agent-token",
"--llm-agent-url", "http://localhost:3284",
"--ai-agentapi-url", "http://localhost:3284",
)
clitest.SetupConfig(t, client, root)

Expand Down Expand Up @@ -741,7 +741,7 @@ func TestExpMcpReporter(t *testing.T) {
"--agent-url", client.URL.String(),
"--agent-token", "fake-agent-token",
"--app-status-slug", "vscode",
"--llm-agent-url", "not a valid url",
"--ai-agentapi-url", "not a valid url",
)
inv = inv.WithContext(ctx)

Expand Down Expand Up @@ -804,7 +804,7 @@ func TestExpMcpReporter(t *testing.T) {

ctx, cancel := context.WithCancel(testutil.Context(t, testutil.WaitShort))

// Mock the LLM agent API server.
// Mock the AI AgentAPI server.
listening := make(chan func(sse codersdk.ServerSentEvent) error)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
send, closed, err := httpapi.ServerSentEventSender(w, r)
Expand All @@ -821,7 +821,7 @@ func TestExpMcpReporter(t *testing.T) {
<-closed
}))
t.Cleanup(srv.Close)
llmAgentURL := srv.URL
aiAgentAPIURL := srv.URL

// Watch the workspace for changes.
watcher, err := client.WatchWorkspace(ctx, r.Workspace.ID)
Expand All @@ -844,11 +844,11 @@ func TestExpMcpReporter(t *testing.T) {

inv, _ := clitest.New(t,
"exp", "mcp", "server",
// We need the agent credentials, LLM API url, and a slug for reporting.
// We need the agent credentials, AI AgentAPI url, and a slug for reporting.
"--agent-url", client.URL.String(),
"--agent-token", r.AgentToken,
"--app-status-slug", "vscode",
"--llm-agent-url", llmAgentURL,
"--ai-agentapi-url", aiAgentAPIURL,
"--allowed-tools=coder_report_task",
)
inv = inv.WithContext(ctx)
Expand Down Expand Up @@ -878,13 +878,13 @@ func TestExpMcpReporter(t *testing.T) {
tests := []struct {
// event simulates an event from the screen watcher.
event *codersdk.ServerSentEvent
// state, summary, and uri simulate a tool call from the LLM.
// state, summary, and uri simulate a tool call from the AI agent.
state codersdk.WorkspaceAppStatusState
summary string
uri string
expected *codersdk.WorkspaceAppStatus
}{
// First the LLM updates with a state change.
// First the AI agent updates with a state change.
{
state: codersdk.WorkspaceAppStatusStateWorking,
summary: "doing work",
Expand All @@ -895,8 +895,8 @@ func TestExpMcpReporter(t *testing.T) {
URI: "https://dev.coder.com",
},
},
// Terminal goes quiet but the LLM forgot the update, and it is caught by
// the screen watcher. Message and URI are preserved.
// Terminal goes quiet but the AI agent forgot the update, and it is
// caught by the screen watcher. Message and URI are preserved.
{
event: makeStatusEvent(agentapi.StatusStable),
expected: &codersdk.WorkspaceAppStatus{
Expand All @@ -910,17 +910,17 @@ func TestExpMcpReporter(t *testing.T) {
event: makeStatusEvent(agentapi.StatusStable),
},
// Terminal becomes active again according to the screen watcher, but no
// new user message. This could be the LLM being active again, but it
// could also be the user messing around. We will prefer not updating the
// status so the "working" update here should be skipped.
// new user message. This could be the AI agent being active again, but
// it could also be the user messing around. We will prefer not updating
// the status so the "working" update here should be skipped.
{
event: makeStatusEvent(agentapi.StatusRunning),
},
// Agent messages are ignored.
{
event: makeMessageEvent(1, agentapi.RoleAgent),
},
// LLM reports that it failed and URI is blank.
// AI agent reports that it failed and URI is blank.
{
state: codersdk.WorkspaceAppStatusStateFailure,
summary: "oops",
Expand All @@ -934,8 +934,8 @@ func TestExpMcpReporter(t *testing.T) {
{
event: makeStatusEvent(agentapi.StatusRunning),
},
// ... but this time we have a new user message so we know there is LLM
// activity. This time the "working" update will not be skipped.
// ... but this time we have a new user message so we know there is AI
// agent activity. This time the "working" update will not be skipped.
{
event: makeMessageEvent(2, agentapi.RoleUser),
expected: &codersdk.WorkspaceAppStatus{
Expand Down
Loading