MCP-Resource工具
MCP Resource 与 MCP Tool 的职责不同:Tool 是调用远端动作,Resource 是列出或读取远端数据。Codex 没有把 resources/list 直接暴露给模型,而是提供三个普通 function tool:list_mcp_resources、list_mcp_resource_templates 和 read_mcp_resource。它们共享 server 访问检查、参数归一、生命周期 item 和模型输出截断,但在分页与聚合上有不同约束。
本文面向已经读过MCP-Tool处理器、ToolOutput与错误模型和ToolRouter解析与分派的读者。本文只研究这三个内建 resource handler 从 registry 到 MCP client 的调用链,不展开 Skills/Plugins 如何使用资源内容,也不把 App Server 直接的 mcp/resource/read RPC 当成模型工具调用。读完后,读者应能解释 list 与 read 的参数差异、为什么跨 server list 不能继续分页、资源输出如何加上 server 字段,以及错误发生在模型参数、server 访问还是 MCP transport 哪一层。
1. 工具注册
1.1 三个规格
三个 handler 都是顶层 Function spec,不属于 MCP namespace,也不通过 ToolSearch 延迟发现。list 两项的参数都是可选 server/cursor;read 的 server 和 uri 都必填。
源码位置:codex-rs/core/src/tools/handlers/mcp_resource_spec.rs :: create_*_mcp_resource_tool
pub fn create_list_mcp_resources_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"server".to_string(),
JsonSchema::string(Some(
"MCP server name. Omit to list resources from every configured server.".to_string(),
)),
),
(
"cursor".to_string(),
JsonSchema::string(Some(
"Opaque cursor from a previous list_mcp_resources call; omit for the first page."
.to_string(),
)),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "list_mcp_resources".to_string(),
description: "Lists resources provided by MCP servers.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
output_schema: None,
})
}read 的 schema 明确告诉模型:server 必须与 list 输出中的 server 字段一致,uri 必须来自 list 返回的 URI。这个约束是模型导航提示,不是 handler 的跨调用状态表;read handler 不保存前一次 list 的结果。
1.2 Environment gate
资源工具不是无条件注册。add_mcp_resource_tools 只有在当前 McpBinding 报告至少有一个 server 时,才把三个 handler 放入 registry。没有 server 时,模型既看不到工具,也不会收到“没有资源”的运行时错误。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: add_mcp_resource_tools
fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistry) {
if context.mcp.has_servers() {
registry.add(ListMcpResourcesHandler);
registry.add(ListMcpResourceTemplatesHandler);
registry.add(ReadMcpResourceHandler);
}
}此 gate 与 MCP Tool 的“工具被 server 暴露”不同:resource client 可以访问 server 的资源列表,即使该 server 没有任何可调用 tool。调试时应检查 McpBinding::has_servers(),不能只看 model-visible MCP tool 数量。
1.3 与MCP Tool区别
MCP Tool handler 需要 ToolInfo、approval metadata、prepared call 和 wait_until_ready;resource handler 不走 MCP tool approval,也不创建 PreparedMcpCall。它直接调用 StepContext.mcp 的 resource API,再用统一 lifecycle helper 把操作显示为一个 MCP tool-call item。
| 层 | MCP Tool | MCP Resource |
|---|---|---|
| 模型入口 | server namespace function | 三个顶层 function |
| 发现 | tools/list 与 exposure | list_mcp_resources / templates |
| 执行 | McpHandler → approval → RMCP tool call | resource handler → resource client |
| 读取对象 | 动作参数 | URI 或 URI template |
| 审批 | 可能需要 | 当前 handler 不走 tool approval |
源码位置:
codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs :: ListMcpResourcesHandlercodex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs :: ListMcpResourceTemplatesHandlercodex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs :: ReadMcpResourceHandlercodex-rs/core/src/tools/handlers/tool_search_spec.rs :: create_tool_search_tool
三个 resource handler 都声明支持 parallel calls,并复用普通 Function 工具的默认 Pre/Post Hook;它们没有 wait_until_ready 或 MCP approval override。ToolSearch 的模型说明还明确要求:发现 Deferred MCP 工具必须使用 tool_search,不能把 resource list/templates 当成工具目录。Resource 与 Tool discovery 是两个协议面。
2. 参数边界
2.1 List参数
list 两个 handler 共享 ListResourceArgs。server/cursor 都先 trim;空字符串变成 None。指定 server 时 cursor 转成 RMCP PaginatedRequestParams;不指定 server 时 cursor 直接报错,因为不同 server 的游标没有共同分页语义。
源码位置:codex-rs/core/src/tools/handlers/mcp_resource.rs :: ListResourceArgs
#[derive(Debug, Deserialize, Default, PartialEq, Eq)]
struct ListResourceArgs {
#[serde(default)]
server: Option<String>,
#[serde(default)]
cursor: Option<String>,
}
impl ListResourceArgs {
fn normalized(self) -> Self {
Self {
server: normalize_optional_string(self.server),
cursor: normalize_optional_string(self.cursor),
}
}
fn target(
&self,
turn: &TurnContext,
) -> Result<Option<(String, Option<PaginatedRequestParams>)>, FunctionCallError> {
match &self.server {
Some(server) => {
ensure_model_can_access_mcp_server(turn, server)?;
let params = self.cursor.clone().map(|cursor| {
PaginatedRequestParams::default().with_cursor(Some(cursor))
});
Ok(Some((server.clone(), params)))
}
None if self.cursor.is_some() => Err(FunctionCallError::RespondToModel(
"cursor can only be used when a server is specified".to_string(),
)),
None => Ok(None),
}
}
}跨 server list 返回一个聚合快照,不返回 next_cursor;单 server list 返回该 server 原生游标。模型要继续翻页,必须保留 server 并把返回的 cursor 原样传回。
2.2 Read参数
read 不允许省略 server,也不允许空 URI。normalize_required_string 在 JSON 反序列化之后执行,因此数值、数组等类型会先由 serde 拒绝,空白字符串则得到明确的 field-specific 错误。
源码位置:codex-rs/core/src/tools/handlers/mcp_resource.rs :: ReadResourceArgs
#[derive(Debug, Deserialize)]
struct ReadResourceArgs {
server: String,
uri: String,
}
fn normalize_required_string(
field: &str,
value: String,
) -> Result<String, FunctionCallError> {
match normalize_optional_string(Some(value)) {
Some(normalized) => Ok(normalized),
None => Err(FunctionCallError::RespondToModel(format!(
"{field} must be provided"
))),
}
}read 不校验 URI 是否来自本次 list,也不展开 URI template;它把 server+uri 交给 MCP server。URI 的有效性最终由 server/resource client 判定。
2.3 Server访问
Codex Apps server 默认受到 orchestrator.mcp.enabled 限制;普通 MCP server 不受这条 Apps-only gate 影响。list 聚合时对每个 server 调用同一个 predicate;read 指定 server 时直接调用 ensure_model_can_access_mcp_server。
源码位置:codex-rs/core/src/tools/handlers/mcp_resource.rs :: ensure_model_can_access_mcp_server
fn model_can_access_mcp_server(turn: &TurnContext, server: &str) -> bool {
turn.config.orchestrator_mcp_enabled || server != CODEX_APPS_MCP_SERVER_NAME
}
fn ensure_model_can_access_mcp_server(
turn: &TurnContext,
server: &str,
) -> Result<(), FunctionCallError> {
if model_can_access_mcp_server(turn, server) {
Ok(())
} else {
Err(FunctionCallError::RespondToModel(format!(
"MCP server '{server}' is disabled by `orchestrator.mcp.enabled`"
)))
}
}这条 gate 是模型资源访问边界,不等同于 App Server 直接 RPC 的 thread/executor 校验。后文会说明直接 RPC 为什么可以在没有模型 turn 的情况下读取资源。
3. 列出资源
3.1 单Server
ListMcpResourcesHandler 解析参数后构造 McpInvocation,指定 server 时调用 mcp.list_resources(server, params)。返回的 MCP Resource 会包装 server 字段,next cursor 原样保留。
源码位置:codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs :: ListMcpResourcesHandler::handle_call
let arguments = parse_arguments(arguments.as_str())?;
let args: ListResourceArgs = parse_args_with_default(arguments.clone())?;
let args = args.normalized();
let invocation = McpInvocation {
server: args.server.clone().unwrap_or_else(|| "codex".to_string()),
tool: "list_mcp_resources".to_string(),
arguments: arguments.clone(),
};
run_resource_operation(&session, turn.as_ref(), &call_id, invocation, async {
if let Some((server_name, params)) = args.target(turn.as_ref())? {
let result = mcp
.list_resources(&server_name, params)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("resources/list failed: {err:#}"))
})?;
Ok(ListResourcesPayload::from_single_server(server_name, result))
} else {
let resources = mcp
.list_all_resources(|server_name| {
model_can_access_mcp_server(turn.as_ref(), server_name)
})
.await;
Ok(ListResourcesPayload::from_all_servers(resources))
}
})
.await这段代码同时覆盖单 server 分页和跨 server 聚合;聚合分支不会在多个 server 之间合成一个 cursor。
3.2 跨Server聚合
McpResourceClient::list_all_resources 返回 HashMap<server, Vec<Resource>>。handler 通过 ResourceWithServer::from_all_servers 按 server 名排序,再按每个 server 的资源顺序展开;结果稳定但没有全局分页游标。
源码位置:codex-rs/core/src/tools/handlers/mcp_resource.rs :: ResourceWithServer::from_all_servers
fn from_all_servers(resources_by_server: HashMap<String, Vec<T>>) -> Vec<Self> {
let mut entries: Vec<_> = resources_by_server.into_iter().collect();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
entries
.into_iter()
.flat_map(|(server, resources)| Self::from_server(&server, resources))
.collect()
}测试传入 beta/alpha 两个无序 map,断言输出 URI 先 alpha 再 beta。它证明的是 Codex 聚合层的确定性排序,不证明 MCP server 自身返回资源的顺序会被重新排序。
3.3 Templates
list_mcp_resource_templates 与 list resources 共用参数、server gate、cursor 规则和聚合排序,但 payload 字段是 resourceTemplates,元素是 ResourceTemplate,包含 uriTemplate 而非固定 uri。
源码位置:codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs :: ListMcpResourceTemplatesHandler::handle_call
let invocation = McpInvocation {
server: args.server.clone().unwrap_or_else(|| "codex".to_string()),
tool: "list_mcp_resource_templates".to_string(),
arguments: arguments.clone(),
};
run_resource_operation(&session, turn.as_ref(), &call_id, invocation, async {
if let Some((server_name, params)) = args.target(turn.as_ref())? {
let result = mcp
.list_resource_templates(&server_name, params)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"resources/templates/list failed: {err:#}"
))
})?;
Ok(ListResourceTemplatesPayload::from_single_server(
server_name,
result,
))
} else {
let templates = mcp
.list_all_resource_templates(|server_name| {
model_can_access_mcp_server(turn.as_ref(), server_name)
})
.await;
Ok(ListResourceTemplatesPayload::from_all_servers(templates))
}
})
.await4. 读取资源
4.1 URI调用
read handler 解析 server/uri 后构造 ReadResourceRequestParams,先检查 Apps access,再调用 mcp.read_resource。它不会从 list 结果缓存中查找,也不会在 Core 侧展开 template 参数。
源码位置:codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs :: ReadMcpResourceHandler::handle_call
let arguments = parse_arguments(arguments.as_str())?;
let args: ReadResourceArgs = parse_args(arguments.clone())?;
let ReadResourceArgs { server, uri } = args;
let server = normalize_required_string("server", server)?;
let uri = normalize_required_string("uri", uri)?;
let invocation = McpInvocation {
server: server.clone(),
tool: "read_mcp_resource".to_string(),
arguments: arguments.clone(),
};
run_resource_operation(&session, turn.as_ref(), &call_id, invocation, async {
ensure_model_can_access_mcp_server(turn.as_ref(), &server)?;
let result = mcp
.read_resource(&server, ReadResourceRequestParams::new(uri.clone()))
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}"))
})?;
Ok(ReadResourcePayload { server, uri, result })
})
.await4.2 Resource client
McpResourceClient 跟随 thread MCP runtime 的 latest connections。它把 RMCP Resource/ResourceContents 序列化再转换为 protocol 类型;resource client 本身不等待 server startup,调用前的连接可用性由 runtime/client 层处理。
源码位置:codex-rs/codex-mcp/src/resource_client.rs :: McpResourceClient
pub struct McpResourceClient {
runtime: Arc<McpRuntime>,
}
impl McpResourceClient {
pub fn new(runtime: Arc<McpRuntime>) -> Self {
Self { runtime }
}
pub async fn list_resources(
&self,
server: &str,
cursor: Option<String>,
) -> Result<McpResourcePage> {
let params = cursor
.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor)));
let result = self
.runtime
.latest_connections()
.list_resources(server, params)
.await?;
let resources = result
.resources
.into_iter()
.map(resource_from_rmcp)
.collect::<Result<Vec<_>>>()?;
Ok(McpResourcePage {
resources,
next_cursor: result.next_cursor,
})
}
pub async fn read_resource(
&self,
server: &str,
uri: &str,
) -> Result<McpResourceReadResult> {
let result = self
.runtime
.latest_connections()
.read_resource(server, ReadResourceRequestParams::new(uri.to_string()))
.await?;
Ok(McpResourceReadResult {
contents: result
.contents
.into_iter()
.map(resource_content_from_rmcp)
.collect::<Result<Vec<_>>>()?,
})
}
}4.3 内容形状
read 返回 ReadResourcePayload { server, uri, result }。文本和 blob content 都保留在 ReadResourceResult.contents 中;模型收到的是 JSON 字符串,而不是自动展开成图片、音频或用户消息。
源码位置:codex-rs/core/src/tools/handlers/mcp_resource.rs :: ReadResourcePayload
#[derive(Debug, Serialize)]
struct ReadResourcePayload {
server: String,
uri: String,
#[serde(flatten)]
result: ReadResourceResult,
}这与 view_image 的 InputImage 语义不同:资源 read 不会自动触发图像准备链。若资源内容要成为模型图片,扩展或上层工具必须明确构造可接受的多模态输出。
三类公开 JSON payload 共用 server identity,但分页字段只属于单 server list;read 则保留请求 URI 并 flatten MCP result。
5. 生命周期
5.1 统一运行器
三个 handler 都调用 run_resource_operation。它先发 MCP tool-call begin item,执行 resource operation,再把成功结果转成文本 CallToolResult 发 completed item;失败则发 failed item 并把错误返回给模型。
源码位置:codex-rs/core/src/tools/handlers/mcp_resource.rs :: run_resource_operation
async fn run_resource_operation<T>(
session: &Arc<Session>,
turn: &TurnContext,
call_id: &str,
invocation: McpInvocation,
operation: impl Future<Output = Result<T, FunctionCallError>>,
) -> Result<Box<dyn ToolOutput>, FunctionCallError>
where
T: Serialize,
{
emit_tool_call_begin(session, turn, call_id, invocation.clone()).await;
let start = Instant::now();
let result = operation.await.and_then(|payload| {
serialize_function_output(payload, turn.model_info.truncation_policy.into())
});
match result {
Ok(output) => {
let content =
function_call_output_content_items_to_text(&output.body).unwrap_or_default();
emit_tool_call_end(
session,
turn,
call_id,
invocation,
start.elapsed(),
Ok(call_tool_result_from_content(&content, output.success)),
)
.await;
Ok(boxed_tool_output(output))
}
Err(error) => {
emit_tool_call_end(
session,
turn,
call_id,
invocation,
start.elapsed(),
Err(error.to_string()),
)
.await;
Err(error)
}
}
}resource operation 的 lifecycle item 使用 McpToolCallItem,但 metadata 中 connector/plugin 字段为空;它记录的是标准化 resource action,不是某个 MCP Tool 的 approval metadata。
5.2 序列化截断
serialize_function_output 将泛型 payload 序列化为 JSON 文本,再按当前 model truncation policy 的 1.2 倍预算截断。大资源的真实内容可能被截断,但 server call 已完成;这和 MCP Tool 的结果截断同样是模型投影边界。
源码位置:codex-rs/core/src/tools/handlers/mcp_resource.rs :: serialize_function_output
fn serialize_function_output<T>(
payload: T,
truncation_policy: TruncationPolicy,
) -> Result<FunctionToolOutput, FunctionCallError>
where
T: Serialize,
{
let content = serde_json::to_string(&payload).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to serialize MCP resource response: {err}"
))
})?;
let content = truncate_text(&content, truncation_policy * 1.2);
Ok(FunctionToolOutput::from_text(content, Some(true)))
}5.3 失败状态
资源 list/read 的 server error、Apps gate、cursor 约束、参数错误和序列化错误都通过 FunctionCallError::RespondToModel 结束当前工具调用,并生成 failed lifecycle item。它们不会进入 MCP Tool approval 流程,也不会伪造一个空成功资源列表。
6. 直接RPC
6.1 非模型读取
App Server 还提供独立的 mcp/resource/read RPC。请求可以带 threadId、originCallId、server、uri 和 connectorId。有 thread 时使用 thread 对应 MCP runtime;没有 thread 时创建 threadless runtime。这个入口不经过模型-visible resource handler,也不会进入 Responses history。
源码位置:codex-rs/app-server/src/request_processors/mcp_processor.rs :: read_mcp_resource
let mut resource_params = ReadResourceRequestParams::new(uri);
if let Some(connector_id) = connector_id {
resource_params.meta = Some(
serde_json::Map::from_iter([(
"x-codex-turn-metadata".to_string(),
serde_json::json!({
"mcp_request_meta": {
"selected_connector_ids": [connector_id],
},
}),
)])
.into(),
);
}
if let Some(thread_id) = thread_id {
let (_, thread) = self.load_thread(&thread_id).await?;
let request_id = request_id.clone();
tokio::spawn(async move {
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, &resource_params.uri)
.await
}
None => thread.read_mcp_resource(&server, resource_params).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"));
}connectorId 只为普通 read 构造 selected-connector metadata;originCallId 则只在 server == codex_apps 时启用 call-scoped authority。没有 threadId 却提供 originCallId 会直接返回 invalid request。unknown thread 是 RPC 请求错误;模型工具的 unknown/unavailable server 则是 function output 错误。
6.2 Origin授权
App widget read 不能只相信客户端传入的 connector id。ResourceOrigins 从成功的 MCP tool completed/end 事件中保存 call id、turn id、raw tool、connector、link id 和 resource URI;只记录 codex_apps、有 connector、且 tool metadata 提供 URI 的调用。link 参数与 tool metadata 不一致时标记为 ambiguous account。
源码位置:codex-rs/codex-mcp/src/resource_origin.rs :: ResourceOrigins::observe
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(),
);
}
}call-scoped read 先要求 requested URI 与 origin URI 完全一致,再检查 ambiguous account、当前 tool/connector/link 是否仍匹配、显式 account link 要求和当前 App policy。最后使用当前 binding 发起 codex_apps resource read,并自动注入 thread id、selected connector 和 link id。原调用只提供授权来源,不冻结旧 client 或绕过当前 policy。
源码位置:codex-rs/codex-mcp/src/resource_origin.rs :: ResourceOrigin::read
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");
}6.3 持久化边界
origin state 是 thread-owned bounded state:最多保留 64 个 origin/turn,每条最多 1024 bytes;rollback 会删除被回滚 turn 的 origins。Compaction 把 checkpoint 写入 CompactedItem.mcp_resource_origins,Session 恢复时先恢复 checkpoint,再重放后续事件,所以 widget read 可以跨 history mode、compaction 和进程重启继续工作。
源码位置:
codex-rs/codex-mcp/src/resource_origin.rs :: ResourceOrigins::checkpointcodex-rs/core/src/session/mod.rs :: Session::replace_compacted_historycodex-rs/core/src/session/session.rs :: Session::new
let compacted_item = CompactedItem {
message: metadata.message,
replacement_history: Some(items.clone()),
mcp_resource_origins: self.services.mcp_runtime.resource_origin_checkpoint(),
/* window metadata */
};恢复 checkpoint 时,只要数量、turn id 或任一 origin 超过边界,或者 connector id 为空,就清空整个 origin store,而不是部分接受不可信状态。
6.4 Skills消费者
Orchestrator Skills 不调用本文的三个模型工具,而是其 skills.list/read executor 在内部持有 McpResourceClient:provider 对 codex_apps 分页 list,再 read skill URI,并用 McpResourceClientCacheKey 判断 connection generation 是否变化。它是同一 resource client 的更高层消费者,不应画成 skills.read → list_mcp_resources 的模型工具嵌套调用。
源码位置:
codex-rs/ext/skills/src/provider/orchestrator.rs :: OrchestratorSkillProvidercodex-rs/ext/skills/src/state.rs :: SkillsThreadState::orchestrator_cachecodex-rs/codex-mcp/src/resource_client.rs :: McpResourceClient::cache_key
7. 源码练习
先复述两条主线:模型工具从 add_mcp_resource_tools → 参数与 server gate → StepContext MCP binding → RMCP resources API → bounded model output;App widget 则从 successful MCP tool item → bounded origin store → originCallId → 当前 binding/policy 复核 → resource read。
再做三个只读验证:
- 找到
list_resources_payload_from_all_servers_is_sorted,说明为什么聚合结果可以排序却不能携带统一 cursor; - 找到
serialize_function_output_caps_read_resource_payload,解释大资源被截断发生在模型输出序列化层,而不是 MCP server 返回层; - 找到
widget_reads_survive_history_modes_compaction_restarts_and_app_only_visibility,列出 wrong URI、failed call、ambiguous account 和 missing thread 分别在哪个 authority check 被拒绝。
在 Codex 源码仓库的 codex-rs/ 目录运行:
rg -n "ListMcpResourcesHandler|ListMcpResourceTemplatesHandler|ReadMcpResourceHandler|run_resource_operation|ResourceOrigins" core codex-mcp
cargo test -p codex-core --lib 'tools::handlers::mcp_resource::tests' -- --test-threads=1
RUST_MIN_STACK=8388608 cargo test -p codex-app-server --test all mcp_resource_read_returns_resource_contents -- --test-threads=1
RUST_MIN_STACK=8388608 cargo test -p codex-app-server --test all mcp_resource_read_returns_error_for_unknown_thread -- --test-threads=1
RUST_MIN_STACK=8388608 cargo test -p codex-app-server --test all widget_reads_survive_history_modes_compaction_restarts_and_app_only_visibility -- --test-threads=1第一组测试覆盖参数归一、排序、cursor、序列化和截断;App Server 测试覆盖普通直接 RPC、unknown-thread 错误和 call-scoped origin 跨 compaction/restart。它们不证明每个 MCP server 都实现 resources/list 或 resources/templates。
