Compare commits

...

7 Commits

Author SHA1 Message Date
Matthew Zeng
eda59fc8f0 Merge branch 'main' of github.com:openai/codex into dev/mzeng/mcp_apps_2 2026-04-06 21:40:21 -07:00
Matthew Zeng
86f6eb8e45 update 2026-04-01 15:17:10 -04:00
Matthew Zeng
41052984fd update 2026-03-31 23:27:16 -04:00
Matthew Zeng
1c7bf9d35a update 2026-03-31 23:17:03 -04:00
Matthew Zeng
1e376a90d3 Merge branch 'main' of github.com:openai/codex into dev/mzeng/mcp_apps_1 2026-03-31 22:25:58 -04:00
Matthew Zeng
c51c16fca9 update 2026-03-28 00:24:08 -07:00
Matthew Zeng
9d85fa81b3 update 2026-03-28 00:17:39 -07:00
26 changed files with 305 additions and 130 deletions

View File

@@ -1736,6 +1736,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -9184,6 +9184,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -6007,6 +6007,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -294,6 +294,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -294,6 +294,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -430,6 +430,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -518,6 +518,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -456,6 +456,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -456,6 +456,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -456,6 +456,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -518,6 +518,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -456,6 +456,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -518,6 +518,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -456,6 +456,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -456,6 +456,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -430,6 +430,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -430,6 +430,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -430,6 +430,7 @@
},
"McpToolCallResult": {
"properties": {
"_meta": true,
"content": {
"items": true,
"type": "array"

View File

@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "../serde_json/JsonValue";
export type McpToolCallResult = { content: Array<JsonValue>, structuredContent: JsonValue | null, };
export type McpToolCallResult = { content: Array<JsonValue>, structuredContent: JsonValue | null, _meta: JsonValue | null, };

View File

@@ -520,6 +520,7 @@ impl ThreadHistoryBuilder {
Some(McpToolCallResult {
content: value.content.clone(),
structured_content: value.structured_content.clone(),
meta: value.meta.clone(),
}),
None,
),
@@ -1167,6 +1168,7 @@ mod tests {
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::items::UserMessageItem as CoreUserMessageItem;
use codex_protocol::items::build_hook_prompt_message;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::MessagePhase as CoreMessagePhase;
use codex_protocol::models::WebSearchAction as CoreWebSearchAction;
use codex_protocol::parse_command::ParsedCommand;
@@ -1861,6 +1863,67 @@ mod tests {
);
}
#[test]
fn reconstructs_mcp_tool_result_meta_from_persisted_completion_events() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".into(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: "mcp-1".into(),
invocation: McpInvocation {
server: "docs".into(),
tool: "lookup".into(),
arguments: Some(serde_json::json!({"id":"123"})),
},
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "result"
})],
structured_content: Some(serde_json::json!({"id":"123"})),
is_error: Some(false),
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/lookup.html"
})),
}),
}),
];
let items = events
.into_iter()
.map(RolloutItem::EventMsg)
.collect::<Vec<_>>();
let turns = build_turns_from_rollout_items(&items);
assert_eq!(turns.len(), 1);
assert_eq!(
turns[0].items[0],
ThreadItem::McpToolCall {
id: "mcp-1".into(),
server: "docs".into(),
tool: "lookup".into(),
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"id":"123"}),
result: Some(McpToolCallResult {
content: vec![serde_json::json!({
"type": "text",
"text": "result"
})],
structured_content: Some(serde_json::json!({"id":"123"})),
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/lookup.html"
})),
}),
error: None,
duration_ms: Some(8),
}
);
}
#[test]
fn reconstructs_dynamic_tool_items_from_request_and_response_events() {
let events = vec![

View File

@@ -5048,6 +5048,9 @@ pub struct McpToolCallResult {
// representations). Using `JsonValue` keeps the payload wire-shaped and easy to export.
pub content: Vec<JsonValue>,
pub structured_content: Option<JsonValue>,
#[serde(rename = "_meta")]
#[ts(rename = "_meta")]
pub meta: Option<JsonValue>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

View File

@@ -2843,6 +2843,7 @@ async fn construct_mcp_tool_call_end_notification(
Some(McpToolCallResult {
content: value.content.clone(),
structured_content: value.structured_content.clone(),
meta: value.meta.clone(),
}),
None,
),
@@ -4233,7 +4234,9 @@ mod tests {
content: content.clone(),
is_error: Some(false),
structured_content: None,
meta: None,
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/list-resources.html"
})),
};
let end_event = McpToolCallEndEvent {
@@ -4268,6 +4271,9 @@ mod tests {
result: Some(McpToolCallResult {
content,
structured_content: None,
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/list-resources.html"
})),
}),
error: None,
duration_ms: Some(0),

