Forward caller metadata on MCP resource reads (#59509)

## What changed

Accept an optional `_meta` object on `mcpServer/resource/read` and forward it to MCP `resources/read`, including reads bound to an originating tool call. Update the protocol schemas and generated TypeScript and Python types.

Reject caller metadata containing host-owned `threadId`, `x-codex-turn-metadata`, or `codex/connectionLease` keys. For `codex_apps`, also reserve `connector_id`, `link_id`, and `_codex_apps.resource_uri` to preserve app and account routing.

## Testing

Add integration coverage for metadata forwarding through stdio, hosted app reads with and without a thread, and origin-bound widget reads. Cover rejection of reserved keys and non-object metadata.

GitOrigin-RevId: 227ee0fb5fe15fa2ff2ec66d4d5ae16de8935a8c
This commit is contained in:
Joe Savona
2026-11-04 22:55:22 +00:00
committed by copyberry
parent ad5e6e57a1
commit aa1399cbcd
22 changed files with 279 additions and 40 deletions
@@ -2173,6 +2173,14 @@
},
"McpResourceReadParams": {
"properties": {
"_meta": {
"additionalProperties": true,
"description": "Additional MCP request metadata. Account and turn routing keys are reserved.",
"type": [
"object",
"null"
]
},
"connectorId": {
"type": [
"string",
@@ -15299,6 +15299,14 @@
"McpResourceReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"_meta": {
"additionalProperties": true,
"description": "Additional MCP request metadata. Account and turn routing keys are reserved.",
"type": [
"object",
"null"
]
},
"connectorId": {
"type": [
"string",
@@ -11260,6 +11260,14 @@
"McpResourceReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"_meta": {
"additionalProperties": true,
"description": "Additional MCP request metadata. Account and turn routing keys are reserved.",
"type": [
"object",
"null"
]
},
"connectorId": {
"type": [
"string",
@@ -22,6 +22,14 @@
}
},
"properties": {
"_meta": {
"additionalProperties": true,
"description": "Additional MCP request metadata. Account and turn routing keys are reserved.",
"type": [
"object",
"null"
]
},
"connectorId": {
"type": [
"string",
@@ -1,9 +1,14 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "../serde_json/JsonValue";
import type { McpResourceReadTarget } from "./McpResourceReadTarget";
export type McpResourceReadParams = { threadId?: string | null,
export type McpResourceReadParams = {
/**
* Additional MCP request metadata. Account and turn routing keys are reserved.
*/
_meta?: { [key in string]?: JsonValue } | null, threadId?: string | null,
/**
* Originating MCP tool call used to select the resource's app.
*/
@@ -2569,6 +2569,7 @@ mod tests {
let mcp_resource_read = ClientRequest::McpResourceRead {
request_id: request_id(),
params: v2::McpResourceReadParams {
meta: None,
thread_id: Some("thread-1".to_string()),
origin_call_id: None,
server: "server-a".to_string(),
@@ -2763,6 +2764,7 @@ mod tests {
let mcp_resource_read = ClientRequest::McpResourceRead {
request_id: request_id(),
params: v2::McpResourceReadParams {
meta: None,
thread_id: None,
origin_call_id: None,
server: "server-a".to_string(),
@@ -112,6 +112,10 @@ pub struct ListMcpServerStatusResponse {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct McpResourceReadParams {
/// Additional MCP request metadata. Account and turn routing keys are reserved.
#[serde(rename = "_meta")]
#[ts(rename = "_meta", optional = nullable)]
pub meta: Option<serde_json::Map<String, JsonValue>>,
#[ts(optional = nullable)]
pub thread_id: Option<String>,
/// Originating MCP tool call used to select the resource's app.
+8
View File
@@ -141,6 +141,14 @@ The field is null for older history and tools that declare widgets only in
result metadata; clients retain catalog discovery for those calls. Existing
resource URI fields remain available for older clients.
Clients can pass an optional `_meta` object on `mcpServer/resource/read`.
The app server forwards its entries to MCP `resources/read`, including reads
bound to an originating tool call. The keys `x-codex-turn-metadata`, `threadId`,
and `codex/connectionLease` are reserved for host context on every MCP server. Reads to `codex_apps`
additionally reserve `connector_id`, `link_id`, and the nested
`_codex_apps.resource_uri` field for hosted app routing. A request that includes
a reserved field in `_meta` is rejected before the MCP read is sent.
# Initial Daybreak choice (experimental)
Persistent threads accept `daybreakEnabled` on `thread/start` with the
@@ -13,7 +13,7 @@ use std::future::Future;
use crate::thread_state::ThreadStateManager;
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
const MCP_THREAD_ID_META_KEY: &str = "threadId";
#[derive(Clone)]
pub(crate) struct McpRequestProcessor {
@@ -611,6 +611,7 @@ impl McpRequestProcessor {
) -> Result<(), JSONRPCErrorError> {
let outgoing = Arc::clone(&self.outgoing);
let McpResourceReadParams {
meta,
thread_id,
origin_call_id,
server,
@@ -619,7 +620,24 @@ impl McpRequestProcessor {
target,
} = params;
let mut resource_params = ReadResourceRequestParams::new(uri);
let mut meta = serde_json::Map::new();
let mut meta = meta.unwrap_or_default();
let is_codex_apps = server == codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
let reserved_key = meta.iter().find_map(|(key, value)| match key.as_str() {
"x-codex-turn-metadata"
| MCP_THREAD_ID_META_KEY
| codex_mcp::MCP_CONNECTION_LEASE_META_KEY => Some(key.as_str()),
"connector_id" | "link_id" if is_codex_apps => Some(key.as_str()),
// This legacy routing hint can override an explicit app and account target.
"_codex_apps" if is_codex_apps && value.get("resource_uri").is_some() => {
Some("_codex_apps.resource_uri")
}
_ => None,
});
if let Some(key) = reserved_key {
return Err(invalid_request(format!(
"reserved resource read metadata key: {key}"
)));
}
if let Some(connector_id) = connector_id {
meta.insert(
"x-codex-turn-metadata".to_string(),
@@ -631,7 +649,7 @@ impl McpRequestProcessor {
if origin_call_id.is_none()
&& let Some(target) = target
{
if server != codex_mcp::CODEX_APPS_MCP_SERVER_NAME
if !is_codex_apps
|| target.connector_id.trim().is_empty()
|| target
.link_id
@@ -657,12 +675,11 @@ impl McpRequestProcessor {
let request_id = request_id.clone();
let task = async move {
let origin_call_id =
origin_call_id.filter(|_| server == codex_mcp::CODEX_APPS_MCP_SERVER_NAME);
let origin_call_id = origin_call_id.filter(|_| is_codex_apps);
let result = match origin_call_id.as_deref() {
Some(call_id) => {
thread
.read_mcp_resource_for_call(call_id, &resource_params.uri)
.read_mcp_resource_for_call(call_id, resource_params)
.await
}
None => thread.read_mcp_resource(&server, resource_params).await,
@@ -778,7 +795,7 @@ fn with_mcp_tool_call_thread_id_meta(
match meta {
Some(serde_json::Value::Object(mut map)) => {
map.insert(
MCP_TOOL_THREAD_ID_META_KEY.to_string(),
MCP_THREAD_ID_META_KEY.to_string(),
serde_json::Value::String(thread_id.to_string()),
);
Some(serde_json::Value::Object(map))
@@ -786,7 +803,7 @@ fn with_mcp_tool_call_thread_id_meta(
None => {
let mut map = serde_json::Map::new();
map.insert(
MCP_TOOL_THREAD_ID_META_KEY.to_string(),
MCP_THREAD_ID_META_KEY.to_string(),
serde_json::Value::String(thread_id.to_string()),
);
Some(serde_json::Value::Object(map))
@@ -121,6 +121,8 @@ use tokio::sync::Notify;
use tokio::task::JoinHandle;
use tokio::time::timeout;
#[path = "mcp_resource/caller_metadata.rs"]
mod caller_metadata;
#[path = "mcp_resource/plugin_metadata_refresh.rs"]
mod plugin_metadata_refresh;
@@ -170,22 +172,28 @@ async fn mcp_resource_read_returns_resource_contents() -> Result<()> {
..Default::default()
})
.await?;
for thread_id in [Some(thread.id), None] {
for target in [
None,
Some(McpResourceReadTarget {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
}),
Some(McpResourceReadTarget {
connector_id: "calendar".to_string(),
link_id: None,
}),
] {
let metadata_value = json!({"sequence": [1, 2]});
let targets = [
None,
Some(McpResourceReadTarget {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
}),
Some(McpResourceReadTarget {
connector_id: "calendar".to_string(),
link_id: None,
}),
];
for thread_id in [Some(thread.id.clone()), None] {
for target in &targets {
let read_response: McpResourceReadResponse = mcp
.request(|request_id| ClientRequest::McpResourceRead {
request_id,
params: McpResourceReadParams {
meta: Some(serde_json::Map::from_iter([(
"example/trace".into(),
metadata_value.clone(),
)])),
thread_id: thread_id.clone(),
origin_call_id: None,
server: "codex_apps".to_string(),
@@ -203,12 +211,13 @@ async fn mcp_resource_read_returns_resource_contents() -> Result<()> {
let metadata = metadata
.last()
.expect("resource read reached the MCP server");
assert_eq!(metadata.get("example/trace"), Some(&metadata_value));
assert_eq!(
(
metadata.get("connector_id").cloned(),
metadata.get("link_id").cloned()
),
match &target {
match target {
Some(target) => (
Some(json!(target.connector_id)),
Some(json!(target.link_id)),
@@ -219,6 +228,40 @@ async fn mcp_resource_read_returns_resource_contents() -> Result<()> {
}
}
let reads_before_invalid_requests = apps_server_calls.read_metadata.lock().unwrap().len();
let other_route = json!({"resource_uri": "/calendar/link_other/events"});
let [_, pinned, no_auth] = targets;
for (key, value, origin_call_id, target) in [
("connector_id", json!("spoofed"), None, None),
("link_id", json!("spoofed"), Some("call".into()), None),
("_codex_apps", other_route.clone(), None, pinned),
("_codex_apps", other_route, None, no_auth),
] {
let request_id = mcp
.send_mcp_resource_read_request(McpResourceReadParams {
thread_id: Some(thread.id.clone()),
origin_call_id,
meta: Some(serde_json::Map::from_iter([(key.into(), value)])),
server: "codex_apps".into(),
uri: TEST_RESOURCE_URI.into(),
connector_id: None,
target,
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert!(error.error.message.contains(key));
}
assert_eq!(
apps_server_calls.read_metadata.lock().unwrap().len(),
reads_before_invalid_requests
);
apps_server_handle.abort();
let _ = apps_server_handle.await;
Ok(())
@@ -271,6 +314,7 @@ async fn mcp_resource_read_preserves_protocol_errors(protocol: ProtocolVersion)
for thread_id in [Some(thread.id.clone()), None] {
let request_id = mcp
.send_mcp_resource_read_request(McpResourceReadParams {
meta: None,
thread_id,
origin_call_id: None,
server: server.to_string(),
@@ -487,6 +531,7 @@ apps = true
.request(|request_id| ClientRequest::McpResourceRead {
request_id,
params: McpResourceReadParams {
meta: None,
thread_id: None,
origin_call_id: None,
server: "codex_apps".to_string(),
@@ -501,6 +546,7 @@ apps = true
.request(|request_id| ClientRequest::McpResourceRead {
request_id,
params: McpResourceReadParams {
meta: None,
thread_id: None,
origin_call_id: None,
server: "codex_apps".to_string(),
@@ -543,6 +589,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
.request(ClientRequest::McpResourceRead {
request_id: RequestId::Integer(1),
params: McpResourceReadParams {
meta: None,
thread_id: Some("00000000-0000-4000-8000-000000000000".to_string()),
origin_call_id: None,
server: "codex_apps".to_string(),
@@ -670,6 +717,7 @@ async fn metadata_and_mcp_requests_complete_while_unrelated_resume_loads_config(
sender.request(ClientRequest::McpResourceRead {
request_id: RequestId::Integer(7),
params: McpResourceReadParams {
meta: None,
thread_id: Some(thread.id.clone()),
origin_call_id: None,
server: "resource_server".to_string(),
@@ -1403,7 +1451,7 @@ fn expected_resource_read_response() -> McpResourceReadResponse {
#[derive(Debug, Default)]
pub(super) struct ResourceAppsMcpCalls {
read_metadata: Mutex<Vec<serde_json::Map<String, serde_json::Value>>>,
pub(super) read_metadata: Mutex<Vec<serde_json::Map<String, serde_json::Value>>>,
list_resources: AtomicUsize,
main_prompt_reads: AtomicUsize,
reference_reads: AtomicUsize,
@@ -0,0 +1,91 @@
//! Public JSON-RPC coverage for caller metadata on a thread's stdio resource read.
use super::*;
use core_test_support::stdio_server_bin;
use pretty_assertions::assert_eq;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stdio_resource_receives_caller_metadata() -> Result<()> {
let responses_server = responses::start_mock_server().await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&responses_server.uri())
.with_extra_config(&format!(
"[mcp_servers.custom]\ncommand = {}",
toml::Value::String(stdio_server_bin()?),
))
.write(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let ThreadStartResponse { thread, .. } = mcp
.request(|request_id| ClientRequest::ThreadStart {
request_id,
params: ThreadStartParams::default(),
})
.await?;
let mut params = json!({
"threadId": thread.id,
"server": "custom",
"uri": "test://codex/request-metadata",
});
let metadata = json!({
"com.openai/codex-statusline-state": {
"config": {"statusline_items": ["model", "context-remaining"]},
"thread": {"id": thread.id, "name": null},
},
"connector_id": "custom-connector",
"link_id": "custom-link",
"_codex_apps": {"resource_uri": "custom://route"},
});
for caller_metadata in [None, Some(&metadata)] {
if let Some(meta) = caller_metadata {
params["_meta"] = meta.clone();
}
let request = mcp
.send_raw_request("mcpServer/resource/read", Some(params.clone()))
.await?;
let response: McpResourceReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request)).await??;
let [McpResourceContent::Text { text, .. }] = response.contents.as_slice() else {
anyhow::bail!("expected the metadata echoed by the stdio server");
};
let received: serde_json::Value = serde_json::from_str(text)?;
for key in metadata.as_object().unwrap().keys() {
assert_eq!(
received.get(key),
caller_metadata.and_then(|meta| meta.get(key))
);
}
}
for (invalid_meta, expected_message) in [
(json!({"threadId": "spoofed"}), "threadId"),
(
json!({"x-codex-turn-metadata": {}}),
"x-codex-turn-metadata",
),
(
json!({"codex/connectionLease": "spoofed"}),
"codex/connectionLease",
),
(json!([]), "map"),
] {
params["_meta"] = invalid_meta;
let request = mcp
.send_raw_request("mcpServer/resource/read", Some(params.clone()))
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert!(
error.error.message.contains(expected_message),
"{}",
error.error.message
);
}
Ok(())
}
@@ -247,6 +247,7 @@ async fn list_plugin_skills(
async fn read_mcp_skill_resource(app_server: &mut TestAppServer, thread_id: &str) -> Result<()> {
let request = app_server
.send_mcp_resource_read_request(McpResourceReadParams {
meta: None,
thread_id: Some(thread_id.to_string()),
origin_call_id: None,
server: "codex_apps".to_string(),
@@ -121,6 +121,16 @@ async fn widget_reads_survive_history_modes_compaction_restarts_and_app_only_vis
for (call_id, connector_id) in [("best-buy-call", "best_buy"), ("walmart-call", "walmart")]
{
let response = read_widget(&mut app_server, &thread.id, call_id).await?;
assert_eq!(
calls
.read_metadata
.lock()
.unwrap()
.last()
.unwrap()
.get("example/trace"),
Some(&json!(call_id))
);
assert_eq!(
response,
McpResourceReadResponse {
@@ -163,6 +173,7 @@ async fn widget_reads_survive_history_modes_compaction_restarts_and_app_only_vis
] {
let request_id = app_server
.send_mcp_resource_read_request(McpResourceReadParams {
meta: None,
thread_id,
origin_call_id: Some(call_id.to_string()),
server: "codex_apps".to_string(),
@@ -287,6 +298,10 @@ async fn read_widget(
.request(|request_id| ClientRequest::McpResourceRead {
request_id,
params: McpResourceReadParams {
meta: Some(serde_json::Map::from_iter([(
"example/trace".into(),
json!(call_id),
)])),
thread_id: Some(thread_id.to_string()),
origin_call_id: Some(call_id.to_string()),
server: "codex_apps".to_string(),
+2 -1
View File
@@ -39,7 +39,8 @@ use crate::tool_catalog_cache::McpToolCatalogCacheContext;
/// Opt-in resource lifecycle for negotiated stdio connections. Tool calls and resource reads
/// carry a host-owned lease ID. Catalog requests remain independent of a particular lease.
pub(crate) const LEASE_CAPABILITY: &str = "codex/connection-lease";
pub(crate) const LEASE_META_KEY: &str = "codex/connectionLease";
/// Request metadata set by the host when a connection lease is negotiated.
pub const LEASE_META_KEY: &str = "codex/connectionLease";
const LEASE_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone)]
+1
View File
@@ -10,6 +10,7 @@ pub use codex_rmcp_client::McpProtocolMode;
pub use connection_manager::NetworkPolicyMetadata;
pub use connection_manager::NetworkPolicyMetadataProvider;
pub use connection_manager::tool_is_model_visible;
pub use connection_pool::LEASE_META_KEY as MCP_CONNECTION_LEASE_META_KEY;
pub use connection_pool::McpConnectionPool;
pub use elicitation::ElicitationLifecycle;
pub use elicitation::ElicitationReviewRequest;
+10 -11
View File
@@ -249,9 +249,9 @@ impl ResourceOrigin {
&self,
binding: &McpBinding,
thread_id: ThreadId,
uri: &str,
mut params: ReadResourceRequestParams,
) -> anyhow::Result<ReadResourceResult> {
if self.uri != uri {
if self.uri != params.uri {
anyhow::bail!("originating MCP tool call does not match the requested resource");
}
if self.ambiguous_account {
@@ -297,20 +297,19 @@ impl ResourceOrigin {
anyhow::bail!("originating MCP tool is disabled by app configuration");
}
let meta = serde_json::from_value(serde_json::json!({
"threadId": thread_id,
"x-codex-turn-metadata": {
let meta = params.meta.get_or_insert_default();
meta.insert("threadId".into(), serde_json::json!(thread_id));
meta.insert(
"x-codex-turn-metadata".into(),
serde_json::json!({
"mcp_request_meta": {
"selected_connector_ids": [&self.connector_id],
"link_id": &self.link_id,
}
},
}))?;
}),
);
binding
.read_resource(
CODEX_APPS_MCP_SERVER_NAME,
ReadResourceRequestParams::new(uri).with_meta(meta),
)
.read_resource(CODEX_APPS_MCP_SERVER_NAME, params)
.await
}
}
+2 -2
View File
@@ -329,7 +329,7 @@ impl McpRuntime {
&self,
thread_id: ThreadId,
call_id: &str,
uri: &str,
params: ReadResourceRequestParams,
) -> anyhow::Result<ReadResourceResult> {
let origin = self
.resource_origins
@@ -341,7 +341,7 @@ impl McpRuntime {
.await
.ok_or_else(|| anyhow::anyhow!("codex_apps MCP server is unavailable"))?;
origin.read(&binding, thread_id, uri).await
origin.read(&binding, thread_id, params).await
}
pub async fn new(input: McpRuntimeInput) -> Self {
+2 -2
View File
@@ -1380,14 +1380,14 @@ impl CodexThread {
pub async fn read_mcp_resource_for_call(
&self,
call_id: &str,
uri: &str,
params: ReadResourceRequestParams,
) -> anyhow::Result<serde_json::Value> {
self.session.refresh_mcp_if_dirty().await;
let result = self
.session
.services
.mcp_runtime
.read_resource_for_call(self.session.thread_id, call_id, uri)
.read_resource_for_call(self.session.thread_id, call_id, params)
.await?;
Ok(serde_json::to_value(result)?)
@@ -697,8 +697,16 @@ impl ServerHandler for TestToolServer {
async fn read_resource(
&self,
ReadResourceRequestParams { uri, .. }: ReadResourceRequestParams,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<rmcp::model::ReadResourceResponse, McpError> {
if uri == "test://codex/request-metadata" {
let metadata = serde_json::Value::Object(context.meta.0.0);
return Ok(ReadResourceResult::new(vec![ResourceContents::text(
metadata.to_string(),
uri,
)])
.into());
}
if uri == MEMO_URI {
Ok(
ReadResourceResult::new(vec![ResourceContents::TextResourceContents {
@@ -9197,6 +9197,13 @@ class McpResourceReadParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
field_meta: Annotated[
dict[str, Any] | None,
Field(
alias="_meta",
description="Additional MCP request metadata. Account and turn routing keys are reserved.",
),
] = None
connector_id: Annotated[str | None, Field(alias="connectorId")] = None
origin_call_id: Annotated[
str | None,