mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
Generated
+1
@@ -1119,6 +1119,7 @@
|
||||
"group_0.13.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ff\",\"req\":\"^0.13\"},{\"name\":\"memuse\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"name\":\"rand_xorshift\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.2.1\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\"],\"tests\":[\"alloc\",\"rand\",\"rand_xorshift\"],\"wnaf-memuse\":[\"alloc\",\"memuse\"]}}",
|
||||
"gzip-header_1.0.0": "{\"dependencies\":[{\"name\":\"crc32fast\",\"req\":\"^1.2.1\"}],\"features\":{}}",
|
||||
"h2_0.4.13": "{\"dependencies\":[{\"name\":\"atomic-waker\",\"req\":\"^1.0.0\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"name\":\"slab\",\"req\":\"^0.4.2\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\",\"sync\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"features\":[\"codec\",\"io\"],\"name\":\"tokio-util\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"}],\"features\":{\"stream\":[],\"unstable\":[]}}",
|
||||
"h2_0.4.16": "{\"dependencies\":[{\"name\":\"atomic-waker\",\"req\":\"^1.0.0\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"name\":\"slab\",\"req\":\"^0.4.2\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\",\"sync\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"features\":[\"codec\",\"io\"],\"name\":\"tokio-util\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"}],\"features\":{\"stream\":[],\"unstable\":[]}}",
|
||||
"half_2.7.1": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.4.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.4.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"crunchy\",\"req\":\"^0.2.2\",\"target\":\"cfg(target_arch = \\\"spirv\\\")\"},{\"kind\":\"dev\",\"name\":\"crunchy\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.16\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"rand_distr\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"derive\",\"simd\"],\"name\":\"zerocopy\",\"req\":\"^0.8.26\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"nightly\":[],\"rand_distr\":[\"dep:rand\",\"dep:rand_distr\"],\"std\":[\"alloc\"],\"use-intrinsics\":[],\"zerocopy\":[]}}",
|
||||
"hash32_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"byteorder\",\"req\":\"^1.2.2\"},{\"kind\":\"dev\",\"name\":\"hash32-derive\",\"req\":\"^0.1.0\"}],\"features\":{}}",
|
||||
"hash32_0.3.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"byteorder\",\"req\":\"^1.2.2\"}],\"features\":{}}",
|
||||
|
||||
Generated
+2
-2
@@ -7687,9 +7687,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
|
||||
@@ -1912,6 +1912,13 @@
|
||||
},
|
||||
"McpResourceReadParams": {
|
||||
"properties": {
|
||||
"originCallId": {
|
||||
"description": "Originating MCP tool call used to select the resource's app.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"server": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
+14
@@ -13295,6 +13295,13 @@
|
||||
"McpResourceReadParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"originCallId": {
|
||||
"description": "Originating MCP tool call used to select the resource's app.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"server": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -13323,6 +13330,13 @@
|
||||
"$ref": "#/definitions/v2/ResourceContent"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"originCallId": {
|
||||
"description": "Originating call when the server applied app-specific resource scoping.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
+14
@@ -9525,6 +9525,13 @@
|
||||
"McpResourceReadParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"originCallId": {
|
||||
"description": "Originating MCP tool call used to select the resource's app.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"server": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -9553,6 +9560,13 @@
|
||||
"$ref": "#/definitions/ResourceContent"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"originCallId": {
|
||||
"description": "Originating call when the server applied app-specific resource scoping.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"originCallId": {
|
||||
"description": "Originating MCP tool call used to select the resource's app.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"server": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -59,6 +59,13 @@
|
||||
"$ref": "#/definitions/ResourceContent"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"originCallId": {
|
||||
"description": "Originating call when the server applied app-specific resource scoping.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+5
-1
@@ -2,4 +2,8 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type McpResourceReadParams = { threadId?: string | null, server: string, uri: string, };
|
||||
export type McpResourceReadParams = { threadId?: string | null,
|
||||
/**
|
||||
* Originating MCP tool call used to select the resource's app.
|
||||
*/
|
||||
originCallId?: string | null, server: string, uri: string, };
|
||||
|
||||
+5
-1
@@ -3,4 +3,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ResourceContent } from "../ResourceContent";
|
||||
|
||||
export type McpResourceReadResponse = { contents: Array<ResourceContent>, };
|
||||
export type McpResourceReadResponse = { contents: Array<ResourceContent>,
|
||||
/**
|
||||
* Originating call when the server applied app-specific resource scoping.
|
||||
*/
|
||||
originCallId: string | null, };
|
||||
|
||||
@@ -2328,6 +2328,7 @@ mod tests {
|
||||
request_id: request_id(),
|
||||
params: v2::McpResourceReadParams {
|
||||
thread_id: Some("thread-1".to_string()),
|
||||
origin_call_id: None,
|
||||
server: "server-a".to_string(),
|
||||
uri: "file:///tmp/resource".to_string(),
|
||||
},
|
||||
@@ -2515,6 +2516,7 @@ mod tests {
|
||||
request_id: request_id(),
|
||||
params: v2::McpResourceReadParams {
|
||||
thread_id: None,
|
||||
origin_call_id: None,
|
||||
server: "server-a".to_string(),
|
||||
uri: "file:///tmp/resource".to_string(),
|
||||
},
|
||||
|
||||
@@ -85,6 +85,9 @@ pub struct ListMcpServerStatusResponse {
|
||||
pub struct McpResourceReadParams {
|
||||
#[ts(optional = nullable)]
|
||||
pub thread_id: Option<String>,
|
||||
/// Originating MCP tool call used to select the resource's app.
|
||||
#[ts(optional = nullable)]
|
||||
pub origin_call_id: Option<String>,
|
||||
pub server: String,
|
||||
pub uri: String,
|
||||
}
|
||||
@@ -94,6 +97,8 @@ pub struct McpResourceReadParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct McpResourceReadResponse {
|
||||
pub contents: Vec<McpResourceContent>,
|
||||
/// Originating call when the server applied app-specific resource scoping.
|
||||
pub origin_call_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
|
||||
@@ -278,7 +278,7 @@ Example with notification opt-out:
|
||||
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
|
||||
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
|
||||
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, owning `pluginId` (`null` for servers not contributed by a plugin), plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. An `unknown` auth status means OAuth support could not be determined; `unsupported` means OAuth is known not to be supported.
|
||||
- `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. If `threadId` is omitted, the server reads from the latest MCP config directly.
|
||||
- `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. Pass `originCallId` with `threadId` to scope a Codex app widget to the app and account of the completed tool call that produced it; successful scoped reads return the same `originCallId`. If `threadId` is omitted, the server reads from the latest MCP config directly.
|
||||
- `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result.
|
||||
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
|
||||
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
@@ -36,11 +37,14 @@ use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::BooleanSchema;
|
||||
use rmcp::model::CallToolRequestParams;
|
||||
use rmcp::model::CallToolResult;
|
||||
use rmcp::model::ElicitRequestParams;
|
||||
use rmcp::model::ElicitResult;
|
||||
use rmcp::model::ElicitationAction;
|
||||
use rmcp::model::ElicitationSchema;
|
||||
use rmcp::model::ListResourcesResult;
|
||||
use rmcp::model::ListToolsResult;
|
||||
use rmcp::model::MetaObject;
|
||||
use rmcp::model::PaginatedRequestParams;
|
||||
use rmcp::model::PrimitiveSchemaDefinition;
|
||||
@@ -62,8 +66,9 @@ use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const TEST_RESOURCE_URI: &str = "test://codex/resource";
|
||||
pub(super) const TEST_WIDGET_RESOURCE_URI: &str = "ui://widget/checkout-session.html";
|
||||
const TEST_BLOB_RESOURCE_URI: &str = "test://codex/resource.bin";
|
||||
const TEST_RESOURCE_BLOB: &str = "YmluYXJ5LXJlc291cmNl";
|
||||
const TEST_RESOURCE_TEXT: &str = "Resource body from the MCP server.";
|
||||
@@ -116,6 +121,7 @@ async fn mcp_resource_read_returns_resource_contents() -> Result<()> {
|
||||
request_id,
|
||||
params: McpResourceReadParams {
|
||||
thread_id: Some(thread.id),
|
||||
origin_call_id: None,
|
||||
server: "codex_apps".to_string(),
|
||||
uri: TEST_RESOURCE_URI.to_string(),
|
||||
},
|
||||
@@ -588,6 +594,7 @@ apps = true
|
||||
request_id,
|
||||
params: McpResourceReadParams {
|
||||
thread_id: None,
|
||||
origin_call_id: None,
|
||||
server: "codex_apps".to_string(),
|
||||
uri: TEST_RESOURCE_URI.to_string(),
|
||||
},
|
||||
@@ -599,6 +606,7 @@ apps = true
|
||||
request_id,
|
||||
params: McpResourceReadParams {
|
||||
thread_id: None,
|
||||
origin_call_id: None,
|
||||
server: "codex_apps".to_string(),
|
||||
uri: TEST_ELICITATION_RESOURCE_URI.to_string(),
|
||||
},
|
||||
@@ -613,6 +621,7 @@ apps = true
|
||||
text: TEST_ELICITATION_RESOURCE_TEXT.to_string(),
|
||||
meta: None,
|
||||
}],
|
||||
origin_call_id: None,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -665,6 +674,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: McpResourceReadParams {
|
||||
thread_id: Some("00000000-0000-4000-8000-000000000000".to_string()),
|
||||
origin_call_id: None,
|
||||
server: "codex_apps".to_string(),
|
||||
uri: TEST_RESOURCE_URI.to_string(),
|
||||
},
|
||||
@@ -684,7 +694,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_resource_test_app_server(
|
||||
pub(super) async fn start_resource_test_app_server(
|
||||
apps_server_url: &str,
|
||||
responses_server_uri: &str,
|
||||
environment: ResourceTestEnvironment,
|
||||
@@ -734,12 +744,12 @@ async fn start_resource_test_app_server_with_extra_config(
|
||||
Ok((codex_home, mcp))
|
||||
}
|
||||
|
||||
enum ResourceTestEnvironment {
|
||||
pub(super) enum ResourceTestEnvironment {
|
||||
Auto,
|
||||
Local,
|
||||
}
|
||||
|
||||
async fn start_resource_apps_mcp_server()
|
||||
pub(super) async fn start_resource_apps_mcp_server()
|
||||
-> Result<(String, Arc<ResourceAppsMcpCalls>, JoinHandle<()>)> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let addr = listener.local_addr()?;
|
||||
@@ -780,14 +790,17 @@ fn expected_resource_read_response() -> McpResourceReadResponse {
|
||||
meta: None,
|
||||
},
|
||||
],
|
||||
origin_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ResourceAppsMcpCalls {
|
||||
pub(super) struct ResourceAppsMcpCalls {
|
||||
list_resources: AtomicUsize,
|
||||
main_prompt_reads: AtomicUsize,
|
||||
reference_reads: AtomicUsize,
|
||||
pub(super) tools_enabled: AtomicBool,
|
||||
pub(super) best_buy_app_only: AtomicBool,
|
||||
}
|
||||
|
||||
impl ResourceAppsMcpCalls {
|
||||
@@ -814,8 +827,70 @@ struct ResourceAppsMcpServer {
|
||||
|
||||
impl ServerHandler for ResourceAppsMcpServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_resources().build())
|
||||
.with_protocol_version(ProtocolVersion::V_2025_06_18)
|
||||
let mut info = ServerInfo::new(ServerCapabilities::builder().enable_resources().build())
|
||||
.with_protocol_version(ProtocolVersion::V_2025_06_18);
|
||||
if self.calls.tools_enabled.load(Ordering::Relaxed) {
|
||||
info.capabilities.tools = Some(Default::default());
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, rmcp::ErrorData> {
|
||||
let tools = ["best_buy", "walmart"]
|
||||
.into_iter()
|
||||
.map(|connector_id| {
|
||||
let mut ui = json!({ "resourceUri": TEST_WIDGET_RESOURCE_URI });
|
||||
if connector_id == "best_buy"
|
||||
&& self.calls.best_buy_app_only.load(Ordering::Relaxed)
|
||||
{
|
||||
ui["visibility"] = json!(["app"]);
|
||||
}
|
||||
serde_json::from_value(json!({
|
||||
"name": format!("{connector_id}_product_search"),
|
||||
"description": "Search products.",
|
||||
"inputSchema": { "type": "object" },
|
||||
"annotations": { "readOnlyHint": true },
|
||||
"_meta": {
|
||||
"connector_id": connector_id,
|
||||
"connector_name": connector_id,
|
||||
"link_id": format!("link_{connector_id}"),
|
||||
"ui": ui,
|
||||
"openai/outputTemplate": TEST_WIDGET_RESOURCE_URI,
|
||||
"_codex_apps": {
|
||||
"resource_uri": format!(
|
||||
"/{connector_id}/link_{connector_id}/{connector_id}_product_search"
|
||||
),
|
||||
"contains_mcp_source": true,
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
.collect::<serde_json::Result<Vec<_>>>()
|
||||
.map_err(|error| rmcp::ErrorData::internal_error(error.to_string(), None))?;
|
||||
Ok(ListToolsResult::with_all_items(tools))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
|
||||
if request
|
||||
.arguments
|
||||
.as_ref()
|
||||
.and_then(|arguments| arguments.get("query"))
|
||||
== Some(&json!("fail"))
|
||||
{
|
||||
return Ok(
|
||||
CallToolResult::structured_error(json!({ "error": "search failed" })).into(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(CallToolResult::structured(json!({ "products": [] })).into())
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
@@ -868,6 +943,36 @@ impl ServerHandler for ResourceAppsMcpServer {
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<rmcp::model::ReadResourceResponse, rmcp::ErrorData> {
|
||||
let uri = request.uri;
|
||||
if uri == TEST_WIDGET_RESOURCE_URI {
|
||||
let request_meta = context
|
||||
.meta
|
||||
.0
|
||||
.0
|
||||
.get("x-codex-turn-metadata")
|
||||
.and_then(|metadata| metadata.get("mcp_request_meta"));
|
||||
let connector_id = request_meta
|
||||
.and_then(|metadata| metadata.pointer("/selected_connector_ids/0"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| rmcp::ErrorData::invalid_params("missing app scope", None))?;
|
||||
let expected_link_id = format!("link_{connector_id}");
|
||||
if request_meta
|
||||
.and_then(|metadata| metadata.get("link_id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
!= Some(expected_link_id.as_str())
|
||||
{
|
||||
return Err(rmcp::ErrorData::invalid_params("wrong account scope", None));
|
||||
}
|
||||
|
||||
return Ok(
|
||||
ReadResourceResult::new(vec![ResourceContents::TextResourceContents {
|
||||
uri,
|
||||
mime_type: Some("text/html".to_string()),
|
||||
text: format!("<html>{connector_id}</html>"),
|
||||
meta: None,
|
||||
}])
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if uri == TEST_ELICITATION_RESOURCE_URI {
|
||||
let requested_schema = ElicitationSchema::builder()
|
||||
.required_property(
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
use super::mcp_resource::DEFAULT_READ_TIMEOUT;
|
||||
use super::mcp_resource::ResourceTestEnvironment;
|
||||
use super::mcp_resource::TEST_WIDGET_RESOURCE_URI;
|
||||
use super::mcp_resource::start_resource_apps_mcp_server;
|
||||
use super::mcp_resource::start_resource_test_app_server;
|
||||
use anyhow::Result;
|
||||
use app_test_support::TestAppServer;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::McpResourceContent;
|
||||
use codex_app_server_protocol::McpResourceReadParams;
|
||||
use codex_app_server_protocol::McpResourceReadResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadHistoryMode;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn widget_reads_survive_history_modes_restarts_and_app_only_visibility() -> Result<()> {
|
||||
let responses_server = responses::start_mock_server().await;
|
||||
let (apps_server_url, calls, apps_server_handle) = start_resource_apps_mcp_server().await?;
|
||||
calls.tools_enabled.store(true, Ordering::Relaxed);
|
||||
let (codex_home, mut app_server) = start_resource_test_app_server(
|
||||
&apps_server_url,
|
||||
&responses_server.uri(),
|
||||
ResourceTestEnvironment::Auto,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut tool_events = vec![responses::ev_response_created("widget-tools")];
|
||||
tool_events.extend(
|
||||
[
|
||||
("best-buy-call", "best_buy", "lamps", "link_best_buy"),
|
||||
("walmart-call", "walmart", "lamps", "link_walmart"),
|
||||
("failed-call", "walmart", "fail", "link_walmart"),
|
||||
("ambiguous-account-call", "walmart", "lamps", "link_other"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(call_id, app, query, link_id)| {
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&format!("mcp__codex_apps__{app}"),
|
||||
"_product_search",
|
||||
&json!({ "query": query, "link_id": link_id }).to_string(),
|
||||
)
|
||||
}),
|
||||
);
|
||||
tool_events.push(responses::ev_completed("widget-tools"));
|
||||
let response = [
|
||||
responses::sse(tool_events),
|
||||
responses::sse(vec![responses::ev_completed("widget-done")]),
|
||||
];
|
||||
let mut model_responses = std::iter::repeat_n(response, 4)
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
model_responses.push(responses::sse(vec![responses::ev_completed(
|
||||
"app-only-visibility",
|
||||
)]));
|
||||
let response_mock = responses::mount_sse_sequence(&responses_server, model_responses).await;
|
||||
|
||||
let mut persistent_thread_ids = Vec::new();
|
||||
for (history_mode, ephemeral) in [
|
||||
(ThreadHistoryMode::Legacy, false),
|
||||
(ThreadHistoryMode::Paginated, false),
|
||||
(ThreadHistoryMode::Legacy, true),
|
||||
(ThreadHistoryMode::Paginated, true),
|
||||
] {
|
||||
let ThreadStartResponse { thread, .. } = app_server
|
||||
.start_thread(ThreadStartParams {
|
||||
history_mode: Some(history_mode),
|
||||
ephemeral: ephemeral.then_some(true),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_id = app_server
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![UserInput::Text {
|
||||
text: "Find a lamp.".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let _: TurnStartResponse =
|
||||
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(turn_id)).await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
app_server.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
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!(
|
||||
response,
|
||||
McpResourceReadResponse {
|
||||
contents: vec![McpResourceContent::Text {
|
||||
uri: TEST_WIDGET_RESOURCE_URI.to_string(),
|
||||
mime_type: Some("text/html".to_string()),
|
||||
text: format!("<html>{connector_id}</html>"),
|
||||
meta: None,
|
||||
}],
|
||||
origin_call_id: Some(call_id.to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
for (thread_id, call_id, uri, expected_error) in [
|
||||
(
|
||||
Some(thread.id.clone()),
|
||||
"failed-call",
|
||||
TEST_WIDGET_RESOURCE_URI,
|
||||
"was not found",
|
||||
),
|
||||
(
|
||||
Some(thread.id.clone()),
|
||||
"walmart-call",
|
||||
"ui://widget/wrong.html",
|
||||
"does not match",
|
||||
),
|
||||
(
|
||||
Some(thread.id.clone()),
|
||||
"ambiguous-account-call",
|
||||
TEST_WIDGET_RESOURCE_URI,
|
||||
"ambiguous account",
|
||||
),
|
||||
(
|
||||
None,
|
||||
"walmart-call",
|
||||
TEST_WIDGET_RESOURCE_URI,
|
||||
"requires threadId",
|
||||
),
|
||||
] {
|
||||
let request_id = app_server
|
||||
.send_mcp_resource_read_request(McpResourceReadParams {
|
||||
thread_id,
|
||||
origin_call_id: Some(call_id.to_string()),
|
||||
server: "codex_apps".to_string(),
|
||||
uri: uri.to_string(),
|
||||
})
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
app_server.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert!(
|
||||
error.error.message.contains(expected_error),
|
||||
"expected {expected_error:?}, got: {error:?}"
|
||||
);
|
||||
}
|
||||
if !ephemeral {
|
||||
persistent_thread_ids.push(thread.id);
|
||||
}
|
||||
}
|
||||
|
||||
timeout(DEFAULT_READ_TIMEOUT, app_server.shutdown_gracefully()).await??;
|
||||
calls.best_buy_app_only.store(true, Ordering::Relaxed);
|
||||
let mut restarted = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized()
|
||||
.await?;
|
||||
let model_visibility_thread_id = persistent_thread_ids[0].clone();
|
||||
for thread_id in persistent_thread_ids {
|
||||
let resume_id = restarted
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let ThreadResumeResponse { thread, .. } =
|
||||
timeout(DEFAULT_READ_TIMEOUT, restarted.read_response(resume_id)).await??;
|
||||
for call_id in ["walmart-call", "best-buy-call"] {
|
||||
let response = read_widget(&mut restarted, &thread.id, call_id).await?;
|
||||
assert_eq!(response.origin_call_id.as_deref(), Some(call_id));
|
||||
}
|
||||
}
|
||||
|
||||
let turn_id = restarted
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: model_visibility_thread_id,
|
||||
input: vec![UserInput::Text {
|
||||
text: "Find another lamp.".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let _: TurnStartResponse =
|
||||
timeout(DEFAULT_READ_TIMEOUT, restarted.read_response(turn_id)).await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
restarted.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
let requests = response_mock.requests();
|
||||
let model_request = requests.last().expect("post-resume model request");
|
||||
assert!(
|
||||
model_request
|
||||
.tool_by_name("mcp__codex_apps__best_buy", "_product_search")
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
model_request
|
||||
.tool_by_name("mcp__codex_apps__walmart", "_product_search")
|
||||
.is_some()
|
||||
);
|
||||
|
||||
apps_server_handle.abort();
|
||||
let _ = apps_server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_widget(
|
||||
app_server: &mut TestAppServer,
|
||||
thread_id: &str,
|
||||
call_id: &str,
|
||||
) -> Result<McpResourceReadResponse> {
|
||||
app_server
|
||||
.request(|request_id| ClientRequest::McpResourceRead {
|
||||
request_id,
|
||||
params: McpResourceReadParams {
|
||||
thread_id: Some(thread_id.to_string()),
|
||||
origin_call_id: Some(call_id.to_string()),
|
||||
server: "codex_apps".to_string(),
|
||||
uri: TEST_WIDGET_RESOURCE_URI.to_string(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -42,6 +42,7 @@ mod marketplace_add;
|
||||
mod marketplace_remove;
|
||||
mod marketplace_upgrade;
|
||||
mod mcp_resource;
|
||||
mod mcp_resource_origin;
|
||||
mod mcp_server_elicitation;
|
||||
mod mcp_server_status;
|
||||
mod mcp_tool;
|
||||
|
||||
@@ -75,15 +75,23 @@ impl McpBinding {
|
||||
self.plugins_available
|
||||
}
|
||||
|
||||
/// Returns the frozen catalog captured for this binding.
|
||||
/// Returns the frozen model-visible catalog captured for this binding.
|
||||
pub fn tools(&self) -> &[ToolInfo] {
|
||||
&self.tools
|
||||
}
|
||||
|
||||
/// Binds a call to the exact client and metadata advertised by this binding.
|
||||
/// Returns permitted tool metadata, including app-only tools.
|
||||
pub fn tool_info(&self, server: &str, tool: &str) -> Option<&ToolInfo> {
|
||||
self.calls
|
||||
.get(&(server.to_string(), tool.to_string()))
|
||||
.map(PreparedMcpCall::tool_info)
|
||||
}
|
||||
|
||||
/// Binds a model-visible call to the exact client and metadata in this binding.
|
||||
pub fn prepare_call(&self, server: &str, tool: &str) -> Option<PreparedMcpCall> {
|
||||
self.calls
|
||||
.get(&(server.to_string(), tool.to_string()))
|
||||
.filter(|call| crate::tool_is_model_visible(call.tool_info()))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
|
||||
@@ -288,11 +288,11 @@ impl McpConnectionSet {
|
||||
let mut tools = Vec::with_capacity(listed_tools.len());
|
||||
let mut calls = std::collections::HashMap::with_capacity(listed_tools.len());
|
||||
for tool_info in listed_tools {
|
||||
if !crate::tool_is_model_visible(&tool_info) {
|
||||
continue;
|
||||
}
|
||||
let model_visible = crate::tool_is_model_visible(&tool_info);
|
||||
let Some(client) = clients.client(&tool_info.server_name) else {
|
||||
tools.push(tool_info);
|
||||
if model_visible {
|
||||
tools.push(tool_info);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(call) = self.prepare_call(&tool_info, client, Arc::clone(&config), *revision)
|
||||
@@ -311,7 +311,9 @@ impl McpConnectionSet {
|
||||
),
|
||||
call,
|
||||
);
|
||||
tools.push(tool_info);
|
||||
if model_visible {
|
||||
tools.push(tool_info);
|
||||
}
|
||||
}
|
||||
McpBinding::new(
|
||||
Arc::clone(self),
|
||||
|
||||
@@ -102,6 +102,7 @@ mod openai_docs_source_attribution;
|
||||
mod pagination;
|
||||
mod plugin_config;
|
||||
mod resource_client;
|
||||
mod resource_origin;
|
||||
pub(crate) mod rmcp_client;
|
||||
pub(crate) mod runtime;
|
||||
pub(crate) mod server;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Thread-owned, bounded provenance for app-hosted widget resources.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use anyhow::Context;
|
||||
use codex_connectors::AppToolPolicyEvaluator;
|
||||
use codex_connectors::AppToolPolicyInput;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::items::McpToolCallStatus;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use rmcp::model::ReadResourceRequestParams;
|
||||
use rmcp::model::ReadResourceResult;
|
||||
|
||||
use crate::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::McpBinding;
|
||||
|
||||
const MAX_ORIGINS: usize = 64;
|
||||
const MAX_ORIGIN_BYTES: usize = 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ResourceOrigins {
|
||||
origins: VecDeque<ResourceOrigin>,
|
||||
turns: VecDeque<String>,
|
||||
current_turn_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ResourceOrigin {
|
||||
call_id: String,
|
||||
turn_id: Option<String>,
|
||||
tool: String,
|
||||
connector_id: String,
|
||||
link_id: Option<String>,
|
||||
uri: String,
|
||||
ambiguous_account: bool,
|
||||
}
|
||||
|
||||
impl ResourceOrigins {
|
||||
pub(crate) fn observe(&mut self, event: &EventMsg) {
|
||||
match event {
|
||||
EventMsg::TurnStarted(event) if event.turn_id.len() <= MAX_ORIGIN_BYTES => {
|
||||
self.current_turn_id = Some(event.turn_id.clone());
|
||||
if self.turns.back() != Some(&event.turn_id) {
|
||||
self.turns.push_back(event.turn_id.clone());
|
||||
if self.turns.len() > MAX_ORIGINS {
|
||||
self.turns.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
EventMsg::ItemCompleted(event) => {
|
||||
let TurnItem::McpToolCall(item) = &event.item else {
|
||||
return;
|
||||
};
|
||||
if item.status == McpToolCallStatus::Completed {
|
||||
self.remember(
|
||||
&item.id,
|
||||
Some(&event.turn_id),
|
||||
&item.server,
|
||||
&item.tool,
|
||||
&item.arguments,
|
||||
item.connector_id.as_deref(),
|
||||
item.link_id.as_deref(),
|
||||
item.mcp_app_resource_uri.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
EventMsg::McpToolCallEnd(event)
|
||||
if event
|
||||
.result
|
||||
.as_ref()
|
||||
.is_ok_and(|result| !result.is_error.unwrap_or(false)) =>
|
||||
{
|
||||
let turn_id = self.current_turn_id.clone();
|
||||
self.remember(
|
||||
&event.call_id,
|
||||
turn_id.as_deref(),
|
||||
&event.invocation.server,
|
||||
&event.invocation.tool,
|
||||
event
|
||||
.invocation
|
||||
.arguments
|
||||
.as_ref()
|
||||
.unwrap_or(&serde_json::Value::Null),
|
||||
event.connector_id.as_deref(),
|
||||
event.link_id.as_deref(),
|
||||
event.mcp_app_resource_uri.as_deref(),
|
||||
);
|
||||
}
|
||||
EventMsg::ThreadRolledBack(event) => {
|
||||
for _ in 0..event.num_turns {
|
||||
let Some(turn_id) = self.turns.pop_back() else {
|
||||
*self = Self::default();
|
||||
return;
|
||||
};
|
||||
self.origins.retain(|origin| {
|
||||
origin.turn_id.is_some()
|
||||
&& origin.turn_id.as_deref() != Some(turn_id.as_str())
|
||||
});
|
||||
}
|
||||
self.current_turn_id = self.turns.back().cloned();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find(&self, call_id: &str) -> anyhow::Result<ResourceOrigin> {
|
||||
self.origins
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|origin| origin.call_id == call_id)
|
||||
.cloned()
|
||||
.context("originating MCP tool call was not found or did not complete successfully")
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "only bounded provenance fields are retained"
|
||||
)]
|
||||
fn remember(
|
||||
&mut self,
|
||||
call_id: &str,
|
||||
turn_id: Option<&str>,
|
||||
server: &str,
|
||||
tool: &str,
|
||||
arguments: &serde_json::Value,
|
||||
connector_id: Option<&str>,
|
||||
link_id: Option<&str>,
|
||||
uri: Option<&str>,
|
||||
) {
|
||||
if server != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return;
|
||||
}
|
||||
let Some(connector_id) = connector_id.filter(|value| !value.trim().is_empty()) else {
|
||||
return;
|
||||
};
|
||||
let Some(uri) = uri else {
|
||||
return;
|
||||
};
|
||||
let link_id = link_id.filter(|value| !value.trim().is_empty());
|
||||
let ambiguous_account = arguments
|
||||
.get("link_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.is_some_and(|argument_link_id| Some(argument_link_id) != link_id);
|
||||
let origin = ResourceOrigin {
|
||||
call_id: call_id.to_owned(),
|
||||
turn_id: turn_id.map(str::to_owned),
|
||||
tool: tool.to_owned(),
|
||||
connector_id: connector_id.to_owned(),
|
||||
link_id: link_id.map(str::to_owned),
|
||||
uri: uri.to_owned(),
|
||||
ambiguous_account,
|
||||
};
|
||||
if origin.byte_len() > MAX_ORIGIN_BYTES {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(index) = self
|
||||
.origins
|
||||
.iter()
|
||||
.position(|existing| existing.call_id == origin.call_id)
|
||||
{
|
||||
self.origins.remove(index);
|
||||
}
|
||||
if self.origins.len() >= MAX_ORIGINS {
|
||||
self.origins.pop_front();
|
||||
}
|
||||
self.origins.push_back(origin);
|
||||
}
|
||||
}
|
||||
|
||||
impl ResourceOrigin {
|
||||
fn byte_len(&self) -> usize {
|
||||
self.call_id.len()
|
||||
+ self.turn_id.as_ref().map_or(0, String::len)
|
||||
+ self.tool.len()
|
||||
+ self.connector_id.len()
|
||||
+ self.link_id.as_ref().map_or(0, String::len)
|
||||
+ self.uri.len()
|
||||
}
|
||||
|
||||
pub(crate) async fn read(
|
||||
&self,
|
||||
binding: &McpBinding,
|
||||
thread_id: ThreadId,
|
||||
uri: &str,
|
||||
) -> anyhow::Result<ReadResourceResult> {
|
||||
if self.uri != uri {
|
||||
anyhow::bail!("originating MCP tool call does not match the requested resource");
|
||||
}
|
||||
if self.ambiguous_account {
|
||||
anyhow::bail!("originating MCP tool call has ambiguous account selection");
|
||||
}
|
||||
|
||||
let tool_info = binding
|
||||
.tool_info(CODEX_APPS_MCP_SERVER_NAME, &self.tool)
|
||||
.context("originating MCP tool is unavailable")?;
|
||||
if tool_info.connector_id.as_deref() != Some(self.connector_id.as_str()) {
|
||||
anyhow::bail!("originating MCP tool connector does not match its app context");
|
||||
}
|
||||
let tool_meta = tool_info.tool.meta.as_ref().map(|meta| &meta.0);
|
||||
let current_link_id = tool_meta
|
||||
.and_then(|meta| meta.get("link_id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
if current_link_id != self.link_id.as_deref() {
|
||||
anyhow::bail!("originating MCP tool link does not match its app context");
|
||||
}
|
||||
if self.link_id.is_none()
|
||||
&& tool_meta
|
||||
.and_then(|meta| meta.get("_codex_apps"))
|
||||
.and_then(|meta| meta.get("requires_explicit_link_id"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
== Some(true)
|
||||
{
|
||||
anyhow::bail!("originating MCP tool requires an explicit account link");
|
||||
}
|
||||
|
||||
let annotations = tool_info.tool.annotations.as_ref();
|
||||
if !AppToolPolicyEvaluator::new(&binding.config().config_layer_stack)
|
||||
.policy(AppToolPolicyInput {
|
||||
connector_id: Some(&self.connector_id),
|
||||
tool_name: tool_info.tool.name.as_ref(),
|
||||
tool_title: tool_info.tool.title.as_deref(),
|
||||
destructive_hint: annotations.and_then(|value| value.destructive_hint),
|
||||
open_world_hint: annotations.and_then(|value| value.open_world_hint),
|
||||
})
|
||||
.enabled
|
||||
{
|
||||
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": {
|
||||
"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),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,13 @@ use codex_exec_server::HttpClient;
|
||||
use codex_exec_server::RouteAwareHttpClient;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::mcp::ClientMcpExtensions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::with_http_headers_helper;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
@@ -46,6 +48,7 @@ use crate::connection_manager::McpConnectionSet;
|
||||
use crate::elicitation::ElicitationLifecycle;
|
||||
use crate::elicitation::ElicitationRequestRouter;
|
||||
use crate::elicitation::ElicitationReviewerHandle;
|
||||
use crate::resource_origin::ResourceOrigins;
|
||||
use crate::server::EffectiveMcpServer;
|
||||
use crate::tool_catalog_cache::McpToolCatalogCache;
|
||||
use crate::tools::ToolInfo;
|
||||
@@ -88,6 +91,7 @@ pub struct McpRuntime {
|
||||
current: ArcSwap<PublishedMcpRuntime>,
|
||||
reconnect_pending: AtomicBool,
|
||||
elicitation_router: ElicitationRequestRouter,
|
||||
resource_origins: Mutex<ResourceOrigins>,
|
||||
}
|
||||
|
||||
struct PublishedMcpRuntime {
|
||||
@@ -171,9 +175,47 @@ impl McpRuntime {
|
||||
}),
|
||||
reconnect_pending: AtomicBool::new(false),
|
||||
elicitation_router: ElicitationRequestRouter::default(),
|
||||
resource_origins: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates this thread's bounded resource provenance from a live or restored event.
|
||||
pub fn observe_event(&self, event: &EventMsg) {
|
||||
if !matches!(
|
||||
event,
|
||||
EventMsg::TurnStarted(_)
|
||||
| EventMsg::ItemCompleted(_)
|
||||
| EventMsg::McpToolCallEnd(_)
|
||||
| EventMsg::ThreadRolledBack(_)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
self.resource_origins
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.observe(event);
|
||||
}
|
||||
|
||||
/// Reads a widget through the current binding of the app tool that produced it.
|
||||
pub async fn read_resource_for_call(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
call_id: &str,
|
||||
uri: &str,
|
||||
) -> anyhow::Result<ReadResourceResult> {
|
||||
let origin = self
|
||||
.resource_origins
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.find(call_id)?;
|
||||
let binding = self
|
||||
.current_binding_for_call(crate::CODEX_APPS_MCP_SERVER_NAME)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("codex_apps MCP server is unavailable"))?;
|
||||
|
||||
origin.read(&binding, thread_id, uri).await
|
||||
}
|
||||
|
||||
pub async fn new(input: McpRuntimeInput) -> Self {
|
||||
let runtime = Self::empty(input.config.prefix_mcp_tool_names);
|
||||
runtime.replace(input).await;
|
||||
|
||||
@@ -747,6 +747,23 @@ impl CodexThread {
|
||||
Ok(serde_json::to_value(result)?)
|
||||
}
|
||||
|
||||
/// Reads an app resource using the current authority of its originating tool call.
|
||||
pub async fn read_mcp_resource_for_call(
|
||||
&self,
|
||||
call_id: &str,
|
||||
uri: &str,
|
||||
) -> 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)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::to_value(result)?)
|
||||
}
|
||||
|
||||
pub async fn call_mcp_tool(
|
||||
&self,
|
||||
server: &str,
|
||||
|
||||
@@ -2129,6 +2129,7 @@ impl Session {
|
||||
}
|
||||
|
||||
async fn send_event_raw_with_persistence(&self, event: Event, persist: bool) {
|
||||
self.services.mcp_runtime.observe_event(&event.msg);
|
||||
// Persist the event into rollout storage; the store applies its persistence policy.
|
||||
if persist {
|
||||
let rollout_items = vec![RolloutItem::EventMsg(event.msg.clone())];
|
||||
|
||||
@@ -1223,6 +1223,11 @@ impl Session {
|
||||
let mcp_runtime = Arc::new(McpRuntime::empty(
|
||||
mcp_projection.config.prefix_mcp_tool_names,
|
||||
));
|
||||
for item in initial_history.get_rollout_items() {
|
||||
if let RolloutItem::EventMsg(event) = item {
|
||||
mcp_runtime.observe_event(event);
|
||||
}
|
||||
}
|
||||
let session_extension_data =
|
||||
codex_extension_api::ExtensionData::new(session_id.to_string());
|
||||
session_extension_data.insert(analytics_events_client.clone());
|
||||
|
||||
Reference in New Issue
Block a user