Expose deferred MCP servers in resource listings (#60423)

## Why

Aggregate resource listings omitted dormant MCP servers, leaving callers without their names for explicit queries.

## What changed

Include a sorted `deferredServers` list in aggregate `list_mcp_resources` and `list_mcp_resource_templates` results alongside resources from ready servers. Apply the listing filters to deferred servers and omit the field when empty. Discovery leaves dormant servers unstarted; callers can query them by passing `server` explicitly.

## Testing

Extend the resource discovery scenario for direct calls and Code Mode to verify deferred server names, confirm discovery leaves the server unstarted, and then list and read resources by server name.

GitOrigin-RevId: f95b8beb64bb1329f4ae3245e8c375614fcb3360
This commit is contained in:
Charlie Marsh
2026-11-09 17:58:16 +00:00
committed by copyberry
parent 4f9e7171a1
commit 27fcd7cbed
11 changed files with 171 additions and 84 deletions
@@ -5,9 +5,12 @@ use super::super::rmcp_client::remote_aware_stdio_server_bin;
use anyhow::Result;
use codex_core::ConfigRefreshOutcome;
use codex_features::Feature;
use codex_protocol::mcp::McpServerConnectionStatus;
use codex_protocol::openai_models::ToolMode;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::McpStartupStatus;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
@@ -63,7 +66,7 @@ async fn mcp_resource_messages(tool_mode: ToolMode) -> Result<()> {
);
}
let catalog = serde_json::from_value(json!(messages))?;
let test = test_codex()
let mut test = test_codex()
.with_config(move |config| {
super::configure_scenario_catalog(config);
config.code_mode.disable_in_process_fallback = true;
@@ -96,34 +99,70 @@ async fn mcp_resource_messages(tool_mode: ToolMode) -> Result<()> {
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&test.codex, "resources").await?;
let mut events = vec![responses::ev_response_created("resources-response")];
events.extend(if tool_mode == ToolMode::CodeModeOnly {
let script = calls
.iter()
.map(|(name, arguments)| format!("text(await tools.{name}({arguments}));"))
.collect::<Vec<_>>()
.join("\n");
vec![responses::ev_custom_tool_call(
"resources-call",
"exec",
&script,
)]
} else {
calls
.iter()
.map(|(name, arguments)| responses::ev_function_call(name, name, arguments))
.collect()
});
events.push(responses::ev_completed("resources-response"));
let mut options = test.start_thread_options().await;
options.session_source = Some(SessionSource::SubAgent(SubAgentSource::Other(
"resource-discovery".to_string(),
)));
test.codex = test.thread_manager.start_thread(options).await?.thread;
let resource_calls = |id: &str, calls: &[(&str, &str)]| {
let mut events = vec![responses::ev_response_created(id)];
if tool_mode == ToolMode::CodeModeOnly {
let script = calls
.iter()
.map(|(name, args)| format!("text(await tools.{name}({args}));"))
.collect::<Vec<_>>()
.join("\n");
events.push(responses::ev_custom_tool_call(id, "exec", &script));
} else {
events.extend(calls.iter().map(|(name, args)| {
responses::ev_function_call(&format!("{id}-{name}"), name, args)
}));
}
events.push(responses::ev_completed(id));
responses::sse(events)
};
let broad = [
("list_mcp_resources", "{}"),
("list_mcp_resource_templates", "{}"),
];
let mock = responses::mount_sse_sequence(
&server,
vec![responses::sse(events), responses::sse_completed("done")],
vec![
resource_calls("broad", &broad),
responses::sse_completed("broad-done"),
resource_calls("resources-call", &calls),
responses::sse_completed("done"),
],
)
.await;
test.submit_turn("List the available resources and templates, then read the example note.")
test.submit_turn("List available MCP resources and templates.")
.await?;
let (config, _) = test.codex.current_mcp_config_and_runtime_context().await;
assert_eq!(
test.codex
.mcp_connection_statuses(&config)
.await
.get("resources"),
Some(&McpServerConnectionStatus::NotStarted)
);
test.submit_turn("Query the deferred server by name and read its example note.")
.await?;
let requests = mock.requests();
assert_eq!(requests.len(), 2);
assert_eq!(requests.len(), 4);
if tool_mode == ToolMode::Direct {
for (tool, key) in [
("list_mcp_resources", "resources"),
("list_mcp_resource_templates", "resourceTemplates"),
] {
let output = requests[1]
.function_call_output_text(&format!("broad-{tool}"))
.expect("aggregate result");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&output)?,
json!({key: [], "deferredServers": ["resources"]})
);
}
}
let body = requests[0].body_json();
for (name, message) in &messages {
if tool_mode == ToolMode::CodeModeOnly {
@@ -149,17 +188,17 @@ async fn mcp_resource_messages(tool_mode: ToolMode) -> Result<()> {
);
}
}
assert!(requests[1].body_contains_text("Example Note"));
assert!(requests[1].body_contains_text("memo://codex/{slug}"));
assert!(requests[3].body_contains_text("Example Note"));
assert!(requests[3].body_contains_text("memo://codex/{slug}"));
assert!(
requests[1]
requests[3]
.body_contains_text("This is a sample MCP resource served by the rmcp test server.")
);
if tool_mode == ToolMode::CodeModeOnly {
insta::assert_snapshot!(
"mcp_resource_messages",
context_snapshot::format_request_history_snapshot(
"Catalog resource descriptions and parameter guidance accompany discovery and reading through Code Mode.",
"Broad discovery names the dormant server. Calling it by name returns its resources and templates through Code Mode.",
&requests,
&ContextSnapshotOptions::default().include_request_settings(),
)
@@ -1,8 +1,8 @@
---
source: core/tests/suite/scenarios_mcp_resource_messages.rs
expression: "context_snapshot::format_request_history_snapshot(\"Catalog resource descriptions and parameter guidance accompany discovery and reading through Code Mode.\",\n&requests, &ContextSnapshotOptions::default().include_request_settings(),)"
expression: "context_snapshot::format_request_history_snapshot(\"Broad discovery names the dormant server. Calling it by name returns its resources and templates through Code Mode.\",\n&requests, &ContextSnapshotOptions::default().include_request_settings(),)"
---
Scenario: Catalog resource descriptions and parameter guidance accompany discovery and reading through Code Mode.
Scenario: Broad discovery names the dormant server. Calling it by name returns its resources and templates through Code Mode.
## Window 1
Settings:
@@ -15,11 +15,10 @@ Settings:
stream: true
text: {"verbosity":"low"}
tool_choice: "auto"
tools (7; hash=83df59b286cda93b):
tools (6; hash=2fa2390674c481b0):
- custom/exec: Run JavaScript code to orchestrate/compose tool calls - Evaluates the provided JavaScript code in...; hash=2051847a9a5d026d
- function/wait: Waits on a yielded `exec` cell and returns new output or completion. - Use `wait` only after `exe...; args=[cell_id, max_tokens, terminate, yield_time_ms]; hash=6692d8f6c55be489
- function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6
- function/request_user_input_async: Ask the user one or more questions during ongoing work. Use this tool only to request missing inf...; args=[questions]; hash=d6d74f33db330162
- namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317
- function/sleep
- namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63
@@ -74,27 +73,7 @@ Settings:
Deferred tool namespaces:
- mcp__resources: Use these tools to exercise the rmcp test server.
</tools>
02:message/developer:
<multi_agent_role>You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals.
At the start of your turn, you are the active agent.
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents.
All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an...message to a running agent without triggering a turn. [hash=7d40ff8b58977202]
`send_message` calls may be read by a human, so ensure they are legible. Always put proper spaces between words and/or numbers.
<OMITTED 17 LINES; ~246 TOKENS; hash=780031e2df907944>
- All agents use the same current working directory.
- As a result, edits made by one agent are immediately visible to all other agents.
When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.
There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you.
Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d... so, set `fork_turns` to `"none"`.</multi_agent_role> [hash=d5928243dc643bcc]
03:message/developer:
<multi_agent_mode>Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work.</multi_agent_mode> [hash=2b6e037067b4390c]
04:message/user:
02:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
@@ -102,14 +81,24 @@ Settings:
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><WORKSPACE_ROOT 1></root></workspace_roots><permission_profile type...e="unrestricted" /></permission_profile></filesystem> [hash=0592734c170299c1]
</environment_context>
05:message/user:
List the available resources and templates, then read the example note.
06:configuration_update:reasoning={"effort":"low"}
03:message/user:
List available MCP resources and templates.
04:configuration_update:reasoning={"effort":"low"}
-- request 2 (turn) --
07:custom_tool_call/exec:text(await tools.list_mcp_resources({"server":"resources"}));
05:custom_tool_call/exec:text(await tools.list_mcp_resources({}));
text(await tools.list_mcp_resource_templates({}));
06:custom_tool_call_output:Script completed
Wall time <DURATION> seconds
Output:
| {"resources":[],"deferredServers":["resources"]} | {"resourceTemplates":[],"deferredServers":["resources"]}
-- request 3 (turn) --
07:message/user:
Query the deferred server by name and read its example note.
-- request 4 (turn) --
08:custom_tool_call/exec:text(await tools.list_mcp_resources({"server":"resources"}));
text(await tools.list_mcp_resource_templates({"server":"resources"}));
text(await tools.read_mcp_resource({"server":"resources","uri":"memo://codex/example-note"}));
08:custom_tool_call_output:Script completed
09:custom_tool_call_output:Script completed
Wall time <DURATION> seconds
Output:
| {"server":"resources","resources":[{"server":"resources","uri":"memo://codex/example-note","name":"examp...ed for integration tests.","mimeType":"text/plain"}]} [hash=75d187638a7af2d4] | {"server":"resources","resourceTemplates":[{"server":"resources","uriTemplate":"memo://codex/{slug}","na... resources used in tests.","mimeType":"text/plain"}]} [hash=b1c7082eb114fc9b] | {"server":"resources","uri":"memo://codex/example-note","contents":[{"uri":"memo://codex/example-note","...mple MCP resource served by the rmcp test server."}]} [hash=45385fc57640b555]