Files
coder/codersdk/mcp.go
T
Michael Suchacz 9d0469fc4c feat: allow approved external MCP tools in root plan mode (#24509)
## Summary

Allow root plan-mode chats to use MCP tools from external servers that
an admin has explicitly approved for plan mode. Workspace MCP and
plan-mode subagents remain blocked.

## Problem

`chatd.go` excluded every MCP tool when `isPlanModeTurn` was true, so
planning had no access to tools like docs search, ticketing, etc.
Lifting that guard wholesale was unsafe: `mcp_server_configs` already
has centralized admin governance, but workspace-local MCP (discovered
from agent `.mcp.json`) does not, and subagents use a narrower trust
boundary.

## Fix

Add an admin-controlled per-server `allow_in_plan_mode` flag (default
`false`) and gate plan-mode MCP access on it.

### Backend / schema
- New migration `000472_mcp_server_allow_in_plan_mode.{up,down}.sql` and
matching fixture update.
- `mcpserverconfigs.sql` + generated code: persist and read the new
column.
- `codersdk/mcp.go`: thread the field through `MCPServerConfig`,
`Create*`, and `Update*` request types.
- `coderd/mcp.go`: validate, persist, and return the flag in
get/list/create/update handlers.

### chatd
- `coderd/x/chatd/chatd.go`: pre-filter selected external MCP configs by
`AllowInPlanMode` before calling `mcpclient.ConnectAll` on plan-mode
root turns. Workspace MCP discovery is skipped entirely on plan-mode
turns.
- Single helper decides whether a tool is available in plan mode, used
both at construction and for active-tool filtering (defense in depth).
Plan-mode subagents, dynamic tools, provider-native tools, computer-use,
and workspace MCP stay unchanged.
- `coderd/x/chatd/prompt.go`: update the root plan-mode overlay text to
match the new boundary.

### UI
- `MCPServerAdminPanel.tsx`: add an explicit toggle ("Allow all tools
from this MCP server in root plan mode") next to the existing governance
controls.
- Regenerated `site/src/api/typesGenerated.ts`.

### Docs
- `docs/ai-coder/agents/architecture.md`: replace the blanket "MCP is
unavailable in plan mode" note with the new root-only, external-only,
admin-approved policy. Explicitly call out that workspace MCP and
plan-mode subagents are still excluded.

### Tests
- Plan-mode visibility (approved vs non-approved external server).
- Plan-mode invocation of an approved external MCP tool.
- End-to-end plan-mode workflow that uses an approved MCP tool and then
reaches `propose_plan`.
- Regressions: workspace MCP still excluded in plan mode; plan-mode
subagents still on the restricted tool boundary; existing tool
allow/deny list filtering still applies.

## Policy precedence

`allow_in_plan_mode` is an **additional** requirement on top of existing
`enabled`, availability, chat-selected / forced server IDs, and tool
allow/deny lists. It approves **all tools on that server** for root plan
mode; a per-tool plan allowlist is deliberately deferred.

## Follow-ups (explicitly out of scope)

- Whether plan-mode subagents should inherit approved external MCP
tools.
- Workspace-local MCP safety model (agent-side `.mcp.json` schema vs. a
coderd-managed workspace MCP config).

## Validation

- `go vet ./coderd/x/chatd/...`
- `go test ./coderd/x/chatd -run 'TestPlan.*|TestMCP.*' -count=1`
- `go test ./coderd/x/chatd -count=1 -timeout 5m` (full chatd suite)
- `make fmt` (no diff)

> Mux opened this PR on Mike's behalf.
2026-04-21 12:26:12 +02:00

198 lines
7.7 KiB
Go

package codersdk
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
)
// MCPServerOAuth2ConnectURL returns the URL the user should visit to
// start the OAuth2 flow for an MCP server. The frontend opens this
// in a new window/popup.
func (c *Client) MCPServerOAuth2ConnectURL(id uuid.UUID) string {
return fmt.Sprintf("%s/api/experimental/mcp/servers/%s/oauth2/connect", c.URL.String(), id)
}
// MCPServerOAuth2Disconnect removes the user's OAuth2 token for an
// MCP server.
func (c *Client) MCPServerOAuth2Disconnect(ctx context.Context, id uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/disconnect", id), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// MCPServerConfig represents an admin-configured MCP server.
type MCPServerConfig struct {
ID uuid.UUID `json:"id" format:"uuid"`
DisplayName string `json:"display_name"`
Slug string `json:"slug"`
Description string `json:"description"`
IconURL string `json:"icon_url"`
Transport string `json:"transport"` // "streamable_http" or "sse"
URL string `json:"url"`
AuthType string `json:"auth_type"` // "none", "oauth2", "api_key", "custom_headers"
// OAuth2 fields (only populated for admins).
OAuth2ClientID string `json:"oauth2_client_id,omitempty"`
HasOAuth2Secret bool `json:"has_oauth2_secret"`
OAuth2AuthURL string `json:"oauth2_auth_url,omitempty"`
OAuth2TokenURL string `json:"oauth2_token_url,omitempty"`
OAuth2Scopes string `json:"oauth2_scopes,omitempty"`
// API key fields (only populated for admins).
APIKeyHeader string `json:"api_key_header,omitempty"`
HasAPIKey bool `json:"has_api_key"`
HasCustomHeaders bool `json:"has_custom_headers"`
// Tool governance.
ToolAllowList []string `json:"tool_allow_list"`
ToolDenyList []string `json:"tool_deny_list"`
// Availability policy set by admin.
Availability string `json:"availability"` // "force_on", "default_on", "default_off"
Enabled bool `json:"enabled"`
ModelIntent bool `json:"model_intent"`
AllowInPlanMode bool `json:"allow_in_plan_mode"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
// Per-user state (populated for non-admin requests).
AuthConnected bool `json:"auth_connected"`
}
// CreateMCPServerConfigRequest is the request to create a new MCP server config.
type CreateMCPServerConfigRequest struct {
DisplayName string `json:"display_name" validate:"required"`
Slug string `json:"slug" validate:"required"`
Description string `json:"description"`
IconURL string `json:"icon_url"`
Transport string `json:"transport" validate:"required,oneof=streamable_http sse"`
URL string `json:"url" validate:"required,url"`
AuthType string `json:"auth_type" validate:"required,oneof=none oauth2 api_key custom_headers"`
OAuth2ClientID string `json:"oauth2_client_id,omitempty"`
OAuth2ClientSecret string `json:"oauth2_client_secret,omitempty"`
OAuth2AuthURL string `json:"oauth2_auth_url,omitempty" validate:"omitempty,url"`
OAuth2TokenURL string `json:"oauth2_token_url,omitempty" validate:"omitempty,url"`
OAuth2Scopes string `json:"oauth2_scopes,omitempty"`
APIKeyHeader string `json:"api_key_header,omitempty"`
APIKeyValue string `json:"api_key_value,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
ToolAllowList []string `json:"tool_allow_list,omitempty"`
ToolDenyList []string `json:"tool_deny_list,omitempty"`
Availability string `json:"availability" validate:"required,oneof=force_on default_on default_off"`
Enabled bool `json:"enabled"`
ModelIntent bool `json:"model_intent"`
AllowInPlanMode bool `json:"allow_in_plan_mode"`
}
// UpdateMCPServerConfigRequest is the request to update an MCP server config.
type UpdateMCPServerConfigRequest struct {
DisplayName *string `json:"display_name,omitempty"`
Slug *string `json:"slug,omitempty"`
Description *string `json:"description,omitempty"`
IconURL *string `json:"icon_url,omitempty"`
Transport *string `json:"transport,omitempty" validate:"omitempty,oneof=streamable_http sse"`
URL *string `json:"url,omitempty" validate:"omitempty,url"`
AuthType *string `json:"auth_type,omitempty" validate:"omitempty,oneof=none oauth2 api_key custom_headers"`
OAuth2ClientID *string `json:"oauth2_client_id,omitempty"`
OAuth2ClientSecret *string `json:"oauth2_client_secret,omitempty"`
OAuth2AuthURL *string `json:"oauth2_auth_url,omitempty" validate:"omitempty,url"`
OAuth2TokenURL *string `json:"oauth2_token_url,omitempty" validate:"omitempty,url"`
OAuth2Scopes *string `json:"oauth2_scopes,omitempty"`
APIKeyHeader *string `json:"api_key_header,omitempty"`
APIKeyValue *string `json:"api_key_value,omitempty"`
CustomHeaders *map[string]string `json:"custom_headers,omitempty"`
ToolAllowList *[]string `json:"tool_allow_list,omitempty"`
ToolDenyList *[]string `json:"tool_deny_list,omitempty"`
Availability *string `json:"availability,omitempty" validate:"omitempty,oneof=force_on default_on default_off"`
Enabled *bool `json:"enabled,omitempty"`
ModelIntent *bool `json:"model_intent,omitempty"`
AllowInPlanMode *bool `json:"allow_in_plan_mode,omitempty"`
}
func (c *Client) MCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/mcp/servers", nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var configs []MCPServerConfig
return configs, json.NewDecoder(res.Body).Decode(&configs)
}
func (c *Client) MCPServerConfigByID(ctx context.Context, id uuid.UUID) (MCPServerConfig, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/mcp/servers/%s", id), nil)
if err != nil {
return MCPServerConfig{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return MCPServerConfig{}, ReadBodyAsError(res)
}
var config MCPServerConfig
return config, json.NewDecoder(res.Body).Decode(&config)
}
func (c *Client) CreateMCPServerConfig(ctx context.Context, req CreateMCPServerConfigRequest) (MCPServerConfig, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/experimental/mcp/servers", req)
if err != nil {
return MCPServerConfig{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return MCPServerConfig{}, ReadBodyAsError(res)
}
var config MCPServerConfig
return config, json.NewDecoder(res.Body).Decode(&config)
}
func (c *Client) UpdateMCPServerConfig(ctx context.Context, id uuid.UUID, req UpdateMCPServerConfigRequest) (MCPServerConfig, error) {
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/mcp/servers/%s", id), req)
if err != nil {
return MCPServerConfig{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return MCPServerConfig{}, ReadBodyAsError(res)
}
var config MCPServerConfig
return config, json.NewDecoder(res.Body).Decode(&config)
}
func (c *Client) DeleteMCPServerConfig(ctx context.Context, id uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/mcp/servers/%s", id), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}