Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 1 addition & 28 deletions codex-rs/app-server/src/request_processors/mcp_processor.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
use super::*;

const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";

#[derive(Clone)]
pub(crate) struct McpRequestProcessor {
auth_manager: Arc<AuthManager>,
Expand Down Expand Up @@ -460,12 +458,11 @@ impl McpRequestProcessor {
let outgoing = Arc::clone(&self.outgoing);
let thread_id = params.thread_id.clone();
let (_, thread) = self.load_thread(&thread_id).await?;
let meta = with_mcp_tool_call_thread_id_meta(params.meta, &thread_id);
let request_id = request_id.clone();

tokio::spawn(async move {
let result = thread
.call_mcp_tool(&params.server, &params.tool, params.arguments, meta)
.call_mcp_tool(&params.server, &params.tool, params.arguments, params.meta)
.await
.map(McpServerToolCallResponse::from)
.map_err(|error| internal_error(format!("{error:#}")));
Expand All @@ -474,27 +471,3 @@ impl McpRequestProcessor {
Ok(())
}
}

fn with_mcp_tool_call_thread_id_meta(
meta: Option<serde_json::Value>,
thread_id: &str,
) -> Option<serde_json::Value> {
match meta {
Some(serde_json::Value::Object(mut map)) => {
map.insert(
MCP_TOOL_THREAD_ID_META_KEY.to_string(),
serde_json::Value::String(thread_id.to_string()),
);
Some(serde_json::Value::Object(map))
}
None => {
let mut map = serde_json::Map::new();
map.insert(
MCP_TOOL_THREAD_ID_META_KEY.to_string(),
serde_json::Value::String(thread_id.to_string()),
);
Some(serde_json::Value::Object(map))
}
other => other,
}
}
13 changes: 1 addition & 12 deletions codex-rs/app-server/tests/suite/v2/mcp_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,9 @@ url = "{mcp_server_url}/mcp"
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
let thread_id = thread.id.clone();

let tool_call_request_id = mcp
.send_mcp_server_tool_call_request(McpServerToolCallParams {
thread_id: thread_id.clone(),
thread_id: thread.id,
server: TEST_SERVER_NAME.to_string(),
tool: TEST_TOOL_NAME.to_string(),
arguments: Some(json!({
Expand Down Expand Up @@ -154,7 +152,6 @@ url = "{mcp_server_url}/mcp"
response.structured_content,
Some(json!({
"echoed": "hello from app",
"threadId": thread_id,
}))
);
assert_eq!(response.is_error, Some(false));
Expand Down Expand Up @@ -736,13 +733,6 @@ impl ServerHandler for ToolAppsMcpServer {
.and_then(|arguments| arguments.get("message"))
.and_then(|value| value.as_str())
.unwrap_or_default();
let thread_id = context
.meta
.0
.get("threadId")
.and_then(|value| value.as_str())
.unwrap_or_default();

let mut meta = Meta::new();
meta.0.insert("calledBy".to_string(), json!("mcp-app"));

Expand Down Expand Up @@ -810,7 +800,6 @@ impl ServerHandler for ToolAppsMcpServer {

let mut result = CallToolResult::structured(json!({
"echoed": message,
"threadId": thread_id,
}));
result.content = vec![Content::text(format!("echo: {message}"))];
result.meta = Some(meta);
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/codex-mcp/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,12 @@ impl ResolvedMcpCatalog {
self.servers.get(name)
}

pub(crate) fn servers(&self) -> impl Iterator<Item = (&str, &ResolvedMcpServer)> {
self.servers
.iter()
.map(|(name, server)| (name.as_str(), server))
}

pub fn configured_servers(&self) -> HashMap<String, McpServerConfig> {
self.servers
.iter()
Expand Down
45 changes: 45 additions & 0 deletions codex-rs/codex-mcp/src/connection_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ use crate::McpAuthStatusEntry;
use crate::codex_apps_cache::CodexAppsToolsCache;
use crate::codex_apps_cache::CodexAppsToolsCacheKey;
use crate::codex_apps_cache::CodexAppsToolsFetchSource;
use crate::egress::McpEgressProfile;
use crate::egress::sanitize_tool_call_meta;
use crate::elicitation::ElicitationRequestManager;
use crate::elicitation::ElicitationRequestRouter;
use crate::elicitation::ElicitationReviewerHandle;
Expand Down Expand Up @@ -436,6 +438,12 @@ impl McpConnectionManager {
server_name == CODEX_APPS_MCP_SERVER_NAME && self.server_metadata.contains_key(server_name)
}

pub fn server_uses_direct_mcp_v1(&self, server_name: &str) -> bool {
self.server_metadata
.get(server_name)
.is_some_and(|metadata| metadata.egress_profile == McpEgressProfile::DirectMcpV1)
}

pub fn set_approval_policy(&self, approval_policy: &Constrained<AskForApproval>) {
if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() {
*policy = approval_policy.value();
Expand Down Expand Up @@ -741,13 +749,50 @@ impl McpConnectionManager {
tool: &str,
arguments: Option<serde_json::Value>,
meta: Option<serde_json::Value>,
) -> Result<CallToolResult> {
self.call_tool_with_meta_policy(
server, tool, arguments, meta, /*allow_sandbox_state_meta*/ true,
)
.await
}

pub async fn call_tool_from_app(
&self,
server: &str,
tool: &str,
arguments: Option<serde_json::Value>,
meta: Option<serde_json::Value>,
) -> Result<CallToolResult> {
self.call_tool_with_meta_policy(
server, tool, arguments, meta, /*allow_sandbox_state_meta*/ false,
)
.await
}

async fn call_tool_with_meta_policy(
&self,
server: &str,
tool: &str,
arguments: Option<serde_json::Value>,
meta: Option<serde_json::Value>,
allow_sandbox_state_meta: bool,
) -> Result<CallToolResult> {
let client = self.client_by_name(server).await?;
if !client.tool_filter.allows(tool) {
return Err(anyhow!(
"tool '{tool}' is disabled for MCP server '{server}'"
));
}
let egress_profile = self
.server_metadata
.get(server)
.map(|metadata| metadata.egress_profile)
.ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?;
let meta = sanitize_tool_call_meta(
egress_profile,
meta,
allow_sandbox_state_meta && client.server_supports_sandbox_state_meta_capability,
);

let result: rmcp::model::CallToolResult = client
.client
Expand Down
1 change: 1 addition & 0 deletions codex-rs/codex-mcp/src/connection_manager_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,7 @@ async fn list_all_tools_adds_server_metadata_to_tools() {
supports_parallel_tool_calls: true,
default_tools_approval_mode: None,
tool_approval_modes: HashMap::new(),
egress_profile: McpEgressProfile::DirectMcpV1,
},
);
manager
Expand Down
113 changes: 113 additions & 0 deletions codex-rs/codex-mcp/src/egress.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
use crate::catalog::McpServerSource;
use crate::rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY;
use serde_json::Value;

const LEGACY_HOOPA_MCP_SERVER_NAME: &str = "hoopa";

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum McpEgressProfile {
DirectMcpV1,
HostOwned,
}

impl McpEgressProfile {
pub(crate) fn for_configured_name(server_name: &str) -> Self {
// TODO: Remove this exception after Hoopa uses a host-owned registration or stops using
// MCP request metadata for thread routing.
if server_name == LEGACY_HOOPA_MCP_SERVER_NAME {
Self::HostOwned
} else {
Self::DirectMcpV1
}
}

pub(crate) fn for_registration(server_name: &str, source: &McpServerSource) -> Self {
if Self::for_configured_name(server_name) == Self::HostOwned {
return Self::HostOwned;
}

match source {
McpServerSource::Config
| McpServerSource::Plugin(_)
| McpServerSource::SelectedPlugin(_) => Self::DirectMcpV1,
McpServerSource::Compatibility { .. } | McpServerSource::Extension { .. } => {
Self::HostOwned
}
}
}
}

pub(crate) fn sanitize_tool_call_meta(
profile: McpEgressProfile,
meta: Option<Value>,
allow_sandbox_state_meta: bool,
) -> Option<Value> {
if profile == McpEgressProfile::HostOwned {
return meta;
}

let Some(Value::Object(mut meta)) = meta else {
return meta;
};
meta.retain(|key, _| {
(allow_sandbox_state_meta && key == MCP_SANDBOX_STATE_META_CAPABILITY)
|| !is_reserved_direct_mcp_meta_key(key)
});
(!meta.is_empty()).then_some(Value::Object(meta))
}

fn is_reserved_direct_mcp_meta_key(key: &str) -> bool {
matches!(
key,
"installation_id"
| "installationId"
| "session_id"
| "sessionId"
| "thread_id"
| "threadId"
| "conversation_id"
| "conversationId"
| "turn_id"
| "turnId"
| "workspace_id"
| "workspaceId"
| "window_id"
| "windowId"
| "request_kind"
| "requestKind"
| "compaction"
| "turn_started_at_unix_ms"
| "turnStartedAtUnixMs"
| "forked_from_thread_id"
| "forkedFromThreadId"
| "parent_thread_id"
| "parentThreadId"
| "subagent_kind"
| "subagentKind"
| "thread_source"
| "threadSource"
| "sandbox"
| "workspaces"
| "plugin_id"
| "pluginId"
| "connector_id"
| "connector_name"
| "connector_display_name"
| "connector_description"
| "connectorDescription"
| "connected_account_email"
| "connectedAccountEmail"
| "link_id"
| "linkId"
| "template_id"
| "templateId"
| "resource_uri"
| "resourceUri"
) || key.starts_with("openai/")
|| key.starts_with("x-openai-")
|| key.starts_with("_openai_")
|| key.starts_with("codex/")
|| key.starts_with("x-codex-")
|| key.starts_with("_codex_")
|| key.starts_with("codex_")
}
1 change: 1 addition & 0 deletions codex-rs/codex-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ mod catalog;
pub(crate) mod codex_apps;
pub(crate) mod codex_apps_cache;
pub(crate) mod connection_manager;
mod egress;
pub(crate) mod elicitation;
pub(crate) mod mcp;
mod plugin_config;
Expand Down
42 changes: 39 additions & 3 deletions codex-rs/codex-mcp/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ use crate::ResolvedMcpCatalog;
use crate::codex_apps_cache::CodexAppsToolsCache;
use crate::codex_apps_cache::codex_apps_tools_cache_key;
use crate::connection_manager::McpConnectionManager;
use crate::egress::McpEgressProfile;
use crate::runtime::McpRuntimeContext;
use crate::server::EffectiveMcpServer;

Expand Down Expand Up @@ -250,7 +251,20 @@ pub fn effective_mcp_servers(
config: &McpConfig,
auth: Option<&CodexAuth>,
) -> HashMap<String, EffectiveMcpServer> {
effective_mcp_servers_from_configured(configured_mcp_servers(config), config, auth)
let configured_servers = config
.mcp_server_catalog
.servers()
.map(|(name, server)| {
(
name.to_string(),
(
server.config().clone(),
McpEgressProfile::for_registration(name, server.source()),
),
)
})
.collect();
effective_mcp_servers_with_profiles(configured_servers, config, auth)
}

/// Converts a materialized server map to its auth-gated runtime view.
Expand All @@ -261,13 +275,32 @@ pub fn effective_mcp_servers_from_configured(
configured_servers: HashMap<String, McpServerConfig>,
config: &McpConfig,
auth: Option<&CodexAuth>,
) -> HashMap<String, EffectiveMcpServer> {
let configured_servers = configured_servers
.into_iter()
.map(|(name, server)| {
let profile = config
.mcp_server_catalog
.server(&name)
.map(|resolved| McpEgressProfile::for_registration(&name, resolved.source()))
.unwrap_or_else(|| McpEgressProfile::for_configured_name(&name));
(name, (server, profile))
})
.collect();
effective_mcp_servers_with_profiles(configured_servers, config, auth)
}

fn effective_mcp_servers_with_profiles(
configured_servers: HashMap<String, (McpServerConfig, McpEgressProfile)>,
config: &McpConfig,
auth: Option<&CodexAuth>,
) -> HashMap<String, EffectiveMcpServer> {
let chatgpt_origin = url::Url::parse(CHATGPT_CODEX_BASE_URL)
.ok()
.map(|url| url.origin());
let mut servers = configured_servers
.into_iter()
.map(|(name, mut server)| {
.map(|(name, (mut server, egress_profile))| {
match server.auth.clone() {
McpServerAuth::ChatGpt => {
let server_origin = match &server.transport {
Expand All @@ -285,7 +318,10 @@ pub fn effective_mcp_servers_from_configured(
}
McpServerAuth::OAuth => {}
}
(name, EffectiveMcpServer::configured(server))
(
name,
EffectiveMcpServer::configured_with_egress_profile(server, egress_profile),
)
})
.collect::<HashMap<_, _>>();
if !host_owned_codex_apps_enabled(config, auth) {
Expand Down
Loading
Loading