Compare commits

...

2 Commits

Author SHA1 Message Date
Dylan Hurd
0fbef73d74 Add spawned agent skills-message toggle
Support configuring whether spawned agents receive the skills developer message through agents.spawn.inject_skills_message and per-call spawn_agent overrides. Full-history forks continue to inherit the parent setting, and request construction now omits skills instructions when disabled.

Co-authored-by: Codex <noreply@openai.com>
2026-04-17 13:25:28 -07:00
Dylan Hurd
6b0ed01fed Add spawned agent MCP allowlists
Support configuring MCP server allowlists for spawned agents through agents.spawn.mcp_servers and per-call spawn_agent overrides. Apply the allowlist when resolving effective MCP servers, including the built-in apps server, and reject unknown server names before spawning.

Co-authored-by: Codex <noreply@openai.com>
2026-04-17 13:25:12 -07:00
16 changed files with 912 additions and 58 deletions

View File

@@ -14,6 +14,7 @@ pub use skill_dependencies::canonical_mcp_server_key;
pub use skill_dependencies::collect_missing_mcp_dependencies;
use std::collections::HashMap;
use std::collections::HashSet;
use std::env;
use std::path::PathBuf;
use std::time::Duration;
@@ -131,6 +132,8 @@ pub struct McpConfig {
pub apps_enabled: bool,
/// User-configured and plugin-provided MCP servers keyed by server name.
pub configured_mcp_servers: HashMap<String, McpServerConfig>,
/// Optional allowlist of MCP server names for the current session.
pub mcp_server_allowlist: Option<Vec<String>>,
/// Plugin metadata used to attribute MCP tools/connectors to plugin display names.
pub plugin_capability_summaries: Vec<PluginCapabilitySummary>,
}
@@ -298,19 +301,34 @@ pub fn with_codex_apps_mcp(
} else {
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
}
servers
filter_mcp_servers_by_allowlist(servers, config.mcp_server_allowlist.as_deref())
}
pub fn configured_mcp_servers(config: &McpConfig) -> HashMap<String, McpServerConfig> {
config.configured_mcp_servers.clone()
filter_mcp_servers_by_allowlist(
config.configured_mcp_servers.clone(),
config.mcp_server_allowlist.as_deref(),
)
}
pub fn effective_mcp_servers(
config: &McpConfig,
auth: Option<&CodexAuth>,
) -> HashMap<String, McpServerConfig> {
let servers = configured_mcp_servers(config);
with_codex_apps_mcp(servers, auth, config)
with_codex_apps_mcp(config.configured_mcp_servers.clone(), auth, config)
}
fn filter_mcp_servers_by_allowlist(
mut servers: HashMap<String, McpServerConfig>,
allowlist: Option<&[String]>,
) -> HashMap<String, McpServerConfig> {
let Some(allowlist) = allowlist else {
return servers;
};
let allowlist = allowlist.iter().map(String::as_str).collect::<HashSet<_>>();
servers.retain(|name, _| allowlist.contains(name.as_str()));
servers
}
pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance {

View File

@@ -21,10 +21,35 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
use_legacy_landlock: false,
apps_enabled: false,
configured_mcp_servers: HashMap::new(),
mcp_server_allowlist: None,
plugin_capability_summaries: Vec::new(),
}
}
fn http_mcp_server(url: &str) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: url.to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
}
}
fn make_tool(name: &str) -> Tool {
Tool {
name: name.to_string(),
@@ -186,51 +211,11 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
config.configured_mcp_servers.insert(
"sample".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://user.example/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
http_mcp_server("https://user.example/mcp"),
);
config.configured_mcp_servers.insert(
"docs".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://docs.example/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
http_mcp_server("https://docs.example/mcp"),
);
let effective = effective_mcp_servers(&config, Some(&auth));
@@ -262,3 +247,35 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
other => panic!("expected streamable http transport, got {other:?}"),
}
}
#[tokio::test]
async fn effective_mcp_servers_respects_session_allowlist() {
let mut config = test_mcp_config(PathBuf::from("/tmp"));
config.apps_enabled = true;
config.configured_mcp_servers.insert(
"docs".to_string(),
http_mcp_server("https://docs.example/mcp"),
);
config.configured_mcp_servers.insert(
"linear".to_string(),
http_mcp_server("https://linear.example/mcp"),
);
config.mcp_server_allowlist = Some(vec![
"docs".to_string(),
CODEX_APPS_MCP_SERVER_NAME.to_string(),
]);
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let effective = effective_mcp_servers(&config, Some(&auth));
assert_eq!(
effective
.keys()
.cloned()
.collect::<std::collections::BTreeSet<_>>(),
std::collections::BTreeSet::from([
"docs".to_string(),
CODEX_APPS_MCP_SERVER_NAME.to_string(),
])
);
}

