WIP: add Codex Apps library MCP integration

This commit is contained in:
Liang-Ting Jiang
2026-04-15 20:17:28 -07:00
parent bd61737e8a
commit 6717d23c57
12 changed files with 1764 additions and 3 deletions
+1
View File
@@ -1987,6 +1987,7 @@ dependencies = [
"serde_json",
"serial_test",
"sha1",
"sha2",
"shlex",
"similar",
"tempfile",
+1 -1
View File
@@ -753,7 +753,7 @@ impl MessageProcessor {
| ClientRequest::TurnSteer { request_id, .. } = &codex_request
{
self.analytics_events_client.track_request(
connection_id.0,
connection_request_id.connection_id.0,
request_id.clone(),
codex_request.clone(),
);
@@ -0,0 +1,238 @@
use std::collections::HashSet;
use std::sync::Arc;
use rmcp::model::Tool;
use rmcp::model::ToolAnnotations;
use serde_json::Map;
use serde_json::Value as JsonValue;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp_connection_manager::ToolInfo;
pub const SEARCH_LIBRARY_FILES_TOOL_NAME: &str = "search_library_files";
pub const LIST_LIBRARY_DIRECTORY_NODES_TOOL_NAME: &str = "list_library_directory_nodes";
pub const DOWNLOAD_LIBRARY_FILE_TOOL_NAME: &str = "download_library_file";
pub const CREATE_LIBRARY_FILE_TOOL_NAME: &str = "create_library_file";
pub const WRITEBACK_LIBRARY_FILE_TOOL_NAME: &str = "writeback_library_file";
pub fn is_codex_apps_library_tool(tool_name: &str) -> bool {
matches!(
tool_name,
SEARCH_LIBRARY_FILES_TOOL_NAME
| LIST_LIBRARY_DIRECTORY_NODES_TOOL_NAME
| DOWNLOAD_LIBRARY_FILE_TOOL_NAME
| CREATE_LIBRARY_FILE_TOOL_NAME
| WRITEBACK_LIBRARY_FILE_TOOL_NAME
)
}
pub(crate) fn append_builtin_codex_apps_library_tools(
mut tools: Vec<ToolInfo>,
server_instructions: Option<&str>,
) -> Vec<ToolInfo> {
let existing_names = tools
.iter()
.filter(|tool| tool.server_name == CODEX_APPS_MCP_SERVER_NAME)
.map(|tool| tool.tool.name.to_string())
.collect::<HashSet<_>>();
for tool in builtin_codex_apps_library_tools(server_instructions) {
if existing_names.contains(tool.tool.name.as_ref()) {
continue;
}
tools.push(tool);
}
tools
}
fn builtin_codex_apps_library_tools(server_instructions: Option<&str>) -> Vec<ToolInfo> {
vec![
library_tool(
SEARCH_LIBRARY_FILES_TOOL_NAME,
"Search or list recent library files through the ChatGPT File API. Use this for library discovery instead of local path walking. Optional arguments: q, limit, cursor, category, state.",
serde_json::json!({
"type": "object",
"properties": {
"q": {
"type": "string",
"description": "Optional filename substring query. Omit to list recent files."
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"description": "Maximum number of results to return. Defaults to 10."
},
"cursor": {
"type": "string",
"description": "Optional pagination cursor from a previous response."
},
"category": {
"type": "string",
"description": "Optional library file category filter."
},
"state": {
"type": "string",
"description": "Optional library file state filter."
}
},
"additionalProperties": false
}),
ToolAnnotations {
destructive_hint: Some(false),
idempotent_hint: Some(true),
open_world_hint: Some(false),
read_only_hint: Some(true),
title: None,
},
server_instructions,
),
library_tool(
LIST_LIBRARY_DIRECTORY_NODES_TOOL_NAME,
"List the immediate child folders and files for a library directory through the ChatGPT File API. Use parent_directory_id omitted for the synthetic root.",
serde_json::json!({
"type": "object",
"properties": {
"parent_directory_id": {
"type": "string",
"description": "Optional parent directory id. Omit to list the synthetic library root."
},
"cursor": {
"type": "string",
"description": "Optional pagination cursor from a previous response."
}
},
"additionalProperties": false
}),
ToolAnnotations {
destructive_hint: Some(false),
idempotent_hint: Some(true),
open_world_hint: Some(false),
read_only_hint: Some(true),
title: None,
},
server_instructions,
),
library_tool(
DOWNLOAD_LIBRARY_FILE_TOOL_NAME,
"Download a library file into a managed per-thread local cache and return the hydrated local filesystem path. Use this when a task needs normal local file access.",
serde_json::json!({
"type": "object",
"properties": {
"file_id": {
"type": "string",
"minLength": 1,
"description": "Backing storage file_id for the library file."
},
"file_name": {
"type": "string",
"minLength": 1,
"description": "Filename to use for the hydrated local copy."
},
"library_file_id": {
"type": "string",
"description": "Optional library object id for sidecar metadata."
}
},
"required": ["file_id", "file_name"],
"additionalProperties": false
}),
ToolAnnotations {
destructive_hint: Some(false),
idempotent_hint: Some(true),
open_world_hint: Some(false),
read_only_hint: Some(true),
title: None,
},
server_instructions,
),
library_tool(
CREATE_LIBRARY_FILE_TOOL_NAME,
"Create a new text file in the user's library through the ChatGPT File API. V1 is create-new-only: do not use this to update, replace, or delete an existing library file.",
serde_json::json!({
"type": "object",
"properties": {
"file_name": {
"type": "string",
"minLength": 1,
"description": "Name for the new library file, including extension."
},
"content": {
"type": "string",
"description": "UTF-8 text content for the new file. Maximum size is 500 MB."
}
},
"required": ["file_name", "content"],
"additionalProperties": false
}),
ToolAnnotations {
destructive_hint: Some(false),
idempotent_hint: Some(false),
open_world_hint: Some(false),
read_only_hint: Some(false),
title: None,
},
server_instructions,
),
library_tool(
WRITEBACK_LIBRARY_FILE_TOOL_NAME,
"Write back a hydrated local library file by creating a new library file only if the local copy changed. V1 never updates, replaces, or deletes the source file.",
serde_json::json!({
"type": "object",
"properties": {
"local_path": {
"type": "string",
"minLength": 1,
"description": "Absolute local path returned by download_library_file for the hydrated copy."
},
"file_name": {
"type": "string",
"minLength": 1,
"description": "Optional name for the new library file. Defaults to the hydrated source filename."
}
},
"required": ["local_path"],
"additionalProperties": false
}),
ToolAnnotations {
destructive_hint: Some(false),
idempotent_hint: Some(false),
open_world_hint: Some(false),
read_only_hint: Some(false),
title: None,
},
server_instructions,
),
]
}
fn library_tool(
name: &str,
description: &str,
input_schema: JsonValue,
annotations: ToolAnnotations,
server_instructions: Option<&str>,
) -> ToolInfo {
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
callable_name: name.to_string(),
callable_namespace: "mcp__codex_apps__".to_string(),
server_instructions: server_instructions.map(str::to_string),
tool: Tool {
name: name.to_string().into(),
title: None,
description: Some(description.to_string().into()),
input_schema: Arc::new(input_schema.as_object().cloned().unwrap_or_else(Map::new)),
output_schema: None,
annotations: Some(annotations),
execution: None,
icons: None,
meta: None,
},
connector_id: None,
connector_name: None,
plugin_display_names: Vec::new(),
connector_description: None,
}
}
+7
View File
@@ -1,7 +1,14 @@
pub(crate) mod codex_apps_library_tools;
pub(crate) mod mcp;
pub(crate) mod mcp_connection_manager;
pub(crate) mod mcp_tool_names;
pub use codex_apps_library_tools::CREATE_LIBRARY_FILE_TOOL_NAME;
pub use codex_apps_library_tools::DOWNLOAD_LIBRARY_FILE_TOOL_NAME;
pub use codex_apps_library_tools::LIST_LIBRARY_DIRECTORY_NODES_TOOL_NAME;
pub use codex_apps_library_tools::SEARCH_LIBRARY_FILES_TOOL_NAME;
pub use codex_apps_library_tools::WRITEBACK_LIBRARY_FILE_TOOL_NAME;
pub use codex_apps_library_tools::is_codex_apps_library_tool;
pub use mcp::CODEX_APPS_MCP_SERVER_NAME;
pub use mcp::McpAuthStatusEntry;
pub use mcp::McpConfig;
@@ -20,6 +20,7 @@ use std::time::Duration;
use std::time::Instant;
use crate::McpAuthStatusEntry;
use crate::codex_apps_library_tools::append_builtin_codex_apps_library_tools;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::McpConfig;
use crate::mcp::ToolPluginProvenance;
@@ -564,7 +565,8 @@ impl AsyncManagedClient {
async fn listed_tools(&self) -> Option<Vec<ToolInfo>> {
let annotate_tools = |tools: Vec<ToolInfo>| {
let mut tools = tools;
let mut tools =
append_builtin_codex_apps_library_tools(tools, /*server_instructions*/ None);
for tool in &mut tools {
if tool.server_name == CODEX_APPS_MCP_SERVER_NAME {
tool.tool = tool_with_model_visible_input_schema(&tool.tool);
@@ -1685,6 +1687,11 @@ async fn list_tools_for_client_uncached(
}
})
.collect();
let tools = if server_name == CODEX_APPS_MCP_SERVER_NAME {
append_builtin_codex_apps_library_tools(tools, server_instructions)
} else {
tools
};
if server_name == CODEX_APPS_MCP_SERVER_NAME {
return Ok(filter_disallowed_codex_apps_tools(tools));
}
@@ -727,7 +727,10 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty(
let timeout_result =
tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await;
let tools = timeout_result.expect("cache-hit startup snapshot should not block");
assert!(tools.is_empty());
assert!(
tools.contains_key("mcp__codex_apps__search_library_files"),
"builtin codex apps library tools should still be available from an empty startup snapshot"
);
}
#[tokio::test]
+1
View File
@@ -97,6 +97,7 @@ rmcp = { workspace = true, default-features = false, features = [
] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
sha1 = { workspace = true }
shlex = { workspace = true }
similar = { workspace = true }
+64
View File
@@ -1131,6 +1131,70 @@ async fn mcp_tool_exposure_directly_exposes_explicit_apps_without_deferred_overl
assert!(deferred_tools.contains_key("mcp__rmcp__tool_0"));
}
#[test]
fn mcp_tool_exposure_directly_exposes_builtin_codex_apps_library_tools_without_connectors() {
let config = test_config();
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true);
let mcp_tools = HashMap::from([(
"mcp__codex_apps__search_library_files".to_string(),
make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"search_library_files",
/*connector_id*/ None,
/*connector_name*/ None,
),
)]);
let exposure = build_mcp_tool_exposure(
&mcp_tools,
/*connectors*/ None,
&[],
&config,
&tools_config,
);
assert_eq!(
exposure.direct_tools.into_keys().collect::<Vec<_>>(),
vec!["mcp__codex_apps__search_library_files".to_string()]
);
assert!(exposure.deferred_tools.is_none());
}
#[test]
fn mcp_tool_exposure_keeps_builtin_codex_apps_library_tools_direct_in_large_search_sets() {
let config = test_config();
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true);
let mut mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD);
mcp_tools.insert(
"mcp__codex_apps__search_library_files".to_string(),
make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"search_library_files",
/*connector_id*/ None,
/*connector_name*/ None,
),
);
let exposure = build_mcp_tool_exposure(
&mcp_tools,
/*connectors*/ None,
&[],
&config,
&tools_config,
);
assert_eq!(
exposure.direct_tools.into_keys().collect::<Vec<_>>(),
vec!["mcp__codex_apps__search_library_files".to_string()]
);
let deferred_tools = exposure
.deferred_tools
.as_ref()
.expect("large tool sets should be discoverable through tool_search");
assert!(deferred_tools.contains_key("mcp__codex_apps__search_library_files"));
assert!(deferred_tools.contains_key("mcp__rmcp__tool_0"));
}
#[tokio::test]
async fn reconstruct_history_matches_live_compactions() {
let (session, turn_context) = make_session_and_context().await;
+1
View File
@@ -44,6 +44,7 @@ mod installation_id;
pub(crate) mod instructions;
pub(crate) mod landlock;
pub use landlock::spawn_command_under_linux_sandbox;
mod library_mcp;
pub(crate) mod mcp;
mod mcp_skill_dependencies;
mod mcp_tool_approval_templates;
File diff suppressed because it is too large Load Diff
+12
View File
@@ -27,6 +27,7 @@ use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::library_mcp::handle_library_mcp_tool_call;
use crate::mcp_openai_file::rewrite_mcp_tool_arguments_for_openai_files;
use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam;
use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template;
@@ -38,6 +39,7 @@ use codex_features::Feature;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::SandboxState;
use codex_mcp::declared_openai_file_input_param_names;
use codex_mcp::is_codex_apps_library_tool;
use codex_mcp::mcp_permission_prompt_is_auto_approved;
use codex_otel::sanitize_metric_tag_value;
use codex_protocol::mcp::CallToolResult;
@@ -460,6 +462,16 @@ async fn execute_mcp_tool_call(
metadata: Option<&McpToolApprovalMetadata>,
request_meta: Option<serde_json::Value>,
) -> Result<CallToolResult, String> {
if server == CODEX_APPS_MCP_SERVER_NAME && is_codex_apps_library_tool(tool_name) {
return sanitize_mcp_tool_result_for_model(
turn_context
.model_info
.input_modalities
.contains(&InputModality::Image),
handle_library_mcp_tool_call(sess, turn_context, tool_name, arguments_value).await,
);
}
let rewritten_arguments = rewrite_mcp_tool_arguments_for_openai_files(
sess,
turn_context,
+21
View File
@@ -4,6 +4,7 @@ use std::collections::HashSet;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo as McpToolInfo;
use codex_mcp::filter_non_codex_apps_mcp_tools_only;
use codex_mcp::is_codex_apps_library_tool;
use codex_tools::ToolsConfig;
use crate::config::Config;
@@ -24,6 +25,7 @@ pub(crate) fn build_mcp_tool_exposure(
tools_config: &ToolsConfig,
) -> McpToolExposure {
let mut deferred_tools = filter_non_codex_apps_mcp_tools_only(all_mcp_tools);
deferred_tools.extend(filter_builtin_codex_apps_library_tools(all_mcp_tools));
if let Some(connectors) = connectors {
deferred_tools.extend(filter_codex_apps_mcp_tools(
all_mcp_tools,
@@ -45,12 +47,31 @@ pub(crate) fn build_mcp_tool_exposure(
deferred_tools.remove(direct_tool_name);
}
let mut direct_tools = filter_builtin_codex_apps_library_tools(all_mcp_tools);
direct_tools.extend(filter_codex_apps_mcp_tools(
all_mcp_tools,
explicitly_enabled_connectors,
config,
));
McpToolExposure {
direct_tools,
deferred_tools: (!deferred_tools.is_empty()).then_some(deferred_tools),
}
}
fn filter_builtin_codex_apps_library_tools(
mcp_tools: &HashMap<String, McpToolInfo>,
) -> HashMap<String, McpToolInfo> {
mcp_tools
.iter()
.filter(|(_, tool)| {
tool.server_name == CODEX_APPS_MCP_SERVER_NAME
&& is_codex_apps_library_tool(tool.tool.name.as_ref())
})
.map(|(name, tool)| (name.clone(), tool.clone()))
.collect()
}
fn filter_codex_apps_mcp_tools(
mcp_tools: &HashMap<String, McpToolInfo>,
connectors: &[connectors::AppInfo],