mirror of
https://github.com/openai/codex.git
synced 2026-02-02 15:03:38 +00:00
Compare commits
3 Commits
prototype
...
dh--mcp-or
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d05ce492 | ||
|
|
fb04d9774e | ||
|
|
e573e9d435 |
23
codex-rs/Cargo.lock
generated
23
codex-rs/Cargo.lock
generated
@@ -726,6 +726,7 @@ dependencies = [
|
||||
"env-flags",
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
"indexmap 2.11.0",
|
||||
"landlock",
|
||||
"libc",
|
||||
"maplit",
|
||||
@@ -927,6 +928,7 @@ name = "codex-protocol"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.11.0",
|
||||
"mcp-types",
|
||||
"mime_guess",
|
||||
"pretty_assertions",
|
||||
@@ -972,6 +974,7 @@ dependencies = [
|
||||
"crossterm",
|
||||
"diffy",
|
||||
"image",
|
||||
"indexmap 2.11.0",
|
||||
"insta",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
@@ -2008,7 +2011,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.10.0",
|
||||
"indexmap 2.11.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -2421,9 +2424,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.10.0"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
|
||||
checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.15.4",
|
||||
@@ -3389,7 +3392,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"indexmap 2.10.0",
|
||||
"indexmap 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3426,7 +3429,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.10.0",
|
||||
"indexmap 2.11.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
@@ -4328,7 +4331,7 @@ version = "1.0.143"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
|
||||
dependencies = [
|
||||
"indexmap 2.10.0",
|
||||
"indexmap 2.11.0",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
@@ -4386,7 +4389,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.10.0",
|
||||
"indexmap 2.11.0",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.0.4",
|
||||
"serde",
|
||||
@@ -5153,7 +5156,7 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8"
|
||||
dependencies = [
|
||||
"indexmap 2.10.0",
|
||||
"indexmap 2.11.0",
|
||||
"serde",
|
||||
"serde_spanned 1.0.0",
|
||||
"toml_datetime 0.7.0",
|
||||
@@ -5186,7 +5189,7 @@ version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap 2.10.0",
|
||||
"indexmap 2.11.0",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
@@ -5199,7 +5202,7 @@ version = "0.23.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17d3b47e6b7a040216ae5302712c94d1cf88c95b47efa80e2c59ce96c878267e"
|
||||
dependencies = [
|
||||
"indexmap 2.10.0",
|
||||
"indexmap 2.11.0",
|
||||
"toml_datetime 0.7.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
|
||||
@@ -25,6 +25,7 @@ dirs = "6"
|
||||
env-flags = "0.1.1"
|
||||
eventsource-stream = "0.2.3"
|
||||
futures = "0.3"
|
||||
indexmap = "2.11.0"
|
||||
libc = "0.2.175"
|
||||
mcp-types = { path = "../mcp-types" }
|
||||
mime_guess = "2.0"
|
||||
|
||||
@@ -20,6 +20,7 @@ use codex_protocol::config_types::ReasoningEffort;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use dirs::home_dir;
|
||||
use indexmap::IndexMap;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -114,7 +115,7 @@ pub struct Config {
|
||||
pub cwd: PathBuf,
|
||||
|
||||
/// Definition for MCP servers that Codex can reach out to for tool calls.
|
||||
pub mcp_servers: HashMap<String, McpServerConfig>,
|
||||
pub mcp_servers: IndexMap<String, McpServerConfig>,
|
||||
|
||||
/// Combined provider map (defaults merged with user-defined overrides).
|
||||
pub model_providers: HashMap<String, ModelProviderInfo>,
|
||||
@@ -421,7 +422,7 @@ pub struct ConfigToml {
|
||||
|
||||
/// Definition for MCP servers that Codex can reach out to for tool calls.
|
||||
#[serde(default)]
|
||||
pub mcp_servers: HashMap<String, McpServerConfig>,
|
||||
pub mcp_servers: IndexMap<String, McpServerConfig>,
|
||||
|
||||
/// User-defined provider entries that extend/override the built-in list.
|
||||
#[serde(default)]
|
||||
@@ -1130,7 +1131,7 @@ disable_response_storage = true
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_servers: IndexMap::new(),
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
codex_home: fixture.codex_home(),
|
||||
@@ -1186,7 +1187,7 @@ disable_response_storage = true
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_servers: IndexMap::new(),
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
codex_home: fixture.codex_home(),
|
||||
@@ -1257,7 +1258,7 @@ disable_response_storage = true
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_servers: IndexMap::new(),
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
codex_home: fixture.codex_home(),
|
||||
|
||||
@@ -15,6 +15,7 @@ use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_mcp_client::McpClient;
|
||||
use indexmap::IndexMap;
|
||||
use mcp_types::ClientCapabilities;
|
||||
use mcp_types::Implementation;
|
||||
use mcp_types::Tool;
|
||||
@@ -43,9 +44,9 @@ const LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// spawned successfully.
|
||||
pub type ClientStartErrors = HashMap<String, anyhow::Error>;
|
||||
|
||||
fn qualify_tools(tools: Vec<ToolInfo>) -> HashMap<String, ToolInfo> {
|
||||
fn qualify_tools(tools: Vec<ToolInfo>) -> IndexMap<String, ToolInfo> {
|
||||
let mut used_names = HashSet::new();
|
||||
let mut qualified_tools = HashMap::new();
|
||||
let mut qualified_tools = IndexMap::new();
|
||||
for tool in tools {
|
||||
let mut qualified_name = format!(
|
||||
"{}{}{}",
|
||||
@@ -88,10 +89,10 @@ pub(crate) struct McpConnectionManager {
|
||||
///
|
||||
/// The server name originates from the keys of the `mcp_servers` map in
|
||||
/// the user configuration.
|
||||
clients: HashMap<String, std::sync::Arc<McpClient>>,
|
||||
clients: IndexMap<String, std::sync::Arc<McpClient>>,
|
||||
|
||||
/// Fully qualified tool name -> tool instance.
|
||||
tools: HashMap<String, ToolInfo>,
|
||||
tools: IndexMap<String, ToolInfo>,
|
||||
}
|
||||
|
||||
impl McpConnectionManager {
|
||||
@@ -104,7 +105,7 @@ impl McpConnectionManager {
|
||||
/// Servers that fail to start are reported in `ClientStartErrors`: the
|
||||
/// user should be informed about these errors.
|
||||
pub async fn new(
|
||||
mcp_servers: HashMap<String, McpServerConfig>,
|
||||
mcp_servers: IndexMap<String, McpServerConfig>,
|
||||
) -> Result<(Self, ClientStartErrors)> {
|
||||
// Early exit if no servers are configured.
|
||||
if mcp_servers.is_empty() {
|
||||
@@ -168,8 +169,8 @@ impl McpConnectionManager {
|
||||
});
|
||||
}
|
||||
|
||||
let mut clients: HashMap<String, std::sync::Arc<McpClient>> =
|
||||
HashMap::with_capacity(join_set.len());
|
||||
let mut clients: IndexMap<String, std::sync::Arc<McpClient>> =
|
||||
IndexMap::with_capacity(join_set.len());
|
||||
|
||||
while let Some(res) = join_set.join_next().await {
|
||||
let (server_name, client_res) = res?; // JoinError propagation
|
||||
@@ -193,7 +194,7 @@ impl McpConnectionManager {
|
||||
|
||||
/// Returns a single map that contains **all** tools. Each key is the
|
||||
/// fully-qualified name for the tool.
|
||||
pub fn list_all_tools(&self) -> HashMap<String, Tool> {
|
||||
pub fn list_all_tools(&self) -> IndexMap<String, Tool> {
|
||||
self.tools
|
||||
.iter()
|
||||
.map(|(name, tool)| (name.clone(), tool.tool.clone()))
|
||||
@@ -230,7 +231,7 @@ impl McpConnectionManager {
|
||||
/// Query every server for its available tools and return a single map that
|
||||
/// contains **all** tools. Each key is the fully-qualified name for the tool.
|
||||
async fn list_all_tools(
|
||||
clients: &HashMap<String, std::sync::Arc<McpClient>>,
|
||||
clients: &IndexMap<String, std::sync::Arc<McpClient>>,
|
||||
) -> Result<Vec<ToolInfo>> {
|
||||
let mut join_set = JoinSet::new();
|
||||
|
||||
@@ -248,27 +249,34 @@ async fn list_all_tools(
|
||||
});
|
||||
}
|
||||
|
||||
let mut aggregated: Vec<ToolInfo> = Vec::with_capacity(join_set.len());
|
||||
|
||||
let mut response_map = HashMap::with_capacity(join_set.len());
|
||||
while let Some(join_res) = join_set.join_next().await {
|
||||
let (server_name, list_result) = join_res?;
|
||||
let list_result = list_result?;
|
||||
|
||||
// sort tools by name to ensure stable ordering
|
||||
let mut tools = Vec::with_capacity(list_result.tools.len());
|
||||
for tool in list_result.tools {
|
||||
let tool_info = ToolInfo {
|
||||
server_name: server_name.clone(),
|
||||
tool_name: tool.name.clone(),
|
||||
tool,
|
||||
};
|
||||
aggregated.push(tool_info);
|
||||
tools.push(tool_info);
|
||||
}
|
||||
tools.sort_by_key(|tool| tool.tool_name.clone());
|
||||
response_map.insert(server_name, tools);
|
||||
}
|
||||
|
||||
info!(
|
||||
"aggregated {} tools from {} servers",
|
||||
aggregated.len(),
|
||||
clients.len()
|
||||
);
|
||||
// Our mcp tool list must be stable across requests to ensure prompt-caching,
|
||||
// so we preserve the cfg ordering
|
||||
let mut aggregated = Vec::with_capacity(response_map.len());
|
||||
for server_name in clients.keys() {
|
||||
if let Some(tools) = response_map.remove(server_name) {
|
||||
info!("loaded {} with {} tools", &server_name, tools.len());
|
||||
aggregated.extend(tools);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(aggregated)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use indexmap::IndexMap;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::model_family::ModelFamily;
|
||||
use crate::plan_tool::PLAN_TOOL;
|
||||
@@ -498,7 +498,7 @@ fn sanitize_json_schema(value: &mut JsonValue) {
|
||||
/// [`McpConnectionManager`] for more details.
|
||||
pub(crate) fn get_openai_tools(
|
||||
config: &ToolsConfig,
|
||||
mcp_tools: Option<HashMap<String, mcp_types::Tool>>,
|
||||
mcp_tools: Option<IndexMap<String, mcp_types::Tool>>,
|
||||
) -> Vec<OpenAiTool> {
|
||||
let mut tools: Vec<OpenAiTool> = Vec::new();
|
||||
|
||||
@@ -542,12 +542,17 @@ pub(crate) fn get_openai_tools(
|
||||
}
|
||||
|
||||
if let Some(mcp_tools) = mcp_tools {
|
||||
// Ensure deterministic ordering to maximize prompt cache hits.
|
||||
// HashMap iteration order is non-deterministic, so sort by fully-qualified tool name.
|
||||
let mut entries: Vec<(String, mcp_types::Tool)> = mcp_tools.into_iter().collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut sorted_tools: Vec<_> = mcp_tools.into_iter().collect();
|
||||
sorted_tools.sort_by(|(name_a, _), (name_b, _)| {
|
||||
let (server_a, tool_a) = name_a.split_once('/').unwrap_or((name_a.as_str(), ""));
|
||||
let (server_b, tool_b) = name_b.split_once('/').unwrap_or((name_b.as_str(), ""));
|
||||
|
||||
for (name, tool) in entries.into_iter() {
|
||||
server_a
|
||||
.cmp(server_b)
|
||||
.then_with(|| tool_a.len().cmp(&tool_b.len()))
|
||||
.then_with(|| tool_a.cmp(tool_b))
|
||||
});
|
||||
for (name, tool) in sorted_tools {
|
||||
match mcp_tool_to_openai_tool(name.clone(), tool.clone()) {
|
||||
Ok(converted_tool) => tools.push(OpenAiTool::Function(converted_tool)),
|
||||
Err(e) => {
|
||||
@@ -605,7 +610,7 @@ mod tests {
|
||||
include_web_search_request: true,
|
||||
use_streamable_shell_tool: false,
|
||||
});
|
||||
let tools = get_openai_tools(&config, Some(HashMap::new()));
|
||||
let tools = get_openai_tools(&config, Some(IndexMap::new()));
|
||||
|
||||
assert_eq_tool_names(&tools, &["local_shell", "update_plan", "web_search"]);
|
||||
}
|
||||
@@ -622,7 +627,7 @@ mod tests {
|
||||
include_web_search_request: true,
|
||||
use_streamable_shell_tool: false,
|
||||
});
|
||||
let tools = get_openai_tools(&config, Some(HashMap::new()));
|
||||
let tools = get_openai_tools(&config, Some(IndexMap::new()));
|
||||
|
||||
assert_eq_tool_names(&tools, &["shell", "update_plan", "web_search"]);
|
||||
}
|
||||
@@ -641,7 +646,7 @@ mod tests {
|
||||
});
|
||||
let tools = get_openai_tools(
|
||||
&config,
|
||||
Some(HashMap::from([(
|
||||
Some(IndexMap::from([(
|
||||
"test_server/do_something_cool".to_string(),
|
||||
mcp_types::Tool {
|
||||
name: "do_something_cool".to_string(),
|
||||
@@ -727,7 +732,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_openai_tools_mcp_tools_sorted_by_name() {
|
||||
fn test_get_openai_tools_mcp_tools_sorted_by_insertion_order() {
|
||||
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
|
||||
let config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &model_family,
|
||||
@@ -740,7 +745,7 @@ mod tests {
|
||||
});
|
||||
|
||||
// Intentionally construct a map with keys that would sort alphabetically.
|
||||
let tools_map: HashMap<String, mcp_types::Tool> = HashMap::from([
|
||||
let tools_map: IndexMap<String, mcp_types::Tool> = IndexMap::from([
|
||||
(
|
||||
"test_server/do".to_string(),
|
||||
mcp_types::Tool {
|
||||
@@ -794,8 +799,8 @@ mod tests {
|
||||
&tools,
|
||||
&[
|
||||
"shell",
|
||||
"test_server/cool",
|
||||
"test_server/do",
|
||||
"test_server/cool",
|
||||
"test_server/something",
|
||||
],
|
||||
);
|
||||
@@ -816,7 +821,7 @@ mod tests {
|
||||
|
||||
let tools = get_openai_tools(
|
||||
&config,
|
||||
Some(HashMap::from([(
|
||||
Some(IndexMap::from([(
|
||||
"dash/search".to_string(),
|
||||
mcp_types::Tool {
|
||||
name: "search".to_string(),
|
||||
@@ -874,7 +879,7 @@ mod tests {
|
||||
|
||||
let tools = get_openai_tools(
|
||||
&config,
|
||||
Some(HashMap::from([(
|
||||
Some(IndexMap::from([(
|
||||
"dash/paginate".to_string(),
|
||||
mcp_types::Tool {
|
||||
name: "paginate".to_string(),
|
||||
@@ -927,7 +932,7 @@ mod tests {
|
||||
|
||||
let tools = get_openai_tools(
|
||||
&config,
|
||||
Some(HashMap::from([(
|
||||
Some(IndexMap::from([(
|
||||
"dash/tags".to_string(),
|
||||
mcp_types::Tool {
|
||||
name: "tags".to_string(),
|
||||
@@ -983,7 +988,7 @@ mod tests {
|
||||
|
||||
let tools = get_openai_tools(
|
||||
&config,
|
||||
Some(HashMap::from([(
|
||||
Some(IndexMap::from([(
|
||||
"dash/value".to_string(),
|
||||
mcp_types::Tool {
|
||||
name: "value".to_string(),
|
||||
|
||||
@@ -128,11 +128,22 @@ pub async fn default_user_shell() -> Shell {
|
||||
.output()
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let shell_from_env = || {
|
||||
let shell_path = std::env::var("SHELL").ok();
|
||||
let home_env = std::env::var("HOME").unwrap_or_else(|_| home.clone());
|
||||
shell_path
|
||||
.filter(|path| path.ends_with("/zsh"))
|
||||
.map(|shell_path| {
|
||||
Shell::Zsh(ZshShell {
|
||||
shell_path,
|
||||
zshrc_path: format!("{home_env}/.zshrc"),
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
match output {
|
||||
Some(o) => {
|
||||
if !o.status.success() {
|
||||
return Shell::Unknown;
|
||||
}
|
||||
Some(o) if o.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
for line in stdout.lines() {
|
||||
if let Some(shell_path) = line.strip_prefix("UserShell: ")
|
||||
@@ -144,10 +155,9 @@ pub async fn default_user_shell() -> Shell {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Shell::Unknown
|
||||
shell_from_env().unwrap_or(Shell::Unknown)
|
||||
}
|
||||
_ => Shell::Unknown,
|
||||
_ => shell_from_env().unwrap_or(Shell::Unknown),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::built_in_model_providers;
|
||||
use codex_core::config_types::McpServerConfig;
|
||||
use codex_core::model_family::find_family_for_model;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::EventMsg;
|
||||
@@ -556,3 +557,222 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() {
|
||||
);
|
||||
assert_eq!(body2["input"], expected_body2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn mcp_server_ordering_is_preserved_across_requests() {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let sse = sse_completed("resp");
|
||||
let template = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(sse, "text/event-stream");
|
||||
|
||||
// Expect two POSTs to /v1/responses
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(template)
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let model_provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.cwd = cwd.path().to_path_buf();
|
||||
config.model_provider = model_provider;
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
let mut mcp_servers = indexmap::IndexMap::new();
|
||||
mcp_servers.insert(
|
||||
"test_mcp_server_1".to_string(),
|
||||
McpServerConfig {
|
||||
command: "bash".to_string(),
|
||||
args: vec![
|
||||
"-lc".to_string(),
|
||||
r#"TMP=$(mktemp)
|
||||
cat <<'PY' > "$TMP"
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
PROTOCOL_VERSION = "2025-06-18"
|
||||
|
||||
|
||||
def send(message):
|
||||
sys.stdout.write(json.dumps(message) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
method = message.get("method")
|
||||
if method == "initialize":
|
||||
params = message.get("params") or {}
|
||||
send(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": message.get("id"),
|
||||
"result": {
|
||||
"capabilities": {"tools": {"listChanged": True}},
|
||||
"protocolVersion": params.get("protocol_version", PROTOCOL_VERSION),
|
||||
"serverInfo": {
|
||||
"name": "test_mcp_server_1",
|
||||
"title": "Test MCP Server 1",
|
||||
"version": "0.0.1",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
elif method == "tools/list":
|
||||
send(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": message.get("id"),
|
||||
"result": {
|
||||
"tools": [
|
||||
{
|
||||
"name": "test_tool_1",
|
||||
"description": "Test tool 1",
|
||||
"inputSchema": {"type": "object"},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
elif method == "notifications/initialized":
|
||||
continue
|
||||
else:
|
||||
send(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": message.get("id"),
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": f"Method {method} not implemented",
|
||||
},
|
||||
}
|
||||
)
|
||||
PY
|
||||
python3 "$TMP"
|
||||
"#
|
||||
.to_string(),
|
||||
],
|
||||
env: None,
|
||||
},
|
||||
);
|
||||
// mcp_servers.insert(
|
||||
// "test_mcp_server_2".to_string(),
|
||||
// McpServerConfig {
|
||||
// command: "echo".to_string(),
|
||||
// args: vec![
|
||||
// "{\"id\": 2, \"tools\": [{\"name\": \"test_tool_2\", \"description\": \"Test tool 2\"}]}".to_string(),
|
||||
// ],
|
||||
// env: None,
|
||||
// },
|
||||
// );
|
||||
config.mcp_servers = mcp_servers;
|
||||
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.conversation;
|
||||
|
||||
// First turn
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello 1".into(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// Second turn using per-turn overrides via UserTurn
|
||||
let new_cwd = TempDir::new().unwrap();
|
||||
let writable = TempDir::new().unwrap();
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello 2".into(),
|
||||
}],
|
||||
cwd: new_cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![writable.path().to_path_buf()],
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
},
|
||||
model: "o3".to_string(),
|
||||
effort: ReasoningEffort::High,
|
||||
summary: ReasoningSummary::Detailed,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// Verify we issued exactly two requests, and the cached prefix stayed identical.
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
assert_eq!(requests.len(), 2, "expected two POST requests");
|
||||
|
||||
let body1 = requests[0].body_json::<serde_json::Value>().unwrap();
|
||||
let body2 = requests[1].body_json::<serde_json::Value>().unwrap();
|
||||
|
||||
// Validate MCP tools presence in the request body. The tool name should be
|
||||
// fully qualified as "<server>__<tool>".
|
||||
let expected_mcp_tool_name = "test_mcp_server_1__test_tool_1";
|
||||
let tools1 = body1["tools"].as_array().unwrap();
|
||||
assert!(
|
||||
tools1.iter().any(|t| {
|
||||
t.get("type").and_then(|v| v.as_str()) == Some("function")
|
||||
&& t.get("name").and_then(|v| v.as_str()) == Some(expected_mcp_tool_name)
|
||||
}),
|
||||
"missing expected MCP tool in first request: {}",
|
||||
expected_mcp_tool_name
|
||||
);
|
||||
let tools2 = body2["tools"].as_array().unwrap();
|
||||
assert!(
|
||||
tools2.iter().any(|t| {
|
||||
t.get("type").and_then(|v| v.as_str()) == Some("function")
|
||||
&& t.get("name").and_then(|v| v.as_str()) == Some(expected_mcp_tool_name)
|
||||
}),
|
||||
"missing expected MCP tool in second request: {}",
|
||||
expected_mcp_tool_name
|
||||
);
|
||||
|
||||
// prompt_cache_key should remain constant across per-turn overrides
|
||||
assert_eq!(
|
||||
body1["prompt_cache_key"], body2["prompt_cache_key"],
|
||||
"prompt_cache_key should not change across per-turn overrides"
|
||||
);
|
||||
|
||||
// The entire prefix from the first request should be identical and reused
|
||||
// as the prefix of the second request.
|
||||
let expected_user_message_2 = serde_json::json!({
|
||||
"type": "message",
|
||||
"id": serde_json::Value::Null,
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "hello 2" } ]
|
||||
});
|
||||
let expected_body2 = serde_json::json!(
|
||||
[
|
||||
body1["input"].as_array().unwrap().as_slice(),
|
||||
[expected_user_message_2].as_slice(),
|
||||
]
|
||||
.concat()
|
||||
);
|
||||
assert_eq!(body2["input"], expected_body2);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22.1"
|
||||
indexmap = { version = "2.11.0", features = ["serde"] }
|
||||
mcp-types = { path = "../mcp-types" }
|
||||
mime_guess = "2.0.5"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::Tool as McpTool;
|
||||
use serde::Deserialize;
|
||||
@@ -798,7 +799,7 @@ pub struct GetHistoryEntryResponseEvent {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct McpListToolsResponseEvent {
|
||||
/// Fully qualified tool name -> tool definition.
|
||||
pub tools: std::collections::HashMap<String, McpTool>,
|
||||
pub tools: IndexMap<String, McpTool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||
|
||||
@@ -46,6 +46,7 @@ image = { version = "^0.25.6", default-features = false, features = [
|
||||
"jpeg",
|
||||
"png",
|
||||
] }
|
||||
indexmap = "2.11.0"
|
||||
lazy_static = "1"
|
||||
mcp-types = { path = "../mcp-types" }
|
||||
once_cell = "1"
|
||||
|
||||
@@ -782,7 +782,7 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
|
||||
/// Render MCP tools grouped by connection using the fully-qualified tool names.
|
||||
pub(crate) fn new_mcp_tools_output(
|
||||
config: &Config,
|
||||
tools: std::collections::HashMap<String, mcp_types::Tool>,
|
||||
tools: indexmap::IndexMap<String, mcp_types::Tool>,
|
||||
) -> PlainHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = vec![
|
||||
Line::from("/mcp".magenta()),
|
||||
|
||||
Reference in New Issue
Block a user