View File

@@ -251,9 +251,8 @@ use codex_login::run_login_server;
use codex_mcp::mcp::McpSnapshotDetail;
use codex_mcp::mcp::auth::discover_supported_scopes;
use codex_mcp::mcp::auth::resolve_oauth_scopes;
use codex_mcp::mcp::collect_mcp_snapshot_with_detail;
use codex_mcp::mcp::collect_mcp_server_status_snapshot_with_detail;
use codex_mcp::mcp::effective_mcp_servers;
use codex_mcp::mcp::qualified_mcp_tool_name_prefix;
use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationMode;
@@ -5183,55 +5182,14 @@ impl CodexMessageProcessor {
McpServerStatusDetail::ToolsAndAuthOnly => McpSnapshotDetail::ToolsAndAuthOnly,
};
let snapshot = collect_mcp_snapshot_with_detail(
let snapshot = collect_mcp_server_status_snapshot_with_detail(
&mcp_config,
auth.as_ref(),
request_id.request_id.to_string(),
detail,
)
.await;
// Rebuild the tool list per original server name instead of using
// `group_tools_by_server()`: qualified tool names are sanitized for the
// Responses API, so a config key like `some-server` is encoded as the
// `mcp__some_server__` prefix. Matching with the original server name's
// sanitized prefix preserves `/mcp` output for hyphenated names.
let effective_servers = effective_mcp_servers(&mcp_config, auth.as_ref());
let mut sanitized_prefix_counts = HashMap::<String, usize>::new();
for name in effective_servers.keys() {
let prefix = qualified_mcp_tool_name_prefix(name);
*sanitized_prefix_counts.entry(prefix).or_default() += 1;
}
let tools_by_server = effective_servers
.keys()
.map(|name| {
let prefix = qualified_mcp_tool_name_prefix(name);
// If multiple server names normalize to the same prefix, the
// qualified tool namespace is ambiguous (for example
// `some-server` and `some_server` both become
// `mcp__some_server__`). In that case, avoid attributing the
// same tools to multiple servers.
let tools = if sanitized_prefix_counts
.get(&prefix)
.copied()
.unwrap_or_default()
== 1
{
snapshot
.tools
.iter()
.filter_map(|(qualified_name, tool)| {
qualified_name
.strip_prefix(&prefix)
.map(|tool_name| (tool_name.to_string(), tool.clone()))
})
.collect::<HashMap<_, _>>()
} else {
HashMap::new()
};
(name.clone(), tools)
})
.collect::<HashMap<_, _>>();
let mut server_names: Vec<String> = config
.mcp_servers
@@ -5283,7 +5241,11 @@ impl CodexMessageProcessor {
.iter()
.map(|name| McpServerStatus {
name: name.clone(),
tools: tools_by_server.get(name).cloned().unwrap_or_default(),
tools: snapshot
.tools_by_server
.get(name)
.cloned()
.unwrap_or_default(),
resources: snapshot.resources.get(name).cloned().unwrap_or_default(),
resource_templates: snapshot
.resource_templates

View File

@@ -19,6 +19,7 @@ use codex_protocol::mcp::Resource;
use codex_protocol::mcp::ResourceTemplate;
use codex_protocol::mcp::Tool;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::McpAuthStatus;
use codex_protocol::protocol::McpListToolsResponseEvent;
use codex_protocol::protocol::SandboxPolicy;
use serde_json::Value;
@@ -26,6 +27,7 @@ use serde_json::Value;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::SandboxState;
use crate::mcp_connection_manager::ToolInfo;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
pub type McpManager = McpConnectionManager;
@@ -292,6 +294,14 @@ pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance {
ToolPluginProvenance::from_capability_summaries(&config.plugin_capability_summaries)
}
#[derive(Debug, Clone, Default)]
pub struct McpServerStatusSnapshot {
pub tools_by_server: HashMap<String, HashMap<String, Tool>>,
pub resources: HashMap<String, Vec<Resource>>,
pub resource_templates: HashMap<String, Vec<ResourceTemplate>>,
pub auth_statuses: HashMap<String, McpAuthStatus>,
}
pub async fn collect_mcp_snapshot(
config: &McpConfig,
auth: Option<&CodexAuth>,
@@ -357,33 +367,119 @@ pub async fn collect_mcp_snapshot_with_detail(
snapshot
}
pub fn split_qualified_tool_name(qualified_name: &str) -> Option<(String, String)> {
let mut parts = qualified_name.split(MCP_TOOL_NAME_DELIMITER);
let prefix = parts.next()?;
if prefix != MCP_TOOL_NAME_PREFIX {
return None;
pub async fn collect_mcp_server_status_snapshot_with_detail(
config: &McpConfig,
auth: Option<&CodexAuth>,
submit_id: String,
detail: McpSnapshotDetail,
) -> McpServerStatusSnapshot {
let mcp_servers = effective_mcp_servers(config, auth);
let tool_plugin_provenance = tool_plugin_provenance(config);
if mcp_servers.is_empty() {
return McpServerStatusSnapshot::default();
}
let server_name = parts.next()?;
let tool_name: String = parts.collect::<Vec<_>>().join(MCP_TOOL_NAME_DELIMITER);
if tool_name.is_empty() {
return None;
let mut sanitized_prefix_counts = HashMap::<String, usize>::new();
for name in mcp_servers.keys() {
let prefix = qualified_mcp_tool_name_prefix(name);
*sanitized_prefix_counts.entry(prefix).or_default() += 1;
}
Some((server_name.to_string(), tool_name))
let auth_status_entries =
compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await;
let (tx_event, rx_event) = unbounded();
drop(rx_event);
let sandbox_state = SandboxState {
sandbox_policy: SandboxPolicy::new_read_only_policy(),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
use_legacy_landlock: config.use_legacy_landlock,
};
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
&mcp_servers,
config.mcp_oauth_credentials_store_mode,
auth_status_entries.clone(),
&config.approval_policy,
submit_id,
tx_event,
sandbox_state,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth),
tool_plugin_provenance,
)
.await;
let snapshot = collect_mcp_server_status_snapshot_from_manager_with_detail(
&mcp_connection_manager,
auth_status_entries,
detail,
&sanitized_prefix_counts,
)
.await;
cancel_token.cancel();
snapshot
}
pub fn group_tools_by_server(
tools: &HashMap<String, Tool>,
) -> HashMap<String, HashMap<String, Tool>> {
let mut grouped = HashMap::new();
for (qualified_name, tool) in tools {
if let Some((server_name, tool_name)) = split_qualified_tool_name(qualified_name) {
grouped
async fn collect_mcp_server_status_snapshot_from_manager_with_detail(
mcp_connection_manager: &McpConnectionManager,
auth_status_entries: HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
detail: McpSnapshotDetail,
sanitized_prefix_counts: &HashMap<String, usize>,
) -> McpServerStatusSnapshot {
let (tools, resources, resource_templates) = tokio::join!(
mcp_connection_manager.list_all_tools(),
async {
if detail.include_resources() {
mcp_connection_manager.list_all_resources().await
} else {
HashMap::new()
}
},
async {
if detail.include_resources() {
mcp_connection_manager.list_all_resource_templates().await
} else {
HashMap::new()
}
},
);
let mut tools_by_server = HashMap::new();
for tool_info in tools.into_values() {
let prefix = qualified_mcp_tool_name_prefix(&tool_info.server_name);
if sanitized_prefix_counts
.get(&prefix)
.copied()
.unwrap_or_default()
!= 1
{
continue;
}
let tool_name = mcp_server_status_tool_name(&tool_info);
let server_name = tool_info.server_name.clone();
if let Some(tool) = convert_mcp_tool(tool_info.tool) {
tools_by_server
.entry(server_name)
.or_insert_with(HashMap::new)
.insert(tool_name, tool.clone());
.insert(tool_name, tool);
}
}
grouped
McpServerStatusSnapshot {
tools_by_server,
resources: convert_mcp_resources(resources),
resource_templates: convert_mcp_resource_templates(resource_templates),
auth_statuses: auth_status_entries
.iter()
.map(|(name, entry)| (name.clone(), entry.auth_status))
.collect(),
}
}
pub async fn collect_mcp_snapshot_from_manager(
@@ -428,22 +524,52 @@ pub async fn collect_mcp_snapshot_from_manager_with_detail(
let tools = tools
.into_iter()
.filter_map(|(name, tool)| match serde_json::to_value(tool.tool) {
Ok(value) => match Tool::from_mcp_value(value) {
Ok(tool) => Some((name, tool)),
Err(err) => {
tracing::warn!("Failed to convert MCP tool '{name}': {err}");
None
}
},
Err(err) => {
tracing::warn!("Failed to serialize MCP tool '{name}': {err}");
None
}
})
.filter_map(|(name, tool_info)| convert_mcp_tool(tool_info.tool).map(|tool| (name, tool)))
.collect::<HashMap<_, _>>();
let resources = resources
McpListToolsResponseEvent {
tools,
resources: convert_mcp_resources(resources),
resource_templates: convert_mcp_resource_templates(resource_templates),
auth_statuses,
}
}
fn mcp_server_status_tool_name(tool: &ToolInfo) -> String {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return tool.tool_name.clone();
}
let codex_apps_prefix = qualified_mcp_tool_name_prefix(CODEX_APPS_MCP_SERVER_NAME);
if let Some(connector_namespace) = tool.tool_namespace.strip_prefix(&codex_apps_prefix) {
return format!("{}{}", connector_namespace, tool.tool_name);
}
tool.tool_name.clone()
}
fn convert_mcp_tool(tool: rmcp::model::Tool) -> Option<Tool> {
let value = match serde_json::to_value(tool) {
Ok(value) => value,
Err(err) => {
tracing::warn!("Failed to serialize MCP tool: {err}");
return None;
}
};
match Tool::from_mcp_value(value) {
Ok(tool) => Some(tool),
Err(err) => {
tracing::warn!("Failed to convert MCP tool: {err}");
None
}
}
}
fn convert_mcp_resources(
resources: HashMap<String, Vec<rmcp::model::Resource>>,
) -> HashMap<String, Vec<Resource>> {
resources
.into_iter()
.map(|(name, resources)| {
let resources = resources
@@ -476,9 +602,13 @@ pub async fn collect_mcp_snapshot_from_manager_with_detail(
.collect::<Vec<_>>();
(name, resources)
})
.collect::<HashMap<_, _>>();
.collect()
}
let resource_templates = resource_templates
fn convert_mcp_resource_templates(
resource_templates: HashMap<String, Vec<rmcp::model::ResourceTemplate>>,
) -> HashMap<String, Vec<ResourceTemplate>> {
resource_templates
.into_iter()
.map(|(name, templates)| {
let templates = templates
@@ -512,14 +642,7 @@ pub async fn collect_mcp_snapshot_from_manager_with_detail(
.collect::<Vec<_>>();
(name, templates)
})
.collect::<HashMap<_, _>>();
McpListToolsResponseEvent {
tools,
resources,
resource_templates,
auth_statuses,
}
.collect()
}
#[cfg(test)]

View File

@@ -5,8 +5,10 @@ use codex_plugin::AppConnectorId;
use codex_plugin::PluginCapabilitySummary;
use codex_protocol::protocol::AskForApproval;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
McpConfig {
@@ -25,27 +27,29 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
}
}
fn make_tool(name: &str) -> Tool {
Tool {
name: name.to_string(),
title: None,
description: None,
input_schema: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
annotations: None,
icons: None,
meta: None,
fn make_tool_info(server_name: &str, tool_name: &str, tool_namespace: &str) -> ToolInfo {
ToolInfo {
server_name: server_name.to_string(),
tool_name: tool_name.to_string(),
tool_namespace: tool_namespace.to_string(),
tool: rmcp::model::Tool {
name: tool_name.to_string().into(),
title: None,
description: None,
input_schema: Arc::new(JsonObject::default()),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: None,
connector_name: None,
plugin_display_names: Vec::new(),
connector_description: None,
}
}
#[test]
fn split_qualified_tool_name_returns_server_and_tool() {
assert_eq!(
split_qualified_tool_name("mcp__alpha__do_thing"),
Some(("alpha".to_string(), "do_thing".to_string()))
);
}
#[test]
fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() {
assert_eq!(
@@ -55,33 +59,27 @@ fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() {
}
#[test]
fn split_qualified_tool_name_rejects_invalid_names() {
assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None);
assert_eq!(split_qualified_tool_name("mcp__alpha__"), None);
fn mcp_server_status_tool_name_preserves_hyphenated_mcp_tool_names() {
let tool_info = make_tool_info("music-studio", "play-live-pattern", "music-studio");
assert_eq!(
mcp_server_status_tool_name(&tool_info),
"play-live-pattern".to_string()
);
}
#[test]
fn group_tools_by_server_strips_prefix_and_groups() {
let mut tools = HashMap::new();
tools.insert("mcp__alpha__do_thing".to_string(), make_tool("do_thing"));
tools.insert(
"mcp__alpha__nested__op".to_string(),
make_tool("nested__op"),
fn mcp_server_status_tool_name_includes_codex_apps_connector_namespace() {
let tool_info = make_tool_info(
CODEX_APPS_MCP_SERVER_NAME,
"_property_search",
"mcp__codex_apps__zillow",
);
tools.insert("mcp__beta__do_other".to_string(), make_tool("do_other"));
let mut expected_alpha = HashMap::new();
expected_alpha.insert("do_thing".to_string(), make_tool("do_thing"));
expected_alpha.insert("nested__op".to_string(), make_tool("nested__op"));
let mut expected_beta = HashMap::new();
expected_beta.insert("do_other".to_string(), make_tool("do_other"));
let mut expected = HashMap::new();
expected.insert("alpha".to_string(), expected_alpha);
expected.insert("beta".to_string(), expected_beta);
assert_eq!(group_tools_by_server(&tools), expected);
assert_eq!(
mcp_server_status_tool_name(&tool_info),
"zillow_property_search".to_string()
);
}
#[test]

View File

@@ -482,6 +482,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() {
result: Some(McpToolCallResult {
content: Vec::new(),
structured_content: None,
meta: None,
}),
error: None,
duration_ms: Some(1_000),
@@ -613,6 +614,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
"text": "done",
})],
structured_content: Some(json!({ "status": "ok" })),
meta: None,
}),
error: None,
duration_ms: Some(10),