From 2de2e03d0b71db990cf4e2dacd8cb0e961eb4cef Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Fri, 13 Nov 2026 03:56:56 +0000 Subject: [PATCH] Resolve MCP statusline providers and read thread-scoped resources (#61249) ## What changed - Parse statusline entrypoints from MCP tool metadata, requiring exactly one valid `ui://` resource URI per tool. - Resolve saved or discovered providers against the displayed thread's tool catalog, checking global advertisement, plugin ownership, and connection status. Reject providers whose effective server, plugin, or marketplace registration includes project configuration. - Read the resolved resource on the displayed thread, forwarding the statusline snapshot in `com.openai/codex-statusline-state` metadata. ## Testing Add tests for malformed and ambiguous entrypoints, thread-specific resource reads, configuration precedence, project overrides, ownership mismatches, and unavailable providers. GitOrigin-RevId: 5a43212177704f546e5d53be53f8979fe3bb8d8a --- codex-rs/tui/src/app/startup.rs | 4 +- codex-rs/tui/src/config_update.rs | 8 +- codex-rs/tui/src/statusline_provider.rs | 176 +++++++++++- .../tui/src/statusline_provider/descriptor.rs | 68 +++++ .../tui/src/statusline_provider/inventory.rs | 27 +- .../statusline_provider/inventory_tests.rs | 2 +- codex-rs/tui/src/statusline_provider_tests.rs | 266 ++++++++++++++++++ 7 files changed, 542 insertions(+), 9 deletions(-) create mode 100644 codex-rs/tui/src/statusline_provider/descriptor.rs create mode 100644 codex-rs/tui/src/statusline_provider_tests.rs diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 2a13db47bb..1c45d9966e 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -67,8 +67,8 @@ pub(super) async fn prepare_fresh_startup_config( } }; let defaults = crate::config_update::read_effective_config_with_layers( - app_server.request_handle(), - defaults_cwd, + &app_server.request_handle(), + defaults_cwd.display().to_string(), ) .await?; super::new_session::overlay_new_session_defaults( diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs index 70a23df860..25e35f1425 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -439,15 +439,15 @@ pub(crate) struct EffectiveConfig { } pub(crate) async fn read_effective_config_with_layers( - request_handle: AppServerRequestHandle, - cwd: &Path, + request_handle: &AppServerRequestHandle, + cwd: String, ) -> Result { request_handle .request_typed(ClientRequest::ConfigRead { request_id: RequestId::String(format!("tui-config-read-{}", Uuid::new_v4())), params: ConfigReadParams { include_layers: true, - cwd: Some(cwd.display().to_string()), + cwd: Some(cwd), }, }) .await @@ -459,7 +459,7 @@ pub(crate) async fn read_effective_config_if_supported( request_handle: AppServerRequestHandle, cwd: &Path, ) -> Result> { - match read_effective_config_with_layers(request_handle, cwd).await { + match read_effective_config_with_layers(&request_handle, cwd.display().to_string()).await { Ok(response) => Ok(Some(response)), Err(err) if matches!( diff --git a/codex-rs/tui/src/statusline_provider.rs b/codex-rs/tui/src/statusline_provider.rs index 1af7b9634f..7ebd18293c 100644 --- a/codex-rs/tui/src/statusline_provider.rs +++ b/codex-rs/tui/src/statusline_provider.rs @@ -1,10 +1,28 @@ -//! MCP server identity and inventory for statusline providers. +//! Resolve globally advertised statusline tools and read them on the displayed thread. +mod descriptor; #[cfg_attr(not(test), expect(dead_code, reason = "staged provider discovery"))] mod inventory; #[cfg(test)] mod test_support; +use crate::config_update::read_effective_config_with_layers; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::McpResourceReadParams; +use codex_app_server_protocol::McpResourceReadResponse; +use codex_app_server_protocol::McpServerConnectionStatus; +use codex_app_server_protocol::RequestId; +use codex_protocol::ThreadId; +use codex_utils_path_uri::PathUri; +use color_eyre::Result; +use color_eyre::eyre::OptionExt as _; +use color_eyre::eyre::bail; +pub(crate) use descriptor::StatuslineProvider; +use serde_json::Value; +use std::collections::HashMap; +use uuid::Uuid; + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct McpServerName(String); @@ -19,3 +37,159 @@ impl From<&str> for McpServerName { Self(name.to_owned()) } } + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct McpToolName(String); + +impl From<&str> for McpToolName { + fn from(name: &str) -> Self { + Self(name.to_owned()) + } +} + +/// The saved identity is independent of the latest display title and resource URI. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct ProviderId { + pub(crate) server: McpServerName, + pub(crate) tool: McpToolName, +} + +#[cfg_attr(not(test), expect(dead_code, reason = "staged provider choice"))] +pub(crate) enum ProviderSelection<'a> { + Saved(&'a ProviderId), + /// A provider advertised during the current picker refresh. + Discovered(&'a StatuslineProvider), +} + +pub(crate) struct ResolvedStatuslineProvider { + client: AppServerRequestHandle, + thread: ThreadId, + provider: StatuslineProvider, +} + +#[cfg_attr(not(test), expect(dead_code, reason = "staged resource reader"))] +impl ResolvedStatuslineProvider { + pub(crate) async fn lookup( + client: &AppServerRequestHandle, + thread: ThreadId, + cwd: &PathUri, + selection: ProviderSelection<'_>, + ) -> Result> { + let config = + read_effective_config_with_layers(client, cwd.inferred_native_path_string()).await?; + let layers = config + .layers + .ok_or_eyre("config/read omitted the requested layers")?; + let project_sets = |section: &str, key: &str| { + let definitions: Vec<_> = layers + .iter() + .filter(|layer| layer["disabledReason"].is_null()) + .filter_map(|layer| { + Some(( + layer["name"]["type"] == "project", + layer["config"][section].get(key)?, + )) + }) + .collect(); + has_project_field(&definitions) + }; + let (id, advertisement) = match selection { + ProviderSelection::Saved(id) => (id, None), + ProviderSelection::Discovered(provider) => (&provider.id, Some(provider)), + }; + if project_sets("mcp_servers", id.server.as_str()) { + return Ok(None); + } + let owner = if let Some(provider) = advertisement { + provider.plugin_id.clone() + } else { + let Some(global) = inventory::server(client, &id.server, /*thread*/ None).await? else { + return Ok(None); + }; + if global.provider(&id.tool)?.is_none() { + return Ok(None); + } + global.plugin_id + }; + if let Some(plugin) = &owner + && (project_sets("plugins", &plugin.as_key()) + || project_sets("marketplaces", &plugin.marketplace_name)) + { + return Ok(None); + } + let Some(live) = inventory::server(client, &id.server, Some(thread)).await? else { + return Ok(None); + }; + if live.plugin_id != owner { + return Ok(None); + } + let provider = live.provider(&id.tool)?; + if live.runtime_status != Some(McpServerConnectionStatus::Connected) { + bail!("MCP server {} is not connected", id.server.as_str()); + } + Ok(provider.map(|provider| Self { + client: client.clone(), + thread, + provider, + })) + } + + pub(crate) fn provider(&self) -> &StatuslineProvider { + &self.provider + } + + /// The host routes by name. A reload can replace that name between lookup and read. + /// A thread/start override can also replace a configured command before lookup. Both + /// registrations report the same name and no plugin ID. Same-owner plugin replacements + /// are indistinguishable for the same reason. + pub(crate) async fn read(&self, snapshot: Value) -> Result { + let provider = &self.provider; + Ok(self + .client + .request_typed(ClientRequest::McpResourceRead { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: McpResourceReadParams { + thread_id: Some(self.thread.to_string()), + server: provider.id.server.0.clone(), + uri: provider.uri.clone(), + connector_id: None, + origin_call_id: None, + target: None, + meta: Some(serde_json::Map::from_iter([( + "com.openai/codex-statusline-state".into(), + snapshot, + )])), + }, + }) + .await?) + } +} + +/// Config layers arrive highest first. A project field only matters if a higher layer +/// leaves it in the effective registration. Arrays and scalars are replaced as a whole. +fn has_project_field(definitions: &[(bool, &Value)]) -> bool { + let Some((&(project, first), rest)) = definitions.split_first() else { + return false; + }; + if !first.is_object() || rest.iter().all(|(origin, _)| *origin == project) { + return project; + } + let mut fields: HashMap<&str, Vec<_>> = HashMap::new(); + for &(project, value) in definitions { + let Some(table) = value.as_object() else { + break; + }; + for (key, value) in table { + fields.entry(key).or_default().push((project, value)); + } + } + if fields.is_empty() { + project + } else { + fields.values().any(|values| has_project_field(values)) + } +} + +#[cfg(test)] +#[path = "statusline_provider_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/statusline_provider/descriptor.rs b/codex-rs/tui/src/statusline_provider/descriptor.rs new file mode 100644 index 0000000000..211fa36e7e --- /dev/null +++ b/codex-rs/tui/src/statusline_provider/descriptor.rs @@ -0,0 +1,68 @@ +//! Interpret the statusline entrypoint advertised by one MCP tool. + +use super::McpServerName; +use super::McpToolName; +use super::ProviderId; +use codex_plugin::PluginId; +use codex_protocol::mcp::Tool; +use std::cell::Cell; +use url::SyntaxViolation; +use url::Url; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct StatuslineProvider { + pub(crate) id: ProviderId, + pub(crate) title: String, + pub(crate) plugin_label: Option, + pub(crate) uri: String, + pub(super) plugin_id: Option, +} + +impl StatuslineProvider { + pub(super) fn from_tool( + server: &McpServerName, + plugin_id: Option<&PluginId>, + tool: &Tool, + ) -> Option { + let meta = tool.meta.as_ref()?; + let entries = meta.get("openai/ui")?.get("entrypoints")?.as_array()?; + let mut valid_uris = entries + .iter() + .filter(|entry| entry["type"] == "statusline") + .filter_map(|entry| entry["resourceUri"].as_str()) + .filter(|uri| { + // The resource read forwards the original spelling. Reject errors the URL parser recovers from. + let valid = Cell::new(true); + let violation = |issue| { + if issue != SyntaxViolation::EmbeddedCredentials { + valid.set(false); + } + }; + uri.starts_with("ui://") + && Url::options() + .syntax_violation_callback(Some(&violation)) + .parse(uri) + .is_ok() + && valid.get() + }); + let uri = valid_uris.next()?; + if valid_uris.next().is_some() { + tracing::warn!( + server = server.as_str(), + tool = tool.name.as_str(), + "ignoring MCP tool with multiple valid statusline entrypoints" + ); + return None; + } + Some(Self { + id: ProviderId { + server: server.clone(), + tool: McpToolName(tool.name.clone()), + }, + title: tool.title.clone().unwrap_or_else(|| tool.name.clone()), + plugin_label: plugin_id.map(PluginId::as_key), + uri: uri.to_owned(), + plugin_id: plugin_id.cloned(), + }) + } +} diff --git a/codex-rs/tui/src/statusline_provider/inventory.rs b/codex-rs/tui/src/statusline_provider/inventory.rs index 498ed9d251..6b0cf57436 100644 --- a/codex-rs/tui/src/statusline_provider/inventory.rs +++ b/codex-rs/tui/src/statusline_provider/inventory.rs @@ -1,6 +1,8 @@ //! Read MCP tool catalogs from the connected app server’s global inventory. use super::McpServerName; +use super::McpToolName; +use super::StatuslineProvider; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ListMcpServerStatusParams; @@ -10,9 +12,11 @@ use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::RequestId; use codex_plugin::PluginId; use codex_plugin::PluginIdError; +use codex_protocol::ThreadId; use codex_protocol::mcp::Tool; use color_eyre::Result; use color_eyre::eyre::bail; +use color_eyre::eyre::eyre; use futures::Stream; use futures::stream; use serde::Deserialize; @@ -49,6 +53,26 @@ impl TryFrom for StatuslineServer { } } +impl StatuslineServer { + fn catalog(&self) -> Result<&HashMap> { + self.tools.as_ref().map_err(|error| eyre!("{error}")) + } + pub(super) fn providers(&self) -> Result> { + Ok(self + .catalog()? + .values() + .filter_map(|tool| { + StatuslineProvider::from_tool(&self.name, self.plugin_id.as_ref(), tool) + }) + .collect()) + } + pub(super) fn provider(&self, name: &McpToolName) -> Result> { + Ok(self.catalog()?.get(&name.0).and_then(|tool| { + StatuslineProvider::from_tool(&self.name, self.plugin_id.as_ref(), tool) + })) + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct InventoryPage { @@ -119,11 +143,12 @@ fn local_server(value: Value) -> Result> { pub(super) async fn server( client: &AppServerRequestHandle, name: &McpServerName, + thread: Option, ) -> Result> { let mut page = read_page( client, ListMcpServerStatusParams { - thread_id: None, + thread_id: thread.map(|id| id.to_string()), server_name: Some(name.0.clone()), detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), cursor: None, diff --git a/codex-rs/tui/src/statusline_provider/inventory_tests.rs b/codex-rs/tui/src/statusline_provider/inventory_tests.rs index a94ecc5268..ec0419f7d8 100644 --- a/codex-rs/tui/src/statusline_provider/inventory_tests.rs +++ b/codex-rs/tui/src/statusline_provider/inventory_tests.rs @@ -173,7 +173,7 @@ async fn targeted_inventory_rejects_another_server() { let name = "calendar".into(); let other = row("other", vec![json!({"name":"footer","inputSchema":{}})]); let (result, ()) = tokio::join!( - server(&client, &name), + server(&client, &name, /*thread*/ None), reply( &mut commands, "mcpServerStatus/list", diff --git a/codex-rs/tui/src/statusline_provider_tests.rs b/codex-rs/tui/src/statusline_provider_tests.rs new file mode 100644 index 0000000000..4aad6fd529 --- /dev/null +++ b/codex-rs/tui/src/statusline_provider_tests.rs @@ -0,0 +1,266 @@ +#![cfg(test)] +//! Tool metadata identifies global picker choices. Selected resources use the displayed thread. + +use super::inventory::StatuslineServer; +use super::test_support::Requests; +use super::test_support::reply; +use super::test_support::row; +use super::test_support::test_client; +use super::*; +use codex_app_server_protocol::McpServerStatus; +use pretty_assertions::assert_eq; +use serde_json::json; + +fn server(raw: Value) -> StatuslineServer { + StatuslineServer::try_from(serde_json::from_value::(raw).unwrap()).unwrap() +} + +fn statusline(uri: &str) -> Value { + json!({"type":"statusline","resourceUri":uri}) +} + +fn tool(name: &str, entries: Value) -> Value { + json!({"name":name,"inputSchema":{"type":"object"},"_meta":{"openai/ui":{"entrypoints":entries}}}) +} + +#[test] +fn a_tool_contributes_exactly_one_valid_statusline_entry() { + let good = statusline("ui://demo/a%20b"); + let malformed = [ + Value::Null, + json!({"type":"statusline"}), + json!({"type":"statusline","resourceUri":12}), + statusline("https://example.test/footer"), + statusline("ui://["), + json!({"type":"desktop","resourceUri":"ui://demo/window"}), + statusline("ui://demo/ba\nd"), + statusline("ui://demo/%"), + ]; + for extra in &malformed { + let mut raw = row("demo", vec![tool("status", json!([extra, good, extra]))]); + raw["pluginId"] = json!("calendar@local"); + let row = server(raw); + let providers = row.providers().unwrap(); + let [provider] = providers.as_slice() else { + panic!("expected one statusline provider") + }; + assert_eq!( + (provider.uri.as_str(), provider.plugin_label.as_deref()), + ("ui://demo/a%20b", Some("calendar@local")) + ); + } + for entries in [ + json!(malformed), + json!([good, good]), + json!([good, statusline("ui://demo/other")]), + json!({}), + ] { + let row = server(row("demo", vec![tool("status", entries)])); + assert!(row.providers().unwrap().is_empty()); + } +} + +async fn scoped_config(commands: &mut Requests, cwd: &str, layers: &[Value]) { + reply( + commands, + "config/read", + json!({"includeLayers":true,"cwd":cwd}), + json!({"config":{},"layers":layers}), + ) + .await; +} + +async fn named(commands: &mut Requests, thread: Option, row: Value) { + reply( + commands, + "mcpServerStatus/list", + json!({"threadId":thread.map(|id| id.to_string()),"serverName":row["name"], + "detail":"toolsAndAuthOnly","cursor":null,"limit":null}), + json!({"data":[row],"nextCursor":null}), + ) + .await; +} + +#[tokio::test] +async fn picker_choice_reads_the_displayed_threads_uri_with_the_snapshot() -> Result<()> { + let thread = ThreadId::new(); + let cwd = PathUri::parse("file:///C:/workspace")?; + let listing = |uri: &str| row("calendar", vec![tool("next", json!([statusline(uri)]))]); + let known = server(listing("ui://calendar/global")) + .providers()? + .pop() + .unwrap(); + let snapshot = json!({"version":1,"thread":{"id":thread.to_string()}}); + let layers = [ + json!({"name":{"type":"sessionFlags"},"config":{"mcp_servers":{"calendar":{"command":"calendar"}}}}), + json!({"name":{"type":"project"},"config":{"mcp_servers":{"calendar":{"command":"project"}}}}), + json!({"name":{"type":"project"},"disabledReason":"untrusted", + "config":{"mcp_servers":{"calendar":{"args":["ignored"]}}}}), + ]; + let (client, mut requests) = test_client(); + let host = async { + scoped_config(&mut requests, r"C:\workspace", &layers).await; + let mut live = listing("ui://calendar/today"); + live["tools"]["next"]["title"] = json!("Next event"); + named(&mut requests, Some(thread), live).await; + reply( + &mut requests, + "mcpServer/resource/read", + json!({ + "threadId": thread.to_string(), + "server": "calendar", + "uri": "ui://calendar/today", + "connectorId": null, + "target": null, + "originCallId": null, + "_meta": {"com.openai/codex-statusline-state": snapshot}, + }), + json!({"contents": [], "originCallId": null}), + ) + .await; + }; + let read = async { + let provider = ResolvedStatuslineProvider::lookup( + &client, + thread, + &cwd, + ProviderSelection::Discovered(&known), + ) + .await? + .unwrap(); + assert_eq!(provider.provider().title, "Next event"); + provider.read(snapshot.clone()).await + }; + let (result, ()) = tokio::join!(read, host); + result?; + Ok(()) +} + +#[tokio::test] +async fn visible_project_registration_blocks_the_selected_provider() -> Result<()> { + let cwd = PathUri::parse("file:///workspace/b")?; + for (owner, session, project) in [ + ( + None, + json!({"mcp_servers":{"calendar":{"command":"calendar"}}}), + json!({"mcp_servers":{"calendar":{"command":"replacement","args":["from-project"]}}}), + ), + ( + Some("daily@local"), + json!({}), + json!({"plugins":{"daily@local":{"release_id":"replacement"}}}), + ), + ( + Some("daily@local"), + json!({}), + json!({"marketplaces":{"local":{"source_type":"local","source":"/replacement"}}}), + ), + ] { + let mut listing = row( + "calendar", + vec![tool("today", json!([statusline("ui://calendar/today")]))], + ); + listing["pluginId"] = json!(owner); + let known = server(listing).providers()?.pop().unwrap(); + let (client, mut requests) = test_client(); + let host = async move { + scoped_config( + &mut requests, + "/workspace/b", + &[ + json!({"name":{"type":"sessionFlags"},"config":session}), + json!({"name":{"type":"project"},"config":project}), + ], + ) + .await; + }; + let lookup = ResolvedStatuslineProvider::lookup( + &client, + ThreadId::new(), + &cwd, + ProviderSelection::Discovered(&known), + ); + let (provider, ()) = tokio::join!(lookup, host); + assert!(provider?.is_none()); + } + Ok(()) +} + +#[tokio::test] +async fn selecting_a_plugin_requires_the_globally_advertised_tool_and_the_connected_owner() +-> Result<()> { + let (client, mut commands) = test_client(); + let thread = ThreadId::new(); + let named_plugin = |name| { + let mut value = row( + "calendar", + vec![tool(name, json!([statusline("ui://calendar/status")]))], + ); + value["pluginId"] = json!("daily@local"); + value + }; + let global = named_plugin("status"); + let mut replacement = global.clone(); + replacement["pluginId"] = json!("project@local"); + replacement["runtimeStatus"] = json!("failed"); + let mut disconnected = global.clone(); + disconnected["runtimeStatus"] = json!("failed"); + let mut list_failure = disconnected.clone(); + list_failure["toolsError"] = json!("calendar catalog failed"); + let mut unverified = global.clone(); + unverified.as_object_mut().unwrap().remove("httpOrigin"); + let unavailable = [ + (named_plugin("other"), None), + (global.clone(), Some(replacement)), + (global.clone(), Some(named_plugin("renamed"))), + (global.clone(), Some(unverified)), + ]; + let rejected = unavailable.len(); + let mut live = global.clone(); + live["tools"]["status"]["_meta"]["openai/ui"]["entrypoints"] = + json!([statusline("ui://calendar/current")]); + let worker = tokio::spawn(async move { + for (global, live) in unavailable.into_iter().chain([ + (global.clone(), Some(disconnected)), + (global.clone(), Some(list_failure)), + (global, Some(live)), + ]) { + scoped_config(&mut commands, "/workspace/a", &[]).await; + named(&mut commands, /*thread*/ None, global).await; + if let Some(live) = live { + named(&mut commands, Some(thread), live).await; + } + } + reply( + &mut commands, + "mcpServer/resource/read", + json!({ + "threadId":thread.to_string(),"server":"calendar","uri":"ui://calendar/current", + "connectorId":null,"target":null,"originCallId":null, + "_meta":{"com.openai/codex-statusline-state":{"version":1}} + }), + json!({"contents":[],"originCallId":null}), + ) + .await; + }); + let cwd = PathUri::parse("file:///workspace/a")?; + let id = ProviderId { + server: "calendar".into(), + tool: "status".into(), + }; + let lookup = + || ResolvedStatuslineProvider::lookup(&client, thread, &cwd, ProviderSelection::Saved(&id)); + for _ in 0..rejected { + assert!(lookup().await?.is_none()); + } + for message in ["is not connected", "calendar catalog failed"] { + assert!( + lookup() + .await + .is_err_and(|e| e.to_string().contains(message)) + ); + } + lookup().await?.unwrap().read(json!({"version":1})).await?; + worker.await?; + Ok(()) +}