Cover direct MCP UI capability forwarding

This commit is contained in:
martinauyeung-oai
2026-07-07 05:43:04 -07:00
parent fed1dd3b8d
commit 1ce184efd4
2 changed files with 163 additions and 1 deletions
+18
View File
@@ -93,6 +93,24 @@ App-server then advertises the downstream `openai/form` MCP extension for
threads started, resumed, or forked by that connection. Clients that cannot
handle the request envelope omit the field or set it to `false`.
Clients that can render MCP App WebViews advertise the MCP UI extension:
```json
{
"capabilities": {
"extensions": {
"io.modelcontextprotocol/ui": {
"mimeTypes": ["text/html;profile=mcp-app"]
}
}
}
}
```
App-server forwards this capability to the host-owned `codex_apps` MCP
server during initialization and on each `tools/call`. Clients without an
MCP App WebView omit the extension.
Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter.
**Important**: `clientInfo.name` is used to identify the client for the OpenAI Compliance Logs Platform. If
+145 -1
View File
@@ -1,17 +1,24 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::TestAppServer;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use app_test_support::write_mock_responses_config_toml;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
use axum::Router;
use codex_app_server_protocol::CapabilityRootLocation;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::EnvironmentAddResponse;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
@@ -33,6 +40,7 @@ use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use codex_features::Feature;
use codex_utils_path_uri::PathUri;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
@@ -68,6 +76,10 @@ use tokio::task::JoinHandle;
use tokio::time::timeout;
use tokio_tungstenite::tungstenite::Message;
use super::connection_handling_websocket::connect_websocket;
use super::connection_handling_websocket::read_response_for_id;
use super::connection_handling_websocket::send_request;
use super::connection_handling_websocket::spawn_websocket_server;
use super::exec_server_test_support::accept_exec_server_environment;
use super::exec_server_test_support::read_exec_server_json;
@@ -80,8 +92,128 @@ const ELICITATION_MESSAGE: &str = "Allow this request?";
const URL_ELICITATION_TRIGGER_MESSAGE: &str = "auth";
const URL_ELICITATION_MESSAGE: &str = "Sign in to GitHub to continue.";
const URL_ELICITATION_URL: &str = "https://github.example/login/device";
const MCP_APP_UI_CAPABILITY_TRIGGER_MESSAGE: &str = "mcp-app-ui-capability";
const LATE_ENVIRONMENT_ID: &str = "late-environment";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_server_tool_call_forwards_mcp_app_ui_capability_from_initialize() -> Result<()> {
let responses_server = responses::start_mock_server().await;
let (apps_server_url, apps_server_handle) =
start_mcp_server_at_path("/api/codex/ps/mcp").await?;
let codex_home = TempDir::new()?;
write_mock_responses_config_toml_with_chatgpt_base_url(
codex_home.path(),
&responses_server.uri(),
&apps_server_url,
)?;
let config_path = codex_home.path().join("config.toml");
let config = std::fs::read_to_string(&config_path)?;
std::fs::write(config_path, format!("{config}\n[features]\napps = true\n"))?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let mut text_only_client = connect_websocket(bind_addr).await?;
send_request(
&mut text_only_client,
"initialize",
1,
Some(serde_json::to_value(InitializeParams {
client_info: ClientInfo {
name: "text-only-client".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
..Default::default()
}),
})?),
)
.await?;
let _ = read_response_for_id(&mut text_only_client, 1).await?;
send_request(
&mut text_only_client,
"thread/start",
2,
Some(serde_json::to_value(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})?),
)
.await?;
let thread_start_response = read_response_for_id(&mut text_only_client, 2).await?;
let ThreadStartResponse { thread, .. } = to_response(thread_start_response)?;
let mut widget_capable_client = connect_websocket(bind_addr).await?;
send_request(
&mut widget_capable_client,
"initialize",
3,
Some(serde_json::to_value(InitializeParams {
client_info: ClientInfo {
name: "widget-capable-client".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
extensions: Some(HashMap::from([(
"io.modelcontextprotocol/ui".to_string(),
json!({
"mimeTypes": ["text/html;profile=mcp-app"],
}),
)])),
..Default::default()
}),
})?),
)
.await?;
let _ = read_response_for_id(&mut widget_capable_client, 3).await?;
send_request(
&mut widget_capable_client,
"mcpServer/tool/call",
4,
Some(serde_json::to_value(McpServerToolCallParams {
thread_id: thread.id,
server: "codex_apps".to_string(),
tool: TEST_TOOL_NAME.to_string(),
arguments: Some(json!({
"message": MCP_APP_UI_CAPABILITY_TRIGGER_MESSAGE,
})),
meta: None,
})?),
)
.await?;
let tool_call_response = read_response_for_id(&mut widget_capable_client, 4).await?;
let response: McpServerToolCallResponse = to_response(tool_call_response)?;
assert_eq!(
response
.structured_content
.as_ref()
.and_then(|content| content.pointer(
"/requestMeta/io.modelcontextprotocol~1clientCapabilities/extensions/io.modelcontextprotocol~1ui/mimeTypes"
)),
Some(&json!(["text/html;profile=mcp-app"]))
);
process.kill().await?;
apps_server_handle.abort();
let _ = apps_server_handle.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_server_tool_call_returns_tool_result() -> Result<()> {
let responses_server = responses::start_mock_server().await;
@@ -743,6 +875,14 @@ impl ServerHandler for ToolAppsMcpServer {
.and_then(|value| value.as_str())
.unwrap_or_default();
if message == MCP_APP_UI_CAPABILITY_TRIGGER_MESSAGE {
let mut result = CallToolResult::structured(json!({
"requestMeta": context.meta.0,
}));
result.content = vec![Content::text("captured request metadata")];
return Ok(result);
}
let mut meta = Meta::new();
meta.0.insert("calledBy".to_string(), json!("mcp-app"));
@@ -819,6 +959,10 @@ impl ServerHandler for ToolAppsMcpServer {
}
async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> {
start_mcp_server_at_path("/mcp").await
}
async fn start_mcp_server_at_path(path: &str) -> Result<(String, JoinHandle<()>)> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let mcp_service = StreamableHttpService::new(
@@ -826,7 +970,7 @@ async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> {
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
let router = Router::new().nest_service("/mcp", mcp_service);
let router = Router::new().nest_service(path, mcp_service);
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;