mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
## Why Hosted Apps returns no resource templates, so querying `resources/templates/list` is unnecessary. ## What changed Return an empty template list for host-owned Apps in single-server binding requests and skip it during aggregate template discovery. Use server registration metadata so extension-provided servers named `codex_apps` continue to expose their templates. ## Testing Add coverage for hosted Apps versus extension registrations and for full MCP server status with standalone and thread connections. Verify that status requests still call `resources/list` but omit `resources/templates/list`. GitOrigin-RevId: 3182b619391aefbb2a47ca7b8d9611402846bfe3
1550 lines
53 KiB
Rust
1550 lines
53 KiB
Rust
use super::mcp_resource::ResourceTestEnvironment;
|
|
use super::mcp_resource::start_resource_test_app_server;
|
|
use std::borrow::Cow;
|
|
use std::collections::BTreeMap;
|
|
use std::collections::BTreeSet;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use app_test_support::MockResponsesConfig;
|
|
use app_test_support::TestAppServer;
|
|
use app_test_support::create_mock_responses_server_sequence_unchecked;
|
|
use axum::Json;
|
|
use axum::Router;
|
|
use axum::body::Bytes;
|
|
use axum::http::HeaderMap;
|
|
use axum::routing::get;
|
|
use axum::routing::post;
|
|
use codex_app_server_protocol::ClientRequest;
|
|
use codex_app_server_protocol::JSONRPCError;
|
|
use codex_app_server_protocol::ListMcpServerStatusParams;
|
|
use codex_app_server_protocol::ListMcpServerStatusResponse;
|
|
use codex_app_server_protocol::McpServerConnectionStatus;
|
|
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
|
|
use codex_app_server_protocol::McpServerOauthLoginResponse;
|
|
use codex_app_server_protocol::McpServerStatusDetail;
|
|
use codex_app_server_protocol::PluginInstallParams;
|
|
use codex_app_server_protocol::PluginInstallResponse;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_core::config::set_project_trust_level;
|
|
use codex_http_client::HttpClientBuilder;
|
|
use codex_protocol::config_types::TrustLevel;
|
|
use codex_rmcp_client::McpOAuthCallbackMode;
|
|
use codex_rmcp_client::resolve_mcp_oauth_callback_url;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use core_test_support::apps_test_server::AppsTestServer;
|
|
use core_test_support::skip_if_remote;
|
|
use core_test_support::stdio_server_bin;
|
|
use pretty_assertions::assert_eq;
|
|
use rmcp::handler::server::ServerHandler;
|
|
use rmcp::model::Implementation;
|
|
use rmcp::model::JsonObject;
|
|
use rmcp::model::ListResourceTemplatesResult;
|
|
use rmcp::model::ListResourcesResult;
|
|
use rmcp::model::ListToolsResult;
|
|
use rmcp::model::PaginatedRequestParams;
|
|
use rmcp::model::ServerCapabilities;
|
|
use rmcp::model::ServerConfig;
|
|
use rmcp::model::Tool;
|
|
use rmcp::model::ToolAnnotations;
|
|
use rmcp::service::RequestContext;
|
|
use rmcp::transport::StreamableHttpServerConfig;
|
|
use rmcp::transport::StreamableHttpService;
|
|
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
use test_case::test_case;
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::mpsc;
|
|
use tokio::task::JoinHandle;
|
|
use tokio::time::sleep;
|
|
use tokio::time::timeout;
|
|
use url::Url;
|
|
use wiremock::Match;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::body_partial_json;
|
|
use wiremock::matchers::header;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
/// Selected status reads return the thread's tools without repeating MCP discovery.
|
|
#[tokio::test]
|
|
async fn mcp_server_status_list_reuses_thread_connection() -> Result<()> {
|
|
let server = MockServer::start().await;
|
|
let tool = json!({
|
|
"name": "open_widget",
|
|
"title": "Open widget",
|
|
"inputSchema": { "type": "object" },
|
|
"_meta": { "ui": { "resourceUri": "ui://widget.html" } }
|
|
});
|
|
let response_tool = tool.clone();
|
|
Mock::given(method("POST"))
|
|
.and(path("/mcp"))
|
|
.respond_with(move |request: &wiremock::Request| {
|
|
let request: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
|
|
let result = match request["method"].as_str() {
|
|
Some("initialize") => json!({
|
|
"protocolVersion": "2025-11-25",
|
|
"capabilities": { "tools": {} },
|
|
"serverInfo": { "name": "widget", "version": "1.0.0" }
|
|
}),
|
|
Some("tools/list") => json!({ "tools": [response_tool.clone()] }),
|
|
Some("resources/list") => json!({ "resources": [] }),
|
|
Some("resources/templates/list") => json!({ "resourceTemplates": [] }),
|
|
_ => return ResponseTemplate::new(202),
|
|
};
|
|
ResponseTemplate::new(200).set_body_json(json!({
|
|
"jsonrpc": "2.0", "id": request["id"], "result": result
|
|
}))
|
|
})
|
|
.mount(&server)
|
|
.await;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri())
|
|
.with_extra_config(&format!(
|
|
"[mcp_servers.widget]\nurl = \"{}/mcp\"",
|
|
server.uri()
|
|
))
|
|
.write(codex_home.path())?;
|
|
let mut app = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
let thread = app.start_thread(ThreadStartParams::default()).await?.thread;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
app.read_stream_until_matching_notification("widget MCP ready", |notification| {
|
|
notification.method == "mcpServer/startupStatus/updated"
|
|
&& notification.params.as_ref().is_some_and(|params| {
|
|
params["threadId"] == thread.id
|
|
&& params["name"] == "widget"
|
|
&& params["status"] == "ready"
|
|
})
|
|
}),
|
|
)
|
|
.await??;
|
|
|
|
for detail in [
|
|
McpServerStatusDetail::ToolsAndAuthOnly,
|
|
McpServerStatusDetail::Full,
|
|
] {
|
|
let response: ListMcpServerStatusResponse = app
|
|
.request(|request_id| ClientRequest::McpServerStatusList {
|
|
request_id,
|
|
params: ListMcpServerStatusParams {
|
|
thread_id: Some(thread.id.clone()),
|
|
server_name: Some("widget".to_string()),
|
|
detail: Some(detail),
|
|
cursor: None,
|
|
limit: None,
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(response.data.len(), 1);
|
|
assert_eq!(response.data[0].name, "widget");
|
|
assert_eq!(
|
|
response.data[0].tools,
|
|
std::collections::HashMap::from([(
|
|
"open_widget".to_string(),
|
|
serde_json::from_value(tool.clone())?
|
|
)])
|
|
);
|
|
assert_eq!(response.next_cursor, None);
|
|
}
|
|
let discovery_methods: Vec<_> = server
|
|
.received_requests()
|
|
.await
|
|
.unwrap()
|
|
.iter()
|
|
.filter_map(|request| serde_json::from_slice::<serde_json::Value>(&request.body).ok())
|
|
.filter_map(|request| request["method"].as_str().map(str::to_owned))
|
|
.filter(|method| matches!(method.as_str(), "initialize" | "tools/list"))
|
|
.collect();
|
|
assert_eq!(discovery_methods, ["initialize", "tools/list"]);
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case(false; "standalone")]
|
|
#[test_case(true; "thread runtime")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn full_mcp_server_status_skips_codex_apps_resource_templates(
|
|
thread_runtime: bool,
|
|
) -> Result<()> {
|
|
let server = MockServer::start().await;
|
|
let apps = AppsTestServer::mount(&server).await?;
|
|
let (_home, mut app_server) = start_resource_test_app_server(
|
|
&apps.chatgpt_base_url,
|
|
&server.uri(),
|
|
ResourceTestEnvironment::Auto,
|
|
)
|
|
.await?;
|
|
let thread_id = if thread_runtime {
|
|
Some(
|
|
app_server
|
|
.start_thread(ThreadStartParams::default())
|
|
.await?
|
|
.thread
|
|
.id,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
let response: ListMcpServerStatusResponse = app_server
|
|
.request(|request_id| ClientRequest::McpServerStatusList {
|
|
request_id,
|
|
params: ListMcpServerStatusParams {
|
|
cursor: None,
|
|
limit: None,
|
|
detail: Some(McpServerStatusDetail::Full),
|
|
thread_id,
|
|
server_name: Some("codex_apps".to_string()),
|
|
},
|
|
})
|
|
.await?;
|
|
let [status] = response.data.as_slice() else {
|
|
anyhow::bail!("expected the selected Apps server in the full inventory");
|
|
};
|
|
assert_eq!(status.name, "codex_apps");
|
|
assert!(status.resource_templates.is_empty());
|
|
let requests = server.received_requests().await.expect("recorded requests");
|
|
let was_requested = |method: &str| {
|
|
let matcher = body_partial_json(json!({ "method": method }));
|
|
requests.iter().any(|request| matcher.matches(request))
|
|
};
|
|
assert!(was_requested("resources/list"));
|
|
assert!(!was_requested("resources/templates/list"));
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case(false, None, None, None, None, true; "legacy callback")]
|
|
#[test_case(
|
|
false,
|
|
Some("http://127.0.0.1/callback/registered"),
|
|
None,
|
|
None,
|
|
None,
|
|
true;
|
|
"ordinary configured callback falls back to default"
|
|
)]
|
|
#[test_case(
|
|
false,
|
|
Some("http://127.0.0.1/callback/registered"),
|
|
Some("http://127.0.0.1/global/callback"),
|
|
None,
|
|
None,
|
|
true;
|
|
"ordinary configured callback falls back to global"
|
|
)]
|
|
#[test_case(
|
|
false,
|
|
Some("http://127.0.0.1/callback/registered"),
|
|
Some("http://127.0.0.1/global/callback"),
|
|
Some("https://unexpected.example"),
|
|
None,
|
|
false;
|
|
"legacy callback fallback rejects mismatched issuer"
|
|
)]
|
|
#[test_case(
|
|
true,
|
|
Some("http://127.0.0.1/callback"),
|
|
None,
|
|
Some("matching"),
|
|
None,
|
|
true;
|
|
"matching issuer"
|
|
)]
|
|
#[test_case(
|
|
true,
|
|
Some("http://127.0.0.1/callback"),
|
|
None,
|
|
Some("https://unexpected.example"),
|
|
None,
|
|
false;
|
|
"mismatched issuer"
|
|
)]
|
|
#[test_case(
|
|
true,
|
|
Some("http://127.0.0.1/callback"),
|
|
None,
|
|
None,
|
|
None,
|
|
false;
|
|
"missing issuer"
|
|
)]
|
|
#[test_case(
|
|
false,
|
|
None,
|
|
None,
|
|
None,
|
|
Some("test-client-secret"),
|
|
true;
|
|
"client secret with legacy callback"
|
|
)]
|
|
#[test_case(
|
|
true,
|
|
Some("http://127.0.0.1/callback"),
|
|
None,
|
|
Some("matching"),
|
|
Some("test-client-secret"),
|
|
true;
|
|
"client secret with matching issuer"
|
|
)]
|
|
#[tokio::test]
|
|
async fn oauth_login_validates_callback_issuer_and_uses_http_headers_helper(
|
|
issuer_supported: bool,
|
|
configured_callback: Option<&str>,
|
|
global_callback: Option<&str>,
|
|
callback_issuer: Option<&str>,
|
|
client_secret: Option<&'static str>,
|
|
expected_success: bool,
|
|
) -> Result<()> {
|
|
let oauth = MockServer::start().await;
|
|
let issuer = format!("{}/mcp", oauth.uri());
|
|
Mock::given(method("GET"))
|
|
.and(path("/.well-known/oauth-authorization-server/mcp"))
|
|
.and(header("x-gateway", "gateway-token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"issuer": issuer,
|
|
"authorization_endpoint": format!("{}/oauth/authorize", oauth.uri()),
|
|
"token_endpoint": format!("{}/oauth/token", oauth.uri()),
|
|
"authorization_response_iss_parameter_supported": issuer_supported,
|
|
"token_endpoint_auth_methods_supported": [if client_secret.is_some() {
|
|
"client_secret_post"
|
|
} else {
|
|
"none"
|
|
}],
|
|
})))
|
|
.mount(&oauth)
|
|
.await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.and(header("x-gateway", "gateway-token"))
|
|
.and(move |request: &wiremock::Request| {
|
|
let body: BTreeMap<_, _> = url::form_urlencoded::parse(&request.body)
|
|
.into_owned()
|
|
.collect();
|
|
body.get("client_secret").map(String::as_str) == client_secret
|
|
&& body.get("client_id").map(String::as_str) == Some("test-client")
|
|
})
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "oauth-token",
|
|
"token_type": "Bearer",
|
|
})))
|
|
.expect(u64::from(expected_success))
|
|
.mount(&oauth)
|
|
.await;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
let helper_command = if cfg!(windows) {
|
|
r#"echo {"X-Gateway":"gateway-token"}"#
|
|
} else {
|
|
r#"printf '{"X-Gateway":"gateway-token"}'"#
|
|
};
|
|
let saved_callback = configured_callback
|
|
.map(|callback| format!("callback_url = \"{callback}\"\n"))
|
|
.unwrap_or_default();
|
|
let saved_client_secret = client_secret
|
|
.map(|secret| format!("client_secret = \"{secret}\"\n"))
|
|
.unwrap_or_default();
|
|
let global_callback_config = global_callback
|
|
.map(|callback| format!("mcp_oauth_callback_url = \"{callback}\"\n"))
|
|
.unwrap_or_default();
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
"mcp_oauth_credentials_store = \"file\"\n\
|
|
{global_callback_config}\
|
|
[mcp_servers.gateway]\n\
|
|
url = \"{}/mcp\"\n\
|
|
http_headers_helper = {}\n\
|
|
[mcp_servers.gateway.oauth]\n\
|
|
client_id = \"test-client\"\n\
|
|
{saved_client_secret}\
|
|
{saved_callback}",
|
|
oauth.uri(),
|
|
toml::Value::String(helper_command.to_string()),
|
|
),
|
|
)?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_raw_request(
|
|
"mcpServer/oauth/login",
|
|
Some(json!({"name": "gateway", "timeoutSecs": 10})),
|
|
)
|
|
.await?;
|
|
let response: McpServerOauthLoginResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
let authorization_url = Url::parse(&response.authorization_url)?;
|
|
let query: BTreeMap<_, _> = authorization_url.query_pairs().into_owned().collect();
|
|
assert!(!query.contains_key("client_secret"));
|
|
let mut callback_url = Url::parse(&query["redirect_uri"])?;
|
|
let expected_callback = if issuer_supported {
|
|
configured_callback
|
|
.expect("issuer-bound cases configure a stable callback")
|
|
.to_string()
|
|
} else {
|
|
resolve_mcp_oauth_callback_url(
|
|
&issuer,
|
|
global_callback,
|
|
McpOAuthCallbackMode::CallbackSpecific,
|
|
)?
|
|
};
|
|
assert_eq!(callback_url.path(), Url::parse(&expected_callback)?.path());
|
|
callback_url
|
|
.query_pairs_mut()
|
|
.append_pair("code", "test-code")
|
|
.append_pair("state", &query["state"]);
|
|
if let Some(callback_issuer) = callback_issuer {
|
|
let callback_issuer = if callback_issuer == "matching" {
|
|
issuer.as_str()
|
|
} else {
|
|
callback_issuer
|
|
};
|
|
callback_url
|
|
.query_pairs_mut()
|
|
.append_pair("iss", callback_issuer);
|
|
}
|
|
HttpClientBuilder::new()
|
|
.build_direct()?
|
|
.get(callback_url)
|
|
.send()
|
|
.await?
|
|
.error_for_status()?;
|
|
let completed: McpServerOauthLoginCompletedNotification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_notification("mcpServer/oauthLogin/completed"),
|
|
)
|
|
.await??;
|
|
assert_eq!(
|
|
completed.login_id.as_deref(),
|
|
Some(
|
|
response
|
|
.login_id
|
|
.as_deref()
|
|
.expect("login response should contain an ID")
|
|
)
|
|
);
|
|
assert_eq!(
|
|
(
|
|
completed.name.as_str(),
|
|
completed.thread_id,
|
|
completed.success,
|
|
completed.error.is_some(),
|
|
),
|
|
("gateway", None, expected_success, !expected_success)
|
|
);
|
|
if let Some(client_secret) = client_secret {
|
|
assert!(!response.authorization_url.contains(client_secret));
|
|
let credentials = std::fs::read_to_string(codex_home.path().join(".credentials.json"))?;
|
|
assert!(!credentials.contains(client_secret));
|
|
}
|
|
oauth.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case(false; "plain HTTP")]
|
|
#[test_case(true; "HTTP headers helper")]
|
|
#[tokio::test]
|
|
async fn oauth_login_rejects_servers_disabled_by_managed_requirements(
|
|
with_headers_helper: bool,
|
|
) -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let marker = codex_home.path().join("helper-ran");
|
|
let helper = with_headers_helper
|
|
.then(|| toml::Value::String(format!("echo invoked > \"{}\"", marker.display())))
|
|
.map_or_else(String::new, |command| {
|
|
format!("http_headers_helper = {command}\n")
|
|
});
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!("[mcp_servers.blocked]\nurl = \"https://example.com/mcp\"\n{helper}"),
|
|
)?;
|
|
std::fs::write(
|
|
codex_home.path().join("requirements.toml"),
|
|
"[mcp_servers.blocked.identity]\nurl = \"https://allowed.example.com/mcp\"\n",
|
|
)?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_raw_request(
|
|
"mcpServer/oauth/login",
|
|
Some(json!({"name": "blocked", "timeoutSecs": 10})),
|
|
)
|
|
.await?;
|
|
let error: JSONRPCError = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
assert!(
|
|
error
|
|
.error
|
|
.message
|
|
.contains("disabled by managed requirements")
|
|
);
|
|
assert!(!marker.exists());
|
|
Ok(())
|
|
}
|
|
|
|
async fn wait_for_new_pid(path: &Path, previous_pid: Option<&str>) -> Result<String> {
|
|
Ok(timeout(DEFAULT_READ_TIMEOUT, async {
|
|
loop {
|
|
if let Ok(contents) = std::fs::read_to_string(path) {
|
|
let pid = contents.trim();
|
|
if !pid.is_empty() && Some(pid) != previous_pid {
|
|
return pid.to_string();
|
|
}
|
|
}
|
|
sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await?)
|
|
}
|
|
|
|
fn assert_dynamic_status(response: &ListMcpServerStatusResponse, process_label: &str) {
|
|
assert_eq!(response.data.len(), 1);
|
|
let status = &response.data[0];
|
|
assert_eq!(status.name, "cached-stdio");
|
|
assert_eq!(
|
|
status
|
|
.server_info
|
|
.as_ref()
|
|
.and_then(|info| info.title.as_deref()),
|
|
Some(process_label)
|
|
);
|
|
assert_eq!(
|
|
status
|
|
.tools
|
|
.get("echo")
|
|
.and_then(|tool| tool.description.as_deref()),
|
|
Some(format!("Echo from {process_label}.").as_str())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_login_automatically_selects_callback_specific_cimd_without_metadata_issuer()
|
|
-> Result<()> {
|
|
let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let base_url = format!("http://{}", listener.local_addr()?);
|
|
let metadata = json!({
|
|
"authorization_endpoint": format!("{base_url}/authorize"),
|
|
"token_endpoint": format!("{base_url}/token"),
|
|
"registration_endpoint": format!("{base_url}/register"),
|
|
"client_id_metadata_document_supported": true,
|
|
"token_endpoint_auth_methods_supported": ["none"],
|
|
"response_types_supported": ["code"],
|
|
"code_challenge_methods_supported": ["S256"],
|
|
});
|
|
let registrations = Arc::new(AtomicUsize::new(0));
|
|
let registration_count = Arc::clone(®istrations);
|
|
let (token_request_tx, mut token_request_rx) = mpsc::unbounded_channel();
|
|
let (mcp_authorization_tx, mut mcp_authorization_rx) = mpsc::unbounded_channel();
|
|
let tool_name = Arc::new("cimd".to_string());
|
|
let mcp_service = StreamableHttpService::new(
|
|
move || {
|
|
Ok(McpStatusServer {
|
|
tool_name: Arc::clone(&tool_name),
|
|
tools_error: None,
|
|
})
|
|
},
|
|
Arc::new(LocalSessionManager::default()),
|
|
StreamableHttpServerConfig::default(),
|
|
);
|
|
let mcp_router =
|
|
Router::new()
|
|
.nest_service("/mcp", mcp_service)
|
|
.layer(axum::middleware::from_fn(
|
|
move |request: axum::extract::Request, next: axum::middleware::Next| {
|
|
let mcp_authorization_tx = mcp_authorization_tx.clone();
|
|
async move {
|
|
if let Some(authorization) = request
|
|
.headers()
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
{
|
|
let _ = mcp_authorization_tx.send(authorization.to_string());
|
|
}
|
|
next.run(request).await
|
|
}
|
|
},
|
|
));
|
|
let oauth_server = Router::new()
|
|
.route(
|
|
"/.well-known/oauth-authorization-server/mcp",
|
|
get(move || {
|
|
let metadata = metadata.clone();
|
|
async move { Json(metadata) }
|
|
}),
|
|
)
|
|
.route(
|
|
"/register",
|
|
post(move || {
|
|
let registrations = Arc::clone(®istration_count);
|
|
async move {
|
|
registrations.fetch_add(1, Ordering::SeqCst);
|
|
Json(json!({"client_id": "unexpected-dcr-client"}))
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/token",
|
|
post(move |headers: HeaderMap, body: Bytes| {
|
|
let token_request_tx = token_request_tx.clone();
|
|
async move {
|
|
let _ = token_request_tx.send((
|
|
String::from_utf8_lossy(&body).into_owned(),
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::to_string),
|
|
));
|
|
Json(json!({
|
|
"access_token": "cimd-access-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_token": "test-refresh-token",
|
|
}))
|
|
}
|
|
}),
|
|
)
|
|
.merge(mcp_router);
|
|
let oauth_server_handle = tokio::spawn(async move {
|
|
let _ = axum::serve(listener, oauth_server).await;
|
|
});
|
|
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&responses_server.uri())
|
|
.with_extra_config(&format!(
|
|
"mcp_oauth_credentials_store = \"file\"\n[mcp_servers.cimd]\nurl = \"{base_url}/mcp\""
|
|
))
|
|
.write(codex_home.path())?;
|
|
let mut app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let request_id = app_server
|
|
.send_raw_request(
|
|
"mcpServer/oauth/login",
|
|
Some(json!({"name": "cimd", "timeoutSecs": 10})),
|
|
)
|
|
.await?;
|
|
let response: McpServerOauthLoginResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??;
|
|
let authorization_url = Url::parse(&response.authorization_url)?;
|
|
let parameters = authorization_url
|
|
.query_pairs()
|
|
.into_owned()
|
|
.collect::<BTreeMap<String, String>>();
|
|
let redirect_uri = parameters["redirect_uri"].clone();
|
|
let mut callback_url = Url::parse(&redirect_uri)?;
|
|
let callback_id = callback_url
|
|
.path()
|
|
.strip_prefix("/callback/")
|
|
.expect("issuerless CIMD should use a resource-specific callback");
|
|
let client_id = format!("https://chatgpt.com/oauth/codex/{callback_id}/client.json");
|
|
assert_eq!(parameters.get("client_id"), Some(&client_id));
|
|
assert_eq!(
|
|
parameters.get("code_challenge_method").map(String::as_str),
|
|
Some("S256")
|
|
);
|
|
assert_eq!(registrations.load(Ordering::SeqCst), 0);
|
|
|
|
callback_url
|
|
.query_pairs_mut()
|
|
.append_pair("code", "cimd-authorization-code")
|
|
.append_pair("state", ¶meters["state"]);
|
|
HttpClientBuilder::new()
|
|
.build_direct()?
|
|
.get(callback_url)
|
|
.send()
|
|
.await?
|
|
.error_for_status()?;
|
|
let (token_request, token_authorization) =
|
|
timeout(DEFAULT_READ_TIMEOUT, token_request_rx.recv())
|
|
.await?
|
|
.expect("CIMD authorization should exchange its authorization code");
|
|
let token_parameters = url::form_urlencoded::parse(token_request.as_bytes())
|
|
.into_owned()
|
|
.collect::<BTreeMap<String, String>>();
|
|
assert_eq!(token_parameters.get("client_id"), Some(&client_id));
|
|
assert!(token_parameters.contains_key("code_verifier"));
|
|
assert_eq!(token_authorization, None);
|
|
|
|
let completed: McpServerOauthLoginCompletedNotification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
app_server.read_notification("mcpServer/oauthLogin/completed"),
|
|
)
|
|
.await??;
|
|
assert_eq!(
|
|
completed.login_id.as_deref(),
|
|
Some(
|
|
response
|
|
.login_id
|
|
.as_deref()
|
|
.expect("login response should contain an ID")
|
|
)
|
|
);
|
|
assert_eq!(
|
|
completed,
|
|
McpServerOauthLoginCompletedNotification {
|
|
name: "cimd".to_string(),
|
|
thread_id: None,
|
|
login_id: response.login_id,
|
|
success: true,
|
|
error: None,
|
|
}
|
|
);
|
|
assert_eq!(registrations.load(Ordering::SeqCst), 0);
|
|
|
|
let request_id = app_server
|
|
.send_raw_request("config/mcpServer/reload", /*params*/ None)
|
|
.await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let _: ListMcpServerStatusResponse = app_server
|
|
.request(|request_id| ClientRequest::McpServerStatusList {
|
|
request_id,
|
|
params: ListMcpServerStatusParams {
|
|
server_name: None,
|
|
cursor: None,
|
|
limit: None,
|
|
detail: Some(McpServerStatusDetail::Full),
|
|
thread_id: None,
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp_authorization_rx.recv())
|
|
.await?
|
|
.expect("MCP startup should use the access token"),
|
|
"Bearer cimd-access-token"
|
|
);
|
|
assert_eq!(registrations.load(Ordering::SeqCst), 0);
|
|
|
|
oauth_server_handle.abort();
|
|
let _ = oauth_server_handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case(false; "configured server")]
|
|
#[test_case(true; "plugin server")]
|
|
#[tokio::test]
|
|
async fn mcp_server_status_list_returns_raw_server_and_tool_names(plugin: bool) -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
|
let (mcp_server_url, mcp_server_handle) =
|
|
start_mcp_server("look-up.raw", /*tools_error*/ None).await?;
|
|
let codex_home = TempDir::new()?;
|
|
let endpoint = format!("{mcp_server_url}/mcp?secret=not-for-clients");
|
|
let http_config = if plugin {
|
|
"[features]\nplugins = true\n".to_string()
|
|
} else {
|
|
format!("[mcp_servers.some-server]\nurl = {endpoint:?}\n")
|
|
};
|
|
mock_responses_config(&server.uri())
|
|
.with_extra_config(&format!(
|
|
"{http_config}[mcp_servers.broken-server]\ncommand = {}",
|
|
toml::Value::String(
|
|
codex_home
|
|
.path()
|
|
.join("missing-mcp-server")
|
|
.display()
|
|
.to_string()
|
|
)
|
|
))
|
|
.write(codex_home.path())?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
let plugin_root = TempDir::new()?;
|
|
if plugin {
|
|
std::fs::create_dir_all(plugin_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(plugin_root.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(plugin_root.path().join("sample/.codex-plugin"))?;
|
|
std::fs::write(
|
|
plugin_root.path().join("sample/.codex-plugin/plugin.json"),
|
|
serde_json::to_vec(&json!({"name": "sample"}))?,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.path().join("sample/.mcp.json"),
|
|
serde_json::to_vec(
|
|
&json!({"mcpServers": {"some-server": {"type": "http", "url": endpoint}}}),
|
|
)?,
|
|
)?;
|
|
let marketplace_path = plugin_root.path().join(".agents/plugins/marketplace.json");
|
|
std::fs::write(
|
|
&marketplace_path,
|
|
serde_json::to_vec(&json!({
|
|
"name": "debug", "plugins": [{"name": "sample", "source": {"source": "local", "path": "./sample"}}]
|
|
}))?,
|
|
)?;
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
marketplace_path: Some(AbsolutePathBuf::try_from(marketplace_path)?),
|
|
remote_marketplace_name: None,
|
|
install_attempt_id: None,
|
|
plugin_name: "sample".to_string(),
|
|
})
|
|
.await?;
|
|
let _: PluginInstallResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
}
|
|
let response: ListMcpServerStatusResponse = mcp
|
|
.request(|request_id| ClientRequest::McpServerStatusList {
|
|
request_id,
|
|
params: ListMcpServerStatusParams {
|
|
server_name: None,
|
|
cursor: None,
|
|
limit: None,
|
|
detail: None,
|
|
thread_id: None,
|
|
},
|
|
})
|
|
.await?;
|
|
|
|
assert_eq!(response.next_cursor, None);
|
|
assert_eq!(response.data.len(), 2);
|
|
let failed = response
|
|
.data
|
|
.iter()
|
|
.find(|status| status.name == "broken-server")
|
|
.expect("broken server status");
|
|
assert!(failed.tools.is_empty());
|
|
assert_eq!(failed.server_capabilities, None);
|
|
assert_eq!(failed.http_origin, None);
|
|
assert!(
|
|
failed
|
|
.tools_error
|
|
.as_deref()
|
|
.is_some_and(|error| error.contains("MCP startup failed"))
|
|
);
|
|
let status = response
|
|
.data
|
|
.iter()
|
|
.find(|status| status.name == "some-server")
|
|
.expect("configured server status");
|
|
assert_eq!(
|
|
status
|
|
.server_capabilities
|
|
.as_ref()
|
|
.and_then(|caps| caps.get("extensions")),
|
|
Some(&json!({
|
|
"openai/settings": { "readTool": "settings.read", "updateTool": "settings.update" }
|
|
}))
|
|
);
|
|
assert_eq!(status.tools_error, None);
|
|
assert_eq!(status.name, "some-server");
|
|
assert_eq!(status.runtime_status, None);
|
|
assert_eq!(
|
|
status.plugin_id.as_deref(),
|
|
plugin.then_some("sample@debug")
|
|
);
|
|
assert_eq!(status.http_origin.as_deref(), Some(mcp_server_url.as_str()));
|
|
assert_eq!(
|
|
status.tools.keys().cloned().collect::<BTreeSet<_>>(),
|
|
BTreeSet::from(["look-up.raw".to_string()])
|
|
);
|
|
assert_eq!(
|
|
status
|
|
.tools
|
|
.get("look-up.raw")
|
|
.map(|tool| tool.name.as_str()),
|
|
Some("look-up.raw")
|
|
);
|
|
assert_eq!(
|
|
status
|
|
.server_info
|
|
.as_ref()
|
|
.and_then(|info| info.title.as_deref()),
|
|
Some("Lookup Server")
|
|
);
|
|
|
|
mcp_server_handle.abort();
|
|
let _ = mcp_server_handle.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_server_status_list_waits_for_live_stdio_metadata_before_using_cached_tools()
|
|
-> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
|
let codex_home = TempDir::new()?;
|
|
let barrier_file = codex_home.path().join("allow-initialize");
|
|
let pid_file = codex_home.path().join("mcp.pid");
|
|
std::fs::write(&barrier_file, "ready")?;
|
|
mock_responses_config(&server.uri())
|
|
.with_extra_config(&format!(
|
|
r#"[mcp_servers.cached-stdio]
|
|
command = {}
|
|
enabled_tools = ["echo"]
|
|
startup_timeout_sec = 10
|
|
|
|
[mcp_servers.cached-stdio.env]
|
|
MCP_TEST_DYNAMIC_SERVER_METADATA = "1"
|
|
MCP_TEST_INITIALIZE_BARRIER_FILE = {}
|
|
MCP_TEST_PID_FILE = {}
|
|
"#,
|
|
toml::Value::String(stdio_server_bin()?),
|
|
toml::Value::String(barrier_file.to_string_lossy().into_owned()),
|
|
toml::Value::String(pid_file.to_string_lossy().into_owned()),
|
|
))
|
|
.write(codex_home.path())?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
let first_response: ListMcpServerStatusResponse = mcp
|
|
.request(|request_id| ClientRequest::McpServerStatusList {
|
|
request_id,
|
|
params: ListMcpServerStatusParams {
|
|
server_name: None,
|
|
cursor: None,
|
|
limit: None,
|
|
detail: Some(McpServerStatusDetail::ToolsAndAuthOnly),
|
|
thread_id: None,
|
|
},
|
|
})
|
|
.await?;
|
|
let first_pid = wait_for_new_pid(&pid_file, /*previous_pid*/ None).await?;
|
|
assert_dynamic_status(&first_response, &format!("rmcp-test-process-{first_pid}"));
|
|
|
|
std::fs::remove_file(&barrier_file)?;
|
|
let second_request_id = mcp
|
|
.send_list_mcp_server_status_request(ListMcpServerStatusParams {
|
|
server_name: None,
|
|
cursor: None,
|
|
limit: None,
|
|
detail: Some(McpServerStatusDetail::ToolsAndAuthOnly),
|
|
thread_id: None,
|
|
})
|
|
.await?;
|
|
let second_pid = wait_for_new_pid(&pid_file, Some(&first_pid)).await?;
|
|
assert!(
|
|
timeout(
|
|
Duration::from_millis(200),
|
|
mcp.read_stream_until_response_message(RequestId::Integer(second_request_id)),
|
|
)
|
|
.await
|
|
.is_err(),
|
|
"status/list should wait for the live stdio server to initialize"
|
|
);
|
|
|
|
std::fs::write(&barrier_file, "ready")?;
|
|
let second_response: ListMcpServerStatusResponse =
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(second_request_id)).await??;
|
|
assert_dynamic_status(&second_response, &format!("rmcp-test-process-{second_pid}"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_server_status_list_uses_thread_project_local_config() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
|
let (mcp_server_url, mcp_server_handle) =
|
|
start_mcp_server("project_lookup", /*tools_error*/ None).await?;
|
|
let codex_home = TempDir::new()?;
|
|
let workspace = TempDir::new()?;
|
|
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
|
std::fs::create_dir_all(workspace.path().join(".git"))?;
|
|
set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } = mcp
|
|
.start_thread(ThreadStartParams {
|
|
cwd: Some(workspace.path().to_string_lossy().into_owned()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
|
|
let project_config_dir = workspace.path().join(".codex");
|
|
std::fs::create_dir_all(&project_config_dir)?;
|
|
std::fs::write(
|
|
project_config_dir.join("config.toml"),
|
|
format!(
|
|
r#"
|
|
[mcp_servers.project-server]
|
|
url = "{mcp_server_url}/mcp"
|
|
"#
|
|
),
|
|
)?;
|
|
|
|
let threadless_response: ListMcpServerStatusResponse = mcp
|
|
.request(|request_id| ClientRequest::McpServerStatusList {
|
|
request_id,
|
|
params: ListMcpServerStatusParams {
|
|
server_name: None,
|
|
cursor: None,
|
|
limit: None,
|
|
detail: Some(McpServerStatusDetail::ToolsAndAuthOnly),
|
|
thread_id: None,
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(threadless_response.data, Vec::new());
|
|
|
|
let thread_response: ListMcpServerStatusResponse = mcp
|
|
.request(|request_id| ClientRequest::McpServerStatusList {
|
|
request_id,
|
|
params: ListMcpServerStatusParams {
|
|
server_name: None,
|
|
cursor: None,
|
|
limit: None,
|
|
detail: Some(McpServerStatusDetail::ToolsAndAuthOnly),
|
|
thread_id: Some(thread.id),
|
|
},
|
|
})
|
|
.await?;
|
|
|
|
assert_eq!(thread_response.next_cursor, None);
|
|
assert_eq!(thread_response.data.len(), 1);
|
|
let status = &thread_response.data[0];
|
|
assert_eq!(status.name, "project-server");
|
|
assert_eq!(status.runtime_status, None);
|
|
assert_eq!(
|
|
status.tools.keys().cloned().collect::<BTreeSet<_>>(),
|
|
BTreeSet::from(["project_lookup".to_string()])
|
|
);
|
|
|
|
mcp_server_handle.abort();
|
|
let _ = mcp_server_handle.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_server_status_list_reports_thread_runtime_connections() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
|
let (mcp_server_url, mcp_server_handle) =
|
|
start_mcp_server("lookup", /*tools_error*/ None).await?;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri())
|
|
.with_extra_config(&format!(
|
|
"[mcp_servers.connected]\nurl = \"{mcp_server_url}/mcp\"\n\
|
|
[mcp_servers.disabled]\nurl = \"{mcp_server_url}/mcp\"\nenabled = false\n"
|
|
))
|
|
.write(codex_home.path())?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } = mcp.start_thread(ThreadStartParams::default()).await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_matching_notification("connected MCP server ready", |notification| {
|
|
notification.method == "mcpServer/startupStatus/updated"
|
|
&& notification.params.as_ref().is_some_and(|params| {
|
|
params["name"] == "connected" && params["status"] == "ready"
|
|
})
|
|
}),
|
|
)
|
|
.await??;
|
|
let response: ListMcpServerStatusResponse = mcp
|
|
.request(|request_id| ClientRequest::McpServerStatusList {
|
|
request_id,
|
|
params: ListMcpServerStatusParams {
|
|
server_name: None,
|
|
cursor: None,
|
|
limit: None,
|
|
detail: Some(McpServerStatusDetail::ToolsAndAuthOnly),
|
|
thread_id: Some(thread.id.clone()),
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(
|
|
response
|
|
.data
|
|
.into_iter()
|
|
.map(|status| (status.name, status.runtime_status))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(
|
|
"connected".to_string(),
|
|
Some(McpServerConnectionStatus::Connected)
|
|
),
|
|
(
|
|
"disabled".to_string(),
|
|
Some(McpServerConnectionStatus::Disabled)
|
|
),
|
|
]
|
|
);
|
|
// Inventory uses the latest config, but a same-name replacement is not the
|
|
// connection that this thread started. Unchanged registrations retain status.
|
|
let (replacement_url, replacement_handle) =
|
|
start_mcp_server("replacement_lookup", /*tools_error*/ None).await?;
|
|
mock_responses_config(&server.uri())
|
|
.with_extra_config(&format!(
|
|
"[mcp_servers.connected]\nurl = \"{replacement_url}/mcp\"\n\
|
|
[mcp_servers.disabled]\nurl = \"{mcp_server_url}/mcp\"\nenabled = false\n"
|
|
))
|
|
.write(codex_home.path())?;
|
|
let response: ListMcpServerStatusResponse = mcp
|
|
.request(|request_id| ClientRequest::McpServerStatusList {
|
|
request_id,
|
|
params: ListMcpServerStatusParams {
|
|
server_name: None,
|
|
cursor: None,
|
|
limit: None,
|
|
detail: Some(McpServerStatusDetail::ToolsAndAuthOnly),
|
|
thread_id: Some(thread.id),
|
|
},
|
|
})
|
|
.await?;
|
|
assert_eq!(
|
|
response
|
|
.data
|
|
.into_iter()
|
|
.map(|status| (
|
|
status.name,
|
|
status.runtime_status,
|
|
status.tools.into_keys().collect::<BTreeSet<_>>()
|
|
))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(
|
|
"connected".to_string(),
|
|
None,
|
|
BTreeSet::from(["replacement_lookup".to_string()])
|
|
),
|
|
(
|
|
"disabled".to_string(),
|
|
Some(McpServerConnectionStatus::Disabled),
|
|
BTreeSet::new()
|
|
),
|
|
]
|
|
);
|
|
replacement_handle.abort();
|
|
let _ = replacement_handle.await;
|
|
mcp_server_handle.abort();
|
|
let _ = mcp_server_handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_server_status_list_reports_disconnected_stdio_transport() -> Result<()> {
|
|
skip_if_remote!(
|
|
Ok(()),
|
|
"the stdio executable and shutdown marker are host-local"
|
|
);
|
|
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
|
let codex_home = TempDir::new()?;
|
|
let exit_file = codex_home.path().join("exit-mcp-server");
|
|
mock_responses_config(&server.uri())
|
|
.with_extra_config(&format!(
|
|
"[mcp_servers.stdio]\ncommand = {}\nstartup_timeout_sec = 2\n\
|
|
[mcp_servers.stdio.env]\nMCP_TEST_EXIT_FILE = {}\n",
|
|
toml::Value::String(stdio_server_bin()?),
|
|
toml::Value::String(exit_file.to_string_lossy().into_owned()),
|
|
))
|
|
.write(codex_home.path())?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
let ThreadStartResponse { thread, .. } = mcp.start_thread(ThreadStartParams::default()).await?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_matching_notification("stdio MCP server ready", |notification| {
|
|
notification.method == "mcpServer/startupStatus/updated"
|
|
&& notification
|
|
.params
|
|
.as_ref()
|
|
.is_some_and(|params| params["name"] == "stdio" && params["status"] == "ready")
|
|
}),
|
|
)
|
|
.await??;
|
|
for expected in [
|
|
McpServerConnectionStatus::Connected,
|
|
McpServerConnectionStatus::Failed,
|
|
] {
|
|
if expected == McpServerConnectionStatus::Failed {
|
|
std::fs::write(&exit_file, "exit")?;
|
|
}
|
|
timeout(DEFAULT_READ_TIMEOUT, async {
|
|
loop {
|
|
let response: ListMcpServerStatusResponse = mcp
|
|
.request(|request_id| ClientRequest::McpServerStatusList {
|
|
request_id,
|
|
params: ListMcpServerStatusParams {
|
|
server_name: None,
|
|
cursor: None,
|
|
limit: None,
|
|
detail: Some(McpServerStatusDetail::ToolsAndAuthOnly),
|
|
thread_id: Some(thread.id.clone()),
|
|
},
|
|
})
|
|
.await?;
|
|
let statuses = response
|
|
.data
|
|
.into_iter()
|
|
.map(|status| (status.name, status.runtime_status))
|
|
.collect::<Vec<_>>();
|
|
if statuses == vec![("stdio".to_string(), Some(expected))] {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
sleep(Duration::from_millis(/*millis*/ 20)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_server_status_retains_capabilities_when_tool_discovery_fails() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
|
let (url, handle) = start_mcp_server(
|
|
"lookup",
|
|
Some(rmcp::ErrorData::internal_error(
|
|
"tool discovery unavailable",
|
|
/*data*/ None,
|
|
)),
|
|
)
|
|
.await?;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri())
|
|
.with_extra_config(&format!("[mcp_servers.degraded]\nurl = \"{url}/mcp\""))
|
|
.write(codex_home.path())?;
|
|
let mut app = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized()
|
|
.await?;
|
|
for detail in [None, Some(McpServerStatusDetail::ToolsAndAuthOnly)] {
|
|
let response: ListMcpServerStatusResponse = app
|
|
.request(|request_id| ClientRequest::McpServerStatusList {
|
|
request_id,
|
|
params: ListMcpServerStatusParams {
|
|
server_name: None,
|
|
cursor: None,
|
|
limit: None,
|
|
detail,
|
|
thread_id: None,
|
|
},
|
|
})
|
|
.await?;
|
|
let status = response
|
|
.data
|
|
.iter()
|
|
.find(|status| status.name == "degraded")
|
|
.expect("configured server is included");
|
|
assert!(status.tools.is_empty());
|
|
assert!(
|
|
status
|
|
.tools_error
|
|
.as_ref()
|
|
.is_some_and(|error| error.contains("tool discovery unavailable"))
|
|
);
|
|
assert_eq!(
|
|
status
|
|
.server_capabilities
|
|
.as_ref()
|
|
.and_then(|caps| caps.get("extensions")),
|
|
Some(&json!({
|
|
"openai/settings": { "readTool": "settings.read", "updateTool": "settings.update" }
|
|
}))
|
|
);
|
|
}
|
|
handle.abort();
|
|
let _ = handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct McpStatusServer {
|
|
tool_name: Arc<String>,
|
|
tools_error: Option<rmcp::ErrorData>,
|
|
}
|
|
|
|
impl ServerHandler for McpStatusServer {
|
|
fn get_info(&self) -> ServerConfig {
|
|
let mut capabilities = ServerCapabilities::builder().enable_tools().build();
|
|
capabilities.extensions = Some(BTreeMap::from([(
|
|
"openai/settings".to_string(),
|
|
serde_json::from_value(
|
|
json!({ "readTool": "settings.read", "updateTool": "settings.update" }),
|
|
)
|
|
.expect("settings capability is a JSON object"),
|
|
)]));
|
|
ServerConfig::new(capabilities).with_server_info(
|
|
Implementation::new("lookup-server", "1.0.0").with_title("Lookup Server"),
|
|
)
|
|
}
|
|
|
|
async fn list_tools(
|
|
&self,
|
|
_request: Option<rmcp::model::PaginatedRequestParams>,
|
|
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
|
) -> Result<ListToolsResult, rmcp::ErrorData> {
|
|
if let Some(error) = &self.tools_error {
|
|
return Err(error.clone());
|
|
}
|
|
let input_schema: JsonObject = serde_json::from_value(json!({
|
|
"type": "object",
|
|
"additionalProperties": false
|
|
}))
|
|
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
|
|
|
|
let mut tool = Tool::new(
|
|
Cow::Owned(self.tool_name.as_ref().clone()),
|
|
Cow::Borrowed("Look up test data."),
|
|
Arc::new(input_schema),
|
|
);
|
|
tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
|
|
|
Ok(ListToolsResult::with_all_items(vec![tool]))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct SlowInventoryServer {
|
|
tool_name: Arc<String>,
|
|
}
|
|
|
|
impl ServerHandler for SlowInventoryServer {
|
|
fn get_info(&self) -> ServerConfig {
|
|
ServerConfig::new(
|
|
ServerCapabilities::builder()
|
|
.enable_tools()
|
|
.enable_resources()
|
|
.build(),
|
|
)
|
|
}
|
|
|
|
async fn list_tools(
|
|
&self,
|
|
_request: Option<PaginatedRequestParams>,
|
|
_context: RequestContext<rmcp::service::RoleServer>,
|
|
) -> Result<ListToolsResult, rmcp::ErrorData> {
|
|
let input_schema: JsonObject = serde_json::from_value(json!({
|
|
"type": "object",
|
|
"additionalProperties": false
|
|
}))
|
|
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
|
|
|
|
let mut tool = Tool::new(
|
|
Cow::Owned(self.tool_name.as_ref().clone()),
|
|
Cow::Borrowed("Look up test data."),
|
|
Arc::new(input_schema),
|
|
);
|
|
tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
|
|
|
Ok(ListToolsResult::with_all_items(vec![tool]))
|
|
}
|
|
|
|
async fn list_resources(
|
|
&self,
|
|
_request: Option<PaginatedRequestParams>,
|
|
_context: RequestContext<rmcp::service::RoleServer>,
|
|
) -> Result<ListResourcesResult, rmcp::ErrorData> {
|
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
Ok(ListResourcesResult::with_all_items(Vec::new()))
|
|
}
|
|
|
|
async fn list_resource_templates(
|
|
&self,
|
|
_request: Option<PaginatedRequestParams>,
|
|
_context: RequestContext<rmcp::service::RoleServer>,
|
|
) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
|
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
Ok(ListResourceTemplatesResult::with_all_items(Vec::new()))
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_server_status_list_tools_and_auth_only_skips_slow_inventory_calls() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
|
let (mcp_server_url, mcp_server_handle) = start_slow_inventory_mcp_server("lookup").await?;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri())
|
|
.with_extra_config(&format!(
|
|
"[mcp_servers.some-server]\nurl = \"{mcp_server_url}/mcp\""
|
|
))
|
|
.write(codex_home.path())?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_list_mcp_server_status_request(ListMcpServerStatusParams {
|
|
server_name: None,
|
|
cursor: None,
|
|
limit: None,
|
|
detail: Some(McpServerStatusDetail::ToolsAndAuthOnly),
|
|
thread_id: None,
|
|
})
|
|
.await?;
|
|
let response: ListMcpServerStatusResponse =
|
|
timeout(Duration::from_millis(500), mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(response.next_cursor, None);
|
|
assert_eq!(response.data.len(), 1);
|
|
let status = &response.data[0];
|
|
assert_eq!(status.name, "some-server");
|
|
assert_eq!(
|
|
status.tools.keys().cloned().collect::<BTreeSet<_>>(),
|
|
BTreeSet::from(["lookup".to_string()])
|
|
);
|
|
assert_eq!(status.resources, Vec::new());
|
|
assert_eq!(status.resource_templates, Vec::new());
|
|
|
|
mcp_server_handle.abort();
|
|
let _ = mcp_server_handle.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_server_status_list_keeps_tools_for_sanitized_name_collisions() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
|
let (dash_server_url, dash_server_handle) =
|
|
start_mcp_server("dash_lookup", /*tools_error*/ None).await?;
|
|
let (underscore_server_url, underscore_server_handle) =
|
|
start_mcp_server("underscore_lookup", /*tools_error*/ None).await?;
|
|
let codex_home = TempDir::new()?;
|
|
mock_responses_config(&server.uri())
|
|
.with_extra_config(&format!(
|
|
r#"[mcp_servers.some-server]
|
|
url = "{dash_server_url}/mcp"
|
|
|
|
[mcp_servers.some_server]
|
|
url = "{underscore_server_url}/mcp"
|
|
"#
|
|
))
|
|
.write(codex_home.path())?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized()
|
|
.await?;
|
|
let response: ListMcpServerStatusResponse = mcp
|
|
.request(|request_id| ClientRequest::McpServerStatusList {
|
|
request_id,
|
|
params: ListMcpServerStatusParams {
|
|
server_name: None,
|
|
cursor: None,
|
|
limit: None,
|
|
detail: None,
|
|
thread_id: None,
|
|
},
|
|
})
|
|
.await?;
|
|
|
|
assert_eq!(response.next_cursor, None);
|
|
assert_eq!(response.data.len(), 2);
|
|
let status_tools = response
|
|
.data
|
|
.iter()
|
|
.map(|status| {
|
|
(
|
|
status.name.as_str(),
|
|
status.tools.keys().cloned().collect::<BTreeSet<_>>(),
|
|
)
|
|
})
|
|
.collect::<BTreeMap<_, _>>();
|
|
assert_eq!(
|
|
status_tools,
|
|
BTreeMap::from([
|
|
("some-server", BTreeSet::from(["dash_lookup".to_string()])),
|
|
(
|
|
"some_server",
|
|
BTreeSet::from(["underscore_lookup".to_string()])
|
|
)
|
|
])
|
|
);
|
|
|
|
dash_server_handle.abort();
|
|
let _ = dash_server_handle.await;
|
|
underscore_server_handle.abort();
|
|
let _ = underscore_server_handle.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn start_mcp_server(
|
|
tool_name: &str,
|
|
tools_error: Option<rmcp::ErrorData>,
|
|
) -> Result<(String, JoinHandle<()>)> {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let addr = listener.local_addr()?;
|
|
let tool_name = Arc::new(tool_name.to_string());
|
|
let mcp_service = StreamableHttpService::new(
|
|
move || {
|
|
Ok(McpStatusServer {
|
|
tool_name: Arc::clone(&tool_name),
|
|
tools_error: tools_error.clone(),
|
|
})
|
|
},
|
|
Arc::new(LocalSessionManager::default()),
|
|
StreamableHttpServerConfig::default(),
|
|
);
|
|
let router = Router::new().nest_service("/mcp", mcp_service);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let _ = axum::serve(listener, router).await;
|
|
});
|
|
|
|
Ok((format!("http://{addr}"), handle))
|
|
}
|
|
|
|
async fn start_slow_inventory_mcp_server(tool_name: &str) -> Result<(String, JoinHandle<()>)> {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let addr = listener.local_addr()?;
|
|
let tool_name = Arc::new(tool_name.to_string());
|
|
let mcp_service = StreamableHttpService::new(
|
|
move || {
|
|
Ok(SlowInventoryServer {
|
|
tool_name: Arc::clone(&tool_name),
|
|
})
|
|
},
|
|
Arc::new(LocalSessionManager::default()),
|
|
StreamableHttpServerConfig::default(),
|
|
);
|
|
let router = Router::new().nest_service("/mcp", mcp_service);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let _ = axum::serve(listener, router).await;
|
|
});
|
|
|
|
Ok((format!("http://{addr}"), handle))
|
|
}
|
|
|
|
fn mock_responses_config(server_uri: &str) -> MockResponsesConfig {
|
|
MockResponsesConfig::new(server_uri)
|
|
.with_root_config("compact_prompt = \"compact\"\nmodel_auto_compact_token_limit = 1024")
|
|
.with_provider_config("supports_websockets = false")
|
|
}
|