Skip to content

Commit 703793c

Browse files
authored
feat(core, mcp): cache codex_apps tools in memory (#29003)
## Description This makes Codex Apps tool reads use a shared in-memory snapshot instead of rereading the disk cache every time `list_all_tools()` runs. Disk still seeds the cache on startup and gets updated after successful fetches, but it is no longer the live read path. The core change is that `McpManager` now owns a process-scoped `CodexAppsToolsCache`. Codex threads in the same app-server process now share this Codex Apps in-memory tools snapshot. The snapshot is keyed by the Codex home plus the Codex Apps identity: the active Codex auth user/workspace and the effective Codex Apps MCP source config. There's already code to hard-refresh the cache, so we respect it in this PR. ## Local benchmark I ran a local steady-state microbenchmark of the exact repeated Codex Apps cached-tools read this PR removes, using the same real local cache payload in both trees: `3,678,138` bytes and `381` tools. The cache file was already warm in the OS page cache, so this measures same-process reread/deserialization work rather than cold-disk latency or full turn latency. Each run is 25 iterations (mimicking a turn that makes 25 inference calls). | Version | Run 1 | Run 2 | Avg | |---|---:|---:|---:| | `origin/main` disk read + JSON deserialize + `filter_tools` | `50.755 ms` | `52.894 ms` | `51.825 ms` | | This branch in-memory `current_tools` + `filter_tools` | `0.740 ms` | `0.778 ms` | `0.759 ms` | That removes about `51 ms` from each repeated Codex Apps cached-tools read on this machine, roughly `68x` faster for that subpath. It is useful evidence for the hot path this PR changes, but not a claim that every production turn gets `51 ms` faster; end-to-end impact also depends on the rest of `list_all_tools()` and tool-payload construction. This is on my M2 Max macbook, so with a slower disk this would be much worse (and indeed we did see this really blew up turn runtime with a slow disk).
1 parent 62c7f50 commit 703793c

15 files changed

Lines changed: 1104 additions & 616 deletions

File tree

codex-rs/app-server/src/request_processors/mcp_processor.rs

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -249,15 +249,12 @@ impl McpRequestProcessor {
249249
}
250250
None => (self.load_latest_config(/*fallback_cwd*/ None).await?, None),
251251
};
252+
let mcp_manager = self.thread_manager.mcp_manager();
252253
let mcp_config = match thread {
253254
Some(thread) => thread.runtime_mcp_config(&config).await,
254-
None => {
255-
self.thread_manager
256-
.mcp_manager()
257-
.runtime_config(&config)
258-
.await
259-
}
255+
None => mcp_manager.runtime_config(&config).await,
260256
};
257+
let codex_apps_tools_cache = mcp_manager.codex_apps_tools_cache();
261258
let auth = self.auth_manager.auth().await;
262259
let environment_manager = self.thread_manager.environment_manager();
263260
// This status path has no turn-selected environment. Use config cwd
@@ -274,6 +271,7 @@ impl McpRequestProcessor {
274271
mcp_config,
275272
auth,
276273
runtime_context,
274+
codex_apps_tools_cache,
277275
)
278276
.await;
279277
});
@@ -287,13 +285,15 @@ impl McpRequestProcessor {
287285
mcp_config: codex_mcp::McpConfig,
288286
auth: Option<CodexAuth>,
289287
runtime_context: McpRuntimeContext,
288+
codex_apps_tools_cache: codex_mcp::CodexAppsToolsCache,
290289
) {
291290
let result = Self::list_mcp_server_status_response(
292291
request_id.request_id.to_string(),
293292
params,
294293
mcp_config,
295294
auth,
296295
runtime_context,
296+
codex_apps_tools_cache,
297297
)
298298
.await;
299299
outgoing.send_result(request_id, result).await;
@@ -305,6 +305,7 @@ impl McpRequestProcessor {
305305
mcp_config: codex_mcp::McpConfig,
306306
auth: Option<CodexAuth>,
307307
runtime_context: McpRuntimeContext,
308+
codex_apps_tools_cache: codex_mcp::CodexAppsToolsCache,
308309
) -> Result<ListMcpServerStatusResponse, JSONRPCErrorError> {
309310
let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) {
310311
McpServerStatusDetail::Full => McpSnapshotDetail::Full,
@@ -316,6 +317,7 @@ impl McpRequestProcessor {
316317
auth.as_ref(),
317318
request_id,
318319
runtime_context,
320+
codex_apps_tools_cache,
319321
detail,
320322
)
321323
.await;
@@ -406,11 +408,9 @@ impl McpRequestProcessor {
406408
}
407409

408410
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
409-
let mcp_config = self
410-
.thread_manager
411-
.mcp_manager()
412-
.runtime_config(&config)
413-
.await;
411+
let mcp_manager = self.thread_manager.mcp_manager();
412+
let mcp_config = mcp_manager.runtime_config(&config).await;
413+
let codex_apps_tools_cache = mcp_manager.codex_apps_tools_cache();
414414
let auth = self.auth_manager.auth().await;
415415
let environment_manager = self.thread_manager.environment_manager();
416416
// This threadless resource-read path has no turn cwd or turn-selected
@@ -425,6 +425,7 @@ impl McpRequestProcessor {
425425
&mcp_config,
426426
auth.as_ref(),
427427
runtime_context,
428+
codex_apps_tools_cache,
428429
&server,
429430
&uri,
430431
)
Lines changed: 1 addition & 211 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,9 @@
11
//! Codex Apps support for the host-owned apps MCP server.
22
//!
3-
//! This module owns the pieces that are unique to ChatGPT-hosted app
4-
//! connectors: cache scoping by authenticated user, disk cache reads/writes,
5-
//! connector allow-list filtering, and the normalization that turns app
3+
//! This module owns the normalization that turns ChatGPT-hosted app
64
//! connector/tool metadata into model-visible MCP callable names.
75
8-
use std::path::PathBuf;
9-
use std::time::Instant;
10-
11-
use crate::runtime::emit_duration;
12-
use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC;
13-
use crate::tools::ToolInfo;
14-
use anyhow::Context;
15-
use codex_login::CodexAuth;
16-
use codex_protocol::mcp::McpServerInfo;
176
use codex_utils_plugins::mcp_connector::sanitize_name;
18-
use serde::Deserialize;
19-
use serde::Serialize;
20-
use sha1::Digest;
21-
use sha1::Sha1;
22-
use tracing::instrument;
23-
24-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25-
pub struct CodexAppsToolsCacheKey {
26-
pub(crate) account_id: Option<String>,
27-
pub(crate) chatgpt_user_id: Option<String>,
28-
pub(crate) is_workspace_account: bool,
29-
}
30-
31-
pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCacheKey {
32-
CodexAppsToolsCacheKey {
33-
account_id: auth.and_then(CodexAuth::get_account_id),
34-
chatgpt_user_id: auth.and_then(CodexAuth::get_chatgpt_user_id),
35-
is_workspace_account: auth.is_some_and(CodexAuth::is_workspace_account),
36-
}
37-
}
38-
39-
#[derive(Clone)]
40-
pub(crate) struct CodexAppsToolsCacheContext {
41-
pub(crate) codex_home: PathBuf,
42-
pub(crate) user_key: CodexAppsToolsCacheKey,
43-
}
44-
45-
impl CodexAppsToolsCacheContext {
46-
pub(crate) fn tools_cache_path(&self) -> PathBuf {
47-
self.cache_path_in(CODEX_APPS_TOOLS_CACHE_DIR)
48-
}
49-
50-
pub(crate) fn server_info_cache_path(&self) -> PathBuf {
51-
self.cache_path_in(CODEX_APPS_SERVER_INFO_CACHE_DIR)
52-
}
53-
54-
fn cache_path_in(&self, cache_dir: &str) -> PathBuf {
55-
let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default();
56-
let user_key_hash = sha1_hex(&user_key_json);
57-
self.codex_home
58-
.join(cache_dir)
59-
.join(format!("{user_key_hash}.json"))
60-
}
61-
}
62-
63-
pub(crate) enum CachedCodexAppsToolsLoad {
64-
Hit(Vec<ToolInfo>),
65-
Missing,
66-
Invalid,
67-
}
687

698
pub(crate) fn normalize_codex_apps_tool_title(connector_name: Option<&str>, value: &str) -> String {
709
let Some(connector_name) = connector_name
@@ -124,152 +63,3 @@ pub(crate) fn normalize_codex_apps_callable_namespace(
12463
server_name.to_string()
12564
}
12665
}
127-
128-
pub(crate) fn write_codex_apps_tools_cache(
129-
cache_context: Option<&CodexAppsToolsCacheContext>,
130-
server_info: &McpServerInfo,
131-
tools: &[ToolInfo],
132-
) {
133-
if let Some(cache_context) = cache_context {
134-
let cache_write_start = Instant::now();
135-
write_cached_codex_apps_tools(cache_context, tools);
136-
if let Err(err) = write_cached_codex_apps_server_info(cache_context, server_info) {
137-
tracing::warn!("failed to write Codex Apps server info cache: {err:#}");
138-
}
139-
emit_duration(
140-
MCP_TOOLS_CACHE_WRITE_DURATION_METRIC,
141-
cache_write_start.elapsed(),
142-
&[],
143-
);
144-
}
145-
}
146-
147-
pub(crate) fn load_startup_cached_codex_apps_tools_snapshot(
148-
cache_context: Option<&CodexAppsToolsCacheContext>,
149-
) -> Option<Vec<ToolInfo>> {
150-
let cache_context = cache_context?;
151-
152-
match load_cached_codex_apps_tools(cache_context) {
153-
CachedCodexAppsToolsLoad::Hit(tools) => Some(tools),
154-
CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None,
155-
}
156-
}
157-
158-
pub(crate) fn load_startup_cached_codex_apps_server_info(
159-
cache_context: Option<&CodexAppsToolsCacheContext>,
160-
) -> Option<McpServerInfo> {
161-
load_cached_codex_apps_server_info(cache_context?)
162-
}
163-
164-
#[cfg(test)]
165-
pub(crate) fn read_cached_codex_apps_tools(
166-
cache_context: &CodexAppsToolsCacheContext,
167-
) -> Option<Vec<ToolInfo>> {
168-
match load_cached_codex_apps_tools(cache_context) {
169-
CachedCodexAppsToolsLoad::Hit(tools) => Some(tools),
170-
CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None,
171-
}
172-
}
173-
174-
#[instrument(level = "trace", skip_all)]
175-
pub(crate) fn load_cached_codex_apps_tools(
176-
cache_context: &CodexAppsToolsCacheContext,
177-
) -> CachedCodexAppsToolsLoad {
178-
let cache_path = cache_context.tools_cache_path();
179-
let bytes = match std::fs::read(cache_path) {
180-
Ok(bytes) => bytes,
181-
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
182-
return CachedCodexAppsToolsLoad::Missing;
183-
}
184-
Err(_) => return CachedCodexAppsToolsLoad::Invalid,
185-
};
186-
let cache: CodexAppsToolsDiskCache = match serde_json::from_slice(&bytes) {
187-
Ok(cache) => cache,
188-
Err(_) => return CachedCodexAppsToolsLoad::Invalid,
189-
};
190-
if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION {
191-
return CachedCodexAppsToolsLoad::Invalid;
192-
}
193-
CachedCodexAppsToolsLoad::Hit(cache.tools)
194-
}
195-
196-
pub(crate) fn write_cached_codex_apps_tools(
197-
cache_context: &CodexAppsToolsCacheContext,
198-
tools: &[ToolInfo],
199-
) {
200-
let cache_path = cache_context.tools_cache_path();
201-
if let Some(parent) = cache_path.parent()
202-
&& std::fs::create_dir_all(parent).is_err()
203-
{
204-
return;
205-
}
206-
let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache {
207-
schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION,
208-
tools: tools.to_vec(),
209-
}) else {
210-
return;
211-
};
212-
let _ = std::fs::write(cache_path, bytes);
213-
}
214-
215-
#[instrument(level = "trace", skip_all)]
216-
pub(crate) fn load_cached_codex_apps_server_info(
217-
cache_context: &CodexAppsToolsCacheContext,
218-
) -> Option<McpServerInfo> {
219-
let bytes = std::fs::read(cache_context.server_info_cache_path()).ok()?;
220-
let cache: CodexAppsServerInfoDiskCache = serde_json::from_slice(&bytes).ok()?;
221-
(cache.schema_version == CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION)
222-
.then_some(cache.server_info)
223-
}
224-
225-
fn write_cached_codex_apps_server_info(
226-
cache_context: &CodexAppsToolsCacheContext,
227-
server_info: &McpServerInfo,
228-
) -> anyhow::Result<()> {
229-
let cache_path = cache_context.server_info_cache_path();
230-
if let Some(parent) = cache_path.parent() {
231-
std::fs::create_dir_all(parent).with_context(|| {
232-
format!(
233-
"failed to create Codex Apps server info cache directory `{}`",
234-
parent.display()
235-
)
236-
})?;
237-
}
238-
let bytes = serde_json::to_vec_pretty(&CodexAppsServerInfoDiskCache {
239-
schema_version: CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION,
240-
server_info: server_info.clone(),
241-
})
242-
.context("failed to serialize Codex Apps server info cache")?;
243-
std::fs::write(&cache_path, bytes).with_context(|| {
244-
format!(
245-
"failed to write Codex Apps server info cache `{}`",
246-
cache_path.display()
247-
)
248-
})?;
249-
Ok(())
250-
}
251-
252-
#[derive(Debug, Clone, Serialize, Deserialize)]
253-
struct CodexAppsToolsDiskCache {
254-
schema_version: u8,
255-
tools: Vec<ToolInfo>,
256-
}
257-
258-
#[derive(Debug, Clone, Serialize, Deserialize)]
259-
struct CodexAppsServerInfoDiskCache {
260-
schema_version: u8,
261-
server_info: McpServerInfo,
262-
}
263-
264-
const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools";
265-
pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 4;
266-
267-
const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info";
268-
const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1;
269-
270-
fn sha1_hex(s: &str) -> String {
271-
let mut hasher = Sha1::new();
272-
hasher.update(s.as_bytes());
273-
let sha1 = hasher.finalize();
274-
format!("{sha1:x}")
275-
}

0 commit comments

Comments
 (0)