Support per-tool MCP output limits (#41421)

## What changed

- Add a positive `output_token_limit` setting to each entry under an MCP server's `tools` configuration.
- Apply the most restrictive limit when plugin and user policies overlap, while keeping approval policy independent.
- Carry the effective MCP output budget in conversation history so tool output, post-tool hook responses, and resumed sessions use the same truncation limit.

## Testing

- Cover configuration parsing, serialization, schema validation, and plugin policy merging.
- Cover MCP output below and above the configured limit, post-tool hook responses, and session resume.

GitOrigin-RevId: d0beb4fca9ba6055d9e1d31c137373b465d50d61
This commit is contained in:
pakrym-oai authored and copyberry committed 2026-08-28 21:40:23 +00:00
1 parent 03147407e3
commit f742dabc6f
38 files changed
+487 -224

No files matched your search

+2
View File
@@ -6773,6 +6773,7 @@ approval_mode = "approve"
server.tools.get("search"),
Some(&McpServerToolConfig {
approval_mode: Some(AppToolApproval::Approve),
..Default::default()
})
);
}
@@ -6816,6 +6817,7 @@ approval_mode = "approve"
tool,
&McpServerToolConfig {
approval_mode: Some(AppToolApproval::Approve),
..Default::default()
}
);
}
+12 -9
View File
@@ -314,7 +314,7 @@ impl ConfigDocument {
&[NOTICE_TABLE_KEY, "model_migrations", from.as_str()],
value(to.clone()),
)),
ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)),
ConfigEdit::ReplaceMcpServers(servers) => self.replace_mcp_servers(servers),
ConfigEdit::AddToolSuggestDisabledTool(disabled_tool) => {
Ok(self.add_tool_suggest_disabled_tool(disabled_tool))
}
@@ -432,9 +432,12 @@ impl ConfigDocument {
self.remove(segments)
}
fn replace_mcp_servers(&mut self, servers: &BTreeMap<String, McpServerConfig>) -> bool {
fn replace_mcp_servers(
&mut self,
servers: &BTreeMap<String, McpServerConfig>,
) -> anyhow::Result<bool> {
if servers.is_empty() {
return self.clear(&["mcp_servers"]);
return Ok(self.clear(&["mcp_servers"]));
}
let root = self.doc.as_table_mut();
@@ -446,7 +449,7 @@ impl ConfigDocument {
}
let Some(item) = root.get_mut("mcp_servers") else {
return false;
return Ok(false);
};
if document_helpers::ensure_table_for_write(item).is_none() {
@@ -454,7 +457,7 @@ impl ConfigDocument {
}
let Some(table) = item.as_table_mut() else {
return false;
return Ok(false);
};
let keys_to_remove: Vec<String> = table
@@ -472,17 +475,17 @@ impl ConfigDocument {
if let TomlItem::Value(value) = existing
&& let Some(inline) = value.as_inline_table_mut()
{
let replacement = document_helpers::serialize_mcp_server_inline(config);
let replacement = document_helpers::serialize_mcp_server_inline(config)?;
document_helpers::merge_inline_table(inline, replacement);
} else {
*existing = document_helpers::serialize_mcp_server(config);
*existing = document_helpers::serialize_mcp_server(config)?;
}
} else {
table.insert(name, document_helpers::serialize_mcp_server(config));
table.insert(name, document_helpers::serialize_mcp_server(config)?);
}
}
true
Ok(true)
}
fn set_skill_config(&mut self, selector: SkillConfigSelector, enabled: bool) -> bool {
@@ -1,3 +1,4 @@
use anyhow::Context;
use codex_config::types::AppToolApproval;
use codex_config::types::McpServerAuth;
use codex_config::types::McpServerConfig;
@@ -45,7 +46,7 @@ pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTabl
}
}
fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable {
fn serialize_mcp_server_table(config: &McpServerConfig) -> anyhow::Result<TomlTable> {
let mut entry = TomlTable::new();
entry.set_implicit(false);
@@ -175,15 +176,15 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable {
let mut tool_entries: Vec<_> = config.tools.iter().collect();
tool_entries.sort_by_key(|(name, _)| *name);
for (name, tool_config) in tool_entries {
tools.insert(name, serialize_mcp_server_tool(tool_config));
tools.insert(name, serialize_mcp_server_tool(tool_config)?);
}
entry.insert("tools", TomlItem::Table(tools));
}
entry
Ok(entry)
}
fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem {
fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> anyhow::Result<TomlItem> {
let mut entry = TomlTable::new();
entry.set_implicit(false);
if let Some(approval_mode) = config.approval_mode {
@@ -194,15 +195,21 @@ fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem {
AppToolApproval::Approve => "approve",
});
}
TomlItem::Table(entry)
if let Some(output_token_limit) = config.output_token_limit {
entry["output_token_limit"] = value(
i64::try_from(output_token_limit.get())
.context("output_token_limit exceeds the TOML integer range")?,
);
}
Ok(TomlItem::Table(entry))
}
pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
TomlItem::Table(serialize_mcp_server_table(config))
pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> anyhow::Result<TomlItem> {
Ok(TomlItem::Table(serialize_mcp_server_table(config)?))
}
pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable {
serialize_mcp_server_table(config).into_inline_table()
pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> anyhow::Result<InlineTable> {
Ok(serialize_mcp_server_table(config)?.into_inline_table())
}
pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) {
+20 -5
View File
@@ -1161,8 +1161,9 @@ B = \"2\"
assert_eq!(raw, expected);
}
#[test]
fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
#[test_case::test_case(30_000; "positive limit")]
#[test_case::test_case(usize::MAX; "largest native limit")]
fn blocking_replace_mcp_servers_serializes_tool_approval_overrides(output_token_limit: usize) {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
@@ -1196,22 +1197,36 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
"search".to_string(),
McpServerToolConfig {
approval_mode: Some(AppToolApproval::Approve),
output_token_limit: std::num::NonZeroUsize::new(output_token_limit),
},
)]),
},
);
apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist");
let result = apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]);
if i64::try_from(output_token_limit).is_err() {
assert!(
result
.unwrap_err()
.to_string()
.contains("output_token_limit exceeds the TOML integer range")
);
return;
}
result.expect("persist");
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = "\
let expected = format!(
"\
[mcp_servers.docs]
command = \"docs-server\"
default_tools_approval_mode = \"prompt\"
[mcp_servers.docs.tools.search]
approval_mode = \"approve\"
";
output_token_limit = {output_token_limit}
"
);
assert_eq!(raw, expected);
}