Scope MCP app resource reads to their originating call (#39187)

## Why

App widgets need to read resources using the app and account context of the
tool call that produced them, including after a thread is restored.

## What changed

- Add an optional `originCallId` to `mcpServer/resource/read` requests and
  return it on successful scoped reads.
- Track bounded provenance for successful app tool calls in thread history and
  use the current tool binding, account link, and app policy when reading the
  associated widget resource.
- Reject missing, mismatched, failed, or ambiguous origins, while keeping
  app-only tools available for widget reads without exposing them to the model.

## Testing

Add app-server coverage for scoped widget reads across both history modes,
ephemeral and persistent threads, server restarts, app-only visibility, and
invalid origin cases.

GitOrigin-RevId: 11eaefc066b2a3b639599e4cbdef680233d150a2
This commit is contained in:
jif authored and copyberry committed 2026-08-18 11:51:21 +00:00
1 parent 711a5f8b3a
commit 880f1135ea
26 files changed
+779 -22

No files matched your search

@@ -422,6 +422,7 @@ impl McpRequestProcessor {
let outgoing = Arc::clone(&self.outgoing);
let McpResourceReadParams {
thread_id,
origin_call_id,
server,
uri,
} = params;
@@ -431,12 +432,22 @@ impl McpRequestProcessor {
let request_id = request_id.clone();
tokio::spawn(async move {
let result = thread.read_mcp_resource(&server, &uri).await;
Self::send_mcp_resource_read_response(outgoing, request_id, result).await;
let origin_call_id =
origin_call_id.filter(|_| server == codex_mcp::CODEX_APPS_MCP_SERVER_NAME);
let result = match origin_call_id.as_deref() {
Some(call_id) => thread.read_mcp_resource_for_call(call_id, &uri).await,
None => thread.read_mcp_resource(&server, &uri).await,
};
Self::send_mcp_resource_read_response(outgoing, request_id, result, origin_call_id)
.await;
});
return Ok(());
}
if origin_call_id.is_some() {
return Err(invalid_request("originCallId requires threadId"));
}
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let mcp_manager = self.thread_manager.mcp_manager();
let mcp_config = mcp_manager.runtime_config(&config).await;
@@ -463,7 +474,10 @@ impl McpRequestProcessor {
)
.await
.and_then(|result| serde_json::to_value(result).map_err(anyhow::Error::from));
Self::send_mcp_resource_read_response(outgoing, request_id, result).await;
Self::send_mcp_resource_read_response(
outgoing, request_id, result, /*origin_call_id*/ None,
)
.await;
});
Ok(())
}
@@ -472,6 +486,7 @@ impl McpRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
request_id: ConnectionRequestId,
result: anyhow::Result<serde_json::Value>,
origin_call_id: Option<String>,
) {
let result = result
.map_err(|error| internal_error(format!("{error:#}")))
@@ -481,6 +496,10 @@ impl McpRequestProcessor {
"failed to deserialize MCP resource read response: {error}"
))
})
})
.map(|mut response| {
response.origin_call_id = origin_call_id;
response
});
outgoing.send_result(request_id, result).await;
}