mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
Generated
+1
@@ -1788,6 +1788,7 @@ CREATE TABLE mcp_server_configs (
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
model_intent boolean DEFAULT false NOT NULL,
|
||||
allow_in_plan_mode boolean DEFAULT false NOT NULL,
|
||||
CONSTRAINT mcp_server_configs_auth_type_check CHECK ((auth_type = ANY (ARRAY['none'::text, 'oauth2'::text, 'api_key'::text, 'custom_headers'::text]))),
|
||||
CONSTRAINT mcp_server_configs_availability_check CHECK ((availability = ANY (ARRAY['force_on'::text, 'default_on'::text, 'default_off'::text]))),
|
||||
CONSTRAINT mcp_server_configs_transport_check CHECK ((transport = ANY (ARRAY['streamable_http'::text, 'sse'::text])))
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE mcp_server_configs
|
||||
DROP COLUMN allow_in_plan_mode;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE mcp_server_configs
|
||||
ADD COLUMN allow_in_plan_mode BOOLEAN NOT NULL DEFAULT false;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- Migration 472 adds allow_in_plan_mode with a default of false.
|
||||
-- Flip the existing fixture row to true here so fixture data exercises
|
||||
-- the non-default state only after the column exists.
|
||||
UPDATE mcp_server_configs
|
||||
SET allow_in_plan_mode = TRUE
|
||||
WHERE id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
@@ -4729,6 +4729,7 @@ type MCPServerConfig struct {
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ModelIntent bool `db:"model_intent" json:"model_intent"`
|
||||
AllowInPlanMode bool `db:"allow_in_plan_mode" json:"allow_in_plan_mode"`
|
||||
}
|
||||
|
||||
type MCPServerUserToken struct {
|
||||
|
||||
@@ -12764,7 +12764,7 @@ func (q *sqlQuerier) DeleteMCPServerUserToken(ctx context.Context, arg DeleteMCP
|
||||
|
||||
const getEnabledMCPServerConfigs = `-- name: GetEnabledMCPServerConfigs :many
|
||||
SELECT
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode
|
||||
FROM
|
||||
mcp_server_configs
|
||||
WHERE
|
||||
@@ -12811,6 +12811,7 @@ func (q *sqlQuerier) GetEnabledMCPServerConfigs(ctx context.Context) ([]MCPServe
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ModelIntent,
|
||||
&i.AllowInPlanMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -12827,7 +12828,7 @@ func (q *sqlQuerier) GetEnabledMCPServerConfigs(ctx context.Context) ([]MCPServe
|
||||
|
||||
const getForcedMCPServerConfigs = `-- name: GetForcedMCPServerConfigs :many
|
||||
SELECT
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode
|
||||
FROM
|
||||
mcp_server_configs
|
||||
WHERE
|
||||
@@ -12875,6 +12876,7 @@ func (q *sqlQuerier) GetForcedMCPServerConfigs(ctx context.Context) ([]MCPServer
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ModelIntent,
|
||||
&i.AllowInPlanMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -12891,7 +12893,7 @@ func (q *sqlQuerier) GetForcedMCPServerConfigs(ctx context.Context) ([]MCPServer
|
||||
|
||||
const getMCPServerConfigByID = `-- name: GetMCPServerConfigByID :one
|
||||
SELECT
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode
|
||||
FROM
|
||||
mcp_server_configs
|
||||
WHERE
|
||||
@@ -12930,13 +12932,14 @@ func (q *sqlQuerier) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ModelIntent,
|
||||
&i.AllowInPlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getMCPServerConfigBySlug = `-- name: GetMCPServerConfigBySlug :one
|
||||
SELECT
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode
|
||||
FROM
|
||||
mcp_server_configs
|
||||
WHERE
|
||||
@@ -12975,13 +12978,14 @@ func (q *sqlQuerier) GetMCPServerConfigBySlug(ctx context.Context, slug string)
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ModelIntent,
|
||||
&i.AllowInPlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getMCPServerConfigs = `-- name: GetMCPServerConfigs :many
|
||||
SELECT
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode
|
||||
FROM
|
||||
mcp_server_configs
|
||||
ORDER BY
|
||||
@@ -13026,6 +13030,7 @@ func (q *sqlQuerier) GetMCPServerConfigs(ctx context.Context) ([]MCPServerConfig
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ModelIntent,
|
||||
&i.AllowInPlanMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -13042,7 +13047,7 @@ func (q *sqlQuerier) GetMCPServerConfigs(ctx context.Context) ([]MCPServerConfig
|
||||
|
||||
const getMCPServerConfigsByIDs = `-- name: GetMCPServerConfigsByIDs :many
|
||||
SELECT
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode
|
||||
FROM
|
||||
mcp_server_configs
|
||||
WHERE
|
||||
@@ -13089,6 +13094,7 @@ func (q *sqlQuerier) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UU
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ModelIntent,
|
||||
&i.AllowInPlanMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -13206,6 +13212,7 @@ INSERT INTO mcp_server_configs (
|
||||
availability,
|
||||
enabled,
|
||||
model_intent,
|
||||
allow_in_plan_mode,
|
||||
created_by,
|
||||
updated_by
|
||||
) VALUES (
|
||||
@@ -13232,11 +13239,12 @@ INSERT INTO mcp_server_configs (
|
||||
$21::text,
|
||||
$22::boolean,
|
||||
$23::boolean,
|
||||
$24::uuid,
|
||||
$25::uuid
|
||||
$24::boolean,
|
||||
$25::uuid,
|
||||
$26::uuid
|
||||
)
|
||||
RETURNING
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode
|
||||
`
|
||||
|
||||
type InsertMCPServerConfigParams struct {
|
||||
@@ -13263,6 +13271,7 @@ type InsertMCPServerConfigParams struct {
|
||||
Availability string `db:"availability" json:"availability"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
ModelIntent bool `db:"model_intent" json:"model_intent"`
|
||||
AllowInPlanMode bool `db:"allow_in_plan_mode" json:"allow_in_plan_mode"`
|
||||
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
|
||||
UpdatedBy uuid.UUID `db:"updated_by" json:"updated_by"`
|
||||
}
|
||||
@@ -13292,6 +13301,7 @@ func (q *sqlQuerier) InsertMCPServerConfig(ctx context.Context, arg InsertMCPSer
|
||||
arg.Availability,
|
||||
arg.Enabled,
|
||||
arg.ModelIntent,
|
||||
arg.AllowInPlanMode,
|
||||
arg.CreatedBy,
|
||||
arg.UpdatedBy,
|
||||
)
|
||||
@@ -13325,6 +13335,7 @@ func (q *sqlQuerier) InsertMCPServerConfig(ctx context.Context, arg InsertMCPSer
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ModelIntent,
|
||||
&i.AllowInPlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -13356,12 +13367,13 @@ SET
|
||||
availability = $21::text,
|
||||
enabled = $22::boolean,
|
||||
model_intent = $23::boolean,
|
||||
updated_by = $24::uuid,
|
||||
allow_in_plan_mode = $24::boolean,
|
||||
updated_by = $25::uuid,
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
id = $25::uuid
|
||||
id = $26::uuid
|
||||
RETURNING
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent
|
||||
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode
|
||||
`
|
||||
|
||||
type UpdateMCPServerConfigParams struct {
|
||||
@@ -13388,6 +13400,7 @@ type UpdateMCPServerConfigParams struct {
|
||||
Availability string `db:"availability" json:"availability"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
ModelIntent bool `db:"model_intent" json:"model_intent"`
|
||||
AllowInPlanMode bool `db:"allow_in_plan_mode" json:"allow_in_plan_mode"`
|
||||
UpdatedBy uuid.UUID `db:"updated_by" json:"updated_by"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
@@ -13417,6 +13430,7 @@ func (q *sqlQuerier) UpdateMCPServerConfig(ctx context.Context, arg UpdateMCPSer
|
||||
arg.Availability,
|
||||
arg.Enabled,
|
||||
arg.ModelIntent,
|
||||
arg.AllowInPlanMode,
|
||||
arg.UpdatedBy,
|
||||
arg.ID,
|
||||
)
|
||||
@@ -13450,6 +13464,7 @@ func (q *sqlQuerier) UpdateMCPServerConfig(ctx context.Context, arg UpdateMCPSer
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ModelIntent,
|
||||
&i.AllowInPlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ INSERT INTO mcp_server_configs (
|
||||
availability,
|
||||
enabled,
|
||||
model_intent,
|
||||
allow_in_plan_mode,
|
||||
created_by,
|
||||
updated_by
|
||||
) VALUES (
|
||||
@@ -104,6 +105,7 @@ INSERT INTO mcp_server_configs (
|
||||
@availability::text,
|
||||
@enabled::boolean,
|
||||
@model_intent::boolean,
|
||||
@allow_in_plan_mode::boolean,
|
||||
@created_by::uuid,
|
||||
@updated_by::uuid
|
||||
)
|
||||
@@ -137,6 +139,7 @@ SET
|
||||
availability = @availability::text,
|
||||
enabled = @enabled::boolean,
|
||||
model_intent = @model_intent::boolean,
|
||||
allow_in_plan_mode = @allow_in_plan_mode::boolean,
|
||||
updated_by = @updated_by::uuid,
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
|
||||
+14
-4
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-5
@@ -100,37 +100,52 @@ func TestMCPServerConfigsCRUD(t *testing.T) {
|
||||
require.Equal(t, "client-id-123", created.OAuth2ClientID)
|
||||
require.Equal(t, "default_on", created.Availability)
|
||||
require.True(t, created.Enabled)
|
||||
require.False(t, created.AllowInPlanMode)
|
||||
|
||||
// Verify the secret is indicated but never returned.
|
||||
require.True(t, created.HasOAuth2Secret)
|
||||
|
||||
// Verify the config appears in the list.
|
||||
// Verify the config appears in the list and direct get responses.
|
||||
configs, err := client.MCPServerConfigs(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, configs, 1)
|
||||
require.Equal(t, created.ID, configs[0].ID)
|
||||
require.True(t, configs[0].HasOAuth2Secret)
|
||||
require.False(t, configs[0].AllowInPlanMode)
|
||||
|
||||
// Update display name and availability.
|
||||
fetched, err := client.MCPServerConfigByID(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, created.ID, fetched.ID)
|
||||
require.False(t, fetched.AllowInPlanMode)
|
||||
|
||||
// Update display name, availability, and allow_in_plan_mode.
|
||||
newName := "Renamed Server"
|
||||
newAvail := "force_on"
|
||||
allowInPlanMode := true
|
||||
updated, err := client.UpdateMCPServerConfig(ctx, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
||||
DisplayName: &newName,
|
||||
Availability: &newAvail,
|
||||
DisplayName: &newName,
|
||||
Availability: &newAvail,
|
||||
AllowInPlanMode: &allowInPlanMode,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Renamed Server", updated.DisplayName)
|
||||
require.Equal(t, "force_on", updated.Availability)
|
||||
require.True(t, updated.AllowInPlanMode)
|
||||
// Unchanged fields should remain the same.
|
||||
require.Equal(t, "my-mcp-server", updated.Slug)
|
||||
require.Equal(t, "oauth2", updated.AuthType)
|
||||
|
||||
// Verify the update took effect through the list.
|
||||
// Verify the update took effect through the list and direct get.
|
||||
configs, err = client.MCPServerConfigs(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, configs, 1)
|
||||
require.Equal(t, "Renamed Server", configs[0].DisplayName)
|
||||
require.Equal(t, "force_on", configs[0].Availability)
|
||||
require.True(t, configs[0].AllowInPlanMode)
|
||||
|
||||
fetched, err = client.MCPServerConfigByID(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, fetched.AllowInPlanMode)
|
||||
|
||||
// Delete it.
|
||||
err = client.DeleteMCPServerConfig(ctx, created.ID)
|
||||
|
||||
+142
-53
@@ -5148,40 +5148,99 @@ func isExploreSubagentMode(mode database.NullChatMode) bool {
|
||||
return mode.Valid && mode.ChatMode == database.ChatModeExplore
|
||||
}
|
||||
|
||||
func allowedPlanToolNames(
|
||||
allTools []fantasy.AgentTool,
|
||||
func filterExternalMCPConfigsForTurn(
|
||||
configs []database.MCPServerConfig,
|
||||
mode database.NullChatPlanMode,
|
||||
parentChatID uuid.NullUUID,
|
||||
) []string {
|
||||
isRootChat := !parentChatID.Valid
|
||||
builtinPlanPolicy := map[string]bool{
|
||||
"read_file": true,
|
||||
"write_file": isRootChat,
|
||||
"edit_files": isRootChat,
|
||||
"execute": true,
|
||||
"process_output": true,
|
||||
"process_list": false,
|
||||
"process_signal": false,
|
||||
"list_templates": isRootChat,
|
||||
"read_template": isRootChat,
|
||||
"create_workspace": isRootChat,
|
||||
"start_workspace": isRootChat,
|
||||
"propose_plan": isRootChat,
|
||||
"spawn_agent": isRootChat,
|
||||
"spawn_explore_agent": isRootChat,
|
||||
"wait_agent": isRootChat,
|
||||
"message_agent": false,
|
||||
"close_agent": false,
|
||||
"spawn_computer_use_agent": false,
|
||||
"read_skill": true,
|
||||
"read_skill_file": true,
|
||||
"ask_user_question": isRootChat,
|
||||
) ([]database.MCPServerConfig, map[uuid.UUID]struct{}) {
|
||||
if !mode.Valid || mode.ChatPlanMode != database.ChatPlanModePlan {
|
||||
return configs, nil
|
||||
}
|
||||
if parentChatID.Valid {
|
||||
// Plan-mode subagents do not receive external MCP tools because
|
||||
// their trust boundary is narrower than the root chat's.
|
||||
return nil, map[uuid.UUID]struct{}{}
|
||||
}
|
||||
|
||||
filtered := make([]database.MCPServerConfig, 0, len(configs))
|
||||
approvedIDs := make(map[uuid.UUID]struct{})
|
||||
for _, cfg := range configs {
|
||||
if !cfg.AllowInPlanMode {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, cfg)
|
||||
approvedIDs[cfg.ID] = struct{}{}
|
||||
}
|
||||
return filtered, approvedIDs
|
||||
}
|
||||
|
||||
func builtinPlanToolAllowed(name string, isRootChat bool) bool {
|
||||
switch name {
|
||||
case "read_file", "execute", "process_output", "read_skill", "read_skill_file":
|
||||
return true
|
||||
case "write_file", "edit_files", "list_templates", "read_template",
|
||||
"create_workspace", "start_workspace", "propose_plan", "spawn_agent",
|
||||
"spawn_explore_agent", "wait_agent", "ask_user_question":
|
||||
return isRootChat
|
||||
case "process_list", "process_signal", "message_agent", "close_agent",
|
||||
"spawn_computer_use_agent":
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func toolAllowedForTurn(
|
||||
tool fantasy.AgentTool,
|
||||
mode database.NullChatPlanMode,
|
||||
parentChatID uuid.NullUUID,
|
||||
approvedMCPConfigIDs map[uuid.UUID]struct{},
|
||||
) bool {
|
||||
if !mode.Valid || mode.ChatPlanMode != database.ChatPlanModePlan {
|
||||
return true
|
||||
}
|
||||
if builtinPlanToolAllowed(tool.Info().Name, !parentChatID.Valid) {
|
||||
return true
|
||||
}
|
||||
mcpTool, ok := tool.(mcpclient.MCPToolIdentifier)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, approved := approvedMCPConfigIDs[mcpTool.MCPServerConfigID()]
|
||||
return approved
|
||||
}
|
||||
|
||||
func filterToolsForTurn(
|
||||
allTools []fantasy.AgentTool,
|
||||
mode database.NullChatPlanMode,
|
||||
parentChatID uuid.NullUUID,
|
||||
approvedMCPConfigIDs map[uuid.UUID]struct{},
|
||||
) []fantasy.AgentTool {
|
||||
if !mode.Valid || mode.ChatPlanMode != database.ChatPlanModePlan {
|
||||
return allTools
|
||||
}
|
||||
|
||||
filtered := make([]fantasy.AgentTool, 0, len(allTools))
|
||||
for _, tool := range allTools {
|
||||
if toolAllowedForTurn(tool, mode, parentChatID, approvedMCPConfigIDs) {
|
||||
filtered = append(filtered, tool)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// activeToolNamesForTurn extends the built-in plan allowlist with approved
|
||||
// external MCP tools for root plan-mode chats.
|
||||
func activeToolNamesForTurn(
|
||||
allTools []fantasy.AgentTool,
|
||||
mode database.NullChatPlanMode,
|
||||
parentChatID uuid.NullUUID,
|
||||
approvedMCPConfigIDs map[uuid.UUID]struct{},
|
||||
) []string {
|
||||
toolNames := make([]string, 0, len(allTools))
|
||||
for _, tool := range allTools {
|
||||
name := tool.Info().Name
|
||||
if builtinPlanPolicy[name] {
|
||||
toolNames = append(toolNames, name)
|
||||
if toolAllowedForTurn(tool, mode, parentChatID, approvedMCPConfigIDs) {
|
||||
toolNames = append(toolNames, tool.Info().Name)
|
||||
}
|
||||
}
|
||||
return toolNames
|
||||
@@ -5222,30 +5281,24 @@ func allowedExploreToolNames(allTools []fantasy.AgentTool) []string {
|
||||
return toolNames
|
||||
}
|
||||
|
||||
// allowedBehaviorToolNames applies behavior-specific precedence for
|
||||
// tool filtering: Explore mode wins over plan mode, and plan mode wins
|
||||
// over the default behavior that allows all tools.
|
||||
// allowedBehaviorToolNames runs only on non-plan turns because
|
||||
// appendDynamicTools returns early for plan mode. Within that boundary,
|
||||
// Explore mode wins over the default behavior that allows all tools.
|
||||
func allowedBehaviorToolNames(
|
||||
allTools []fantasy.AgentTool,
|
||||
planMode database.NullChatPlanMode,
|
||||
chatMode database.NullChatMode,
|
||||
parentChatID uuid.NullUUID,
|
||||
) []string {
|
||||
if isExploreSubagentMode(chatMode) {
|
||||
return allowedExploreToolNames(allTools)
|
||||
}
|
||||
if planMode.Valid && planMode.ChatPlanMode == database.ChatPlanModePlan {
|
||||
return allowedPlanToolNames(allTools, parentChatID)
|
||||
}
|
||||
return allToolNames(allTools)
|
||||
}
|
||||
|
||||
func stopAfterBehaviorTools(
|
||||
func stopAfterPlanTools(
|
||||
planMode database.NullChatPlanMode,
|
||||
chatMode database.NullChatMode,
|
||||
parentChatID uuid.NullUUID,
|
||||
) map[string]struct{} {
|
||||
if isExploreSubagentMode(chatMode) || !planMode.Valid || planMode.ChatPlanMode != database.ChatPlanModePlan {
|
||||
if !planMode.Valid || planMode.ChatPlanMode != database.ChatPlanModePlan {
|
||||
return nil
|
||||
}
|
||||
stopTools := map[string]struct{}{
|
||||
@@ -5257,6 +5310,17 @@ func stopAfterBehaviorTools(
|
||||
return stopTools
|
||||
}
|
||||
|
||||
func stopAfterBehaviorTools(
|
||||
planMode database.NullChatPlanMode,
|
||||
chatMode database.NullChatMode,
|
||||
parentChatID uuid.NullUUID,
|
||||
) map[string]struct{} {
|
||||
if isExploreSubagentMode(chatMode) {
|
||||
return nil
|
||||
}
|
||||
return stopAfterPlanTools(planMode, parentChatID)
|
||||
}
|
||||
|
||||
type systemPromptBehaviorContext struct {
|
||||
planMode database.NullChatPlanMode
|
||||
chatMode database.NullChatMode
|
||||
@@ -5432,7 +5496,6 @@ func appendDynamicTools(
|
||||
raw pqtype.NullRawMessage,
|
||||
planMode database.NullChatPlanMode,
|
||||
chatMode database.NullChatMode,
|
||||
parentChatID uuid.NullUUID,
|
||||
) ([]fantasy.AgentTool, map[string]bool, error) {
|
||||
if isExploreSubagentMode(chatMode) || (planMode.Valid && planMode.ChatPlanMode == database.ChatPlanModePlan) {
|
||||
return tools, nil, nil
|
||||
@@ -5454,7 +5517,7 @@ func appendDynamicTools(
|
||||
}
|
||||
|
||||
activeToolNames := make(map[string]struct{}, len(tools))
|
||||
for _, name := range allowedBehaviorToolNames(tools, planMode, chatMode, parentChatID) {
|
||||
for _, name := range allowedBehaviorToolNames(tools, chatMode) {
|
||||
activeToolNames[name] = struct{}{}
|
||||
}
|
||||
for _, t := range tools {
|
||||
@@ -5569,6 +5632,12 @@ func (p *Server) runChat(
|
||||
currentPlanMode := chat.PlanMode
|
||||
isPlanModeTurn := currentPlanMode.Valid && currentPlanMode.ChatPlanMode == database.ChatPlanModePlan
|
||||
isExploreSubagent := isExploreSubagentMode(chat.Mode)
|
||||
isRootChat := !chat.ParentChatID.Valid
|
||||
mcpConnectConfigs, approvedPlanMCPConfigIDs := filterExternalMCPConfigsForTurn(
|
||||
mcpConfigs,
|
||||
currentPlanMode,
|
||||
chat.ParentChatID,
|
||||
)
|
||||
planModeInstructions := p.loadPlanModeInstructions(ctx, currentPlanMode, logger)
|
||||
|
||||
chainInfo := resolveChainMode(messages)
|
||||
@@ -5767,17 +5836,20 @@ func (p *Server) runChat(
|
||||
resolvedUserPrompt = p.resolveUserPrompt(ctx, chat.OwnerID)
|
||||
return nil
|
||||
})
|
||||
if len(mcpConfigs) > 0 {
|
||||
if len(mcpConnectConfigs) > 0 {
|
||||
g2.Go(func() error {
|
||||
// Refresh expired OAuth2 tokens before connecting.
|
||||
mcpTokens = p.refreshExpiredMCPTokens(ctx, logger, mcpConfigs, mcpTokens)
|
||||
mcpTokens = p.refreshExpiredMCPTokens(ctx, logger, mcpConnectConfigs, mcpTokens)
|
||||
mcpTools, mcpCleanup = mcpclient.ConnectAll(
|
||||
ctx, logger, mcpConfigs, mcpTokens,
|
||||
ctx, logger, mcpConnectConfigs, mcpTokens,
|
||||
)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if chat.WorkspaceID.Valid {
|
||||
// Workspace MCP discovery stays disabled for all plan-mode turns.
|
||||
// Root plan mode only gets approved external MCP servers, and
|
||||
// plan-mode subagents get no MCP tools.
|
||||
if chat.WorkspaceID.Valid && !isPlanModeTurn {
|
||||
g2.Go(func() error {
|
||||
// Fast path: check cache using the in-memory cached
|
||||
// agent (ensureWorkspaceAgent is free when already
|
||||
@@ -5848,7 +5920,6 @@ func (p *Server) runChat(
|
||||
if err := g2.Wait(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
isRootChat := !chat.ParentChatID.Valid
|
||||
subagentInstruction := ""
|
||||
if !isRootChat {
|
||||
subagentInstruction = defaultSubagentInstruction
|
||||
@@ -6280,13 +6351,22 @@ func (p *Server) runChat(
|
||||
builtinToolNames[t.Info().Name] = true
|
||||
}
|
||||
|
||||
// Append tools from external MCP servers. These appear
|
||||
// after the built-in tools so the LLM sees them as
|
||||
// additional capabilities.
|
||||
if !isPlanModeTurn && !isExploreSubagent {
|
||||
// Append external and workspace MCP tools after the built-ins so the
|
||||
// LLM sees them as additional capabilities. Explore subagents keep
|
||||
// the narrower built-in-only boundary from main. Root plan mode gets
|
||||
// only approved external MCP tools because mcpConnectConfigs was
|
||||
// pre-filtered above, and filterToolsForTurn removes any remaining
|
||||
// plan-mode ineligible tools from the assembled set.
|
||||
if !isExploreSubagent {
|
||||
tools = append(tools, mcpTools...)
|
||||
tools = append(tools, workspaceMCPTools...)
|
||||
}
|
||||
tools = filterToolsForTurn(
|
||||
tools,
|
||||
currentPlanMode,
|
||||
chat.ParentChatID,
|
||||
approvedPlanMCPConfigIDs,
|
||||
)
|
||||
// Append dynamic tools declared by the client at chat
|
||||
// creation time. These appear in the LLM's tool list but
|
||||
// are never executed by the chatloop. The client handles
|
||||
@@ -6299,7 +6379,6 @@ func (p *Server) runChat(
|
||||
chat.DynamicTools,
|
||||
currentPlanMode,
|
||||
chat.Mode,
|
||||
chat.ParentChatID,
|
||||
)
|
||||
if err != nil {
|
||||
return result, err
|
||||
@@ -6351,6 +6430,16 @@ func (p *Server) runChat(
|
||||
)
|
||||
prompt = filterPromptForChainMode(prompt, chainInfo)
|
||||
}
|
||||
activeToolNames := activeToolNamesForTurn(
|
||||
tools,
|
||||
currentPlanMode,
|
||||
chat.ParentChatID,
|
||||
approvedPlanMCPConfigIDs,
|
||||
)
|
||||
if isExploreSubagent {
|
||||
activeToolNames = allowedExploreToolNames(tools)
|
||||
}
|
||||
|
||||
var loopErr error
|
||||
triggerMessageID, historyTipMessageID, triggerLabel := deriveChatDebugSeed(messages)
|
||||
result.TriggerMessageID = triggerMessageID
|
||||
@@ -6382,7 +6471,7 @@ func (p *Server) runChat(
|
||||
Model: model,
|
||||
Messages: prompt,
|
||||
Tools: tools,
|
||||
ActiveTools: allowedBehaviorToolNames(tools, currentPlanMode, chat.Mode, chat.ParentChatID),
|
||||
ActiveTools: activeToolNames,
|
||||
StopAfterTools: stopAfterBehaviorTools(currentPlanMode, chat.Mode, chat.ParentChatID),
|
||||
MaxSteps: maxChatSteps,
|
||||
Metrics: p.metrics,
|
||||
|
||||
@@ -59,7 +59,75 @@ func (t *testAgentTool) SetProviderOptions(opts fantasy.ProviderOptions) {
|
||||
t.providerOptions = opts
|
||||
}
|
||||
|
||||
func TestAllowedPlanToolNames(t *testing.T) {
|
||||
type testMCPAgentTool struct {
|
||||
*testAgentTool
|
||||
configID uuid.UUID
|
||||
}
|
||||
|
||||
func newTestMCPAgentTool(name string, configID uuid.UUID) fantasy.AgentTool {
|
||||
return &testMCPAgentTool{
|
||||
testAgentTool: &testAgentTool{info: fantasy.ToolInfo{Name: name}},
|
||||
configID: configID,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *testMCPAgentTool) MCPServerConfigID() uuid.UUID {
|
||||
return t.configID
|
||||
}
|
||||
|
||||
func TestFilterExternalMCPConfigsForTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
approvedConfig := database.MCPServerConfig{ID: uuid.New(), AllowInPlanMode: true}
|
||||
blockedConfig := database.MCPServerConfig{ID: uuid.New(), AllowInPlanMode: false}
|
||||
configs := []database.MCPServerConfig{approvedConfig, blockedConfig}
|
||||
planMode := database.NullChatPlanMode{
|
||||
ChatPlanMode: database.ChatPlanModePlan,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
t.Run("NonPlanModePassesThroughAllConfigs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filtered, approvedIDs := filterExternalMCPConfigsForTurn(
|
||||
configs,
|
||||
database.NullChatPlanMode{},
|
||||
uuid.NullUUID{},
|
||||
)
|
||||
|
||||
require.Equal(t, configs, filtered)
|
||||
require.Nil(t, approvedIDs)
|
||||
})
|
||||
|
||||
t.Run("PlanModeSubagentsReturnNoConfigs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filtered, approvedIDs := filterExternalMCPConfigsForTurn(
|
||||
configs,
|
||||
planMode,
|
||||
uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
)
|
||||
|
||||
require.Nil(t, filtered)
|
||||
require.NotNil(t, approvedIDs)
|
||||
require.Empty(t, approvedIDs)
|
||||
})
|
||||
|
||||
t.Run("PlanModeRootFiltersToApprovedConfigs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filtered, approvedIDs := filterExternalMCPConfigsForTurn(
|
||||
configs,
|
||||
planMode,
|
||||
uuid.NullUUID{},
|
||||
)
|
||||
|
||||
require.Equal(t, []database.MCPServerConfig{approvedConfig}, filtered)
|
||||
require.Equal(t, map[uuid.UUID]struct{}{approvedConfig.ID: {}}, approvedIDs)
|
||||
})
|
||||
}
|
||||
|
||||
func TestActiveToolNamesForTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
makeTools := func(names ...string) []fantasy.AgentTool {
|
||||
@@ -70,10 +138,33 @@ func TestAllowedPlanToolNames(t *testing.T) {
|
||||
return tools
|
||||
}
|
||||
|
||||
t.Run("RootPlanModeIncludesOnlyAllowlistedBuiltIns", func(t *testing.T) {
|
||||
planMode := database.NullChatPlanMode{
|
||||
ChatPlanMode: database.ChatPlanModePlan,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
t.Run("NormalModeReturnsAllRegisteredTools", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := allowedPlanToolNames(makeTools(
|
||||
got := activeToolNamesForTurn(makeTools(
|
||||
"read_file",
|
||||
"propose_plan",
|
||||
"custom_tool",
|
||||
"execute",
|
||||
), database.NullChatPlanMode{}, uuid.NullUUID{}, nil)
|
||||
|
||||
require.Equal(t, []string{
|
||||
"read_file",
|
||||
"propose_plan",
|
||||
"custom_tool",
|
||||
"execute",
|
||||
}, got)
|
||||
})
|
||||
|
||||
t.Run("PlanModeIncludesOnlyAllowlistedBuiltIns", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := activeToolNamesForTurn(makeTools(
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_files",
|
||||
@@ -95,7 +186,7 @@ func TestAllowedPlanToolNames(t *testing.T) {
|
||||
"read_skill",
|
||||
"read_skill_file",
|
||||
"ask_user_question",
|
||||
), uuid.NullUUID{})
|
||||
), planMode, uuid.NullUUID{}, nil)
|
||||
|
||||
require.Equal(t, []string{
|
||||
"read_file",
|
||||
@@ -117,10 +208,10 @@ func TestAllowedPlanToolNames(t *testing.T) {
|
||||
}, got)
|
||||
})
|
||||
|
||||
t.Run("ChildPlanModeAllowsExplorationOnly", func(t *testing.T) {
|
||||
t.Run("PlanModeChildChatsAllowExplorationOnly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := allowedPlanToolNames(makeTools(
|
||||
got := activeToolNamesForTurn(makeTools(
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_files",
|
||||
@@ -137,7 +228,7 @@ func TestAllowedPlanToolNames(t *testing.T) {
|
||||
"read_skill",
|
||||
"read_skill_file",
|
||||
"ask_user_question",
|
||||
), uuid.NullUUID{UUID: uuid.New(), Valid: true})
|
||||
), planMode, uuid.NullUUID{UUID: uuid.New(), Valid: true}, nil)
|
||||
|
||||
require.Equal(t, []string{
|
||||
"read_file",
|
||||
@@ -146,6 +237,67 @@ func TestAllowedPlanToolNames(t *testing.T) {
|
||||
"read_skill",
|
||||
"read_skill_file",
|
||||
}, got)
|
||||
require.NotContains(t, got, "write_file")
|
||||
require.NotContains(t, got, "edit_files")
|
||||
require.NotContains(t, got, "ask_user_question")
|
||||
require.NotContains(t, got, "propose_plan")
|
||||
require.NotContains(t, got, "spawn_explore_agent")
|
||||
})
|
||||
|
||||
t.Run("PlanModeStillExcludesDangerousTools", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := activeToolNamesForTurn(makeTools(
|
||||
"execute",
|
||||
"process_output",
|
||||
"message_agent",
|
||||
"spawn_computer_use_agent",
|
||||
"propose_plan",
|
||||
), planMode, uuid.NullUUID{}, nil)
|
||||
|
||||
require.Equal(t, []string{"execute", "process_output", "propose_plan"}, got)
|
||||
require.NotContains(t, got, "message_agent")
|
||||
require.NotContains(t, got, "spawn_computer_use_agent")
|
||||
})
|
||||
|
||||
t.Run("PlanModeExcludesUnknownTools", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := activeToolNamesForTurn(makeTools(
|
||||
"read_file",
|
||||
"custom_tool",
|
||||
"another_custom_tool",
|
||||
"propose_plan",
|
||||
), planMode, uuid.NullUUID{}, nil)
|
||||
|
||||
require.Equal(t, []string{
|
||||
"read_file",
|
||||
"propose_plan",
|
||||
}, got)
|
||||
require.NotContains(t, got, "custom_tool")
|
||||
require.NotContains(t, got, "another_custom_tool")
|
||||
})
|
||||
|
||||
t.Run("PlanModeIncludesOnlyApprovedExternalMCPTools", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
approvedConfigID := uuid.New()
|
||||
blockedConfigID := uuid.New()
|
||||
got := activeToolNamesForTurn([]fantasy.AgentTool{
|
||||
newTestAgentTool("read_file"),
|
||||
newTestMCPAgentTool("approved-mcp__echo", approvedConfigID),
|
||||
newTestMCPAgentTool("blocked-mcp__echo", blockedConfigID),
|
||||
newTestAgentTool("workspace-mcp__echo"),
|
||||
}, planMode, uuid.NullUUID{}, map[uuid.UUID]struct{}{
|
||||
approvedConfigID: {},
|
||||
})
|
||||
|
||||
require.Equal(t, []string{
|
||||
"read_file",
|
||||
"approved-mcp__echo",
|
||||
}, got)
|
||||
require.NotContains(t, got, "blocked-mcp__echo")
|
||||
require.NotContains(t, got, "workspace-mcp__echo")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -197,10 +349,6 @@ func TestAllowedBehaviorToolNames(t *testing.T) {
|
||||
}
|
||||
|
||||
allTools := makeTools("read_file", "custom_tool", "spawn_explore_agent")
|
||||
planMode := database.NullChatPlanMode{
|
||||
ChatPlanMode: database.ChatPlanModePlan,
|
||||
Valid: true,
|
||||
}
|
||||
exploreMode := database.NullChatMode{
|
||||
ChatMode: database.ChatModeExplore,
|
||||
Valid: true,
|
||||
@@ -210,19 +358,7 @@ func TestAllowedBehaviorToolNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, []string{"read_file", "custom_tool", "spawn_explore_agent"}, allowedBehaviorToolNames(
|
||||
allTools,
|
||||
database.NullChatPlanMode{},
|
||||
database.NullChatMode{},
|
||||
uuid.NullUUID{},
|
||||
))
|
||||
})
|
||||
|
||||
t.Run("PlanModeUsesPlanAllowlist", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, []string{"read_file", "spawn_explore_agent"}, allowedBehaviorToolNames(
|
||||
allTools,
|
||||
planMode,
|
||||
database.NullChatMode{},
|
||||
uuid.NullUUID{},
|
||||
))
|
||||
})
|
||||
|
||||
@@ -230,13 +366,40 @@ func TestAllowedBehaviorToolNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, []string{"read_file"}, allowedBehaviorToolNames(
|
||||
allTools,
|
||||
database.NullChatPlanMode{},
|
||||
exploreMode,
|
||||
uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopAfterPlanTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planMode := database.NullChatPlanMode{
|
||||
ChatPlanMode: database.ChatPlanModePlan,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
t.Run("NormalModeReturnsNil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Nil(t, stopAfterPlanTools(database.NullChatPlanMode{}, uuid.NullUUID{}))
|
||||
})
|
||||
|
||||
t.Run("RootPlanModeIncludesClarificationTool", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, map[string]struct{}{
|
||||
"propose_plan": {},
|
||||
"ask_user_question": {},
|
||||
}, stopAfterPlanTools(planMode, uuid.NullUUID{}))
|
||||
})
|
||||
|
||||
t.Run("ChildPlanModeSkipsClarificationTool", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, map[string]struct{}{
|
||||
"propose_plan": {},
|
||||
}, stopAfterPlanTools(planMode, uuid.NullUUID{UUID: uuid.New(), Valid: true}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopAfterBehaviorTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -379,6 +379,37 @@ func TestPlanModeSubagentChatExcludesAskUserQuestion(t *testing.T) {
|
||||
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
|
||||
// Start an external MCP server whose tools should remain available to the
|
||||
// root plan-mode chat but stay hidden from plan-mode subagents.
|
||||
mcpSrv := mcpserver.NewMCPServer("plan-root-mcp", "1.0.0")
|
||||
mcpSrv.AddTools(mcpserver.ServerTool{
|
||||
Tool: mcpgo.NewTool("echo",
|
||||
mcpgo.WithDescription("Echoes the input"),
|
||||
mcpgo.WithString("input",
|
||||
mcpgo.Description("The input string"),
|
||||
mcpgo.Required(),
|
||||
),
|
||||
),
|
||||
Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
input, _ := req.GetArguments()["input"].(string)
|
||||
return mcpgo.NewToolResultText("echo: " + input), nil
|
||||
},
|
||||
})
|
||||
mcpTS := httptest.NewServer(mcpserver.NewStreamableHTTPServer(mcpSrv))
|
||||
t.Cleanup(mcpTS.Close)
|
||||
|
||||
mcpConfig, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{
|
||||
DisplayName: "Plan Root MCP",
|
||||
Slug: "plan-root-mcp",
|
||||
Transport: "streamable_http",
|
||||
URL: mcpTS.URL,
|
||||
AuthType: "none",
|
||||
Availability: "default_off",
|
||||
Enabled: true,
|
||||
AllowInPlanMode: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var toolsMu sync.Mutex
|
||||
toolsByCall := make([][]string, 0, 2)
|
||||
requestsByCall := make([]recordedOpenAIRequest, 0, 2)
|
||||
@@ -408,7 +439,7 @@ func TestPlanModeSubagentChatExcludesAskUserQuestion(t *testing.T) {
|
||||
)
|
||||
})
|
||||
|
||||
_, err := expClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
|
||||
_, err = expClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
|
||||
Provider: "openai-compat",
|
||||
APIKey: "test-api-key",
|
||||
BaseURL: openAIURL,
|
||||
@@ -428,6 +459,7 @@ func TestPlanModeSubagentChatExcludesAskUserQuestion(t *testing.T) {
|
||||
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: user.OrganizationID,
|
||||
PlanMode: codersdk.ChatPlanModePlan,
|
||||
MCPServerIDs: []uuid.UUID{mcpConfig.ID},
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
@@ -486,6 +518,8 @@ func TestPlanModeSubagentChatExcludesAskUserQuestion(t *testing.T) {
|
||||
"root plan-mode chat should have execute")
|
||||
require.Contains(t, rootCalls[0], "process_output",
|
||||
"root plan-mode chat should have process_output")
|
||||
require.Contains(t, rootCalls[0], "plan-root-mcp__echo",
|
||||
"root plan-mode chat should have approved external MCP tools")
|
||||
require.NotContains(t, childCalls[0], "ask_user_question",
|
||||
"plan-mode subagent should NOT have ask_user_question")
|
||||
require.NotContains(t, childCalls[0], "write_file",
|
||||
@@ -496,6 +530,8 @@ func TestPlanModeSubagentChatExcludesAskUserQuestion(t *testing.T) {
|
||||
"plan-mode subagent should have execute")
|
||||
require.Contains(t, childCalls[0], "process_output",
|
||||
"plan-mode subagent should have process_output")
|
||||
require.NotContains(t, childCalls[0], "plan-root-mcp__echo",
|
||||
"plan-mode subagent should NOT have external MCP tools")
|
||||
require.True(t, requestHasSystemSubstring(rootRequests[0], "You are in Plan Mode."))
|
||||
require.True(t, requestHasSystemSubstring(childRequests[0], "You are in Plan Mode as a delegated sub-agent."))
|
||||
require.False(t, requestHasSystemSubstring(childRequests[0], "When the plan is ready, call propose_plan"))
|
||||
@@ -663,6 +699,227 @@ func TestExploreSubagentIsReadOnly(t *testing.T) {
|
||||
require.Len(t, exploreChildren, 1)
|
||||
}
|
||||
|
||||
func TestPlanModeRootChatAllowsApprovedExternalMCPTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
echoMCP := mcpserver.NewMCPServer("plan-visibility-echo", "1.0.0")
|
||||
echoMCP.AddTools(mcpserver.ServerTool{
|
||||
Tool: mcpgo.NewTool("echo",
|
||||
mcpgo.WithDescription("Echoes the input"),
|
||||
mcpgo.WithString("input",
|
||||
mcpgo.Description("The input string"),
|
||||
mcpgo.Required(),
|
||||
),
|
||||
),
|
||||
Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
input, _ := req.GetArguments()["input"].(string)
|
||||
return mcpgo.NewToolResultText("echo: " + input), nil
|
||||
},
|
||||
})
|
||||
echoTS := httptest.NewServer(mcpserver.NewStreamableHTTPServer(echoMCP))
|
||||
t.Cleanup(echoTS.Close)
|
||||
|
||||
filteredMCP := mcpserver.NewMCPServer("plan-visibility-filtered", "1.0.0")
|
||||
filteredMCP.AddTools(
|
||||
mcpserver.ServerTool{
|
||||
Tool: mcpgo.NewTool("visible",
|
||||
mcpgo.WithDescription("Visible tool"),
|
||||
mcpgo.WithString("input",
|
||||
mcpgo.Description("The input string"),
|
||||
mcpgo.Required(),
|
||||
),
|
||||
),
|
||||
Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
input, _ := req.GetArguments()["input"].(string)
|
||||
return mcpgo.NewToolResultText("visible: " + input), nil
|
||||
},
|
||||
},
|
||||
mcpserver.ServerTool{
|
||||
Tool: mcpgo.NewTool("hidden",
|
||||
mcpgo.WithDescription("Hidden tool"),
|
||||
mcpgo.WithString("input",
|
||||
mcpgo.Description("The input string"),
|
||||
mcpgo.Required(),
|
||||
),
|
||||
),
|
||||
Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
input, _ := req.GetArguments()["input"].(string)
|
||||
return mcpgo.NewToolResultText("hidden: " + input), nil
|
||||
},
|
||||
},
|
||||
)
|
||||
filteredTS := httptest.NewServer(mcpserver.NewStreamableHTTPServer(filteredMCP))
|
||||
t.Cleanup(filteredTS.Close)
|
||||
|
||||
var (
|
||||
requests []recordedOpenAIRequest
|
||||
requestsMu sync.Mutex
|
||||
)
|
||||
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
if !req.Stream {
|
||||
return chattest.OpenAINonStreamingResponse("title")
|
||||
}
|
||||
|
||||
requestsMu.Lock()
|
||||
requests = append(requests, recordOpenAIRequest(req))
|
||||
requestsMu.Unlock()
|
||||
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAITextChunks("Done.")...,
|
||||
)
|
||||
})
|
||||
|
||||
user, org, model := seedChatDependenciesWithProvider(ctx, t, db, "openai-compat", openAIURL)
|
||||
|
||||
approvedConfig, err := db.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{
|
||||
DisplayName: "Plan Approved MCP",
|
||||
Slug: "plan-approved-mcp",
|
||||
Url: echoTS.URL,
|
||||
Transport: "streamable_http",
|
||||
AuthType: "none",
|
||||
Availability: "default_off",
|
||||
Enabled: true,
|
||||
AllowInPlanMode: true,
|
||||
ToolAllowList: []string{},
|
||||
ToolDenyList: []string{},
|
||||
CreatedBy: user.ID,
|
||||
UpdatedBy: user.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
blockedConfig, err := db.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{
|
||||
DisplayName: "Plan Blocked MCP",
|
||||
Slug: "plan-blocked-mcp",
|
||||
Url: echoTS.URL,
|
||||
Transport: "streamable_http",
|
||||
AuthType: "none",
|
||||
Availability: "default_off",
|
||||
Enabled: true,
|
||||
AllowInPlanMode: false,
|
||||
ToolAllowList: []string{},
|
||||
ToolDenyList: []string{},
|
||||
CreatedBy: user.ID,
|
||||
UpdatedBy: user.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
filteredConfig, err := db.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{
|
||||
DisplayName: "Plan Filtered MCP",
|
||||
Slug: "plan-filtered-mcp",
|
||||
Url: filteredTS.URL,
|
||||
Transport: "streamable_http",
|
||||
AuthType: "none",
|
||||
Availability: "default_off",
|
||||
Enabled: true,
|
||||
AllowInPlanMode: true,
|
||||
ToolAllowList: []string{"visible"},
|
||||
ToolDenyList: []string{},
|
||||
CreatedBy: user.ID,
|
||||
UpdatedBy: user.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID)
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
mockConn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes()
|
||||
mockConn.EXPECT().ContextConfig(gomock.Any()).
|
||||
Return(workspacesdk.ContextConfigResponse{}, xerrors.New("not supported")).AnyTimes()
|
||||
workspaceToolName := "workspace-plan-mcp__echo"
|
||||
mockConn.EXPECT().ListMCPTools(gomock.Any()).
|
||||
Return(workspacesdk.ListMCPToolsResponse{Tools: []workspacesdk.MCPToolInfo{{
|
||||
ServerName: "workspace-plan-mcp",
|
||||
Name: workspaceToolName,
|
||||
Description: "Workspace echo tool",
|
||||
Schema: map[string]any{
|
||||
"input": map[string]any{"type": "string"},
|
||||
},
|
||||
Required: []string{"input"},
|
||||
}}}, nil).
|
||||
Times(1)
|
||||
mockConn.EXPECT().LS(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(workspacesdk.LSResponse{AbsolutePathString: "/home/coder"}, nil).AnyTimes()
|
||||
mockConn.EXPECT().ReadFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(io.NopCloser(strings.NewReader("")), "", nil).AnyTimes()
|
||||
|
||||
server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) {
|
||||
cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) {
|
||||
require.Equal(t, dbAgent.ID, agentID)
|
||||
return mockConn, func() {}, nil
|
||||
}
|
||||
})
|
||||
|
||||
planChat, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
Title: "plan-mode-root-mcp-visibility",
|
||||
ModelConfigID: model.ID,
|
||||
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
|
||||
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
|
||||
MCPServerIDs: []uuid.UUID{approvedConfig.ID, blockedConfig.ID, filteredConfig.ID},
|
||||
InitialUserContent: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("List the available tools in plan mode."),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
waitForChatProcessed(ctx, t, db, planChat.ID, server)
|
||||
|
||||
planChatResult, err := db.GetChatByID(ctx, planChat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, database.ChatStatusWaiting, planChatResult.Status)
|
||||
|
||||
askChat, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
Title: "ask-mode-root-mcp-visibility",
|
||||
ModelConfigID: model.ID,
|
||||
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
|
||||
MCPServerIDs: []uuid.UUID{approvedConfig.ID, blockedConfig.ID, filteredConfig.ID},
|
||||
InitialUserContent: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("List the available tools outside plan mode."),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
waitForChatProcessed(ctx, t, db, askChat.ID, server)
|
||||
|
||||
askChatResult, err := db.GetChatByID(ctx, askChat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, database.ChatStatusWaiting, askChatResult.Status)
|
||||
|
||||
requestsMu.Lock()
|
||||
recorded := append([]recordedOpenAIRequest(nil), requests...)
|
||||
requestsMu.Unlock()
|
||||
require.Len(t, recorded, 2, "expected exactly one streamed model call per chat")
|
||||
|
||||
planTools := recorded[0].Tools
|
||||
askTools := recorded[1].Tools
|
||||
|
||||
require.Contains(t, planTools, "plan-approved-mcp__echo",
|
||||
"root plan mode should expose approved external MCP tools")
|
||||
require.NotContains(t, planTools, "plan-blocked-mcp__echo",
|
||||
"root plan mode should hide unapproved external MCP tools")
|
||||
require.Contains(t, planTools, "plan-filtered-mcp__visible",
|
||||
"root plan mode should keep allowlisted tools from approved MCP servers")
|
||||
require.NotContains(t, planTools, "plan-filtered-mcp__hidden",
|
||||
"root plan mode should still respect MCP tool allowlists")
|
||||
require.NotContains(t, planTools, workspaceToolName,
|
||||
"root plan mode should exclude workspace MCP tools")
|
||||
|
||||
require.Contains(t, askTools, "plan-approved-mcp__echo",
|
||||
"ask mode should keep approved external MCP tools")
|
||||
require.Contains(t, askTools, "plan-blocked-mcp__echo",
|
||||
"ask mode should keep unapproved-for-plan external MCP tools")
|
||||
require.Contains(t, askTools, "plan-filtered-mcp__visible",
|
||||
"ask mode should keep allowlisted tools from external MCP servers")
|
||||
require.NotContains(t, askTools, "plan-filtered-mcp__hidden",
|
||||
"ask mode should continue respecting MCP tool allowlists")
|
||||
require.Contains(t, askTools, workspaceToolName,
|
||||
"ask mode should continue exposing workspace MCP tools")
|
||||
}
|
||||
|
||||
func TestInterruptChatClearsWorkerInDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1151,6 +1408,8 @@ func TestPlanTurnPromptContract(t *testing.T) {
|
||||
require.True(t, requestHasSystemSubstring(recorded[0], "You are in Plan Mode."))
|
||||
require.True(t, requestHasSystemSubstring(recorded[0], "The only intentional authored workspace artifact is the plan file"))
|
||||
require.True(t, requestHasSystemSubstring(recorded[0], "You may use execute and process_output for exploration"))
|
||||
require.True(t, requestHasSystemSubstring(recorded[0], "approved external MCP tools when available"))
|
||||
require.True(t, requestHasSystemSubstring(recorded[0], "Workspace MCP tools are not available in root plan mode"))
|
||||
require.True(t, requestHasSystemSubstring(recorded[0], "After a successful propose_plan call, stop immediately"))
|
||||
require.True(t, requestHasSystemSubstring(recorded[0], planModeInstructions))
|
||||
for _, msg := range recorded[0].Messages {
|
||||
@@ -5971,6 +6230,286 @@ func TestMCPServerToolInvocation(t *testing.T) {
|
||||
"MCP tool result should be persisted as a tool message in the database")
|
||||
}
|
||||
|
||||
func TestPlanModeRootChatApprovedExternalMCPToolInvocation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
mcpSrv := mcpserver.NewMCPServer("plan-mode-mcp", "1.0.0")
|
||||
mcpSrv.AddTools(mcpserver.ServerTool{
|
||||
Tool: mcpgo.NewTool("echo",
|
||||
mcpgo.WithDescription("Echoes the input"),
|
||||
mcpgo.WithString("input",
|
||||
mcpgo.Description("The input string"),
|
||||
mcpgo.Required(),
|
||||
),
|
||||
),
|
||||
Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
input, _ := req.GetArguments()["input"].(string)
|
||||
return mcpgo.NewToolResultText("echo: " + input), nil
|
||||
},
|
||||
})
|
||||
mcpTS := httptest.NewServer(mcpserver.NewStreamableHTTPServer(mcpSrv))
|
||||
t.Cleanup(mcpTS.Close)
|
||||
|
||||
var (
|
||||
callCount atomic.Int32
|
||||
llmToolNames []string
|
||||
llmToolsMu sync.Mutex
|
||||
foundMCPResult atomic.Bool
|
||||
)
|
||||
|
||||
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
if !req.Stream {
|
||||
return chattest.OpenAINonStreamingResponse("title")
|
||||
}
|
||||
|
||||
if callCount.Add(1) == 1 {
|
||||
names := make([]string, 0, len(req.Tools))
|
||||
for _, tool := range req.Tools {
|
||||
names = append(names, tool.Function.Name)
|
||||
}
|
||||
llmToolsMu.Lock()
|
||||
llmToolNames = names
|
||||
llmToolsMu.Unlock()
|
||||
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAIToolCallChunk(
|
||||
"plan-mode-mcp__echo",
|
||||
`{"input":"hello from root plan mode"}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for _, msg := range req.Messages {
|
||||
if msg.Role == "tool" && strings.Contains(msg.Content, "echo: hello from root plan mode") {
|
||||
foundMCPResult.Store(true)
|
||||
}
|
||||
}
|
||||
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAITextChunks("Planning complete.")...,
|
||||
)
|
||||
})
|
||||
|
||||
user, org, model := seedChatDependenciesWithProvider(ctx, t, db, "openai-compat", openAIURL)
|
||||
|
||||
mcpConfig, err := db.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{
|
||||
DisplayName: "Plan Mode MCP",
|
||||
Slug: "plan-mode-mcp",
|
||||
Url: mcpTS.URL,
|
||||
Transport: "streamable_http",
|
||||
AuthType: "none",
|
||||
Availability: "default_off",
|
||||
Enabled: true,
|
||||
AllowInPlanMode: true,
|
||||
ToolAllowList: []string{},
|
||||
ToolDenyList: []string{},
|
||||
CreatedBy: user.ID,
|
||||
UpdatedBy: user.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
server := newActiveTestServer(t, db, ps)
|
||||
|
||||
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
Title: "plan-mode-mcp-invocation",
|
||||
ModelConfigID: model.ID,
|
||||
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
|
||||
MCPServerIDs: []uuid.UUID{mcpConfig.ID},
|
||||
InitialUserContent: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("Use the approved MCP tool while planning."),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
waitForChatProcessed(ctx, t, db, chat.ID, server)
|
||||
|
||||
chatResult, err := db.GetChatByID(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, database.ChatStatusWaiting, chatResult.Status)
|
||||
|
||||
llmToolsMu.Lock()
|
||||
recordedNames := append([]string(nil), llmToolNames...)
|
||||
llmToolsMu.Unlock()
|
||||
require.Contains(t, recordedNames, "plan-mode-mcp__echo",
|
||||
"approved external MCP tools should be available in root plan mode")
|
||||
require.True(t, foundMCPResult.Load(),
|
||||
"approved external MCP tool results should feed back into the follow-up plan-mode turn")
|
||||
}
|
||||
|
||||
func TestPlanModeRootChatApprovedExternalMCPWorkflowCanReachProposePlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
mcpSrv := mcpserver.NewMCPServer("plan-workflow-mcp", "1.0.0")
|
||||
mcpSrv.AddTools(mcpserver.ServerTool{
|
||||
Tool: mcpgo.NewTool("echo",
|
||||
mcpgo.WithDescription("Echoes the input"),
|
||||
mcpgo.WithString("input",
|
||||
mcpgo.Description("The input string"),
|
||||
mcpgo.Required(),
|
||||
),
|
||||
),
|
||||
Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
input, _ := req.GetArguments()["input"].(string)
|
||||
return mcpgo.NewToolResultText("echo: " + input), nil
|
||||
},
|
||||
})
|
||||
mcpTS := httptest.NewServer(mcpserver.NewStreamableHTTPServer(mcpSrv))
|
||||
t.Cleanup(mcpTS.Close)
|
||||
|
||||
var (
|
||||
callCount atomic.Int32
|
||||
llmToolNames []string
|
||||
llmToolsMu sync.Mutex
|
||||
sawMCPResult atomic.Bool
|
||||
proposePlanReached atomic.Bool
|
||||
)
|
||||
|
||||
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
if !req.Stream {
|
||||
return chattest.OpenAINonStreamingResponse("title")
|
||||
}
|
||||
|
||||
switch callCount.Add(1) {
|
||||
case 1:
|
||||
names := make([]string, 0, len(req.Tools))
|
||||
for _, tool := range req.Tools {
|
||||
names = append(names, tool.Function.Name)
|
||||
}
|
||||
llmToolsMu.Lock()
|
||||
llmToolNames = names
|
||||
llmToolsMu.Unlock()
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAIToolCallChunk(
|
||||
"plan-workflow-mcp__echo",
|
||||
`{"input":"prepare the plan"}`,
|
||||
),
|
||||
)
|
||||
case 2:
|
||||
for _, msg := range req.Messages {
|
||||
if msg.Role == "tool" && strings.Contains(msg.Content, "echo: prepare the plan") {
|
||||
sawMCPResult.Store(true)
|
||||
}
|
||||
}
|
||||
proposePlanReached.Store(true)
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAIToolCallChunk("propose_plan", `{}`),
|
||||
)
|
||||
default:
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAITextChunks("should not continue")...,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
user, org, model := seedChatDependenciesWithProvider(ctx, t, db, "openai-compat", openAIURL)
|
||||
|
||||
mcpConfig, err := db.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{
|
||||
DisplayName: "Plan Workflow MCP",
|
||||
Slug: "plan-workflow-mcp",
|
||||
Url: mcpTS.URL,
|
||||
Transport: "streamable_http",
|
||||
AuthType: "none",
|
||||
Availability: "default_off",
|
||||
Enabled: true,
|
||||
AllowInPlanMode: true,
|
||||
ToolAllowList: []string{},
|
||||
ToolDenyList: []string{},
|
||||
CreatedBy: user.ID,
|
||||
UpdatedBy: user.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID)
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
mockConn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes()
|
||||
mockConn.EXPECT().ContextConfig(gomock.Any()).
|
||||
Return(workspacesdk.ContextConfigResponse{}, xerrors.New("not supported")).AnyTimes()
|
||||
mockConn.EXPECT().LS(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(workspacesdk.LSResponse{AbsolutePathString: "/home/coder"}, nil).AnyTimes()
|
||||
mockConn.EXPECT().ReadFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, path string, _, _ int64) (io.ReadCloser, string, error) {
|
||||
if strings.HasSuffix(path, ".md") {
|
||||
return io.NopCloser(strings.NewReader("# Plan\n- Use the approved MCP tool findings.\n")), "", nil
|
||||
}
|
||||
return io.NopCloser(strings.NewReader("")), "", nil
|
||||
}).AnyTimes()
|
||||
|
||||
server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) {
|
||||
cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) {
|
||||
require.Equal(t, dbAgent.ID, agentID)
|
||||
return mockConn, func() {}, nil
|
||||
}
|
||||
})
|
||||
|
||||
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
Title: "plan-mode-mcp-propose-plan",
|
||||
ModelConfigID: model.ID,
|
||||
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
|
||||
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
|
||||
MCPServerIDs: []uuid.UUID{mcpConfig.ID},
|
||||
InitialUserContent: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("Use the approved MCP tool, then propose the plan."),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
waitForChatProcessed(ctx, t, db, chat.ID, server)
|
||||
|
||||
chatResult, err := db.GetChatByID(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, database.ChatStatusWaiting, chatResult.Status)
|
||||
|
||||
llmToolsMu.Lock()
|
||||
recordedNames := append([]string(nil), llmToolNames...)
|
||||
llmToolsMu.Unlock()
|
||||
require.Contains(t, recordedNames, "plan-workflow-mcp__echo",
|
||||
"approved external MCP tools should be available in the root plan-mode workflow")
|
||||
require.True(t, sawMCPResult.Load(),
|
||||
"the root plan-mode workflow should feed the approved MCP result into the propose_plan turn")
|
||||
require.True(t, proposePlanReached.Load(),
|
||||
"the root plan-mode workflow should reach propose_plan after using the approved MCP tool")
|
||||
require.Equal(t, int32(2), callCount.Load(),
|
||||
"the workflow should stop immediately after propose_plan succeeds")
|
||||
|
||||
var foundProposePlanResult bool
|
||||
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
|
||||
messages, dbErr := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
|
||||
ChatID: chat.ID,
|
||||
AfterID: 0,
|
||||
})
|
||||
if dbErr != nil {
|
||||
return false
|
||||
}
|
||||
for _, msg := range messages {
|
||||
if msg.Role != database.ChatMessageRoleTool {
|
||||
continue
|
||||
}
|
||||
parts, parseErr := chatprompt.ParseContent(msg)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
for _, part := range parts {
|
||||
if part.Type == codersdk.ChatMessagePartTypeToolResult && part.ToolName == "propose_plan" {
|
||||
foundProposePlanResult = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, testutil.IntervalFast)
|
||||
require.True(t, foundProposePlanResult,
|
||||
"the root plan-mode workflow should persist a propose_plan tool result")
|
||||
}
|
||||
|
||||
// TestMCPServerOAuth2TokenRefresh verifies that when a chat uses an
|
||||
// MCP server with OAuth2 auth and the stored access token is expired,
|
||||
// chatd refreshes the token using the stored refresh_token before
|
||||
|
||||
@@ -110,13 +110,17 @@ You may use execute and process_output for exploration, including cloning reposi
|
||||
Do not use Plan Mode to implement the requested changes or intentionally modify project files outside the plan file.
|
||||
If no workspace is attached to this chat yet, create and start one with create_workspace and start_workspace before investigating.
|
||||
If the plan file already exists, read it first with read_file before replacing or refining it.
|
||||
Use read_file, execute, process_output, list_templates, read_template, and spawn_agent to gather context. In Plan Mode, spawn_agent delegation is for investigation and planning support, not code writing or implementation.
|
||||
Use read_file, execute, process_output, list_templates, read_template, spawn_agent, and approved external MCP tools when available to gather context. Workspace MCP tools are not available in root plan mode, and side-effecting built-in tools such as process_list, process_signal, message_agent, close_agent, and spawn_computer_use_agent remain unavailable. In Plan Mode, spawn_agent delegation is for investigation and planning support, not code writing or implementation.
|
||||
Use write_file to create the plan file and edit_files to refine it.
|
||||
Use ask_user_question for structured clarification instead of freeform questions.
|
||||
When the plan is ready, call propose_plan with the plan file path.
|
||||
After a successful propose_plan call, stop immediately. Do not produce follow-up output.
|
||||
` + defaultSystemPromptPlanPathBlockPlaceholder
|
||||
|
||||
// Root plan mode may use approved external MCP tools, but delegated
|
||||
// plan-mode subagents stay on the narrower built-in-only boundary
|
||||
// because their trust boundary is narrower than the root chat's.
|
||||
|
||||
// PlanningSubagentOverlayPrompt contains plan-mode instructions for
|
||||
// delegated child chats. Child chats may investigate with shell tools
|
||||
// but should return findings to the parent instead of authoring the
|
||||
|
||||
+13
-10
@@ -64,10 +64,11 @@ type MCPServerConfig struct {
|
||||
// Availability policy set by admin.
|
||||
Availability string `json:"availability"` // "force_on", "default_on", "default_off"
|
||||
|
||||
Enabled bool `json:"enabled"`
|
||||
ModelIntent bool `json:"model_intent"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
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"`
|
||||
@@ -96,9 +97,10 @@ type CreateMCPServerConfigRequest struct {
|
||||
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"`
|
||||
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.
|
||||
@@ -124,9 +126,10 @@ type UpdateMCPServerConfigRequest struct {
|
||||
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"`
|
||||
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) {
|
||||
|
||||
@@ -162,8 +162,10 @@ and cannot create workspaces or spawn further sub-agents.
|
||||
active. In that mode, `write_file` and `edit_files` are restricted to the
|
||||
chat-specific plan file, while `execute` and `process_output` remain available
|
||||
for exploration such as cloning repositories, searching code, and running
|
||||
inspection commands. MCP, dynamic, provider-native, and computer-use tools are
|
||||
not available.
|
||||
inspection commands. Root plan-mode chats may also receive administrator-approved
|
||||
external MCP tools. Workspace MCP tools remain unavailable in plan mode, and
|
||||
plan-mode sub-agents still do not receive any MCP tools. Dynamic,
|
||||
provider-native, and computer-use tools are not available.
|
||||
|
||||
### Orchestration tools
|
||||
|
||||
|
||||
Generated
+3
@@ -2790,6 +2790,7 @@ export interface CreateMCPServerConfigRequest {
|
||||
readonly availability: string;
|
||||
readonly enabled: boolean;
|
||||
readonly model_intent: boolean;
|
||||
readonly allow_in_plan_mode: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/organizations.go
|
||||
@@ -4461,6 +4462,7 @@ export interface MCPServerConfig {
|
||||
readonly availability: string; // "force_on", "default_on", "default_off"
|
||||
readonly enabled: boolean;
|
||||
readonly model_intent: boolean;
|
||||
readonly allow_in_plan_mode: boolean;
|
||||
readonly created_at: string;
|
||||
readonly updated_at: string;
|
||||
/**
|
||||
@@ -7796,6 +7798,7 @@ export interface UpdateMCPServerConfigRequest {
|
||||
readonly availability?: string;
|
||||
readonly enabled?: boolean;
|
||||
readonly model_intent?: boolean;
|
||||
readonly allow_in_plan_mode?: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/notifications.go
|
||||
|
||||
@@ -528,6 +528,7 @@ const makeMCPServer = (
|
||||
availability: overrides.availability ?? "default_on",
|
||||
enabled: overrides.enabled ?? true,
|
||||
model_intent: overrides.model_intent ?? false,
|
||||
allow_in_plan_mode: overrides.allow_in_plan_mode ?? false,
|
||||
created_at: overrides.created_at ?? now,
|
||||
updated_at: overrides.updated_at ?? now,
|
||||
auth_connected: overrides.auth_connected ?? false,
|
||||
|
||||
@@ -517,6 +517,7 @@ const sampleMCPServers = [
|
||||
availability: "default_on",
|
||||
enabled: true,
|
||||
model_intent: false,
|
||||
allow_in_plan_mode: false,
|
||||
auth_connected: true,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
|
||||
@@ -33,6 +33,7 @@ const createServerConfig = (
|
||||
availability: overrides.availability ?? "default_on",
|
||||
enabled: overrides.enabled ?? true,
|
||||
model_intent: overrides.model_intent ?? false,
|
||||
allow_in_plan_mode: overrides.allow_in_plan_mode ?? false,
|
||||
created_at: overrides.created_at ?? now,
|
||||
updated_at: overrides.updated_at ?? now,
|
||||
auth_connected: overrides.auth_connected ?? false,
|
||||
|
||||
@@ -228,6 +228,7 @@ interface MCPServerFormValues {
|
||||
availability: string;
|
||||
enabled: boolean;
|
||||
modelIntent: boolean;
|
||||
allowInPlanMode: boolean;
|
||||
toolAllowList: string;
|
||||
toolDenyList: string;
|
||||
customHeaders: Array<{ key: string; value: string }>;
|
||||
@@ -257,6 +258,7 @@ const buildInitialValues = (
|
||||
availability: server?.availability ?? "default_off",
|
||||
enabled: server?.enabled ?? true,
|
||||
modelIntent: server?.model_intent ?? false,
|
||||
allowInPlanMode: server?.allow_in_plan_mode ?? false,
|
||||
toolAllowList: joinList(server?.tool_allow_list),
|
||||
toolDenyList: joinList(server?.tool_deny_list),
|
||||
customHeaders: [],
|
||||
@@ -311,6 +313,7 @@ const ServerForm: FC<ServerFormProps> = ({
|
||||
availability: values.availability,
|
||||
enabled: values.enabled,
|
||||
model_intent: values.modelIntent,
|
||||
allow_in_plan_mode: values.allowInPlanMode,
|
||||
...(values.authType === "oauth2" && {
|
||||
oauth2_client_id: values.oauth2ClientID.trim(),
|
||||
oauth2_client_secret: effectiveOAuth2Secret,
|
||||
@@ -778,6 +781,26 @@ const ServerForm: FC<ServerFormProps> = ({
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor={`${formId}-allow-in-plan-mode`}>
|
||||
Allow all tools from this MCP server in root plan mode
|
||||
</Label>
|
||||
<p className="text-sm text-content-secondary">
|
||||
When enabled, the root plan-mode agent can call these tools
|
||||
during planning. Workspace MCP and plan-mode subagents remain
|
||||
restricted.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={`${formId}-allow-in-plan-mode`}
|
||||
checked={form.values.allowInPlanMode}
|
||||
onCheckedChange={(v) => {
|
||||
form.setFieldValue("allowInPlanMode", v);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid items-start gap-5 sm:grid-cols-2">
|
||||
{" "}
|
||||
<Field
|
||||
|
||||
@@ -32,6 +32,7 @@ const createServerConfig = (
|
||||
availability: overrides.availability ?? "default_on",
|
||||
enabled: overrides.enabled ?? true,
|
||||
model_intent: overrides.model_intent ?? false,
|
||||
allow_in_plan_mode: overrides.allow_in_plan_mode ?? false,
|
||||
created_at: overrides.created_at ?? now,
|
||||
updated_at: overrides.updated_at ?? now,
|
||||
auth_connected: overrides.auth_connected ?? false,
|
||||
|
||||
Reference in New Issue
Block a user