Files
codex/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs
T
stevenlee-oai 4c89139da9 Add CIMD support to MCP OAuth registration (#38089)
## What changed

- Make automatic MCP OAuth registration prefer Client ID Metadata Documents
  (CIMD) when the authorization server advertises support for public clients and
  Codex is using its native loopback callback. Fall back to advertised Dynamic
  Client Registration (DCR) otherwise.
- Add explicit `cimd` and `dcr` registration overrides to the CLI and app-server
  OAuth login API. Validate CIMD metadata and callback URLs before starting the
  authorization flow.
- Use a callback-specific Codex client metadata URL for CIMD and retain the
  exact redirect URI through authorization and token exchange.

## Testing

- Cover automatic and forced CIMD selection, DCR fallback, invalid metadata and
  redirects, token refresh, authenticated MCP requests, and conformance
  regression checks.

GitOrigin-RevId: 4238372ca53b0f38e781e141ab5da97e0a6ddf45
2026-08-12 00:01:01 +00:00

775 lines
27 KiB
Rust

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::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
use codex_app_server_protocol::McpServerOauthLoginResponse;
use codex_app_server_protocol::McpServerStatusDetail;
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_protocol::config_types::TrustLevel;
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::ServerInfo;
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 tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::time::sleep;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
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(&registrations);
let token_count = Arc::new(AtomicUsize::new(0));
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),
})
},
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(&registration_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();
let token_count = Arc::clone(&token_count);
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),
));
if token_count.fetch_add(1, Ordering::SeqCst) == 0 {
Json(json!({
"access_token": "expired-cimd-access-token",
"token_type": "Bearer",
"expires_in": 0,
"refresh_token": "test-refresh-token",
}))
} else {
Json(json!({
"access_token": "refreshed-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 = reqwest::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 = reqwest::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", &parameters["state"]);
reqwest::Client::builder()
.no_proxy()
.build()?
.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,
McpServerOauthLoginCompletedNotification {
name: "cimd".to_string(),
thread_id: None,
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 {
cursor: None,
limit: None,
detail: Some(McpServerStatusDetail::Full),
thread_id: None,
},
})
.await?;
let (refresh_request, refresh_authorization) =
timeout(DEFAULT_READ_TIMEOUT, token_request_rx.recv())
.await?
.expect("expired CIMD token should be refreshed");
let refresh_parameters = url::form_urlencoded::parse(refresh_request.as_bytes())
.into_owned()
.collect::<BTreeMap<String, String>>();
assert_eq!(
refresh_parameters.get("grant_type").map(String::as_str),
Some("refresh_token")
);
assert_eq!(
refresh_parameters.get("refresh_token").map(String::as_str),
Some("test-refresh-token")
);
assert_eq!(refresh_parameters.get("client_id"), Some(&client_id));
assert!(!refresh_parameters.contains_key("client_secret"));
assert_eq!(refresh_authorization, None);
assert_eq!(
timeout(DEFAULT_READ_TIMEOUT, mcp_authorization_rx.recv())
.await?
.expect("MCP startup should use the refreshed token"),
"Bearer refreshed-cimd-access-token"
);
assert_eq!(registrations.load(Ordering::SeqCst), 0);
oauth_server_handle.abort();
let _ = oauth_server_handle.await;
Ok(())
}
#[tokio::test]
async fn mcp_server_status_list_returns_raw_server_and_tool_names() -> 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").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 response: ListMcpServerStatusResponse = mcp
.request(|request_id| ClientRequest::McpServerStatusList {
request_id,
params: ListMcpServerStatusParams {
cursor: None,
limit: None,
detail: None,
thread_id: None,
},
})
.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.plugin_id, None);
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 {
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 {
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").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 {
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 {
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.tools.keys().cloned().collect::<BTreeSet<_>>(),
BTreeSet::from(["project_lookup".to_string()])
);
mcp_server_handle.abort();
let _ = mcp_server_handle.await;
Ok(())
}
#[derive(Clone)]
struct McpStatusServer {
tool_name: Arc<String>,
}
impl ServerHandler for McpStatusServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).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> {
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) -> ServerInfo {
ServerInfo::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 {
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").await?;
let (underscore_server_url, underscore_server_handle) =
start_mcp_server("underscore_lookup").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 {
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) -> 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),
})
},
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")
}