Expose MCP server info as part of server status (#24698)

# Summary

Expose MCP server info via App Server (when available) so apps can
render a richer MCP experience
This commit is contained in:
Gabriel Peal
2026-05-28 09:38:34 -07:00
committed by GitHub
Unverified
parent 2a1158b8e2
commit 8a827d6426
20 changed files with 599 additions and 40 deletions
@@ -10964,6 +10964,47 @@
"title": "McpResourceReadResponse",
"type": "object"
},
"McpServerInfo": {
"description": "Presentation metadata advertised by an initialized MCP server.",
"properties": {
"description": {
"type": [
"string",
"null"
]
},
"icons": {
"items": true,
"type": [
"array",
"null"
]
},
"name": {
"type": "string"
},
"title": {
"type": [
"string",
"null"
]
},
"version": {
"type": "string"
},
"websiteUrl": {
"type": [
"string",
"null"
]
}
},
"required": [
"name",
"version"
],
"type": "object"
},
"McpServerMigration": {
"properties": {
"name": {
@@ -11074,6 +11115,16 @@
},
"type": "array"
},
"serverInfo": {
"anyOf": [
{
"$ref": "#/definitions/v2/McpServerInfo"
},
{
"type": "null"
}
]
},
"tools": {
"additionalProperties": {
"$ref": "#/definitions/v2/Tool"
@@ -7493,6 +7493,47 @@
"title": "McpResourceReadResponse",
"type": "object"
},
"McpServerInfo": {
"description": "Presentation metadata advertised by an initialized MCP server.",
"properties": {
"description": {
"type": [
"string",
"null"
]
},
"icons": {
"items": true,
"type": [
"array",
"null"
]
},
"name": {
"type": "string"
},
"title": {
"type": [
"string",
"null"
]
},
"version": {
"type": "string"
},
"websiteUrl": {
"type": [
"string",
"null"
]
}
},
"required": [
"name",
"version"
],
"type": "object"
},
"McpServerMigration": {
"properties": {
"name": {
@@ -7603,6 +7644,16 @@
},
"type": "array"
},
"serverInfo": {
"anyOf": [
{
"$ref": "#/definitions/McpServerInfo"
},
{
"type": "null"
}
]
},
"tools": {
"additionalProperties": {
"$ref": "#/definitions/Tool"
@@ -10,6 +10,47 @@
],
"type": "string"
},
"McpServerInfo": {
"description": "Presentation metadata advertised by an initialized MCP server.",
"properties": {
"description": {
"type": [
"string",
"null"
]
},
"icons": {
"items": true,
"type": [
"array",
"null"
]
},
"name": {
"type": "string"
},
"title": {
"type": [
"string",
"null"
]
},
"version": {
"type": "string"
},
"websiteUrl": {
"type": [
"string",
"null"
]
}
},
"required": [
"name",
"version"
],
"type": "object"
},
"McpServerStatus": {
"properties": {
"authStatus": {
@@ -30,6 +71,16 @@
},
"type": "array"
},
"serverInfo": {
"anyOf": [
{
"$ref": "#/definitions/McpServerInfo"
},
{
"type": "null"
}
]
},
"tools": {
"additionalProperties": {
"$ref": "#/definitions/Tool"
@@ -0,0 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// 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";
/**
* Presentation metadata advertised by an initialized MCP server.
*/
export type McpServerInfo = { name: string, title: string | null, version: string, description: string | null, icons: Array<JsonValue> | null, websiteUrl: string | null, };
+1
View File
@@ -42,6 +42,7 @@ export type { InternalSessionSource } from "./InternalSessionSource";
export type { LocalShellAction } from "./LocalShellAction";
export type { LocalShellExecAction } from "./LocalShellExecAction";
export type { LocalShellStatus } from "./LocalShellStatus";
export type { McpServerInfo } from "./McpServerInfo";
export type { MessagePhase } from "./MessagePhase";
export type { ModeKind } from "./ModeKind";
export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
@@ -1,9 +1,10 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { McpServerInfo } from "../McpServerInfo";
import type { Resource } from "../Resource";
import type { ResourceTemplate } from "../ResourceTemplate";
import type { Tool } from "../Tool";
import type { McpAuthStatus } from "./McpAuthStatus";
export type McpServerStatus = { name: string, tools: { [key in string]?: Tool }, resources: Array<Resource>, resourceTemplates: Array<ResourceTemplate>, authStatus: McpAuthStatus, };
export type McpServerStatus = { name: string, serverInfo: McpServerInfo | null, tools: { [key in string]?: Tool }, resources: Array<Resource>, resourceTemplates: Array<ResourceTemplate>, authStatus: McpAuthStatus, };
@@ -2,6 +2,7 @@ use super::shared::v2_enum_from_core;
use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest;
use codex_protocol::items::McpToolCallError as CoreMcpToolCallError;
use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult;
use codex_protocol::mcp::McpServerInfo;
use codex_protocol::mcp::Resource as McpResource;
pub use codex_protocol::mcp::ResourceContent as McpResourceContent;
use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate;
@@ -53,6 +54,7 @@ pub enum McpServerStatusDetail {
#[ts(export_to = "v2/")]
pub struct McpServerStatus {
pub name: String,
pub server_info: Option<McpServerInfo>,
pub tools: std::collections::HashMap<String, McpTool>,
pub resources: Vec<McpResource>,
pub resource_templates: Vec<McpResourceTemplate>,
@@ -11,6 +11,7 @@ use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::McpServerInfo;
use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation;
use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry;
use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile;
@@ -2003,6 +2004,80 @@ fn mcp_server_elicitation_response_serializes_nullable_content() {
);
}
#[test]
fn mcp_server_status_serializes_absent_server_info_as_null() {
let response = ListMcpServerStatusResponse {
data: vec![McpServerStatus {
name: "not-ready".to_string(),
server_info: None,
tools: HashMap::new(),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: McpAuthStatus::Unsupported,
}],
next_cursor: None,
};
assert_eq!(
serde_json::to_value(response).expect("response should serialize"),
json!({
"data": [{
"name": "not-ready",
"serverInfo": null,
"tools": {},
"resources": [],
"resourceTemplates": [],
"authStatus": "unsupported",
}],
"nextCursor": null,
})
);
}
#[test]
fn mcp_server_status_serializes_absent_server_info_metadata_as_null() {
let response = ListMcpServerStatusResponse {
data: vec![McpServerStatus {
name: "initialized".to_string(),
server_info: Some(McpServerInfo {
name: "lookup-server".to_string(),
title: None,
version: "1.0.0".to_string(),
description: None,
icons: None,
website_url: None,
}),
tools: HashMap::new(),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: McpAuthStatus::Unsupported,
}],
next_cursor: None,
};
assert_eq!(
serde_json::to_value(response).expect("response should serialize"),
json!({
"data": [{
"name": "initialized",
"serverInfo": {
"name": "lookup-server",
"title": null,
"version": "1.0.0",
"description": null,
"icons": null,
"websiteUrl": null,
},
"tools": {},
"resources": [],
"resourceTemplates": [],
"authStatus": "unsupported",
}],
"nextCursor": null,
})
);
}
#[test]
fn sandbox_policy_round_trips_workspace_write_access() {
let v2_policy = SandboxPolicy::WorkspaceWrite {
+1 -1
View File
@@ -216,7 +216,7 @@ Example with notification opt-out:
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
- `tool/requestUserInput` — prompt the user with 13 short questions for a tool call and return their answers (experimental).
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools and auth status, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`.
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`.
- `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. If `threadId` is omitted, the server reads from the latest MCP config directly.
- `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result.
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
@@ -276,6 +276,7 @@ impl McpRequestProcessor {
.await;
let McpServerStatusSnapshot {
server_infos,
tools_by_server,
resources,
resource_templates,
@@ -315,6 +316,7 @@ impl McpRequestProcessor {
.iter()
.map(|name| McpServerStatus {
name: name.clone(),
server_info: server_infos.get(name).cloned(),
tools: tools_by_server.get(name).cloned().unwrap_or_default(),
resources: resources.get(name).cloned().unwrap_or_default(),
resource_templates: resource_templates.get(name).cloned().unwrap_or_default(),
@@ -20,6 +20,7 @@ use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::TrustLevel;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
use rmcp::model::Implementation;
use rmcp::model::JsonObject;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
@@ -99,6 +100,13 @@ url = "{mcp_server_url}/mcp"
.map(|tool| tool.name.as_str()),
Some("look-up.raw")
);
assert_eq!(
status
.server_info
.as_ref()
.and_then(|info| info.title.as_deref()),
Some("Lookup Server")
);
mcp_server_handle.abort();
let _ = mcp_server_handle.await;
@@ -205,7 +213,9 @@ struct McpStatusServer {
impl ServerHandler for McpStatusServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_server_info(
Implementation::new("lookup-server", "1.0.0").with_title("Lookup Server"),
)
}
async fn list_tools(
+75 -6
View File
@@ -12,7 +12,9 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::runtime::emit_duration;
use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC;
use crate::tools::ToolInfo;
use anyhow::Context;
use codex_login::CodexAuth;
use codex_protocol::mcp::McpServerInfo;
use codex_utils_plugins::mcp_connector::is_connector_id_allowed;
use codex_utils_plugins::mcp_connector::sanitize_name;
use serde::Deserialize;
@@ -20,8 +22,6 @@ use serde::Serialize;
use sha1::Digest;
use sha1::Sha1;
pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodexAppsToolsCacheKey {
pub(crate) account_id: Option<String>,
@@ -44,11 +44,19 @@ pub(crate) struct CodexAppsToolsCacheContext {
}
impl CodexAppsToolsCacheContext {
pub(crate) fn cache_path(&self) -> PathBuf {
pub(crate) fn tools_cache_path(&self) -> PathBuf {
self.cache_path_in(CODEX_APPS_TOOLS_CACHE_DIR)
}
pub(crate) fn server_info_cache_path(&self) -> PathBuf {
self.cache_path_in(CODEX_APPS_SERVER_INFO_CACHE_DIR)
}
fn cache_path_in(&self, cache_dir: &str) -> PathBuf {
let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default();
let user_key_hash = sha1_hex(&user_key_json);
self.codex_home
.join(CODEX_APPS_TOOLS_CACHE_DIR)
.join(cache_dir)
.join(format!("{user_key_hash}.json"))
}
}
@@ -136,6 +144,7 @@ pub(crate) fn normalize_codex_apps_callable_namespace(
pub(crate) fn write_cached_codex_apps_tools_if_needed(
server_name: &str,
cache_context: Option<&CodexAppsToolsCacheContext>,
server_info: &McpServerInfo,
tools: &[ToolInfo],
) {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
@@ -145,6 +154,9 @@ pub(crate) fn write_cached_codex_apps_tools_if_needed(
if let Some(cache_context) = cache_context {
let cache_write_start = Instant::now();
write_cached_codex_apps_tools(cache_context, tools);
if let Err(err) = write_cached_codex_apps_server_info(cache_context, server_info) {
tracing::warn!("failed to write Codex Apps server info cache: {err:#}");
}
emit_duration(
MCP_TOOLS_CACHE_WRITE_DURATION_METRIC,
cache_write_start.elapsed(),
@@ -169,6 +181,17 @@ pub(crate) fn load_startup_cached_codex_apps_tools_snapshot(
}
}
pub(crate) fn load_startup_cached_codex_apps_server_info(
server_name: &str,
cache_context: Option<&CodexAppsToolsCacheContext>,
) -> Option<McpServerInfo> {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
return None;
}
load_cached_codex_apps_server_info(cache_context?)
}
#[cfg(test)]
pub(crate) fn read_cached_codex_apps_tools(
cache_context: &CodexAppsToolsCacheContext,
@@ -182,7 +205,7 @@ pub(crate) fn read_cached_codex_apps_tools(
pub(crate) fn load_cached_codex_apps_tools(
cache_context: &CodexAppsToolsCacheContext,
) -> CachedCodexAppsToolsLoad {
let cache_path = cache_context.cache_path();
let cache_path = cache_context.tools_cache_path();
let bytes = match std::fs::read(cache_path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
@@ -204,7 +227,7 @@ pub(crate) fn write_cached_codex_apps_tools(
cache_context: &CodexAppsToolsCacheContext,
tools: &[ToolInfo],
) {
let cache_path = cache_context.cache_path();
let cache_path = cache_context.tools_cache_path();
if let Some(parent) = cache_path.parent()
&& std::fs::create_dir_all(parent).is_err()
{
@@ -220,6 +243,42 @@ pub(crate) fn write_cached_codex_apps_tools(
let _ = std::fs::write(cache_path, bytes);
}
pub(crate) fn load_cached_codex_apps_server_info(
cache_context: &CodexAppsToolsCacheContext,
) -> Option<McpServerInfo> {
let bytes = std::fs::read(cache_context.server_info_cache_path()).ok()?;
let cache: CodexAppsServerInfoDiskCache = serde_json::from_slice(&bytes).ok()?;
(cache.schema_version == CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION)
.then_some(cache.server_info)
}
fn write_cached_codex_apps_server_info(
cache_context: &CodexAppsToolsCacheContext,
server_info: &McpServerInfo,
) -> anyhow::Result<()> {
let cache_path = cache_context.server_info_cache_path();
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create Codex Apps server info cache directory `{}`",
parent.display()
)
})?;
}
let bytes = serde_json::to_vec_pretty(&CodexAppsServerInfoDiskCache {
schema_version: CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION,
server_info: server_info.clone(),
})
.context("failed to serialize Codex Apps server info cache")?;
std::fs::write(&cache_path, bytes).with_context(|| {
format!(
"failed to write Codex Apps server info cache `{}`",
cache_path.display()
)
})?;
Ok(())
}
pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec<ToolInfo>) -> Vec<ToolInfo> {
tools
.into_iter()
@@ -237,7 +296,17 @@ struct CodexAppsToolsDiskCache {
tools: Vec<ToolInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CodexAppsServerInfoDiskCache {
schema_version: u8,
server_info: McpServerInfo,
}
const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools";
pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3;
const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info";
const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1;
fn sha1_hex(s: &str) -> String {
let mut hasher = Sha1::new();
+31 -3
View File
@@ -9,6 +9,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
@@ -44,6 +45,7 @@ use codex_config::McpServerTransportConfig;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_login::CodexAuth;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::McpServerInfo;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
@@ -385,13 +387,13 @@ impl McpConnectionManager {
pub async fn list_all_tools(&self) -> Vec<ToolInfo> {
let mut tools = Vec::new();
for (server_name, managed_client) in &self.clients {
let has_startup_snapshot = managed_client.startup_snapshot.is_some();
let has_cached_tool_info_snapshot = managed_client.cached_tool_info_snapshot.is_some();
let startup_complete = managed_client
.startup_complete
.load(std::sync::atomic::Ordering::Acquire);
trace!(
server_name = %server_name,
has_startup_snapshot,
has_cached_tool_info_snapshot,
startup_complete,
"waiting for MCP server tools while building tool list"
);
@@ -400,7 +402,7 @@ impl McpConnectionManager {
.instrument(trace_span!(
"list_tools_for_server",
server_name = %server_name,
has_startup_snapshot,
has_cached_tool_info_snapshot,
startup_complete
))
.await
@@ -421,6 +423,31 @@ impl McpConnectionManager {
normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names)
}
/// Returns presentation metadata without waiting for uncached clients still initializing.
/// Cached values will be used if available and the server is still starting up.
pub async fn list_available_server_infos(&self) -> HashMap<String, McpServerInfo> {
let mut server_infos = HashMap::new();
for (server_name, client) in &self.clients {
if !client.startup_complete.load(Ordering::Acquire) {
if let Some(server_info) = client.cached_server_info.clone() {
server_infos.insert(server_name.clone(), server_info);
}
continue;
}
match client.client().await {
Ok(managed_client) => {
server_infos.insert(server_name.clone(), managed_client.server_info);
}
Err(_) => {
if let Some(server_info) = client.cached_server_info.clone() {
server_infos.insert(server_name.clone(), server_info);
}
}
}
}
server_infos
}
/// Force-refresh codex apps tools by bypassing the in-process cache.
///
/// On success, the refreshed tools replace the cache contents and the
@@ -456,6 +483,7 @@ impl McpConnectionManager {
write_cached_codex_apps_tools_if_needed(
CODEX_APPS_MCP_SERVER_NAME,
managed_client.codex_apps_tools_cache_context.as_ref(),
&managed_client.server_info,
&tools,
);
emit_duration(
@@ -1,9 +1,11 @@
use super::*;
use crate::codex_apps::CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION;
use crate::codex_apps::CodexAppsToolsCacheContext;
use crate::codex_apps::load_startup_cached_codex_apps_server_info;
use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot;
use crate::codex_apps::read_cached_codex_apps_tools;
use crate::codex_apps::write_cached_codex_apps_tools;
use crate::codex_apps::write_cached_codex_apps_tools_if_needed;
use crate::declared_openai_file_input_param_names;
use crate::elicitation::ElicitationRequestManager;
use crate::elicitation::elicitation_is_rejected_by_policy;
@@ -20,6 +22,7 @@ use codex_config::Constrained;
use codex_config::McpServerConfig;
use codex_exec_server::EnvironmentManager;
use codex_protocol::ToolName;
use codex_protocol::mcp::McpServerInfo;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::McpAuthStatus;
@@ -82,6 +85,17 @@ fn create_codex_apps_tools_cache_context(
}
}
fn create_test_server_info(title: &str) -> McpServerInfo {
McpServerInfo {
name: "codex-apps".to_string(),
title: Some(title.to_string()),
version: "1.0.0".to_string(),
description: None,
icons: None,
website_url: None,
}
}
fn model_tool_names(tools: &[ToolInfo]) -> HashSet<ToolName> {
tools
.iter()
@@ -582,8 +596,8 @@ fn codex_apps_tools_cache_is_scoped_per_user() {
assert_eq!(read_user_1[0].callable_name, "one");
assert_eq!(read_user_2[0].callable_name, "two");
assert_ne!(
cache_context_user_1.cache_path(),
cache_context_user_2.cache_path(),
cache_context_user_1.tools_cache_path(),
cache_context_user_2.tools_cache_path(),
"each user should get an isolated cache file"
);
}
@@ -627,7 +641,7 @@ fn codex_apps_tools_cache_is_ignored_when_schema_version_mismatches() {
Some("account-one"),
Some("user-one"),
);
let cache_path = cache_context.cache_path();
let cache_path = cache_context.tools_cache_path();
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
@@ -649,7 +663,7 @@ fn codex_apps_tools_cache_is_ignored_when_json_is_invalid() {
Some("account-one"),
Some("user-one"),
);
let cache_path = cache_context.cache_path();
let cache_path = cache_context.tools_cache_path();
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
@@ -670,21 +684,112 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() {
CODEX_APPS_MCP_SERVER_NAME,
"calendar_search",
)];
write_cached_codex_apps_tools(&cache_context, &cached_tools);
let server_info = create_test_server_info("Codex Apps");
write_cached_codex_apps_tools_if_needed(
CODEX_APPS_MCP_SERVER_NAME,
Some(&cache_context),
&server_info,
&cached_tools,
);
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
let startup_tools = load_startup_cached_codex_apps_tools_snapshot(
CODEX_APPS_MCP_SERVER_NAME,
Some(&cache_context),
)
.expect("expected startup snapshot to load from cache");
let cached_server_info = load_startup_cached_codex_apps_server_info(
CODEX_APPS_MCP_SERVER_NAME,
Some(&cache_context),
);
let startup_tools = startup_snapshot.expect("expected startup snapshot to load from cache");
assert_eq!(startup_tools.len(), 1);
assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME);
assert_eq!(startup_tools[0].callable_name, "calendar_search");
assert_eq!(cached_server_info, Some(server_info));
}
#[test]
fn startup_cached_codex_apps_tools_loads_without_server_info_cache() {
let codex_home = tempdir().expect("tempdir");
let cache_context = create_codex_apps_tools_cache_context(
codex_home.path().to_path_buf(),
Some("account-one"),
Some("user-one"),
);
let cache_path = cache_context.tools_cache_path();
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
let bytes = serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION,
"tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "calendar_search")],
}))
.expect("serialize");
std::fs::write(cache_path, bytes).expect("write");
let startup_tools = load_startup_cached_codex_apps_tools_snapshot(
CODEX_APPS_MCP_SERVER_NAME,
Some(&cache_context),
)
.expect("legacy startup snapshot should remain available");
let cached_server_info = load_startup_cached_codex_apps_server_info(
CODEX_APPS_MCP_SERVER_NAME,
Some(&cache_context),
);
assert_eq!(startup_tools.len(), 1);
assert_eq!(startup_tools[0].callable_name, "calendar_search");
assert_eq!(cached_server_info, None);
}
#[test]
fn codex_apps_server_info_cache_survives_legacy_tools_cache_write() {
let codex_home = tempdir().expect("tempdir");
let cache_context = create_codex_apps_tools_cache_context(
codex_home.path().to_path_buf(),
Some("account-one"),
Some("user-one"),
);
let server_info = create_test_server_info("Codex Apps");
write_cached_codex_apps_tools_if_needed(
CODEX_APPS_MCP_SERVER_NAME,
Some(&cache_context),
&server_info,
&[create_test_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_search",
)],
);
let cache_path = cache_context.tools_cache_path();
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
let bytes = serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION - 1,
"tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "calendar_search")],
}))
.expect("serialize");
std::fs::write(cache_path, bytes).expect("write legacy tools cache");
assert_eq!(
load_startup_cached_codex_apps_server_info(
CODEX_APPS_MCP_SERVER_NAME,
Some(&cache_context),
),
Some(server_info)
);
assert!(
load_startup_cached_codex_apps_tools_snapshot(
CODEX_APPS_MCP_SERVER_NAME,
Some(&cache_context),
)
.is_none()
);
}
#[tokio::test]
async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() {
async fn list_all_tools_uses_cached_tool_info_snapshot_while_client_is_pending() {
let startup_tools = vec![create_test_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_create_event",
@@ -703,7 +808,8 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() {
CODEX_APPS_MCP_SERVER_NAME.to_string(),
AsyncManagedClient {
client: pending_client,
startup_snapshot: Some(startup_tools),
cached_tool_info_snapshot: Some(startup_tools),
cached_server_info: None,
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
@@ -722,6 +828,43 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() {
assert_eq!(tool.callable_name, "calendar_create_event");
}
#[tokio::test]
async fn list_available_server_infos_uses_cache_while_client_is_pending() {
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
.boxed()
.shared();
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
let permission_profile = Constrained::allow_any(PermissionProfile::default());
let mut manager = McpConnectionManager::new_uninitialized(
&approval_policy,
&permission_profile,
/*prefix_mcp_tool_names*/ true,
);
let server_info = create_test_server_info("Codex Apps");
manager.clients.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
AsyncManagedClient {
client: pending_client,
cached_tool_info_snapshot: Some(Vec::new()),
cached_server_info: Some(server_info.clone()),
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
},
);
let timeout_result = tokio::time::timeout(
Duration::from_millis(10),
manager.list_available_server_infos(),
)
.await;
let server_infos = timeout_result.expect("server info lookup should not block on startup");
assert_eq!(
server_infos.get(CODEX_APPS_MCP_SERVER_NAME),
Some(&server_info)
);
}
#[tokio::test]
async fn list_all_tools_accepts_canonical_namespaced_tool_names() {
let startup_tools = vec![create_test_tool("rmcp", "echo")];
@@ -739,7 +882,8 @@ async fn list_all_tools_accepts_canonical_namespaced_tool_names() {
"rmcp".to_string(),
AsyncManagedClient {
client: pending_client,
startup_snapshot: Some(startup_tools),
cached_tool_info_snapshot: Some(startup_tools),
cached_server_info: None,
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
@@ -781,7 +925,8 @@ async fn list_all_tools_applies_legacy_mcp_prefix_by_default() {
"rmcp".to_string(),
AsyncManagedClient {
client: pending_client,
startup_snapshot: Some(startup_tools),
cached_tool_info_snapshot: Some(startup_tools),
cached_server_info: None,
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
@@ -807,7 +952,7 @@ async fn list_all_tools_applies_legacy_mcp_prefix_by_default() {
}
#[tokio::test]
async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() {
async fn list_all_tools_blocks_while_client_is_pending_without_cached_tool_info_snapshot() {
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
.boxed()
.shared();
@@ -822,7 +967,8 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
AsyncManagedClient {
client: pending_client,
startup_snapshot: None,
cached_tool_info_snapshot: None,
cached_server_info: None,
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
@@ -835,7 +981,7 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot(
}
#[tokio::test]
async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty() {
async fn list_all_tools_does_not_block_when_cached_tool_info_snapshot_is_empty() {
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
.boxed()
.shared();
@@ -850,7 +996,8 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
AsyncManagedClient {
client: pending_client,
startup_snapshot: Some(Vec::new()),
cached_tool_info_snapshot: Some(Vec::new()),
cached_server_info: None,
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
@@ -864,11 +1011,12 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty(
}
#[tokio::test]
async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {
async fn list_all_tools_uses_cached_tool_info_snapshot_when_client_startup_fails() {
let startup_tools = vec![create_test_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_create_event",
)];
let server_info = create_test_server_info("Codex Apps");
let failed_client = futures::future::ready::<Result<ManagedClient, StartupOutcomeError>>(Err(
StartupOutcomeError::Failed {
error: "startup failed".to_string(),
@@ -888,7 +1036,8 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {
CODEX_APPS_MCP_SERVER_NAME.to_string(),
AsyncManagedClient {
client: failed_client,
startup_snapshot: Some(startup_tools),
cached_tool_info_snapshot: Some(startup_tools),
cached_server_info: Some(server_info.clone()),
startup_complete,
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
@@ -905,6 +1054,13 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {
.expect("tool from startup cache");
assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME);
assert_eq!(tool.callable_name, "calendar_create_event");
assert_eq!(
manager
.list_available_server_infos()
.await
.get(CODEX_APPS_MCP_SERVER_NAME),
Some(&server_info)
);
}
#[tokio::test]
@@ -935,7 +1091,8 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() {
server_name.to_string(),
AsyncManagedClient {
client: pending_client,
startup_snapshot: Some(startup_tools),
cached_tool_info_snapshot: Some(startup_tools),
cached_server_info: None,
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
+5
View File
@@ -25,6 +25,7 @@ use codex_config::types::ApprovalsReviewer;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_login::CodexAuth;
use codex_plugin::PluginCapabilitySummary;
use codex_protocol::mcp::McpServerInfo;
use codex_protocol::mcp::Resource;
use codex_protocol::mcp::ResourceTemplate;
use codex_protocol::mcp::Tool;
@@ -308,6 +309,7 @@ pub async fn read_mcp_resource(
#[derive(Debug, Clone)]
pub struct McpServerStatusSnapshot {
pub server_infos: HashMap<String, McpServerInfo>,
pub tools_by_server: HashMap<String, HashMap<String, Tool>>,
pub resources: HashMap<String, Vec<Resource>>,
pub resource_templates: HashMap<String, Vec<ResourceTemplate>>,
@@ -327,6 +329,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
let tool_plugin_provenance = tool_plugin_provenance(config);
if mcp_servers.is_empty() {
return McpServerStatusSnapshot {
server_infos: HashMap::new(),
tools_by_server: HashMap::new(),
resources: HashMap::new(),
resource_templates: HashMap::new(),
@@ -599,6 +602,7 @@ async fn collect_mcp_server_status_snapshot_from_manager(
}
},
);
let server_infos = mcp_connection_manager.list_available_server_infos().await;
let mut tools_by_server = HashMap::<String, HashMap<String, Tool>>::new();
for tool_info in tools {
@@ -614,6 +618,7 @@ async fn collect_mcp_server_status_snapshot_from_manager(
}
McpServerStatusSnapshot {
server_infos,
tools_by_server,
resources: convert_mcp_resources(resources),
resource_templates: convert_mcp_resource_templates(resource_templates),
+40 -10
View File
@@ -20,6 +20,7 @@ use crate::codex_apps::CachedCodexAppsToolsLoad;
use crate::codex_apps::CodexAppsToolsCacheContext;
use crate::codex_apps::filter_disallowed_codex_apps_tools;
use crate::codex_apps::load_cached_codex_apps_tools;
use crate::codex_apps::load_startup_cached_codex_apps_server_info;
use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot;
use crate::codex_apps::normalize_codex_apps_callable_name;
use crate::codex_apps::normalize_codex_apps_callable_namespace;
@@ -47,6 +48,7 @@ use codex_config::McpServerTransportConfig;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::HttpClient;
use codex_exec_server::ReqwestHttpClient;
use codex_protocol::mcp::McpServerInfo;
use codex_protocol::protocol::Event;
use codex_rmcp_client::ExecutorStdioServerLauncher;
use codex_rmcp_client::LocalStdioServerLauncher;
@@ -85,6 +87,7 @@ const UNTRUSTED_CONNECTOR_META_KEYS: &[&str] = &[
#[derive(Clone)]
pub(crate) struct ManagedClient {
pub(crate) client: Arc<RmcpClient>,
pub(crate) server_info: McpServerInfo,
pub(crate) tools: Vec<ToolInfo>,
pub(crate) tool_filter: ToolFilter,
pub(crate) tool_timeout: Option<Duration>,
@@ -123,7 +126,8 @@ impl ManagedClient {
#[derive(Clone)]
pub(crate) struct AsyncManagedClient {
pub(crate) client: Shared<BoxFuture<'static, Result<ManagedClient, StartupOutcomeError>>>,
pub(crate) startup_snapshot: Option<Vec<ToolInfo>>,
pub(crate) cached_tool_info_snapshot: Option<Vec<ToolInfo>>,
pub(crate) cached_server_info: Option<McpServerInfo>,
pub(crate) startup_complete: Arc<AtomicBool>,
pub(crate) tool_plugin_provenance: Arc<ToolPluginProvenance>,
pub(crate) cancel_token: CancellationToken,
@@ -150,11 +154,16 @@ impl AsyncManagedClient {
.configured_config()
.map(ToolFilter::from_config)
.unwrap_or_default();
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
let cached_tool_info_snapshot = load_startup_cached_codex_apps_tools_snapshot(
&server_name,
codex_apps_tools_cache_context.as_ref(),
)
.map(|tools| filter_tools(tools, &tool_filter));
);
let cached_tool_info_snapshot =
cached_tool_info_snapshot.map(|tools| filter_tools(tools, &tool_filter));
let cached_server_info = load_startup_cached_codex_apps_server_info(
&server_name,
codex_apps_tools_cache_context.as_ref(),
);
let startup_tool_filter = tool_filter;
let startup_complete = Arc::new(AtomicBool::new(false));
let startup_complete_for_fut = Arc::clone(&startup_complete);
@@ -207,7 +216,7 @@ impl AsyncManagedClient {
outcome
};
let client = fut.boxed().shared();
if startup_snapshot.is_some() {
if cached_tool_info_snapshot.is_some() {
let startup_task = client.clone();
tokio::spawn(async move {
let _ = startup_task.await;
@@ -216,7 +225,8 @@ impl AsyncManagedClient {
Self {
client,
startup_snapshot,
cached_tool_info_snapshot,
cached_server_info,
startup_complete,
tool_plugin_provenance,
cancel_token,
@@ -238,9 +248,9 @@ impl AsyncManagedClient {
}
}
fn startup_snapshot_while_initializing(&self) -> Option<Vec<ToolInfo>> {
fn cached_tool_info_snapshot_while_initializing(&self) -> Option<Vec<ToolInfo>> {
if !self.startup_complete.load(Ordering::Acquire) {
return self.startup_snapshot.clone();
return self.cached_tool_info_snapshot.clone();
}
None
}
@@ -298,12 +308,13 @@ impl AsyncManagedClient {
};
// Keep cache payloads raw; plugin provenance is resolved per-session at read time.
let tools = if let Some(startup_tools) = self.startup_snapshot_while_initializing() {
let tools = if let Some(startup_tools) = self.cached_tool_info_snapshot_while_initializing()
{
Some(startup_tools)
} else {
match self.client().await {
Ok(client) => Some(client.listed_tools()),
Err(_) => self.startup_snapshot.clone(),
Err(_) => self.cached_tool_info_snapshot.clone(),
}
};
tools.map(annotate_tools)
@@ -506,9 +517,11 @@ async fn start_server_task(
fetch_start.elapsed(),
&[],
);
let server_info = mcp_server_info_from_implementation(initialize_result.server_info);
write_cached_codex_apps_tools_if_needed(
&server_name,
codex_apps_tools_cache_context.as_ref(),
&server_info,
&tools,
);
if server_name == CODEX_APPS_MCP_SERVER_NAME {
@@ -522,6 +535,7 @@ async fn start_server_task(
let managed = ManagedClient {
client: Arc::clone(&client),
server_info,
tools,
tool_timeout: Some(tool_timeout),
tool_filter,
@@ -533,6 +547,22 @@ async fn start_server_task(
Ok(managed)
}
fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo {
McpServerInfo {
name: server_info.name,
title: server_info.title,
version: server_info.version,
description: server_info.description,
icons: server_info.icons.map(|icons| {
icons
.into_iter()
.filter_map(|icon| serde_json::to_value(icon).ok())
.collect()
}),
website_url: server_info.website_url,
}
}
struct StartServerTaskParams {
startup_timeout: Option<Duration>, // TODO: cancel_token should handle this.
tool_timeout: Duration,
+12
View File
@@ -26,6 +26,18 @@ impl std::fmt::Display for RequestId {
}
}
/// Presentation metadata advertised by an initialized MCP server.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct McpServerInfo {
pub name: String,
pub title: Option<String>,
pub version: String,
pub description: Option<String>,
pub icons: Option<Vec<serde_json::Value>>,
pub website_url: Option<String>,
}
/// Definition for a tool the client can call.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
@@ -1061,6 +1061,7 @@ mod tests {
let statuses = vec![
McpServerStatus {
name: "docs".to_string(),
server_info: None,
tools: HashMap::from([(
"list".to_string(),
Tool {
@@ -1080,6 +1081,7 @@ mod tests {
},
McpServerStatus {
name: "disabled".to_string(),
server_info: None,
tools: HashMap::new(),
resources: Vec::new(),
resource_templates: Vec::new(),
+1
View File
@@ -147,6 +147,7 @@ async fn handle_mcp_inventory_result_respects_origin_thread() {
app.handle_mcp_inventory_result(
Ok(vec![McpServerStatus {
name: "docs".to_string(),
server_info: None,
tools: HashMap::new(),
resources: Vec::new(),
resource_templates: Vec::new(),
+2
View File
@@ -843,6 +843,7 @@ async fn mcp_tools_output_lists_tools_for_hyphenated_server_names() {
fn mcp_tools_output_from_statuses_renders_status_only_servers() {
let statuses = vec![McpServerStatus {
name: "plugin_docs".to_string(),
server_info: None,
tools: HashMap::from([(
"lookup".to_string(),
Tool {
@@ -872,6 +873,7 @@ fn mcp_tools_output_from_statuses_renders_status_only_servers() {
fn mcp_tools_output_from_statuses_renders_verbose_inventory() {
let statuses = vec![McpServerStatus {
name: "plugin_docs".to_string(),
server_info: None,
tools: HashMap::from([(
"lookup".to_string(),
Tool {