Emit Trusted MCP App Identity on Tool-Call Items (#27132)

## Summary

- Add optional `appContext` to app-server MCP tool-call items with
trusted `connectorId`, `linkId`, and `mcpAppResourceUri` metadata.
- Preserve that context across tool-call events, persisted history,
reconnects, and thread resume.
- Keep the deprecated top-level `mcpAppResourceUri` temporarily for
client migration.

The consumer contract is `{ appContext: { connectorId, linkId,
mcpAppResourceUri }, tool }`.

## Validation

- Full GitHub Actions suite passes, including CLA, Bazel tests, clippy,
release builds, and argument-comment lint.

---------

Co-authored-by: martinauyeung-oai <280153141+martinauyeung-oai@users.noreply.github.com>
This commit is contained in:
martinauyeung-oai
2026-06-18 14:02:54 -07:00
committed by GitHub
co-authored by martinauyeung-oai
parent 9bcc09f9f7
commit 765309d5a6
40 changed files with 899 additions and 15 deletions
@@ -10,6 +10,7 @@ use crate::protocol::v2::CollabAgentToolCallStatus;
use crate::protocol::v2::CommandExecutionStatus;
use crate::protocol::v2::DynamicToolCallOutputContentItem;
use crate::protocol::v2::DynamicToolCallStatus;
use crate::protocol::v2::McpToolCallAppContext;
use crate::protocol::v2::McpToolCallError;
use crate::protocol::v2::McpToolCallResult;
use crate::protocol::v2::McpToolCallStatus;
@@ -765,6 +766,14 @@ impl ThreadHistoryBuilder {
.arguments
.clone()
.unwrap_or(serde_json::Value::Null),
app_context: payload
.connector_id
.clone()
.map(|connector_id| McpToolCallAppContext {
connector_id,
link_id: payload.link_id.clone(),
resource_uri: payload.mcp_app_resource_uri.clone(),
}),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
plugin_id: payload.plugin_id.clone(),
result: None,
@@ -807,6 +816,14 @@ impl ThreadHistoryBuilder {
.arguments
.clone()
.unwrap_or(serde_json::Value::Null),
app_context: payload
.connector_id
.clone()
.map(|connector_id| McpToolCallAppContext {
connector_id,
link_id: payload.link_id.clone(),
resource_uri: payload.mcp_app_resource_uri.clone(),
}),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
plugin_id: payload.plugin_id.clone(),
result,
@@ -1567,7 +1584,6 @@ mod tests {
use codex_protocol::protocol::DynamicToolCallResponseEvent;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpToolCallEndEvent;
@@ -2397,7 +2413,9 @@ mod tests {
tool: "lookup".into(),
arguments: Some(serde_json::json!({"id":"123"})),
},
connector_id: None,
mcp_app_resource_uri: None,
link_id: None,
plugin_id: None,
duration: Duration::from_millis(8),
result: Err("boom".into()),
@@ -2447,6 +2465,7 @@ mod tests {
tool: "lookup".into(),
status: McpToolCallStatus::Failed,
arguments: serde_json::json!({"id":"123"}),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
@@ -2475,7 +2494,9 @@ mod tests {
tool: "lookup".into(),
arguments: Some(serde_json::json!({"id":"123"})),
},
connector_id: Some("calendar".into()),
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
link_id: Some("link_calendar".into()),
plugin_id: Some("sample@test".into()),
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
@@ -2506,6 +2527,11 @@ mod tests {
tool: "lookup".into(),
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"id":"123"}),
app_context: Some(McpToolCallAppContext {
connector_id: "calendar".into(),
link_id: Some("link_calendar".into()),
resource_uri: Some("ui://widget/lookup.html".into()),
}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
plugin_id: Some("sample@test".into()),
result: Some(Box::new(McpToolCallResult {
@@ -290,8 +290,10 @@ pub enum ThreadItem {
tool: String,
status: McpToolCallStatus,
arguments: JsonValue,
app_context: Option<McpToolCallAppContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
/// Deprecated: use `appContext.resourceUri` instead.
mcp_app_resource_uri: Option<String>,
plugin_id: Option<String>,
result: Option<Box<McpToolCallResult>>,
@@ -384,6 +386,15 @@ pub enum ThreadItem {
ContextCompaction { id: String },
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub struct McpToolCallAppContext {
pub connector_id: String,
pub link_id: Option<String>,
pub resource_uri: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
@@ -877,6 +888,11 @@ impl From<CoreTurnItem> for ThreadItem {
tool: mcp.tool,
status: McpToolCallStatus::from(mcp.status),
arguments: mcp.arguments,
app_context: mcp.connector_id.map(|connector_id| McpToolCallAppContext {
connector_id,
link_id: mcp.link_id,
resource_uri: mcp.mcp_app_resource_uri.clone(),
}),
mcp_app_resource_uri: mcp.mcp_app_resource_uri,
plugin_id: mcp.plugin_id,
result: mcp.result.map(McpToolCallResult::from).map(Box::new),
@@ -2615,7 +2615,9 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
server: "server".to_string(),
tool: "tool".to_string(),
arguments: json!({"arg": "value"}),
connector_id: Some("calendar".to_string()),
mcp_app_resource_uri: Some("app://connector".to_string()),
link_id: Some("link_calendar".to_string()),
plugin_id: Some("sample@test".to_string()),
status: CoreMcpToolCallStatus::InProgress,
result: None,
@@ -2631,6 +2633,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
tool: "tool".to_string(),
status: McpToolCallStatus::InProgress,
arguments: json!({"arg": "value"}),
app_context: Some(McpToolCallAppContext {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("app://connector".to_string()),
}),
mcp_app_resource_uri: Some("app://connector".to_string()),
plugin_id: Some("sample@test".to_string()),
result: None,
@@ -2644,7 +2651,9 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
server: "server".to_string(),
tool: "tool".to_string(),
arguments: JsonValue::Null,
connector_id: None,
mcp_app_resource_uri: None,
link_id: None,
plugin_id: None,
status: CoreMcpToolCallStatus::Completed,
result: Some(CallToolResult {
@@ -2665,6 +2674,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
tool: "tool".to_string(),
status: McpToolCallStatus::Completed,
arguments: JsonValue::Null,
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: Some(Box::new(McpToolCallResult {
@@ -2678,6 +2688,49 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
);
}
#[test]
fn mcp_tool_call_app_context_serializes_connector_id() {
let item = ThreadItem::McpToolCall {
id: "mcp-1".to_string(),
server: "codex_apps".to_string(),
tool: "calendar.create_event".to_string(),
status: McpToolCallStatus::InProgress,
arguments: json!({}),
app_context: Some(McpToolCallAppContext {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("app://connector".to_string()),
}),
mcp_app_resource_uri: Some("app://connector".to_string()),
plugin_id: None,
result: None,
error: None,
duration_ms: None,
};
assert_eq!(
serde_json::to_value(item).expect("MCP tool call should serialize"),
json!({
"type": "mcpToolCall",
"id": "mcp-1",
"server": "codex_apps",
"tool": "calendar.create_event",
"status": "inProgress",
"arguments": {},
"appContext": {
"connectorId": "calendar",
"linkId": "link_calendar",
"resourceUri": "app://connector",
},
"mcpAppResourceUri": "app://connector",
"pluginId": null,
"result": null,
"error": null,
"durationMs": null,
})
);
}
#[test]
fn user_input_into_core_preserves_image_detail() {
assert_eq!(