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.
This commit is contained in:
Michael Suchacz
2026-04-21 12:26:12 +02:00
committed by GitHub
parent c968a1f3a3
commit 9d0469fc4c
21 changed files with 998 additions and 113 deletions
+14 -4
View File
@@ -171,6 +171,7 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
Availability: strings.TrimSpace(req.Availability),
Enabled: req.Enabled,
ModelIntent: req.ModelIntent,
AllowInPlanMode: req.AllowInPlanMode,
CreatedBy: apiKey.UserID,
UpdatedBy: apiKey.UserID,
})
@@ -258,6 +259,7 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
Availability: inserted.Availability,
Enabled: inserted.Enabled,
ModelIntent: inserted.ModelIntent,
AllowInPlanMode: inserted.AllowInPlanMode,
UpdatedBy: apiKey.UserID,
})
if err != nil {
@@ -326,6 +328,7 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
Availability: strings.TrimSpace(req.Availability),
Enabled: req.Enabled,
ModelIntent: req.ModelIntent,
AllowInPlanMode: req.AllowInPlanMode,
CreatedBy: apiKey.UserID,
UpdatedBy: apiKey.UserID,
})
@@ -580,6 +583,11 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
modelIntent = *req.ModelIntent
}
allowInPlanMode := existing.AllowInPlanMode
if req.AllowInPlanMode != nil {
allowInPlanMode = *req.AllowInPlanMode
}
// When auth_type changes, clear fields belonging to the
// previous auth type so stale secrets don't persist.
if authType != existing.AuthType {
@@ -648,6 +656,7 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
Availability: availability,
Enabled: enabled,
ModelIntent: modelIntent,
AllowInPlanMode: allowInPlanMode,
UpdatedBy: apiKey.UserID,
ID: existing.ID,
})
@@ -1129,10 +1138,11 @@ func convertMCPServerConfig(config database.MCPServerConfig) codersdk.MCPServerC
Availability: config.Availability,
Enabled: config.Enabled,
ModelIntent: config.ModelIntent,
CreatedAt: config.CreatedAt,
UpdatedAt: config.UpdatedAt,
Enabled: config.Enabled,
ModelIntent: config.ModelIntent,
AllowInPlanMode: config.AllowInPlanMode,
CreatedAt: config.CreatedAt,
UpdatedAt: config.UpdatedAt,
}
}