fix(mcp): keep client capabilities request scoped

This commit is contained in:
Casey Chow
2026-06-06 22:46:54 +00:00
parent e6bbcb4f94
commit e4d609fd30
13 changed files with 244 additions and 67 deletions

View File

@@ -1218,7 +1218,9 @@ impl MessageProcessor {
self.catalog_processor.skills_config_write(params).await
}
ClientRequest::PluginInstall { params, .. } => {
self.plugin_processor.plugin_install(params).await
self.plugin_processor
.plugin_install(params, mcp_client_capabilities.clone())
.await
}
ClientRequest::PluginUninstall { params, .. } => {
self.plugin_processor.plugin_uninstall(params).await
@@ -1316,7 +1318,7 @@ impl MessageProcessor {
}
ClientRequest::McpServerToolCall { params, .. } => {
self.mcp_processor
.mcp_server_tool_call(&request_id, params)
.mcp_server_tool_call(&request_id, params, mcp_client_capabilities.clone())
.await
}
ClientRequest::WindowsSandboxSetupStart { params, .. } => {

View File

@@ -3,6 +3,7 @@ use std::sync::atomic::Ordering;
use axum::http::HeaderValue;
use codex_analytics::AppServerRpcTransport;
use codex_app_server_protocol::InitializeCapabilities;
use codex_login::default_client::SetOriginatorError;
use codex_login::default_client::USER_AGENT_SUFFIX;
use codex_login::default_client::get_codex_user_agent;
@@ -67,22 +68,13 @@ impl InitializeRequestProcessor {
// experimental API). Proposed direction is instance-global first-write-wins
// with initialize-time mismatch rejection.
let analytics_initialize_params = params.clone();
let (
experimental_api_enabled,
let InitializeCapabilities {
experimental_api: experimental_api_enabled,
request_attestation,
opt_out_notification_methods,
mcp_client_capabilities,
) = match params.capabilities {
Some(capabilities) => (
capabilities.experimental_api,
capabilities.request_attestation,
capabilities
.opt_out_notification_methods
.unwrap_or_default(),
capabilities.mcp_client_capabilities,
),
None => (false, false, Vec::new(), None),
};
} = params.capabilities.unwrap_or_default();
let opt_out_notification_methods = opt_out_notification_methods.unwrap_or_default();
let ClientInfo {
name,
title: _title,

View File

@@ -70,8 +70,9 @@ impl McpRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: McpServerToolCallParams,
mcp_client_capabilities: Option<McpClientCapabilities>,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.call_mcp_server_tool(request_id, params)
self.call_mcp_server_tool(request_id, params, mcp_client_capabilities)
.await
.map(|()| None)
}
@@ -364,7 +365,9 @@ impl McpRequestProcessor {
let request_id = request_id.clone();
tokio::spawn(async move {
let result = thread.read_mcp_resource(&server, &uri).await;
let result = thread
.read_mcp_resource(&server, &uri, mcp_client_capabilities.as_ref())
.await;
Self::send_mcp_resource_read_response(outgoing, request_id, result).await;
});
return Ok(());
@@ -420,6 +423,7 @@ impl McpRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: McpServerToolCallParams,
mcp_client_capabilities: Option<McpClientCapabilities>,
) -> Result<(), JSONRPCErrorError> {
let outgoing = Arc::clone(&self.outgoing);
let thread_id = params.thread_id.clone();
@@ -429,7 +433,13 @@ impl McpRequestProcessor {
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,
meta,
mcp_client_capabilities.as_ref(),
)
.await
.map(McpServerToolCallResponse::from)
.map_err(|error| internal_error(format!("{error:#}")));

View File

@@ -428,8 +428,9 @@ impl PluginRequestProcessor {
pub(crate) async fn plugin_install(
&self,
params: PluginInstallParams,
mcp_client_capabilities: Option<McpClientCapabilities>,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_install_response(params)
self.plugin_install_response(params, mcp_client_capabilities)
.await
.map(|response| Some(response.into()))
}
@@ -1364,6 +1365,7 @@ impl PluginRequestProcessor {
async fn plugin_install_response(
&self,
params: PluginInstallParams,
mcp_client_capabilities: Option<McpClientCapabilities>,
) -> Result<PluginInstallResponse, JSONRPCErrorError> {
let PluginInstallParams {
marketplace_path,
@@ -1374,7 +1376,11 @@ impl PluginRequestProcessor {
(Some(marketplace_path), None) => marketplace_path,
(None, Some(remote_marketplace_name)) => {
return self
.remote_plugin_install_response(remote_marketplace_name, plugin_name)
.remote_plugin_install_response(
remote_marketplace_name,
plugin_name,
mcp_client_capabilities,
)
.await;
}
(Some(_), Some(_)) | (None, None) => {
@@ -1432,6 +1438,7 @@ impl PluginRequestProcessor {
auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth),
&result.plugin_id.as_key(),
&plugin_apps,
mcp_client_capabilities,
)
.await;
@@ -1445,6 +1452,7 @@ impl PluginRequestProcessor {
&self,
remote_marketplace_name: String,
remote_plugin_id: String,
mcp_client_capabilities: Option<McpClientCapabilities>,
) -> Result<PluginInstallResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins) {
@@ -1547,6 +1555,7 @@ impl PluginRequestProcessor {
auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth),
&result.plugin_id.as_key(),
&plugin_apps,
mcp_client_capabilities,
)
.await;
@@ -1562,6 +1571,7 @@ impl PluginRequestProcessor {
is_chatgpt_auth: bool,
plugin_id: &str,
plugin_apps: &[codex_plugin::AppConnectorId],
mcp_client_capabilities: Option<McpClientCapabilities>,
) -> Vec<AppSummary> {
if plugin_apps.is_empty() || !config.features.apps_enabled_for_auth(is_chatgpt_auth) {
return Vec::new();
@@ -1574,7 +1584,7 @@ impl PluginRequestProcessor {
config,
/*force_refetch*/ true,
Arc::clone(&environment_manager),
None,
mcp_client_capabilities.clone(),
),
);
@@ -1599,9 +1609,12 @@ impl PluginRequestProcessor {
"failed to load accessible apps after plugin install: {err:#}"
);
(
connectors::list_cached_accessible_connectors_from_mcp_tools(config, None)
.await
.unwrap_or_default(),
connectors::list_cached_accessible_connectors_from_mcp_tools(
config,
mcp_client_capabilities.as_ref(),
)
.await
.unwrap_or_default(),
false,
)
}

View File

@@ -10,9 +10,13 @@ use app_test_support::create_mock_responses_server_sequence;
use app_test_support::to_response;
use app_test_support::write_mock_responses_config_toml;
use axum::Router;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::McpAppUiCapability;
use codex_app_server_protocol::McpClientCapabilities;
use codex_app_server_protocol::McpElicitationSchema;
use codex_app_server_protocol::McpServerElicitationAction;
use codex_app_server_protocol::McpServerElicitationRequest;
@@ -95,7 +99,24 @@ url = "{mcp_server_url}/mcp"
std::fs::write(config_path, config_toml)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_capabilities(
ClientInfo {
name: "mcp-tool-capability-test".to_string(),
title: None,
version: "1.0.0".to_string(),
},
Some(InitializeCapabilities {
experimental_api: true,
mcp_client_capabilities: Some(McpClientCapabilities {
app_ui: [McpAppUiCapability::WebView].into_iter().collect(),
}),
..Default::default()
}),
),
)
.await??;
let thread_start_id = mcp
.send_thread_start_request(ThreadStartParams {
@@ -577,6 +598,7 @@ impl ServerHandler for ToolAppsMcpServer {
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
assert_eq!(request.name.as_ref(), TEST_TOOL_NAME);
assert!(!context.meta.0.contains_key("openai/clientCapabilities"));
let message = request
.arguments
.as_ref()

View File

@@ -13,7 +13,6 @@ use codex_connectors::merge::merge_connectors;
use codex_connectors::merge::merge_plugin_connectors;
use codex_core::config::Config;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_client_capabilities;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status;

View File

@@ -268,11 +268,15 @@ impl McpConnectionManager {
},
)
.await;
let effective_client_capabilities = (host_owned_codex_apps_enabled
&& server_name == CODEX_APPS_MCP_SERVER_NAME)
.then(|| codex_apps_client_capabilities.clone())
.flatten();
let codex_apps_tools_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME {
Some(CodexAppsToolsCacheContext {
codex_home: codex_home.clone(),
user_key: codex_apps_tools_cache_key.clone(),
client_capabilities_fingerprint: codex_apps_client_capabilities
client_capabilities_fingerprint: effective_client_capabilities
.as_ref()
.map(client_capabilities::fingerprint),
})
@@ -303,9 +307,7 @@ impl McpConnectionManager {
tx_event.clone(),
elicitation_requests.clone(),
codex_apps_tools_cache_context,
(host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME)
.then(|| codex_apps_client_capabilities.clone())
.flatten(),
effective_client_capabilities,
Arc::clone(&tool_plugin_provenance),
runtime_context.clone(),
runtime_auth_provider,
@@ -616,7 +618,53 @@ impl McpConnectionManager {
.client()
.await
.context("failed to get client")?;
let client_capabilities = managed_client.client_capabilities.clone();
self.hard_refresh_codex_apps_tools_cache_for_client(
&managed_client,
client_capabilities.as_ref(),
managed_client.codex_apps_tools_cache_context.clone(),
)
.await
}
pub async fn hard_refresh_codex_apps_tools_cache_with_client_capabilities(
&self,
client_capabilities: Option<&McpClientCapabilities>,
) -> Result<Vec<ToolInfo>> {
let client_capabilities = self
.is_host_owned_codex_apps_server(CODEX_APPS_MCP_SERVER_NAME)
.then_some(client_capabilities)
.flatten();
let managed_client = self
.clients
.get(CODEX_APPS_MCP_SERVER_NAME)
.ok_or_else(|| anyhow!("unknown MCP server '{CODEX_APPS_MCP_SERVER_NAME}'"))?
.client()
.await
.context("failed to get client")?;
let cache_context =
managed_client
.codex_apps_tools_cache_context
.clone()
.map(|mut context| {
context.client_capabilities_fingerprint =
client_capabilities.map(client_capabilities::fingerprint);
context
});
self.hard_refresh_codex_apps_tools_cache_for_client(
&managed_client,
client_capabilities,
cache_context,
)
.await
}
async fn hard_refresh_codex_apps_tools_cache_for_client(
&self,
managed_client: &ManagedClient,
client_capabilities: Option<&McpClientCapabilities>,
cache_context: Option<CodexAppsToolsCacheContext>,
) -> Result<Vec<ToolInfo>> {
let list_start = Instant::now();
let fetch_start = Instant::now();
let tools = list_tools_for_client_uncached(
@@ -624,7 +672,7 @@ impl McpConnectionManager {
&managed_client.client,
managed_client.tool_timeout,
managed_client.server_instructions.as_deref(),
managed_client.client_capabilities.as_ref(),
client_capabilities,
)
.await
.with_context(|| {
@@ -638,7 +686,7 @@ impl McpConnectionManager {
write_cached_codex_apps_tools_if_needed(
CODEX_APPS_MCP_SERVER_NAME,
managed_client.codex_apps_tools_cache_context.as_ref(),
cache_context.as_ref(),
&managed_client.server_info,
&tools,
);
@@ -818,16 +866,53 @@ impl McpConnectionManager {
server: &str,
tool: &str,
arguments: Option<serde_json::Value>,
mut meta: Option<serde_json::Value>,
meta: Option<serde_json::Value>,
) -> Result<CallToolResult> {
let client = self.client_by_name(server).await?;
let client_capabilities = client.client_capabilities.clone();
Self::call_tool_for_client(
&client,
server,
tool,
arguments,
meta,
client_capabilities.as_ref(),
)
.await
}
pub async fn call_tool_with_client_capabilities(
&self,
server: &str,
tool: &str,
arguments: Option<serde_json::Value>,
meta: Option<serde_json::Value>,
client_capabilities: Option<&McpClientCapabilities>,
) -> Result<CallToolResult> {
let client_capabilities = self
.is_host_owned_codex_apps_server(server)
.then_some(client_capabilities)
.flatten();
let client = self.client_by_name(server).await?;
Self::call_tool_for_client(&client, server, tool, arguments, meta, client_capabilities)
.await
}
async fn call_tool_for_client(
client: &ManagedClient,
server: &str,
tool: &str,
arguments: Option<serde_json::Value>,
mut meta: Option<serde_json::Value>,
client_capabilities: Option<&McpClientCapabilities>,
) -> Result<CallToolResult> {
if !client.tool_filter.allows(tool) {
return Err(anyhow!(
"tool '{tool}' is disabled for MCP server '{server}'"
));
}
client_capabilities::add_json_meta(&mut meta, client.client_capabilities.as_ref());
client_capabilities::add_json_meta(&mut meta, client_capabilities);
let result: rmcp::model::CallToolResult = client
.client
.call_tool(tool.to_string(), arguments, meta, client.tool_timeout)
@@ -870,9 +955,9 @@ impl McpConnectionManager {
let managed = self.client_by_name(server).await?;
let timeout = managed.tool_timeout;
if managed.client_capabilities.is_some() {
if let Some(capabilities) = managed.client_capabilities.as_ref() {
let params = params.get_or_insert_with(PaginatedRequestParams::default);
client_capabilities::add_meta(&mut params.meta, managed.client_capabilities.as_ref());
client_capabilities::add_meta(&mut params.meta, Some(capabilities));
}
managed
.client
@@ -891,9 +976,9 @@ impl McpConnectionManager {
let client = managed.client.clone();
let timeout = managed.tool_timeout;
if managed.client_capabilities.is_some() {
if let Some(capabilities) = managed.client_capabilities.as_ref() {
let params = params.get_or_insert_with(PaginatedRequestParams::default);
client_capabilities::add_meta(&mut params.meta, managed.client_capabilities.as_ref());
client_capabilities::add_meta(&mut params.meta, Some(capabilities));
}
client
.list_resource_templates(params, timeout)
@@ -905,13 +990,37 @@ impl McpConnectionManager {
pub async fn read_resource(
&self,
server: &str,
mut params: ReadResourceRequestParams,
params: ReadResourceRequestParams,
) -> Result<ReadResourceResult> {
let managed = self.client_by_name(server).await?;
let client_capabilities = managed.client_capabilities.clone();
Self::read_resource_for_client(&managed, server, params, client_capabilities.as_ref()).await
}
pub async fn read_resource_with_client_capabilities(
&self,
server: &str,
params: ReadResourceRequestParams,
client_capabilities: Option<&McpClientCapabilities>,
) -> Result<ReadResourceResult> {
let client_capabilities = self
.is_host_owned_codex_apps_server(server)
.then_some(client_capabilities)
.flatten();
let managed = self.client_by_name(server).await?;
Self::read_resource_for_client(&managed, server, params, client_capabilities).await
}
async fn read_resource_for_client(
managed: &ManagedClient,
server: &str,
mut params: ReadResourceRequestParams,
client_capabilities: Option<&McpClientCapabilities>,
) -> Result<ReadResourceResult> {
let client = managed.client.clone();
let timeout = managed.tool_timeout;
let uri = params.uri.clone();
client_capabilities::add_meta(&mut params.meta, managed.client_capabilities.as_ref());
client_capabilities::add_meta(&mut params.meta, client_capabilities);
client
.read_resource(params, timeout)

View File

@@ -553,11 +553,16 @@ impl CodexThread {
&self,
server: &str,
uri: &str,
client_capabilities: Option<&McpClientCapabilities>,
) -> anyhow::Result<serde_json::Value> {
let result = self
.codex
.session
.read_resource(server, ReadResourceRequestParams::new(uri))
.read_resource_with_client_capabilities(
server,
ReadResourceRequestParams::new(uri),
client_capabilities,
)
.await?;
Ok(serde_json::to_value(result)?)
@@ -569,10 +574,11 @@ impl CodexThread {
tool: &str,
arguments: Option<serde_json::Value>,
meta: Option<serde_json::Value>,
client_capabilities: Option<&McpClientCapabilities>,
) -> anyhow::Result<CallToolResult> {
self.codex
.session
.call_tool(server, tool, arguments, meta)
.call_tool_with_client_capabilities(server, tool, arguments, meta, client_capabilities)
.await
}

View File

@@ -203,22 +203,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options(
)
}
pub async fn list_accessible_connectors_from_mcp_tools_with_client_capabilities(
config: &Config,
force_refetch: bool,
mcp_client_capabilities: Option<McpClientCapabilities>,
) -> anyhow::Result<Vec<AppInfo>> {
Ok(
list_accessible_connectors_from_mcp_tools_with_options_and_status(
config,
force_refetch,
mcp_client_capabilities,
)
.await?
.connectors,
)
}
pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
config: &Config,
force_refetch: bool,

View File

@@ -569,11 +569,12 @@ async fn execute_mcp_tool_call(
.start_mcp_call_trace(call_id);
let request_meta = mcp_call_trace.add_request_meta(request_meta);
let result = sess
.call_tool(
.call_tool_with_client_capabilities(
&invocation.server,
&invocation.tool,
rewritten_arguments,
request_meta,
turn_context.mcp_client_capabilities.as_ref(),
)
.await
.map_err(|e| format!("tool call error: {e:?}"))?;
@@ -676,7 +677,11 @@ async fn maybe_request_codex_apps_auth_elicitation(
async fn refresh_codex_apps_after_connector_auth(sess: &Session, turn_context: &TurnContext) {
let mcp_tools_result = {
let manager = sess.services.mcp_connection_manager.read().await;
manager.hard_refresh_codex_apps_tools_cache().await
manager
.hard_refresh_codex_apps_tools_cache_with_client_capabilities(
turn_context.mcp_client_capabilities.as_ref(),
)
.await
};
match mcp_tools_result {
@@ -1441,13 +1446,14 @@ pub(crate) async fn lookup_mcp_tool_metadata(
.await
{
Some(connectors) => Some(connectors),
None => connectors::list_accessible_connectors_from_mcp_tools_with_client_capabilities(
None => connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status(
turn_context.config.as_ref(),
false,
turn_context.mcp_client_capabilities.clone(),
)
.await
.ok(),
.ok()
.map(|status| status.connectors),
};
connectors.and_then(|connectors| {
let connector_id = tool_info.connector_id.as_deref()?;

View File

@@ -270,16 +270,17 @@ impl Session {
clippy::await_holding_invalid_type,
reason = "MCP resource calls are serialized through the session-owned manager guard"
)]
pub async fn read_resource(
pub async fn read_resource_with_client_capabilities(
&self,
server: &str,
params: ReadResourceRequestParams,
client_capabilities: Option<&McpClientCapabilities>,
) -> anyhow::Result<ReadResourceResult> {
self.services
.mcp_connection_manager
.read()
.await
.read_resource(server, params)
.read_resource_with_client_capabilities(server, params, client_capabilities)
.await
}
@@ -287,18 +288,19 @@ impl Session {
clippy::await_holding_invalid_type,
reason = "MCP tool calls are serialized through the session-owned manager guard"
)]
pub async fn call_tool(
pub async fn call_tool_with_client_capabilities(
&self,
server: &str,
tool: &str,
arguments: Option<serde_json::Value>,
meta: Option<serde_json::Value>,
client_capabilities: Option<&McpClientCapabilities>,
) -> anyhow::Result<CallToolResult> {
self.services
.mcp_connection_manager
.read()
.await
.call_tool(server, tool, arguments, meta)
.call_tool_with_client_capabilities(server, tool, arguments, meta, client_capabilities)
.await
}

View File

@@ -78,7 +78,11 @@ impl ToolExecutor<ToolInvocation> for ReadMcpResourceHandler {
let payload_result: Result<ReadResourcePayload, FunctionCallError> = async {
let result = session
.read_resource(&server, ReadResourceRequestParams::new(uri.clone()))
.read_resource_with_client_capabilities(
&server,
ReadResourceRequestParams::new(uri.clone()),
turn.mcp_client_capabilities.as_ref(),
)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}"))

View File

@@ -8,6 +8,8 @@ use codex_features::Feature;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::mcp::McpAppUiCapability;
use codex_protocol::mcp::McpClientCapabilities;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ElicitationAction;
@@ -142,6 +144,17 @@ async fn approved_mcp_tool_call_metadata_records_prior_user_input_request() -> R
});
let test = builder.build(&server).await?;
test.codex
.set_app_server_client_info(
Some("turn-client".to_string()),
Some("1.0.0".to_string()),
false,
Some(McpClientCapabilities {
app_ui: [McpAppUiCapability::WebView].into_iter().collect(),
}),
)
.await?;
submit_user_turn(
&test,
"Use [$calendar](app://calendar) to create a calendar event.",
@@ -167,6 +180,15 @@ async fn approved_mcp_tool_call_metadata_records_prior_user_input_request() -> R
unreachable!("event guard guarantees ElicitationRequest");
};
test.codex
.set_app_server_client_info(
Some("resumed-client".to_string()),
Some("1.0.0".to_string()),
false,
Some(McpClientCapabilities::default()),
)
.await?;
test.codex
.submit(Op::ResolveElicitation {
server_name: request.server_name,
@@ -190,6 +212,12 @@ async fn approved_mcp_tool_call_metadata_records_prior_user_input_request() -> R
.pointer("/params/_meta/x-codex-turn-metadata/user_input_requested_during_turn"),
Some(&json!(true))
);
assert_eq!(
apps_tool_call.pointer(
"/params/_meta/openai~1clientCapabilities/extensions/io.modelcontextprotocol~1ui/mimeTypes"
),
Some(&json!(["text/html;profile=mcp-app"]))
);
Ok(())
}