mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
Honor explicit account selectors for Apps tool calls (#42054)
## What changed - Resolve `link_id` from tool-call arguments when Apps metadata sets `requires_explicit_link_id` to `true`. - Reject the call before approval or execution when the required selector is missing, empty, or not a string. - Preserve catalog-provided account metadata for legacy Apps tools and leave non-Apps MCP tools unchanged. ## Testing - Add unit coverage for required selectors, malformed values, legacy fallbacks, and non-Apps tools. - Add end-to-end coverage for execution, approval prompts, and rejection when an Apps call omits `link_id`. GitOrigin-RevId: 667fd04102a3934d53ded020da6d1bf68a1ca3e5
This commit is contained in:
committed by
copyberry
parent
0ec375eb70
commit
0e37d834d4
@@ -96,8 +96,10 @@ use tracing::error;
|
||||
use tracing::field::Empty;
|
||||
use url::Url;
|
||||
|
||||
mod account;
|
||||
mod telemetry;
|
||||
|
||||
use account::McpToolAccountError;
|
||||
use telemetry::McpCallMetricOutcome;
|
||||
use telemetry::emit_mcp_call_metrics;
|
||||
use telemetry::mcp_call_metric_outcome;
|
||||
@@ -173,7 +175,32 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
|
||||
};
|
||||
};
|
||||
let metadata = mcp_tool_metadata(&prepared_call);
|
||||
let metadata = match mcp_tool_metadata(
|
||||
prepared_call.tool_info(),
|
||||
prepared_call.plugin_id(),
|
||||
invocation.arguments.as_ref(),
|
||||
) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) => {
|
||||
let item_metadata =
|
||||
McpToolCallItemMetadata::from_tool_metadata(&server, /*metadata*/ None);
|
||||
let result = notify_mcp_tool_call_skip(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
item_metadata,
|
||||
err.to_string(),
|
||||
/*already_started*/ false,
|
||||
)
|
||||
.await;
|
||||
return HandledMcpToolCall {
|
||||
result: CallToolResult::from_result(result),
|
||||
tool_input: arguments_value
|
||||
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
|
||||
};
|
||||
}
|
||||
};
|
||||
let item_metadata = McpToolCallItemMetadata::from_tool_metadata(&server, Some(&metadata));
|
||||
let runtime_config = prepared_call.config();
|
||||
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
@@ -1642,9 +1669,13 @@ pub(crate) fn build_guardian_mcp_tool_review_request(
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_tool_metadata(prepared_call: &PreparedMcpCall) -> McpToolApprovalMetadata {
|
||||
let server = prepared_call.server_name();
|
||||
let tool_info = prepared_call.tool_info().clone();
|
||||
fn mcp_tool_metadata(
|
||||
tool_info: &ToolInfo,
|
||||
plugin_id: Option<&str>,
|
||||
arguments: Option<&JsonValue>,
|
||||
) -> Result<McpToolApprovalMetadata, McpToolAccountError> {
|
||||
let server = tool_info.server_name.as_str();
|
||||
let tool_info = tool_info.clone();
|
||||
let connector_description = (server == CODEX_APPS_MCP_SERVER_NAME)
|
||||
.then(|| tool_info.namespace_description.clone())
|
||||
.flatten();
|
||||
@@ -1656,6 +1687,11 @@ fn mcp_tool_metadata(prepared_call: &PreparedMcpCall) -> McpToolApprovalMetadata
|
||||
.and_then(|meta| meta.get(MCP_TOOL_CODEX_APPS_META_KEY))
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.cloned();
|
||||
let link_id = if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
account::resolve_account(&tool_info, arguments)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let connected_account_email = if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
codex_apps_meta
|
||||
.as_ref()
|
||||
@@ -1668,20 +1704,14 @@ fn mcp_tool_metadata(prepared_call: &PreparedMcpCall) -> McpToolApprovalMetadata
|
||||
None
|
||||
};
|
||||
|
||||
McpToolApprovalMetadata {
|
||||
Ok(McpToolApprovalMetadata {
|
||||
annotations: tool_info.tool.annotations,
|
||||
connector_id: tool_info.connector_id,
|
||||
link_id: tool_info
|
||||
.tool
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get(MCP_TOOL_LINK_ID_META_KEY))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string),
|
||||
link_id,
|
||||
connector_name: tool_info.connector_name,
|
||||
connector_description,
|
||||
connected_account_email,
|
||||
plugin_id: prepared_call.plugin_id().map(str::to_string),
|
||||
plugin_id: plugin_id.map(str::to_string),
|
||||
tool_title: tool_info.tool.title,
|
||||
tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned),
|
||||
mcp_app_resource_uri: get_mcp_app_resource_uri(tool_info.tool.meta.as_deref()),
|
||||
@@ -1691,7 +1721,7 @@ fn mcp_tool_metadata(prepared_call: &PreparedMcpCall) -> McpToolApprovalMetadata
|
||||
server,
|
||||
&tool_info.openai_file_input_optional_fields,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn openai_file_input_optional_fields_for_server(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Resolves account identity before native Apps approvals. Required selectors
|
||||
//! must be valid; optional catalog identities never block legacy calls.
|
||||
|
||||
use super::MCP_TOOL_LINK_ID_META_KEY;
|
||||
use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY;
|
||||
use codex_mcp::ToolInfo;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub(super) enum McpToolAccountError {
|
||||
#[error("This app tool requires a non-empty string link_id argument")]
|
||||
InvalidSelector,
|
||||
}
|
||||
|
||||
pub(super) fn resolve_account(
|
||||
tool_info: &ToolInfo,
|
||||
arguments: Option<&JsonValue>,
|
||||
) -> Result<Option<String>, McpToolAccountError> {
|
||||
let tool_meta = tool_info.tool.meta.as_deref();
|
||||
let requires_explicit_link_id = tool_meta
|
||||
.and_then(|meta| meta.get(MCP_TOOL_CODEX_APPS_META_KEY))
|
||||
.and_then(|meta| meta.get("requires_explicit_link_id"))
|
||||
.and_then(JsonValue::as_bool)
|
||||
== Some(true);
|
||||
let link_id = if requires_explicit_link_id {
|
||||
arguments.and_then(|arguments| arguments.get(MCP_TOOL_LINK_ID_META_KEY))
|
||||
} else {
|
||||
tool_meta.and_then(|meta| meta.get(MCP_TOOL_LINK_ID_META_KEY))
|
||||
}
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|link_id| !link_id.trim().is_empty())
|
||||
.map(str::to_owned);
|
||||
|
||||
if requires_explicit_link_id && link_id.is_none() {
|
||||
Err(McpToolAccountError::InvalidSelector)
|
||||
} else {
|
||||
Ok(link_id)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::account::McpToolAccountError;
|
||||
use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::ManagedFeatures;
|
||||
@@ -97,6 +98,97 @@ fn approval_config(turn_context: &TurnContext) -> codex_mcp::McpConfig {
|
||||
(*mcp_config_for_test(&turn_context.config)).clone()
|
||||
}
|
||||
|
||||
#[test_case::test_case(false, None, Ok(Some("default_link")); "catalog_account")]
|
||||
#[test_case::test_case(true, None, Err(McpToolAccountError::InvalidSelector); "required_selector_omitted")]
|
||||
#[test_case::test_case(true, Some(serde_json::json!(null)), Err(McpToolAccountError::InvalidSelector); "null_selector")]
|
||||
#[test_case::test_case(true, Some(serde_json::json!(42)), Err(McpToolAccountError::InvalidSelector); "invalid_selector")]
|
||||
#[test_case::test_case(true, Some(serde_json::json!(" ")), Err(McpToolAccountError::InvalidSelector); "empty_selector")]
|
||||
#[test_case::test_case(true, Some(serde_json::json!(" selected_link ")), Ok(Some(" selected_link ")); "opaque_selector")]
|
||||
#[test_case::test_case(false, Some(serde_json::json!("selected_link")), Ok(Some("default_link")); "unrelated_link_argument")]
|
||||
fn mcp_tool_metadata_resolves_advertised_account_selector(
|
||||
requires_explicit_link_id: bool,
|
||||
selected_link: Option<JsonValue>,
|
||||
expected: Result<Option<&str>, McpToolAccountError>,
|
||||
) {
|
||||
let tool_info = serde_json::from_value(serde_json::json!({
|
||||
"server_name": CODEX_APPS_MCP_SERVER_NAME,
|
||||
"tool_name": "events/create",
|
||||
"tool_namespace": "calendar",
|
||||
"connector_id": "calendar",
|
||||
"tool": {
|
||||
"name": "calendar/events/create",
|
||||
"inputSchema": {},
|
||||
"_meta": {
|
||||
"link_id": "default_link",
|
||||
"_codex_apps": {
|
||||
"requires_explicit_link_id": requires_explicit_link_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
.expect("tool info");
|
||||
let arguments = selected_link.map(|link_id| serde_json::json!({ "link_id": link_id }));
|
||||
assert_eq!(
|
||||
mcp_tool_metadata(&tool_info, /*plugin_id*/ None, arguments.as_ref())
|
||||
.map(|metadata| metadata.link_id),
|
||||
expected.map(|link_id| link_id.map(str::to_owned)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test_case::test_case(None, Ok(None); "no_tool_metadata_uses_legacy_fallback")]
|
||||
#[test_case::test_case(Some(serde_json::json!({})), Ok(None); "no_apps_metadata_uses_legacy_fallback")]
|
||||
#[test_case::test_case(Some(serde_json::json!({ "_codex_apps": {} })), Ok(None); "missing_selector_flag_uses_legacy_fallback")]
|
||||
#[test_case::test_case(Some(serde_json::json!({ "_codex_apps": { "requires_explicit_link_id": false } })), Ok(None); "false_selector_flag_uses_legacy_fallback")]
|
||||
#[test_case::test_case(Some(serde_json::json!({ "_codex_apps": { "requires_explicit_link_id": null } })), Ok(None); "null_selector_flag_uses_legacy_fallback")]
|
||||
#[test_case::test_case(Some(serde_json::json!({ "_codex_apps": { "requires_explicit_link_id": "true" } })), Ok(None); "string_true_does_not_require_selector")]
|
||||
#[test_case::test_case(Some(serde_json::json!({ "_codex_apps": { "requires_explicit_link_id": 1 } })), Ok(None); "numeric_one_does_not_require_selector")]
|
||||
#[test_case::test_case(Some(serde_json::json!({ "_codex_apps": [] })), Ok(None); "malformed_apps_metadata_uses_legacy_fallback")]
|
||||
#[test_case::test_case(Some(serde_json::json!({ "link_id": null })), Ok(None); "null_catalog_link_uses_legacy_fallback")]
|
||||
#[test_case::test_case(Some(serde_json::json!({ "link_id": 42, "_codex_apps": { "requires_explicit_link_id": false } })), Ok(None); "invalid_optional_catalog_link_uses_legacy_fallback")]
|
||||
#[test_case::test_case(Some(serde_json::json!({ "link_id": " ", "_codex_apps": { "requires_explicit_link_id": false } })), Ok(None); "empty_optional_catalog_link_uses_legacy_fallback")]
|
||||
#[test_case::test_case(Some(serde_json::json!({ "link_id": "default_link" })), Ok(Some("default_link")); "legacy_catalog_link_needs_no_selector_metadata")]
|
||||
fn mcp_tool_metadata_preserves_legacy_account_fallback(
|
||||
meta: Option<JsonValue>,
|
||||
expected: Result<Option<&str>, McpToolAccountError>,
|
||||
) {
|
||||
let mut tool = serde_json::json!({ "name": "calendar/events/create", "inputSchema": {} });
|
||||
if let Some(meta) = meta {
|
||||
tool["_meta"] = meta;
|
||||
}
|
||||
let tool_info = serde_json::from_value(serde_json::json!({
|
||||
"server_name": CODEX_APPS_MCP_SERVER_NAME,
|
||||
"tool_name": "events/create",
|
||||
"tool_namespace": "calendar",
|
||||
"connector_id": "calendar",
|
||||
"tool": tool,
|
||||
}))
|
||||
.expect("tool info");
|
||||
|
||||
assert_eq!(
|
||||
mcp_tool_metadata(&tool_info, /*plugin_id*/ None, /*arguments*/ None)
|
||||
.map(|metadata| metadata.link_id),
|
||||
expected.map(|link_id| link_id.map(str::to_owned)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test_case::test_case(serde_json::json!({}); "no_account_metadata")]
|
||||
#[test_case::test_case(serde_json::json!({ "_codex_apps": { "requires_explicit_link_id": true } }); "unrelated_apps_metadata")]
|
||||
fn non_apps_tool_does_not_require_account_metadata(meta: JsonValue) {
|
||||
let tool_info = serde_json::from_value(serde_json::json!({
|
||||
"server_name": "custom_server",
|
||||
"tool_name": "events/create",
|
||||
"tool_namespace": "calendar",
|
||||
"tool": { "name": "calendar/events/create", "inputSchema": {}, "_meta": meta },
|
||||
}))
|
||||
.expect("tool info");
|
||||
|
||||
assert_eq!(
|
||||
mcp_tool_metadata(&tool_info, /*plugin_id*/ None, /*arguments*/ None)
|
||||
.map(|metadata| metadata.link_id),
|
||||
Ok(None),
|
||||
);
|
||||
}
|
||||
|
||||
fn mcp_turn_metadata_context(turn_context: &TurnContext) -> McpTurnMetadataContext<'_> {
|
||||
McpTurnMetadataContext {
|
||||
model: turn_context.model_info().slug.as_str(),
|
||||
|
||||
Reference in New Issue
Block a user