diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 88e1cac439..c41bcb9c8b 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -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]))) diff --git a/coderd/database/migrations/000472_mcp_server_allow_in_plan_mode.down.sql b/coderd/database/migrations/000472_mcp_server_allow_in_plan_mode.down.sql new file mode 100644 index 0000000000..66802e2455 --- /dev/null +++ b/coderd/database/migrations/000472_mcp_server_allow_in_plan_mode.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE mcp_server_configs + DROP COLUMN allow_in_plan_mode; diff --git a/coderd/database/migrations/000472_mcp_server_allow_in_plan_mode.up.sql b/coderd/database/migrations/000472_mcp_server_allow_in_plan_mode.up.sql new file mode 100644 index 0000000000..e8c93c6cb1 --- /dev/null +++ b/coderd/database/migrations/000472_mcp_server_allow_in_plan_mode.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE mcp_server_configs + ADD COLUMN allow_in_plan_mode BOOLEAN NOT NULL DEFAULT false; diff --git a/coderd/database/migrations/testdata/fixtures/000472_mcp_server_allow_in_plan_mode.up.sql b/coderd/database/migrations/testdata/fixtures/000472_mcp_server_allow_in_plan_mode.up.sql new file mode 100644 index 0000000000..281117240f --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000472_mcp_server_allow_in_plan_mode.up.sql @@ -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'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 257c1c5c2a..d5ffc05e82 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -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 { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d24723cef3..3edb997c82 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 } diff --git a/coderd/database/queries/mcpserverconfigs.sql b/coderd/database/queries/mcpserverconfigs.sql index d4b22cc280..103bbaea17 100644 --- a/coderd/database/queries/mcpserverconfigs.sql +++ b/coderd/database/queries/mcpserverconfigs.sql @@ -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 diff --git a/coderd/mcp.go b/coderd/mcp.go index c4de049f67..1059530629 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -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, } } diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 85ee74ec95..8b0e847aac 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -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) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 99b5f924dd..c36d5b5765 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -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, diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 6deb0db2d6..5f8e718c6f 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -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() diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index d8302ab793..b1ad259a04 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -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 diff --git a/coderd/x/chatd/prompt.go b/coderd/x/chatd/prompt.go index c7e5a05fc5..b6b129be73 100644 --- a/coderd/x/chatd/prompt.go +++ b/coderd/x/chatd/prompt.go @@ -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 diff --git a/codersdk/mcp.go b/codersdk/mcp.go index 3d7a535b7f..41b3adb220 100644 --- a/codersdk/mcp.go +++ b/codersdk/mcp.go @@ -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) { diff --git a/docs/ai-coder/agents/architecture.md b/docs/ai-coder/agents/architecture.md index 7528ced69a..282d0ed1bf 100644 --- a/docs/ai-coder/agents/architecture.md +++ b/docs/ai-coder/agents/architecture.md @@ -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 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a012981818..bdc4892db7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index baab81fcc5..b033162163 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -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, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 9d5e7ee828..efae4a584f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -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", diff --git a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx index b225145b8a..a88d84038f 100644 --- a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.stories.tsx @@ -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, diff --git a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx index 2ec01ad833..1b3d12c6ae 100644 --- a/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerAdminPanel.tsx @@ -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 = ({ 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 = ({ disabled={isDisabled} /> +
+
+ +

+ When enabled, the root plan-mode agent can call these tools + during planning. Workspace MCP and plan-mode subagents remain + restricted. +

+
+ { + form.setFieldValue("allowInPlanMode", v); + }} + disabled={isDisabled} + /> +
{" "}