View File

@@ -543,6 +543,9 @@ pub struct AgentsToml {
#[schemars(range(min = 1))]
pub job_max_runtime_seconds: Option<u64>,
/// Defaults applied to agents when they are spawned.
pub spawn: Option<AgentSpawnToml>,
/// User-defined role declarations keyed by role name.
///
/// Example:
@@ -556,6 +559,17 @@ pub struct AgentsToml {
pub roles: BTreeMap<String, AgentRoleToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AgentSpawnToml {
/// MCP server names to start in spawned agents. When unset, spawned agents inherit all
/// effective MCP servers. An empty list disables MCP servers for spawned agents.
pub mcp_servers: Option<Vec<String>>,
/// Whether to inject the skills developer message in spawned agents.
pub inject_skills_message: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AgentRoleToml {

View File

@@ -31,6 +31,23 @@
},
"type": "object"
},
"AgentSpawnToml": {
"additionalProperties": false,
"properties": {
"inject_skills_message": {
"description": "Whether to inject the skills developer message in spawned agents.",
"type": "boolean"
},
"mcp_servers": {
"description": "MCP server names to start in spawned agents. When unset, spawned agents inherit all effective MCP servers. An empty list disables MCP servers for spawned agents.",
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"AgentsToml": {
"additionalProperties": {
"$ref": "#/definitions/AgentRoleToml"
@@ -53,6 +70,14 @@
"format": "uint",
"minimum": 1.0,
"type": "integer"
},
"spawn": {
"allOf": [
{
"$ref": "#/definitions/AgentSpawnToml"
}
],
"description": "Defaults applied to agents when they are spawned."
}
},
"type": "object"

View File

@@ -3547,6 +3547,7 @@ async fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
spawn: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -4413,6 +4414,7 @@ async fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Res
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
spawn: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -4455,6 +4457,7 @@ async fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io::
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
spawn: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -4491,6 +4494,7 @@ async fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std::
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
spawn: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -4527,6 +4531,7 @@ async fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io:
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
spawn: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -4767,6 +4772,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_server_allowlist: None,
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
@@ -4779,6 +4785,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_spawn: AgentSpawnConfig::default(),
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
@@ -4830,6 +4837,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
inject_skills_message: true,
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("o3".to_string()),
@@ -4917,6 +4925,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_server_allowlist: None,
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
@@ -4929,6 +4938,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_spawn: AgentSpawnConfig::default(),
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
@@ -4980,6 +4990,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
inject_skills_message: true,
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("gpt3".to_string()),
@@ -5065,6 +5076,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_server_allowlist: None,
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
@@ -5077,6 +5089,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_spawn: AgentSpawnConfig::default(),
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
@@ -5128,6 +5141,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
inject_skills_message: true,
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("zdr".to_string()),
@@ -5198,6 +5212,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_server_allowlist: None,
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
@@ -5210,6 +5225,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_spawn: AgentSpawnConfig::default(),
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
@@ -5261,6 +5277,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
inject_skills_message: true,
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("gpt5".to_string()),
@@ -6509,6 +6526,62 @@ hide_spawn_agent_metadata = false
Ok(())
}
#[tokio::test]
async fn agents_spawn_config_sets_child_defaults_only() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.spawn]
mcp_servers = ["docs", " linear ", "docs"]
inject_skills_message = false
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
config.agent_spawn,
AgentSpawnConfig {
mcp_servers: Some(vec!["docs".to_string(), "linear".to_string()]),
inject_skills_message: false,
}
);
assert_eq!(config.mcp_server_allowlist, None);
assert!(config.inject_skills_message);
Ok(())
}
#[tokio::test]
async fn agents_spawn_config_rejects_blank_mcp_server_names() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[agents.spawn]
mcp_servers = ["docs", " "]
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("blank MCP server names should be rejected");
assert!(
err.to_string()
.contains("agents.spawn.mcp_servers cannot contain blank MCP server names"),
"unexpected error: {err}"
);
Ok(())
}
#[tokio::test]
async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io::Result<()> {
let codex_home = TempDir::new()?;

View File

@@ -21,6 +21,7 @@ use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS;
use crate::windows_sandbox::WindowsSandboxLevelExt;
use crate::windows_sandbox::resolve_windows_sandbox_mode;
use crate::windows_sandbox::resolve_windows_sandbox_private_desktop;
use codex_config::config_toml::AgentSpawnToml;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_config::config_toml::RealtimeAudioConfig;
@@ -376,6 +377,9 @@ pub struct Config {
/// Definition for MCP servers that Codex can reach out to for tool calls.
pub mcp_servers: Constrained<HashMap<String, McpServerConfig>>,
/// Optional MCP server allowlist for the current session.
pub mcp_server_allowlist: Option<Vec<String>>,
/// Preferred store for MCP OAuth credentials.
/// keyring: Use an OS-specific keyring service.
/// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access.
@@ -417,6 +421,9 @@ pub struct Config {
/// Maximum nesting depth allowed for spawned agent threads.
pub agent_max_depth: i32,
/// Defaults applied when this session spawns child agents.
pub agent_spawn: AgentSpawnConfig,
/// User-defined role declarations keyed by role name.
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
@@ -553,6 +560,9 @@ pub struct Config {
/// Settings specific to the task-path-based multi-agent tool surface.
pub multi_agent_v2: MultiAgentV2Config,
/// Whether to inject the `<skills_instructions>` developer block for this session.
pub inject_skills_message: bool,
/// Centralized feature flags; source of truth for feature gating.
pub features: ManagedFeatures,
@@ -604,6 +614,21 @@ pub struct MultiAgentV2Config {
pub hide_spawn_agent_metadata: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentSpawnConfig {
pub mcp_servers: Option<Vec<String>>,
pub inject_skills_message: bool,
}
impl Default for AgentSpawnConfig {
fn default() -> Self {
Self {
mcp_servers: None,
inject_skills_message: true,
}
}
}
impl Default for MultiAgentV2Config {
fn default() -> Self {
Self {
@@ -774,6 +799,7 @@ impl Config {
use_legacy_landlock: self.features.use_legacy_landlock(),
apps_enabled: self.features.enabled(Feature::Apps),
configured_mcp_servers,
mcp_server_allowlist: self.mcp_server_allowlist.clone(),
plugin_capability_summaries: loaded_plugins.capability_summaries().to_vec(),
}
}
@@ -1407,6 +1433,48 @@ fn resolve_multi_agent_v2_config(
}
}
fn resolve_agent_spawn_config(spawn: Option<&AgentSpawnToml>) -> std::io::Result<AgentSpawnConfig> {
let default = AgentSpawnConfig::default();
let Some(spawn) = spawn else {
return Ok(default);
};
let mcp_servers = spawn
.mcp_servers
.as_ref()
.map(|servers| normalize_mcp_server_allowlist(servers, "agents.spawn.mcp_servers"))
.transpose()?;
Ok(AgentSpawnConfig {
mcp_servers,
inject_skills_message: spawn
.inject_skills_message
.unwrap_or(default.inject_skills_message),
})
}
fn normalize_mcp_server_allowlist(
servers: &[String],
field_label: &str,
) -> std::io::Result<Vec<String>> {
let mut normalized = Vec::with_capacity(servers.len());
let mut seen = std::collections::BTreeSet::new();
for server in servers {
let server = server.trim();
if server.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{field_label} cannot contain blank MCP server names"),
));
}
if seen.insert(server.to_string()) {
normalized.push(server.to_string());
}
}
Ok(normalized)
}
fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiAgentV2ConfigToml> {
match features?.multi_agent_v2.as_ref()? {
FeatureToml::Enabled(_) => None,
@@ -1839,6 +1907,11 @@ impl Config {
"agents.job_max_runtime_seconds must fit within a 64-bit signed integer",
));
}
let agent_spawn = resolve_agent_spawn_config(
cfg.agents
.as_ref()
.and_then(|agents| agents.spawn.as_ref()),
)?;
let background_terminal_max_timeout = cfg
.background_terminal_max_timeout
.unwrap_or(DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS)
@@ -2151,6 +2224,7 @@ impl Config {
env!("CARGO_PKG_VERSION"),
),
mcp_servers,
mcp_server_allowlist: None,
// The config.toml omits "_mode" because it's a config file. However, "_mode"
// is important in code to differentiate the mode from the store implementation.
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
@@ -2177,6 +2251,7 @@ impl Config {
tool_output_token_limit: cfg.tool_output_token_limit,
agent_max_threads,
agent_max_depth,
agent_spawn,
agent_roles,
memories: cfg.memories.unwrap_or_default().into(),
agent_job_max_runtime_seconds,
@@ -2247,6 +2322,7 @@ impl Config {
background_terminal_max_timeout,
ghost_snapshot,
multi_agent_v2,
inject_skills_message: true,
features,
suppress_unstable_features_warning: cfg
.suppress_unstable_features_warning

View File

@@ -2411,12 +2411,14 @@ impl Session {
developer_sections.push(apps_section);
}
}
let implicit_skills = turn_context
.turn_skills
.outcome
.allowed_skills_for_implicit_invocation();
if let Some(skills_section) = render_skills_section(&implicit_skills) {
developer_sections.push(skills_section);
if turn_context.config.inject_skills_message {
let implicit_skills = turn_context
.turn_skills
.outcome
.allowed_skills_for_implicit_invocation();
if let Some(skills_section) = render_skills_section(&implicit_skills) {
developer_sections.push(skills_section);
}
}
let loaded_plugins = self
.services

View File

@@ -65,6 +65,8 @@ impl ToolHandler for Handler {
role_name,
args.model.as_deref(),
args.reasoning_effort,
args.mcp_servers.as_deref(),
args.inject_skills_message,
)?;
} else {
apply_requested_spawn_agent_model_overrides(
@@ -78,6 +80,13 @@ impl ToolHandler for Handler {
apply_role_to_config(&mut config, role_name)
.await
.map_err(FunctionCallError::RespondToModel)?;
apply_spawn_agent_capability_overrides(
&session,
&mut config,
args.mcp_servers,
args.inject_skills_message,
)
.await?;
}
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
apply_spawn_agent_overrides(&mut config, child_depth);
@@ -182,6 +191,8 @@ struct SpawnAgentArgs {
agent_type: Option<String>,
model: Option<String>,
reasoning_effort: Option<ReasoningEffort>,
mcp_servers: Option<Vec<String>>,
inject_skills_message: Option<bool>,
#[serde(default)]
fork_context: bool,
}

View File

@@ -7,6 +7,7 @@ use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use codex_features::Feature;
use codex_mcp::effective_mcp_servers;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
@@ -23,6 +24,7 @@ use codex_protocol::protocol::SubAgentSource;
use codex_protocol::user_input::UserInput;
use serde::Serialize;
use serde_json::Value as JsonValue;
use std::collections::BTreeSet;
use std::collections::HashMap;
/// Minimum wait timeout to prevent tight polling loops from burning CPU.
@@ -240,15 +242,104 @@ pub(crate) fn reject_full_fork_spawn_overrides(
agent_type: Option<&str>,
model: Option<&str>,
reasoning_effort: Option<ReasoningEffort>,
mcp_servers: Option<&[String]>,
inject_skills_message: Option<bool>,
) -> Result<(), FunctionCallError> {
if agent_type.is_some() || model.is_some() || reasoning_effort.is_some() {
if agent_type.is_some()
|| model.is_some()
|| reasoning_effort.is_some()
|| mcp_servers.is_some()
|| inject_skills_message.is_some()
{
return Err(FunctionCallError::RespondToModel(
"Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without fork_context/fork_turns=all.".to_string(),
"Full-history forked agents inherit the parent agent type, model, reasoning effort, MCP servers, and skills-message setting; omit agent_type, model, reasoning_effort, mcp_servers, and inject_skills_message, or spawn without fork_context/fork_turns=all.".to_string(),
));
}
Ok(())
}
pub(crate) async fn apply_spawn_agent_capability_overrides(
session: &Session,
config: &mut Config,
requested_mcp_servers: Option<Vec<String>>,
requested_inject_skills_message: Option<bool>,
) -> Result<(), FunctionCallError> {
let mcp_server_allowlist = match requested_mcp_servers {
Some(servers) => Some(normalize_spawn_mcp_server_allowlist(
&servers,
"mcp_servers",
)?),
None => config.agent_spawn.mcp_servers.clone(),
};
if let Some(allowlist) = mcp_server_allowlist.as_ref() {
validate_spawn_mcp_server_allowlist(session, config, allowlist).await?;
}
config.mcp_server_allowlist = mcp_server_allowlist;
config.inject_skills_message =
requested_inject_skills_message.unwrap_or(config.agent_spawn.inject_skills_message);
Ok(())
}
fn normalize_spawn_mcp_server_allowlist(
servers: &[String],
field_label: &str,
) -> Result<Vec<String>, FunctionCallError> {
let mut normalized = Vec::with_capacity(servers.len());
let mut seen = BTreeSet::new();
for server in servers {
let server = server.trim();
if server.is_empty() {
return Err(FunctionCallError::RespondToModel(format!(
"{field_label} cannot contain blank MCP server names"
)));
}
if seen.insert(server.to_string()) {
normalized.push(server.to_string());
}
}
Ok(normalized)
}
async fn validate_spawn_mcp_server_allowlist(
session: &Session,
config: &Config,
allowlist: &[String],
) -> Result<(), FunctionCallError> {
let auth = session.services.auth_manager.auth().await;
let mut unfiltered_config = config.clone();
unfiltered_config.mcp_server_allowlist = None;
let mcp_config = unfiltered_config
.to_mcp_config(session.services.plugins_manager.as_ref())
.await;
let available_servers = effective_mcp_servers(&mcp_config, auth.as_ref())
.keys()
.cloned()
.collect::<BTreeSet<_>>();
let unknown_servers = allowlist
.iter()
.filter(|server| !available_servers.contains(*server))
.cloned()
.collect::<Vec<_>>();
if unknown_servers.is_empty() {
return Ok(());
}
let available = if available_servers.is_empty() {
"none".to_string()
} else {
available_servers.into_iter().collect::<Vec<_>>().join(", ")
};
Err(FunctionCallError::RespondToModel(format!(
"Unknown MCP server(s) for spawned agent: {}. Available MCP servers: {available}",
unknown_servers.join(", ")
)))
}
/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`.
///
/// These values are chosen by the live turn rather than persisted config, so leaving them stale

View File

@@ -17,6 +17,8 @@ use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHa
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::ShellEnvironmentPolicy;
use codex_features::Feature;
use codex_login::AuthManager;
@@ -92,6 +94,30 @@ fn thread_manager() -> ThreadManager {
)
}
fn test_mcp_server(url: &str) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: url.to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
enabled: false,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
}
}
async fn install_role_with_model_override(turn: &mut TurnContext) -> String {
let role_name = "fork-context-role".to_string();
tokio::fs::create_dir_all(&turn.config.codex_home)
@@ -402,6 +428,312 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() {
assert_eq!(snapshot.model_provider_id, "ollama");
}
#[tokio::test]
async fn spawn_agent_applies_per_call_mcp_and_skills_settings() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
agent_id: String,
nickname: Option<String>,
}
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
session.services.agent_control = manager.agent_control();
let mut config = (*turn.config).clone();
let mut mcp_servers = config.mcp_servers.get().clone();
mcp_servers.insert(
"docs".to_string(),
test_mcp_server("https://docs.example/mcp"),
);
mcp_servers.insert(
"linear".to_string(),
test_mcp_server("https://linear.example/mcp"),
);
config
.mcp_servers
.set(mcp_servers)
.expect("test config should allow MCP updates");
turn.config = Arc::new(config);
let output = SpawnAgentHandler
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"mcp_servers": [" docs ", "docs"],
"inject_skills_message": false
})),
))
.await
.expect("spawn_agent should succeed");
let (content, _) = expect_text_output(output);
let result: SpawnAgentResult =
serde_json::from_str(&content).expect("spawn_agent result should be json");
assert!(result.nickname.is_some());
let child_config = manager
.get_thread(parse_agent_id(&result.agent_id))
.await
.expect("spawned agent thread should exist")
.codex
.session
.get_config()
.await;
assert_eq!(
child_config.mcp_server_allowlist,
Some(vec!["docs".to_string()])
);
assert!(!child_config.inject_skills_message);
}
#[tokio::test]
async fn spawn_agent_uses_role_spawn_defaults_and_allows_per_call_override() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
agent_id: String,
}
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
session.services.agent_control = manager.agent_control();
let role_name = "scoped".to_string();
tokio::fs::create_dir_all(&turn.config.codex_home)
.await
.expect("codex home should be created");
let role_config_path = turn.config.codex_home.as_path().join("scoped.toml");
tokio::fs::write(
&role_config_path,
r#"[mcp_servers.docs]
url = "https://docs.example/mcp"
enabled = false
[mcp_servers.linear]
url = "https://linear.example/mcp"
enabled = false
[agents.spawn]
mcp_servers = ["linear"]
inject_skills_message = false
"#,
)
.await
.expect("role config should be written");
let mut config = (*turn.config).clone();
let mut mcp_servers = config.mcp_servers.get().clone();
mcp_servers.insert(
"docs".to_string(),
test_mcp_server("https://docs.example/mcp"),
);
mcp_servers.insert(
"linear".to_string(),
test_mcp_server("https://linear.example/mcp"),
);
config
.mcp_servers
.set(mcp_servers)
.expect("test config should allow MCP updates");
config.agent_roles.insert(
role_name.clone(),
AgentRoleConfig {
description: Some("Scoped tools".to_string()),
config_file: Some(role_config_path),
nickname_candidates: None,
},
);
turn.config = Arc::new(config);
let output = SpawnAgentHandler
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"agent_type": role_name,
"mcp_servers": ["docs"],
"inject_skills_message": true
})),
))
.await
.expect("spawn_agent should succeed");
let (content, _) = expect_text_output(output);
let result: SpawnAgentResult =
serde_json::from_str(&content).expect("spawn_agent result should be json");
let child_config = manager
.get_thread(parse_agent_id(&result.agent_id))
.await
.expect("spawned agent thread should exist")
.codex
.session
.get_config()
.await;
assert_eq!(
child_config.agent_spawn.mcp_servers,
Some(vec!["linear".to_string()])
);
assert_eq!(
child_config.mcp_server_allowlist,
Some(vec!["docs".to_string()])
);
assert!(child_config.inject_skills_message);
}
#[tokio::test]
async fn spawn_agent_applies_role_spawn_defaults_without_per_call_overrides() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
agent_id: String,
}
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
session.services.agent_control = manager.agent_control();
let role_name = "scoped_defaults".to_string();
tokio::fs::create_dir_all(&turn.config.codex_home)
.await
.expect("codex home should be created");
let role_config_path = turn
.config
.codex_home
.as_path()
.join("scoped-defaults.toml");
tokio::fs::write(
&role_config_path,
r#"[mcp_servers.linear]
url = "https://linear.example/mcp"
enabled = false
[agents.spawn]
mcp_servers = ["linear"]
inject_skills_message = false
"#,
)
.await
.expect("role config should be written");
let mut config = (*turn.config).clone();
let mut mcp_servers = config.mcp_servers.get().clone();
mcp_servers.insert(
"linear".to_string(),
test_mcp_server("https://linear.example/mcp"),
);
config
.mcp_servers
.set(mcp_servers)
.expect("test config should allow MCP updates");
config.agent_roles.insert(
role_name.clone(),
AgentRoleConfig {
description: Some("Scoped defaults".to_string()),
config_file: Some(role_config_path),
nickname_candidates: None,
},
);
turn.config = Arc::new(config);
let output = SpawnAgentHandler
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"agent_type": role_name
})),
))
.await
.expect("spawn_agent should succeed");
let (content, _) = expect_text_output(output);
let result: SpawnAgentResult =
serde_json::from_str(&content).expect("spawn_agent result should be json");
let child_config = manager
.get_thread(parse_agent_id(&result.agent_id))
.await
.expect("spawned agent thread should exist")
.codex
.session
.get_config()
.await;
assert_eq!(
child_config.mcp_server_allowlist,
Some(vec!["linear".to_string()])
);
assert!(!child_config.inject_skills_message);
}
#[tokio::test]
async fn spawn_agent_rejects_unknown_mcp_server() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
session.services.agent_control = manager.agent_control();
let mut config = (*turn.config).clone();
let mut mcp_servers = config.mcp_servers.get().clone();
mcp_servers.insert(
"docs".to_string(),
test_mcp_server("https://docs.example/mcp"),
);
config
.mcp_servers
.set(mcp_servers)
.expect("test config should allow MCP updates");
turn.config = Arc::new(config);
let err = SpawnAgentHandler
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"mcp_servers": ["missing"]
})),
))
.await
.expect_err("unknown MCP server should be rejected");
assert_eq!(
err,
FunctionCallError::RespondToModel(
"Unknown MCP server(s) for spawned agent: missing. Available MCP servers: docs"
.to_string()
)
);
}
#[tokio::test]
async fn spawn_agent_fork_context_rejects_capability_overrides() {
let (session, turn) = make_session_and_context().await;
let err = SpawnAgentHandler
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"fork_context": true,
"mcp_servers": [],
"inject_skills_message": false
})),
))
.await
.expect_err("fork_context should reject capability overrides");
assert_eq!(
err,
FunctionCallError::RespondToModel(
"Full-history forked agents inherit the parent agent type, model, reasoning effort, MCP servers, and skills-message setting; omit agent_type, model, reasoning_effort, mcp_servers, and inject_skills_message, or spawn without fork_context/fork_turns=all.".to_string(),
)
);
}
#[tokio::test]
async fn spawn_agent_fork_context_rejects_agent_type_override() {
let (mut session, mut turn) = make_session_and_context().await;
@@ -430,7 +762,7 @@ async fn spawn_agent_fork_context_rejects_agent_type_override() {
assert_eq!(
err,
FunctionCallError::RespondToModel(
"Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without fork_context/fork_turns=all.".to_string(),
"Full-history forked agents inherit the parent agent type, model, reasoning effort, MCP servers, and skills-message setting; omit agent_type, model, reasoning_effort, mcp_servers, and inject_skills_message, or spawn without fork_context/fork_turns=all.".to_string(),
)
);
}
@@ -464,7 +796,7 @@ async fn spawn_agent_fork_context_rejects_child_model_overrides() {
assert_eq!(
err,
FunctionCallError::RespondToModel(
"Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without fork_context/fork_turns=all.".to_string(),
"Full-history forked agents inherit the parent agent type, model, reasoning effort, MCP servers, and skills-message setting; omit agent_type, model, reasoning_effort, mcp_servers, and inject_skills_message, or spawn without fork_context/fork_turns=all.".to_string(),
)
);
}
@@ -508,7 +840,7 @@ async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() {
assert_eq!(
err,
FunctionCallError::RespondToModel(
"Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without fork_context/fork_turns=all.".to_string(),
"Full-history forked agents inherit the parent agent type, model, reasoning effort, MCP servers, and skills-message setting; omit agent_type, model, reasoning_effort, mcp_servers, and inject_skills_message, or spawn without fork_context/fork_turns=all.".to_string(),
)
);
}
@@ -549,7 +881,7 @@ async fn multi_agent_v2_spawn_fork_turns_rejects_child_model_overrides() {
assert_eq!(
err,
FunctionCallError::RespondToModel(
"Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without fork_context/fork_turns=all.".to_string(),
"Full-history forked agents inherit the parent agent type, model, reasoning effort, MCP servers, and skills-message setting; omit agent_type, model, reasoning_effort, mcp_servers, and inject_skills_message, or spawn without fork_context/fork_turns=all.".to_string(),
)
);
}
@@ -829,6 +1161,70 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
}));
}
#[tokio::test]
async fn multi_agent_v2_spawn_applies_per_call_mcp_and_skills_settings() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = (*turn.config).clone();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
let mut mcp_servers = config.mcp_servers.get().clone();
mcp_servers.insert(
"docs".to_string(),
test_mcp_server("https://docs.example/mcp"),
);
config
.mcp_servers
.set(mcp_servers)
.expect("test config should allow MCP updates");
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
SpawnAgentHandlerV2
.handle(invocation(
session.clone(),
turn,
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"task_name": "test_process",
"mcp_servers": ["docs"],
"inject_skills_message": false
})),
))
.await
.expect("spawn_agent should succeed");
let child_thread_id = session
.services
.agent_control
.resolve_agent_reference(root.thread_id, &SessionSource::Cli, "test_process")
.await
.expect("relative path should resolve");
let child_config = manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist")
.codex
.session
.get_config()
.await;
assert_eq!(
child_config.mcp_server_allowlist,
Some(vec!["docs".to_string()])
);
assert!(!child_config.inject_skills_message);
}
#[tokio::test]
async fn multi_agent_v2_spawn_rejects_legacy_fork_context() {
let (mut session, mut turn) = make_session_and_context().await;

View File

@@ -75,6 +75,8 @@ impl ToolHandler for Handler {
role_name,
args.model.as_deref(),
args.reasoning_effort,
args.mcp_servers.as_deref(),
args.inject_skills_message,
)?;
} else {
apply_requested_spawn_agent_model_overrides(
@@ -88,6 +90,13 @@ impl ToolHandler for Handler {
apply_role_to_config(&mut config, role_name)
.await
.map_err(FunctionCallError::RespondToModel)?;
apply_spawn_agent_capability_overrides(
&session,
&mut config,
args.mcp_servers,
args.inject_skills_message,
)
.await?;
}
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
apply_spawn_agent_overrides(&mut config, child_depth);
@@ -234,6 +243,8 @@ struct SpawnAgentArgs {
agent_type: Option<String>,
model: Option<String>,
reasoning_effort: Option<ReasoningEffort>,
mcp_servers: Option<Vec<String>>,
inject_skills_message: Option<bool>,
fork_turns: Option<String>,
fork_context: Option<bool>,
}

View File

@@ -1480,6 +1480,67 @@ async fn skills_append_to_developer_message() {
let _codex_home_guard = codex_home;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inject_skills_message_false_suppresses_developer_skills_section() {
skip_if_no_network!();
let server = MockServer::start().await;
let resp_mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let codex_home = Arc::new(TempDir::new().unwrap());
let skill_dir = codex_home.path().join("skills/demo");
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: demo\ndescription: build charts\n---\n\n# body\n",
)
.expect("write skill");
let codex_home_path = codex_home.path().to_path_buf();
let codex = test_codex()
.with_home(codex_home.clone())
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(move |config| {
config.cwd = codex_home_path.abs();
config.inject_skills_message = false;
})
.build(&server)
.await
.expect("create new conversation")
.codex;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = resp_mock.single_request();
let developer_messages = request.message_input_texts("developer");
let developer_text = developer_messages.join("\n\n");
assert!(
!developer_text.contains("## Skills"),
"did not expect skills section: {developer_messages:?}"
);
assert!(
!developer_text.contains("demo: build charts"),
"did not expect skill summary: {developer_messages:?}"
);
let _codex_home_guard = codex_home;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn includes_configured_effort_in_request() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -538,6 +538,23 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<St
.to_string(),
)),
),
(
"mcp_servers".to_string(),
JsonSchema::array(
JsonSchema::string(None),
Some(
"Optional MCP server allowlist for the new agent. Omit to use spawn defaults; pass an empty array to disable MCP servers."
.to_string(),
),
),
),
(
"inject_skills_message".to_string(),
JsonSchema::boolean(Some(
"Optional override for whether the new agent receives the skills developer message."
.to_string(),
)),
),
])
}
@@ -572,6 +589,23 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<St
.to_string(),
)),
),
(
"mcp_servers".to_string(),
JsonSchema::array(
JsonSchema::string(None),
Some(
"Optional MCP server allowlist for the new agent. Omit to use spawn defaults; pass an empty array to disable MCP servers."
.to_string(),
),
),
),
(
"inject_skills_message".to_string(),
JsonSchema::boolean(Some(
"Optional override for whether the new agent receives the skills developer message."
.to_string(),
)),
),
])
}

View File

@@ -66,6 +66,8 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
assert!(properties.contains_key("task_name"));
assert!(properties.contains_key("message"));
assert!(properties.contains_key("fork_turns"));
assert!(properties.contains_key("mcp_servers"));
assert!(properties.contains_key("inject_skills_message"));
assert!(!properties.contains_key("items"));
assert!(!properties.contains_key("fork_context"));
assert_eq!(
@@ -105,6 +107,8 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
.expect("spawn_agent should use object params");
assert!(properties.contains_key("fork_context"));
assert!(properties.contains_key("mcp_servers"));
assert!(properties.contains_key("inject_skills_message"));
assert!(!properties.contains_key("fork_turns"));
}

View File

@@ -183,6 +183,8 @@ fn test_build_specs_collab_tools_enabled() {
};
let (properties, _) = expect_object_schema(parameters);
assert!(properties.contains_key("fork_context"));
assert!(properties.contains_key("mcp_servers"));
assert!(properties.contains_key("inject_skills_message"));
assert!(!properties.contains_key("fork_turns"));
}
@@ -235,6 +237,8 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
assert!(properties.contains_key("task_name"));
assert!(properties.contains_key("message"));
assert!(properties.contains_key("fork_turns"));
assert!(properties.contains_key("mcp_servers"));
assert!(properties.contains_key("inject_skills_message"));
assert!(!properties.contains_key("items"));
assert!(!properties.contains_key("fork_context"));
assert_eq!(

View File

@@ -42,6 +42,23 @@ default_tools_approval_mode = "approve"
approval_mode = "prompt"
```
## Spawned agent defaults
Use `[agents.spawn]` to set defaults for newly spawned agents. `mcp_servers`
is an optional allowlist of MCP server names; omit it to inherit all effective
MCP servers, or set it to an empty list to disable MCP servers in spawned
agents. `inject_skills_message` controls whether spawned agents receive the
skills developer message.
```toml
[agents.spawn]
mcp_servers = ["docs"]
inject_skills_message = false
```
The `spawn_agent` tool can override either setting per spawned agent. Full
history forks inherit the parent session settings and reject these overrides.
## Apps (Connectors)
Use `$` in the composer to insert a ChatGPT connector; the popover lists accessible