feat: load from plugins (#12864)

Support loading plugins.

Plugins can now be enabled via [plugins.<name>] in config.toml. They are
loaded as first-class entities through PluginsManager, and their default
skills/ and .mcp.json contributions are integrated into the existing
skills and MCP flows.
This commit is contained in:
xl-openai
2026-03-01 10:50:56 -08:00
committed by GitHub
Unverified
parent 6a673e7339
commit 752402c4fe
24 changed files with 1389 additions and 113 deletions
@@ -4146,7 +4146,11 @@ impl CodexMessageProcessor {
}
};
let mcp_servers = match serde_json::to_value(config.mcp_servers.get()) {
let configured_servers = self
.thread_manager
.mcp_manager()
.configured_servers(&config);
let mcp_servers = match serde_json::to_value(configured_servers) {
Ok(value) => value,
Err(err) => {
let error = JSONRPCErrorError {
@@ -4207,7 +4211,11 @@ impl CodexMessageProcessor {
timeout_secs,
} = params;
let Some(server) = config.mcp_servers.get().get(&name) else {
let configured_servers = self
.thread_manager
.mcp_manager()
.configured_servers(&config);
let Some(server) = configured_servers.get(&name) else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("No MCP server named '{name}' found."),
+17 -11
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::Context;
use anyhow::Result;
@@ -11,9 +12,11 @@ use codex_core::config::find_codex_home;
use codex_core::config::load_global_mcp_servers;
use codex_core::config::types::McpServerConfig;
use codex_core::config::types::McpServerTransportConfig;
use codex_core::mcp::McpManager;
use codex_core::mcp::auth::McpOAuthLoginSupport;
use codex_core::mcp::auth::compute_auth_statuses;
use codex_core::mcp::auth::oauth_login_support;
use codex_core::plugins::PluginsManager;
use codex_protocol::protocol::McpAuthStatus;
use codex_rmcp_client::delete_oauth_tokens;
use codex_rmcp_client::perform_oauth_login;
@@ -329,10 +332,12 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
let mcp_servers = mcp_manager.effective_servers(&config, None);
let LoginArgs { name, scopes } = login_args;
let Some(server) = config.mcp_servers.get().get(&name) else {
let Some(server) = mcp_servers.get(&name) else {
bail!("No MCP server named '{name}' found.");
};
@@ -374,12 +379,12 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
let mcp_servers = mcp_manager.effective_servers(&config, None);
let LogoutArgs { name } = logout_args;
let server = config
.mcp_servers
.get()
let server = mcp_servers
.get(&name)
.ok_or_else(|| anyhow!("No MCP server named '{name}' found in configuration."))?;
@@ -404,14 +409,13 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
let mcp_servers = mcp_manager.effective_servers(&config, None);
let mut entries: Vec<_> = config.mcp_servers.iter().collect();
let mut entries: Vec<_> = mcp_servers.iter().collect();
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
let auth_statuses = compute_auth_statuses(
config.mcp_servers.iter(),
config.mcp_oauth_credentials_store_mode,
)
.await;
let auth_statuses =
compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await;
if list_args.json {
let json_entries: Vec<_> = entries
@@ -654,8 +658,10 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
let mcp_servers = mcp_manager.effective_servers(&config, None);
let Some(server) = config.mcp_servers.get().get(&get_args.name) else {
let Some(server) = mcp_servers.get(&get_args.name) else {
bail!("No MCP server named '{name}' found.", name = get_args.name);
};
+30
View File
@@ -370,6 +370,9 @@
"personality": {
"type": "boolean"
},
"plugins": {
"type": "boolean"
},
"powershell_utf8": {
"type": "boolean"
},
@@ -1085,6 +1088,22 @@
],
"type": "string"
},
"PluginConfig": {
"additionalProperties": false,
"properties": {
"enabled": {
"default": true,
"type": "boolean"
},
"path": {
"$ref": "#/definitions/AbsolutePathBuf"
}
},
"required": [
"path"
],
"type": "object"
},
"ProjectConfig": {
"additionalProperties": false,
"properties": {
@@ -1718,6 +1737,9 @@
"personality": {
"type": "boolean"
},
"plugins": {
"type": "boolean"
},
"powershell_utf8": {
"type": "boolean"
},
@@ -2015,6 +2037,14 @@
"plan_mode_reasoning_effort": {
"$ref": "#/definitions/ReasoningEffort"
},
"plugins": {
"additionalProperties": {
"$ref": "#/definitions/PluginConfig"
},
"default": {},
"description": "User-level plugin config entries keyed by plugin name.",
"type": "object"
},
"profile": {
"description": "Profile to use from the `profiles` map.",
"type": "string"
+4 -1
View File
@@ -227,11 +227,13 @@ mod tests {
use super::*;
use crate::config::ConfigBuilder;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::plugins::PluginsManager;
use crate::skills::SkillsManager;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::TempDir;
async fn test_config_with_cli_overrides(
@@ -508,7 +510,8 @@ enabled = false
.await
.expect("custom role should apply");
let skills_manager = SkillsManager::new(home.path().to_path_buf());
let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf()));
let skills_manager = SkillsManager::new(home.path().to_path_buf(), plugins_manager);
let outcome = skills_manager.skills_for_config(&config);
let skill = outcome
.skills
+44 -7
View File
@@ -173,8 +173,8 @@ use crate::file_watcher::FileWatcherEvent;
use crate::git_info::get_git_repo_root;
use crate::instructions::UserInstructions;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::McpManager;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp::effective_mcp_servers;
use crate::mcp::maybe_prompt_and_install_mcp_dependencies;
use crate::mcp::with_codex_apps_mcp;
use crate::mcp_connection_manager::McpConnectionManager;
@@ -188,6 +188,7 @@ use crate::mentions::build_skill_name_counts;
use crate::mentions::collect_explicit_app_ids;
use crate::mentions::collect_tool_mentions_from_messages;
use crate::network_policy_decision::execpolicy_network_rule_amendment;
use crate::plugins::PluginsManager;
use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageContentDeltaEvent;
use crate::protocol::AgentReasoningSectionBreakEvent;
@@ -322,6 +323,8 @@ impl Codex {
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
skills_manager: Arc<SkillsManager>,
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
file_watcher: Arc<FileWatcher>,
conversation_history: InitialHistory,
session_source: SessionSource,
@@ -333,6 +336,7 @@ impl Codex {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
plugins_manager.plugins_for_config(&config);
let loaded_skills = skills_manager.skills_for_config(&config);
for err in &loaded_skills.errors {
@@ -476,6 +480,8 @@ impl Codex {
conversation_history,
session_source_clone,
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
agent_control,
)
@@ -1118,6 +1124,8 @@ impl Session {
initial_history: InitialHistory,
session_source: SessionSource,
skills_manager: Arc<SkillsManager>,
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
file_watcher: Arc<FileWatcher>,
agent_control: AgentControl,
) -> anyhow::Result<Arc<Self>> {
@@ -1209,9 +1217,10 @@ impl Session {
};
let auth_manager_clone = Arc::clone(&auth_manager);
let config_for_mcp = Arc::clone(&config);
let mcp_manager_for_mcp = Arc::clone(&mcp_manager);
let auth_and_mcp_fut = async move {
let auth = auth_manager_clone.auth().await;
let mcp_servers = effective_mcp_servers(&config_for_mcp, auth.as_ref());
let mcp_servers = mcp_manager_for_mcp.effective_servers(&config_for_mcp, auth.as_ref());
let auth_statuses = compute_auth_statuses(
mcp_servers.iter(),
config_for_mcp.mcp_oauth_credentials_store_mode,
@@ -1461,6 +1470,8 @@ impl Session {
tool_approvals: Mutex::new(ApprovalStore::default()),
execve_session_approvals: RwLock::new(HashMap::new()),
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
agent_control,
network_proxy,
@@ -2235,6 +2246,8 @@ impl Session {
.config_layer_stack
.with_user_config(&config_toml_path, user_config);
state.session_configuration.original_config_do_not_use = Arc::new(config);
self.services.skills_manager.clear_cache();
self.services.plugins_manager.clear_cache();
}
pub(crate) async fn new_default_turn_with_sub_id(&self, sub_id: String) -> Arc<TurnContext> {
@@ -3728,7 +3741,6 @@ mod handlers {
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp::collect_mcp_snapshot_from_manager;
use crate::mcp::effective_mcp_servers;
use crate::review_prompts::resolve_review_request;
use crate::rollout::session_index;
use crate::tasks::CompactTask;
@@ -4058,7 +4070,10 @@ mod handlers {
pub async fn list_mcp_tools(sess: &Session, config: &Arc<Config>, sub_id: String) {
let mcp_connection_manager = sess.services.mcp_connection_manager.read().await;
let auth = sess.services.auth_manager.auth().await;
let mcp_servers = effective_mcp_servers(config, auth.as_ref());
let mcp_servers = sess
.services
.mcp_manager
.effective_servers(config, auth.as_ref());
let snapshot = collect_mcp_snapshot_from_manager(
&mcp_connection_manager,
compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode)
@@ -8057,6 +8072,12 @@ mod tests {
let (tx_event, _rx_event) = async_channel::unbounded();
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_manager = Arc::new(SkillsManager::new(
config.codex_home.clone(),
Arc::clone(&plugins_manager),
));
let result = Session::new(
session_configuration,
Arc::clone(&config),
@@ -8067,7 +8088,9 @@ mod tests {
agent_status_tx,
InitialHistory::New,
SessionSource::Exec,
Arc::new(SkillsManager::new(config.codex_home.clone())),
skills_manager,
plugins_manager,
mcp_manager,
Arc::new(FileWatcher::noop()),
AgentControl::default(),
)
@@ -8149,7 +8172,12 @@ mod tests {
);
let state = SessionState::new(session_configuration.clone());
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone()));
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_manager = Arc::new(SkillsManager::new(
config.codex_home.clone(),
Arc::clone(&plugins_manager),
));
let network_approval = Arc::new(NetworkApprovalService::default());
let file_watcher = Arc::new(FileWatcher::noop());
@@ -8183,6 +8211,8 @@ mod tests {
tool_approvals: Mutex::new(ApprovalStore::default()),
execve_session_approvals: RwLock::new(HashMap::new()),
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
agent_control,
network_proxy: None,
@@ -8309,7 +8339,12 @@ mod tests {
);
let state = SessionState::new(session_configuration.clone());
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone()));
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_manager = Arc::new(SkillsManager::new(
config.codex_home.clone(),
Arc::clone(&plugins_manager),
));
let network_approval = Arc::new(NetworkApprovalService::default());
let file_watcher = Arc::new(FileWatcher::noop());
@@ -8343,6 +8378,8 @@ mod tests {
tool_approvals: Mutex::new(ApprovalStore::default()),
execve_session_approvals: RwLock::new(HashMap::new()),
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
agent_control,
network_proxy: None,
+2
View File
@@ -53,6 +53,8 @@ pub(crate) async fn run_codex_thread_interactive(
auth_manager,
models_manager,
Arc::clone(&parent_session.services.skills_manager),
Arc::clone(&parent_session.services.plugins_manager),
Arc::clone(&parent_session.services.mcp_manager),
Arc::clone(&parent_session.services.file_watcher),
initial_history.unwrap_or(InitialHistory::New),
SessionSource::SubAgent(SubAgentSource::Review),
+5
View File
@@ -16,6 +16,7 @@ use crate::config::types::Notifications;
use crate::config::types::OtelConfig;
use crate::config::types::OtelConfigToml;
use crate::config::types::OtelExporterKind;
use crate::config::types::PluginConfig;
use crate::config::types::SandboxWorkspaceWrite;
use crate::config::types::ShellEnvironmentPolicy;
use crate::config::types::ShellEnvironmentPolicyToml;
@@ -1211,6 +1212,10 @@ pub struct ConfigToml {
/// User-level skill config entries keyed by SKILL.md path.
pub skills: Option<SkillsConfig>,
/// User-level plugin config entries keyed by plugin name.
#[serde(default)]
pub plugins: HashMap<String, PluginConfig>,
/// Centralized feature flags (new). Prefer this over individual toggles.
#[serde(default)]
// Injects known feature keys into the schema and forbids unknown keys.
+8
View File
@@ -767,6 +767,14 @@ pub struct SkillConfig {
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PluginConfig {
pub path: AbsolutePathBuf,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SkillsConfig {
+8
View File
@@ -127,6 +127,8 @@ pub enum Feature {
Collab,
/// Enable apps.
Apps,
/// Enable plugins.
Plugins,
/// Route apps MCP calls through the configured gateway.
AppsMcpGateway,
/// Allow prompting and installing missing MCP dependencies.
@@ -610,6 +612,12 @@ pub const FEATURES: &[FeatureSpec] = &[
},
default_enabled: false,
},
FeatureSpec {
id: Feature::Plugins,
key: "plugins",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::AppsMcpGateway,
key: "apps_mcp_gateway",
+11 -7
View File
@@ -23,7 +23,7 @@ use tokio::time::sleep_until;
use tracing::warn;
use crate::config::Config;
use crate::skills::loader::skill_roots_from_layer_stack_with_agents;
use crate::skills::SkillsManager;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileWatcherEvent {
@@ -143,12 +143,16 @@ impl FileWatcher {
self.tx.subscribe()
}
pub(crate) fn register_config(self: &Arc<Self>, config: &Config) -> WatchRegistration {
let deduped_roots: HashSet<PathBuf> =
skill_roots_from_layer_stack_with_agents(&config.config_layer_stack, &config.cwd)
.into_iter()
.map(|root| root.path)
.collect();
pub(crate) fn register_config(
self: &Arc<Self>,
config: &Config,
skills_manager: &SkillsManager,
) -> WatchRegistration {
let deduped_roots: HashSet<PathBuf> = skills_manager
.skill_roots_for_config(config)
.into_iter()
.map(|root| root.path)
.collect();
let mut registered_roots: Vec<PathBuf> = deduped_roots.into_iter().collect();
registered_roots.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
for root in &registered_roots {
+1
View File
@@ -57,6 +57,7 @@ mod message_history;
mod model_provider_info;
pub mod path_utils;
pub mod personality_migration;
pub mod plugins;
mod sandbox_tags;
pub mod sandboxing;
mod session_prefix;
+153 -3
View File
@@ -5,6 +5,7 @@ pub(crate) use skill_dependencies::maybe_prompt_and_install_mcp_dependencies;
use std::collections::HashMap;
use std::env;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use async_channel::unbounded;
@@ -25,6 +26,7 @@ use crate::mcp::auth::compute_auth_statuses;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::SandboxState;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
use crate::plugins::PluginsManager;
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
const MCP_TOOL_NAME_DELIMITER: &str = "__";
@@ -160,12 +162,48 @@ pub(crate) fn with_codex_apps_mcp(
servers
}
pub(crate) fn effective_mcp_servers(
pub struct McpManager {
plugins_manager: Arc<PluginsManager>,
}
impl McpManager {
pub fn new(plugins_manager: Arc<PluginsManager>) -> Self {
Self { plugins_manager }
}
pub fn configured_servers(&self, config: &Config) -> HashMap<String, McpServerConfig> {
configured_mcp_servers(config, self.plugins_manager.as_ref())
}
pub fn effective_servers(
&self,
config: &Config,
auth: Option<&CodexAuth>,
) -> HashMap<String, McpServerConfig> {
effective_mcp_servers(config, auth, self.plugins_manager.as_ref())
}
}
fn configured_mcp_servers(
config: &Config,
plugins_manager: &PluginsManager,
) -> HashMap<String, McpServerConfig> {
let loaded_plugins = plugins_manager.plugins_for_config(config);
let mut servers = config.mcp_servers.get().clone();
for (name, plugin_server) in loaded_plugins.effective_mcp_servers() {
servers.entry(name).or_insert(plugin_server);
}
servers
}
fn effective_mcp_servers(
config: &Config,
auth: Option<&CodexAuth>,
plugins_manager: &PluginsManager,
) -> HashMap<String, McpServerConfig> {
let servers = configured_mcp_servers(config, plugins_manager);
with_codex_apps_mcp(
config.mcp_servers.get().clone(),
servers,
config.features.enabled(Feature::Apps),
auth,
config,
@@ -179,7 +217,8 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
config.cli_auth_credentials_store_mode,
);
let auth = auth_manager.auth().await;
let mcp_servers = effective_mcp_servers(config, auth.as_ref());
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
let mcp_servers = mcp_manager.effective_servers(config, auth.as_ref());
if mcp_servers.is_empty() {
return McpListToolsResponseEvent {
tools: HashMap::new(),
@@ -366,7 +405,38 @@ pub(crate) async fn collect_mcp_snapshot_from_manager(
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use toml::Value;
fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
fs::write(path, contents).unwrap();
}
fn plugin_config_toml(plugin_root: &Path) -> String {
let mut root = toml::map::Map::new();
let mut features = toml::map::Map::new();
features.insert("plugins".to_string(), Value::Boolean(true));
root.insert("features".to_string(), Value::Table(features));
let mut plugin = toml::map::Map::new();
plugin.insert(
"path".to_string(),
Value::String(plugin_root.display().to_string()),
);
plugin.insert("enabled".to_string(), Value::Boolean(true));
let mut plugins = toml::map::Map::new();
plugins.insert("sample".to_string(), Value::Table(plugin));
root.insert("plugins".to_string(), Value::Table(plugins));
toml::to_string(&Value::Table(root)).expect("plugin test config should serialize")
}
fn make_tool(name: &str) -> Tool {
Tool {
@@ -542,4 +612,84 @@ mod tests {
let expected_url = format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}");
assert_eq!(url, &expected_url);
}
#[tokio::test]
async fn effective_mcp_servers_include_plugins_without_overriding_user_config() {
let codex_home = tempfile::tempdir().expect("tempdir");
let plugin_root = codex_home.path().join("plugin-sample");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://plugin.example/mcp"
},
"docs": {
"type": "http",
"url": "https://docs.example/mcp"
}
}
}"#,
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&plugin_config_toml(&plugin_root),
);
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config should load");
let mut configured_servers = config.mcp_servers.get().clone();
configured_servers.insert(
"sample".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://user.example/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
config
.mcp_servers
.set(configured_servers)
.expect("test config should accept MCP servers");
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
let effective = mcp_manager.effective_servers(&config, None);
let sample = effective.get("sample").expect("user server should exist");
let docs = effective.get("docs").expect("plugin server should exist");
match &sample.transport {
McpServerTransportConfig::StreamableHttp { url, .. } => {
assert_eq!(url, "https://user.example/mcp");
}
other => panic!("expected streamable http transport, got {other:?}"),
}
match &docs.transport {
McpServerTransportConfig::StreamableHttp { url, .. } => {
assert_eq!(url, "https://docs.example/mcp");
}
other => panic!("expected streamable http transport, got {other:?}"),
}
}
}
+9 -4
View File
@@ -13,7 +13,6 @@ use tracing::warn;
use super::auth::McpOAuthLoginSupport;
use super::auth::oauth_login_support;
use super::effective_mcp_servers;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::Config;
@@ -149,7 +148,10 @@ pub(crate) async fn maybe_prompt_and_install_mcp_dependencies(
return;
}
let installed = config.mcp_servers.get().clone();
let installed = sess
.services
.mcp_manager
.configured_servers(config.as_ref());
let missing = collect_missing_mcp_dependencies(mentioned_skills, &installed);
if missing.is_empty() {
return;
@@ -178,7 +180,7 @@ pub(crate) async fn maybe_install_mcp_dependencies(
}
let codex_home = config.codex_home.clone();
let installed = config.mcp_servers.get().clone();
let installed = sess.services.mcp_manager.configured_servers(config);
let missing = collect_missing_mcp_dependencies(mentioned_skills, &installed);
if missing.is_empty() {
return;
@@ -254,7 +256,10 @@ pub(crate) async fn maybe_install_mcp_dependencies(
// Refresh from the effective merged MCP map (global + repo + managed) and
// overlay the updated global servers so we don't drop repo-scoped servers.
let auth = sess.services.auth_manager.auth().await;
let mut refresh_servers = effective_mcp_servers(config, auth.as_ref());
let mut refresh_servers = sess
.services
.mcp_manager
.effective_servers(config, auth.as_ref());
for (name, server_config) in &servers {
refresh_servers
.entry(name.clone())
+627
View File
@@ -0,0 +1,627 @@
use crate::config::Config;
use crate::config::ConfigToml;
use crate::config::profile::ConfigProfile;
use crate::config::types::McpServerConfig;
use crate::config::types::PluginConfig;
use crate::config_loader::ConfigLayerStack;
use crate::features::Feature;
use crate::features::FeatureOverrides;
use crate::features::Features;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde_json::Map as JsonMap;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::sync::RwLock;
use tracing::warn;
const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
#[derive(Debug, Clone, PartialEq)]
pub struct LoadedPlugin {
pub config_name: String,
pub manifest_name: Option<String>,
pub root: AbsolutePathBuf,
pub enabled: bool,
pub skill_roots: Vec<PathBuf>,
pub mcp_servers: HashMap<String, McpServerConfig>,
pub error: Option<String>,
}
impl LoadedPlugin {
fn is_active(&self) -> bool {
self.enabled && self.error.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PluginLoadOutcome {
pub plugins: Vec<LoadedPlugin>,
}
impl PluginLoadOutcome {
pub fn effective_skill_roots(&self) -> Vec<PathBuf> {
let mut skill_roots: Vec<PathBuf> = self
.plugins
.iter()
.filter(|plugin| plugin.is_active())
.flat_map(|plugin| plugin.skill_roots.iter().cloned())
.collect();
skill_roots.sort_unstable();
skill_roots.dedup();
skill_roots
}
pub fn effective_mcp_servers(&self) -> HashMap<String, McpServerConfig> {
let mut mcp_servers = HashMap::new();
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
for (name, config) in &plugin.mcp_servers {
mcp_servers
.entry(name.clone())
.or_insert_with(|| config.clone());
}
}
mcp_servers
}
}
pub struct PluginsManager {
cache_by_cwd: RwLock<HashMap<PathBuf, PluginLoadOutcome>>,
}
impl PluginsManager {
pub fn new(_codex_home: PathBuf) -> Self {
Self {
cache_by_cwd: RwLock::new(HashMap::new()),
}
}
pub fn plugins_for_config(&self, config: &Config) -> PluginLoadOutcome {
self.plugins_for_layer_stack(&config.cwd, &config.config_layer_stack, false)
}
pub fn plugins_for_layer_stack(
&self,
cwd: &Path,
config_layer_stack: &ConfigLayerStack,
force_reload: bool,
) -> PluginLoadOutcome {
if !plugins_feature_enabled_from_stack(config_layer_stack) {
let mut cache = match self.cache_by_cwd.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
cache.insert(cwd.to_path_buf(), PluginLoadOutcome::default());
return PluginLoadOutcome::default();
}
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) {
return outcome;
}
let outcome = load_plugins_from_layer_stack(config_layer_stack);
log_plugin_load_errors(&outcome);
let mut cache = match self.cache_by_cwd.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
cache.insert(cwd.to_path_buf(), outcome.clone());
outcome
}
pub fn clear_cache(&self) {
let mut cache_by_cwd = match self.cache_by_cwd.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
cache_by_cwd.clear();
}
fn cached_outcome_for_cwd(&self, cwd: &Path) -> Option<PluginLoadOutcome> {
match self.cache_by_cwd.read() {
Ok(cache) => cache.get(cwd).cloned(),
Err(err) => err.into_inner().get(cwd).cloned(),
}
}
}
fn plugins_feature_enabled_from_stack(config_layer_stack: &ConfigLayerStack) -> bool {
let effective_config = config_layer_stack.effective_config();
let Ok(config_toml) = effective_config.try_into::<ConfigToml>() else {
warn!("failed to deserialize config when checking plugin feature flag");
return false;
};
let config_profile = config_toml
.get_config_profile(config_toml.profile.clone())
.unwrap_or_else(|_| ConfigProfile::default());
let features =
Features::from_config(&config_toml, &config_profile, FeatureOverrides::default());
features.enabled(Feature::Plugins)
}
fn log_plugin_load_errors(outcome: &PluginLoadOutcome) {
for plugin in outcome
.plugins
.iter()
.filter(|plugin| plugin.error.is_some())
{
if let Some(error) = plugin.error.as_deref() {
warn!(
plugin = plugin.config_name,
path = %plugin.root.display(),
"failed to load plugin: {error}"
);
}
}
}
#[derive(Debug, Default, Deserialize)]
struct PluginManifest {
name: String,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginMcpFile {
#[serde(default)]
mcp_servers: HashMap<String, JsonValue>,
}
pub fn load_plugins_from_layer_stack(config_layer_stack: &ConfigLayerStack) -> PluginLoadOutcome {
let mut configured_plugins: Vec<_> = configured_plugins_from_stack(config_layer_stack)
.into_iter()
.collect();
configured_plugins.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
let mut plugins = Vec::with_capacity(configured_plugins.len());
let mut seen_mcp_server_names = HashMap::<String, String>::new();
for (configured_name, plugin) in configured_plugins {
let loaded_plugin = load_plugin(configured_name.clone(), &plugin);
for name in loaded_plugin.mcp_servers.keys() {
if let Some(previous_plugin) =
seen_mcp_server_names.insert(name.clone(), configured_name.clone())
{
warn!(
plugin = configured_name,
previous_plugin,
server = name,
"skipping duplicate plugin MCP server name"
);
}
}
plugins.push(loaded_plugin);
}
PluginLoadOutcome { plugins }
}
pub(crate) fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
for ancestor in path.ancestors() {
if let Some(manifest) = load_plugin_manifest(ancestor) {
return Some(plugin_manifest_name(&manifest, ancestor));
}
}
None
}
fn configured_plugins_from_stack(
config_layer_stack: &ConfigLayerStack,
) -> HashMap<String, PluginConfig> {
let effective_config = config_layer_stack.effective_config();
let Some(plugins_value) = effective_config.get("plugins") else {
return HashMap::new();
};
match plugins_value.clone().try_into() {
Ok(plugins) => plugins,
Err(err) => {
warn!("invalid plugins config: {err}");
HashMap::new()
}
}
}
fn load_plugin(config_name: String, plugin: &PluginConfig) -> LoadedPlugin {
let plugin_root = plugin.path.clone();
let mut loaded_plugin = LoadedPlugin {
config_name,
manifest_name: None,
root: plugin_root.clone(),
enabled: plugin.enabled,
skill_roots: Vec::new(),
mcp_servers: HashMap::new(),
error: None,
};
if !plugin.enabled {
return loaded_plugin;
}
if !plugin_root.as_path().is_dir() {
loaded_plugin.error = Some("path does not exist or is not a directory".to_string());
return loaded_plugin;
}
let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else {
loaded_plugin.error = Some("missing or invalid .codex-plugin/plugin.json".to_string());
return loaded_plugin;
};
loaded_plugin.manifest_name = Some(plugin_manifest_name(&manifest, plugin_root.as_path()));
loaded_plugin.skill_roots = default_skill_roots(plugin_root.as_path());
let mut mcp_servers = HashMap::new();
for mcp_config_path in default_mcp_config_paths(plugin_root.as_path()) {
let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path);
for (name, config) in plugin_mcp.mcp_servers {
if mcp_servers.insert(name.clone(), config).is_some() {
warn!(
plugin = %plugin_root.display(),
path = %mcp_config_path.display(),
server = name,
"plugin MCP file overwrote an earlier server definition"
);
}
}
}
loaded_plugin.mcp_servers = mcp_servers;
loaded_plugin
}
fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
if !manifest_path.is_file() {
return None;
}
let contents = fs::read_to_string(&manifest_path).ok()?;
match serde_json::from_str(&contents) {
Ok(manifest) => Some(manifest),
Err(err) => {
warn!(
path = %manifest_path.display(),
"failed to parse plugin manifest: {err}"
);
None
}
}
}
fn plugin_manifest_name(manifest: &PluginManifest, plugin_root: &Path) -> String {
plugin_root
.file_name()
.and_then(|name| name.to_str())
.filter(|_| manifest.name.trim().is_empty())
.unwrap_or(&manifest.name)
.to_string()
}
fn default_skill_roots(plugin_root: &Path) -> Vec<PathBuf> {
let skills_dir = plugin_root.join(DEFAULT_SKILLS_DIR_NAME);
if skills_dir.is_dir() {
vec![skills_dir]
} else {
Vec::new()
}
}
fn default_mcp_config_paths(plugin_root: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
let default_path = plugin_root.join(DEFAULT_MCP_CONFIG_FILE);
if default_path.is_file() {
paths.push(default_path);
}
paths.sort_unstable();
paths.dedup();
paths
}
fn load_mcp_servers_from_file(plugin_root: &Path, mcp_config_path: &Path) -> PluginMcpDiscovery {
let Ok(contents) = fs::read_to_string(mcp_config_path) else {
return PluginMcpDiscovery::default();
};
let parsed = match serde_json::from_str::<PluginMcpFile>(&contents) {
Ok(parsed) => parsed,
Err(err) => {
warn!(
path = %mcp_config_path.display(),
"failed to parse plugin MCP config: {err}"
);
return PluginMcpDiscovery::default();
}
};
normalize_plugin_mcp_servers(
plugin_root,
parsed.mcp_servers,
mcp_config_path.to_string_lossy().as_ref(),
)
}
fn normalize_plugin_mcp_servers(
plugin_root: &Path,
plugin_mcp_servers: HashMap<String, JsonValue>,
source: &str,
) -> PluginMcpDiscovery {
let mut mcp_servers = HashMap::new();
for (name, config_value) in plugin_mcp_servers {
let normalized = normalize_plugin_mcp_server_value(plugin_root, config_value);
match serde_json::from_value::<McpServerConfig>(JsonValue::Object(normalized)) {
Ok(config) => {
mcp_servers.insert(name, config);
}
Err(err) => {
warn!(
plugin = %plugin_root.display(),
server = name,
"failed to parse plugin MCP server from {source}: {err}"
);
}
}
}
PluginMcpDiscovery { mcp_servers }
}
fn normalize_plugin_mcp_server_value(
plugin_root: &Path,
value: JsonValue,
) -> JsonMap<String, JsonValue> {
let mut object = match value {
JsonValue::Object(object) => object,
_ => return JsonMap::new(),
};
if let Some(JsonValue::String(transport_type)) = object.remove("type") {
match transport_type.as_str() {
"http" | "streamable_http" | "streamable-http" => {}
"stdio" => {}
other => {
warn!(
plugin = %plugin_root.display(),
transport = other,
"plugin MCP server uses an unknown transport type"
);
}
}
}
if let Some(JsonValue::Object(oauth)) = object.remove("oauth")
&& oauth.contains_key("callbackPort")
{
warn!(
plugin = %plugin_root.display(),
"plugin MCP server OAuth callbackPort is ignored; Codex uses global MCP OAuth callback settings"
);
}
if let Some(JsonValue::String(cwd)) = object.get("cwd")
&& !Path::new(cwd).is_absolute()
{
object.insert(
"cwd".to_string(),
JsonValue::String(plugin_root.join(cwd).display().to_string()),
);
}
object
}
#[derive(Debug, Default)]
struct PluginMcpDiscovery {
mcp_servers: HashMap<String, McpServerConfig>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::config::types::McpServerTransportConfig;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use toml::Value;
fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
fs::write(path, contents).unwrap();
}
fn plugin_config_toml(
plugin_root: &Path,
enabled: bool,
plugins_feature_enabled: bool,
) -> String {
let mut root = toml::map::Map::new();
let mut features = toml::map::Map::new();
features.insert(
"plugins".to_string(),
Value::Boolean(plugins_feature_enabled),
);
root.insert("features".to_string(), Value::Table(features));
let mut plugin = toml::map::Map::new();
plugin.insert(
"path".to_string(),
Value::String(plugin_root.display().to_string()),
);
plugin.insert("enabled".to_string(), Value::Boolean(enabled));
let mut plugins = toml::map::Map::new();
plugins.insert("sample".to_string(), Value::Table(plugin));
root.insert("plugins".to_string(), Value::Table(plugins));
toml::to_string(&Value::Table(root)).expect("plugin test config should serialize")
}
async fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> PluginLoadOutcome {
write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml);
let config = ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.build()
.await
.expect("config should load");
PluginsManager::new(codex_home.to_path_buf()).plugins_for_config(&config)
}
#[tokio::test]
async fn load_plugins_loads_default_skills_and_mcp_servers() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home.path().join("plugin-sample");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&plugin_root.join("skills/sample-search/SKILL.md"),
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp",
"oauth": {
"clientId": "client-id",
"callbackPort": 3118
}
}
}
}"#,
);
let outcome = load_plugins_from_config(
&plugin_config_toml(&plugin_root, true, true),
codex_home.path(),
)
.await;
assert_eq!(
outcome.plugins,
vec![LoadedPlugin {
config_name: "sample".to_string(),
manifest_name: Some("sample".to_string()),
root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(),
enabled: true,
skill_roots: vec![plugin_root.join("skills")],
mcp_servers: HashMap::from([(
"sample".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://sample.example/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]),
error: None,
}]
);
assert_eq!(
outcome.effective_skill_roots(),
vec![plugin_root.join("skills")]
);
assert_eq!(outcome.effective_mcp_servers().len(), 1);
}
#[tokio::test]
async fn load_plugins_preserves_disabled_plugins_without_effective_contributions() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home.path().join("plugin-sample");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
);
let outcome = load_plugins_from_config(
&plugin_config_toml(&plugin_root, false, true),
codex_home.path(),
)
.await;
assert_eq!(
outcome.plugins,
vec![LoadedPlugin {
config_name: "sample".to_string(),
manifest_name: None,
root: AbsolutePathBuf::try_from(plugin_root).unwrap(),
enabled: false,
skill_roots: Vec::new(),
mcp_servers: HashMap::new(),
error: None,
}]
);
assert!(outcome.effective_skill_roots().is_empty());
assert!(outcome.effective_mcp_servers().is_empty());
}
#[test]
fn plugin_namespace_for_skill_path_uses_manifest_name() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home.path().join("plugins/sample");
let skill_path = plugin_root.join("skills/search/SKILL.md");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(&skill_path, "---\ndescription: search\n---\n");
assert_eq!(
plugin_namespace_for_skill_path(&skill_path),
Some("sample".to_string())
);
}
#[tokio::test]
async fn load_plugins_returns_empty_when_feature_disabled() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home.path().join("plugin-sample");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&plugin_root.join("skills/sample-search/SKILL.md"),
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
let outcome = load_plugins_from_config(
&plugin_config_toml(&plugin_root, true, false),
codex_home.path(),
)
.await;
assert_eq!(outcome, PluginLoadOutcome::default());
}
}
+10 -1
View File
@@ -464,7 +464,7 @@ fn text_mentions_skill(text: &str, skill_name: &str) -> bool {
}
fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b':')
}
#[cfg(test)]
@@ -586,6 +586,15 @@ mod tests {
);
}
#[test]
fn extract_tool_mentions_keeps_plugin_skill_namespaces() {
assert_mentions(
"use $slack:search and $alpha",
&["alpha", "slack:search"],
&[],
);
}
#[test]
fn collect_explicit_skill_mentions_text_respects_skill_order() {
let alpha = make_skill("alpha-skill", "/tmp/alpha");
+173 -52
View File
@@ -1,10 +1,10 @@
use crate::config::Config;
use crate::config::Permissions;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::default_project_root_markers;
use crate::config_loader::merge_toml_values;
use crate::config_loader::project_root_markers_from_config;
use crate::plugins::plugin_namespace_for_skill_path;
use crate::skills::model::SkillDependencies;
use crate::skills::model::SkillError;
use crate::skills::model::SkillInterface;
@@ -32,10 +32,15 @@ use std::path::PathBuf;
use toml::Value as TomlValue;
use tracing::error;
#[cfg(test)]
use crate::config::Config;
#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
name: String,
description: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
metadata: SkillFrontmatterMetadata,
}
@@ -146,20 +151,6 @@ impl fmt::Display for SkillParseError {
impl Error for SkillParseError {}
pub fn load_skills(config: &Config) -> SkillLoadOutcome {
load_skills_with_home_dir(config, home_dir().as_deref())
}
fn load_skills_with_home_dir(config: &Config, home_dir: Option<&Path>) -> SkillLoadOutcome {
let mut roots = skill_roots_from_layer_stack_inner(&config.config_layer_stack, home_dir);
roots.extend(repo_agents_skill_roots(
&config.config_layer_stack,
&config.cwd,
));
dedupe_skill_roots_by_path(&mut roots);
load_skills_from_roots(roots)
}
pub(crate) struct SkillRoot {
pub(crate) path: PathBuf,
pub(crate) scope: SkillScope,
@@ -199,6 +190,35 @@ where
outcome
}
pub(crate) fn skill_roots(
config_layer_stack: &ConfigLayerStack,
cwd: &Path,
plugin_skill_roots: Vec<PathBuf>,
) -> Vec<SkillRoot> {
skill_roots_with_home_dir(
config_layer_stack,
cwd,
home_dir().as_deref(),
plugin_skill_roots,
)
}
fn skill_roots_with_home_dir(
config_layer_stack: &ConfigLayerStack,
cwd: &Path,
home_dir: Option<&Path>,
plugin_skill_roots: Vec<PathBuf>,
) -> Vec<SkillRoot> {
let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir);
roots.extend(plugin_skill_roots.into_iter().map(|path| SkillRoot {
path,
scope: SkillScope::User,
}));
roots.extend(repo_agents_skill_roots(config_layer_stack, cwd));
dedupe_skill_roots_by_path(&mut roots);
roots
}
fn skill_roots_from_layer_stack_inner(
config_layer_stack: &ConfigLayerStack,
home_dir: Option<&Path>,
@@ -260,34 +280,6 @@ fn skill_roots_from_layer_stack_inner(
roots
}
#[cfg(test)]
fn skill_roots(config: &Config) -> Vec<SkillRoot> {
skill_roots_from_layer_stack_with_agents(&config.config_layer_stack, &config.cwd)
}
#[cfg(test)]
pub(crate) fn skill_roots_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
home_dir: Option<&Path>,
) -> Vec<SkillRoot> {
skill_roots_from_layer_stack_inner(config_layer_stack, home_dir)
}
pub(crate) fn skill_roots_from_layer_stack_with_agents(
config_layer_stack: &ConfigLayerStack,
cwd: &Path,
) -> Vec<SkillRoot> {
let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir().as_deref());
roots.extend(repo_agents_skill_roots(config_layer_stack, cwd));
dedupe_skill_roots_by_path(&mut roots);
roots
}
fn dedupe_skill_roots_by_path(roots: &mut Vec<SkillRoot>) {
let mut seen: HashSet<PathBuf> = HashSet::new();
roots.retain(|root| seen.insert(root.path.clone()));
}
fn repo_agents_skill_roots(config_layer_stack: &ConfigLayerStack, cwd: &Path) -> Vec<SkillRoot> {
let project_root_markers = project_root_markers_from_stack(config_layer_stack);
let project_root = find_project_root(cwd, &project_root_markers);
@@ -361,6 +353,11 @@ fn dirs_between_project_root_and_cwd(cwd: &Path, project_root: &Path) -> Vec<Pat
dirs
}
fn dedupe_skill_roots_by_path(roots: &mut Vec<SkillRoot>) {
let mut seen: HashSet<PathBuf> = HashSet::new();
roots.retain(|root| seen.insert(root.path.clone()));
}
fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut SkillLoadOutcome) {
let Ok(root) = canonicalize_path(root) else {
return;
@@ -508,8 +505,18 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, Ski
let parsed: SkillFrontmatter =
serde_yaml::from_str(&frontmatter).map_err(SkillParseError::InvalidYaml)?;
let name = sanitize_single_line(&parsed.name);
let description = sanitize_single_line(&parsed.description);
let base_name = parsed
.name
.as_deref()
.map(sanitize_single_line)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| default_skill_name(path));
let name = namespaced_skill_name(path, &base_name);
let description = parsed
.description
.as_deref()
.map(sanitize_single_line)
.unwrap_or_default();
let short_description = parsed
.metadata
.short_description
@@ -550,6 +557,21 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, Ski
})
}
fn default_skill_name(path: &Path) -> String {
path.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.map(sanitize_single_line)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "skill".to_string())
}
fn namespaced_skill_name(path: &Path, base_name: &str) -> String {
plugin_namespace_for_skill_path(path)
.map(|namespace| format!("{namespace}:{base_name}"))
.unwrap_or_else(|| base_name.to_string())
}
fn load_skill_metadata(skill_path: &Path) -> LoadedSkillMetadata {
// Fail open: optional metadata should not block loading SKILL.md.
let Some(skill_dir) = skill_path.parent() else {
@@ -828,6 +850,13 @@ fn extract_frontmatter(contents: &str) -> Option<String> {
Some(frontmatter_lines.join("\n"))
}
#[cfg(test)]
pub(crate) fn skill_roots_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
home_dir: Option<&Path>,
) -> Vec<SkillRoot> {
skill_roots_with_home_dir(config_layer_stack, Path::new("."), home_dir, Vec::new())
}
#[cfg(test)]
mod tests {
@@ -897,7 +926,12 @@ mod tests {
fn load_skills_for_test(config: &Config) -> SkillLoadOutcome {
// Keep unit tests hermetic by never scanning the real `$HOME/.agents/skills`.
super::load_skills_with_home_dir(config, None)
super::load_skills_from_roots(super::skill_roots_with_home_dir(
&config.config_layer_stack,
&config.cwd,
None,
Vec::new(),
))
}
fn mark_as_git_repo(dir: &Path) {
@@ -1105,6 +1139,15 @@ mod tests {
path
}
fn write_raw_skill_at(root: &Path, dir: &str, frontmatter: &str) -> PathBuf {
let skill_dir = root.join(dir);
fs::create_dir_all(&skill_dir).unwrap();
let path = skill_dir.join(SKILLS_FILENAME);
let content = format!("---\n{frontmatter}\n---\n\n# Body\n");
fs::write(&path, content).unwrap();
path
}
fn write_skill_metadata_at(skill_dir: &Path, contents: &str) -> PathBuf {
let path = skill_dir
.join(SKILLS_METADATA_DIR)
@@ -2057,6 +2100,83 @@ permissions:
);
}
#[tokio::test]
async fn falls_back_to_directory_name_when_skill_name_is_missing() {
let codex_home = tempfile::tempdir().expect("tempdir");
let skill_path = write_raw_skill_at(
&codex_home.path().join("skills"),
"directory-derived",
"description: fallback name",
);
let cfg = make_config(&codex_home).await;
let outcome = load_skills_for_test(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "directory-derived".to_string(),
description: "fallback name".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
permission_profile: None,
permissions: None,
path_to_skills_md: normalized(&skill_path),
scope: SkillScope::User,
}]
);
}
#[tokio::test]
async fn namespaces_plugin_skills_using_plugin_name() {
let root = tempfile::tempdir().expect("tempdir");
let plugin_root = root.path().join("plugins/sample");
let skill_path = write_raw_skill_at(
&plugin_root.join("skills"),
"sample-search",
"description: search sample data",
);
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.unwrap();
let outcome = load_skills_from_roots([SkillRoot {
path: plugin_root.join("skills"),
scope: SkillScope::User,
}]);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "sample:sample-search".to_string(),
description: "search sample data".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
permission_profile: None,
permissions: None,
path_to_skills_md: normalized(&skill_path),
scope: SkillScope::User,
}]
);
}
#[tokio::test]
async fn loads_short_description_from_metadata() {
let codex_home = tempfile::tempdir().expect("tempdir");
@@ -2659,10 +2779,11 @@ permissions:
let codex_home = tempfile::tempdir().expect("tempdir");
let cfg = make_config(&codex_home).await;
let scopes: Vec<SkillScope> = skill_roots(&cfg)
.into_iter()
.map(|root| root.scope)
.collect();
let scopes: Vec<SkillScope> =
super::skill_roots(&cfg.config_layer_stack, &cfg.cwd, Vec::new())
.into_iter()
.map(|root| root.scope)
.collect();
let mut expected = vec![SkillScope::User, SkillScope::System];
if home_dir().is_some() {
expected.insert(1, SkillScope::User);
+45 -19
View File
@@ -18,26 +18,29 @@ use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::load_config_layers_state;
use crate::plugins::PluginsManager;
use crate::skills::SkillLoadOutcome;
use crate::skills::build_implicit_skill_path_indexes;
use crate::skills::loader::SkillRoot;
use crate::skills::loader::load_skills_from_roots;
use crate::skills::loader::skill_roots_from_layer_stack_with_agents;
use crate::skills::loader::skill_roots;
use crate::skills::system::install_system_skills;
pub struct SkillsManager {
codex_home: PathBuf,
plugins_manager: Arc<PluginsManager>,
cache_by_cwd: RwLock<HashMap<PathBuf, SkillLoadOutcome>>,
}
impl SkillsManager {
pub fn new(codex_home: PathBuf) -> Self {
pub fn new(codex_home: PathBuf, plugins_manager: Arc<PluginsManager>) -> Self {
if let Err(err) = install_system_skills(&codex_home) {
tracing::error!("failed to install system skills: {err}");
}
Self {
codex_home,
plugins_manager,
cache_by_cwd: RwLock::new(HashMap::new()),
}
}
@@ -50,14 +53,9 @@ impl SkillsManager {
return outcome;
}
let roots =
skill_roots_from_layer_stack_with_agents(&config.config_layer_stack, &config.cwd);
let mut outcome = load_skills_from_roots(roots);
outcome.disabled_paths = disabled_paths_from_stack(&config.config_layer_stack);
let (by_scripts_dir, by_doc_path) =
build_implicit_skill_path_indexes(outcome.allowed_skills_for_implicit_invocation());
outcome.implicit_skills_by_scripts_dir = Arc::new(by_scripts_dir);
outcome.implicit_skills_by_doc_path = Arc::new(by_doc_path);
let roots = self.skill_roots_for_config(config);
let outcome =
finalize_skill_outcome(load_skills_from_roots(roots), &config.config_layer_stack);
let mut cache = match self.cache_by_cwd.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
@@ -66,6 +64,15 @@ impl SkillsManager {
outcome
}
pub(crate) fn skill_roots_for_config(&self, config: &Config) -> Vec<SkillRoot> {
let loaded_plugins = self.plugins_manager.plugins_for_config(config);
skill_roots(
&config.config_layer_stack,
&config.cwd,
loaded_plugins.effective_skill_roots(),
)
}
pub async fn skills_for_cwd(&self, cwd: &Path, force_reload: bool) -> SkillLoadOutcome {
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) {
return outcome;
@@ -121,7 +128,14 @@ impl SkillsManager {
}
};
let mut roots = skill_roots_from_layer_stack_with_agents(&config_layer_stack, cwd);
let loaded_plugins =
self.plugins_manager
.plugins_for_layer_stack(cwd, &config_layer_stack, force_reload);
let mut roots = skill_roots(
&config_layer_stack,
cwd,
loaded_plugins.effective_skill_roots(),
);
roots.extend(
normalized_extra_user_roots
.iter()
@@ -138,11 +152,7 @@ impl SkillsManager {
.skills
.retain(|skill| skill.scope != SkillScope::System);
}
outcome.disabled_paths = disabled_paths_from_stack(&config_layer_stack);
let (by_scripts_dir, by_doc_path) =
build_implicit_skill_path_indexes(outcome.allowed_skills_for_implicit_invocation());
outcome.implicit_skills_by_scripts_dir = Arc::new(by_scripts_dir);
outcome.implicit_skills_by_doc_path = Arc::new(by_doc_path);
let outcome = finalize_skill_outcome(outcome, &config_layer_stack);
let mut cache = match self.cache_by_cwd.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
@@ -210,6 +220,18 @@ fn disabled_paths_from_stack(
disabled
}
fn finalize_skill_outcome(
mut outcome: SkillLoadOutcome,
config_layer_stack: &crate::config_loader::ConfigLayerStack,
) -> SkillLoadOutcome {
outcome.disabled_paths = disabled_paths_from_stack(config_layer_stack);
let (by_scripts_dir, by_doc_path) =
build_implicit_skill_path_indexes(outcome.allowed_skills_for_implicit_invocation());
outcome.implicit_skills_by_scripts_dir = Arc::new(by_scripts_dir);
outcome.implicit_skills_by_doc_path = Arc::new(by_doc_path);
outcome
}
fn normalize_override_path(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
@@ -232,6 +254,7 @@ mod tests {
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirementsToml;
use crate::plugins::PluginsManager;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::PathBuf;
@@ -259,7 +282,8 @@ mod tests {
.await
.expect("defaults for test should always succeed");
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf());
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager);
write_user_skill(&codex_home, "a", "skill-a", "from a");
let outcome1 = skills_manager.skills_for_config(&cfg);
@@ -292,7 +316,8 @@ mod tests {
.await
.expect("defaults for test should always succeed");
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf());
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager);
let _ = skills_manager.skills_for_config(&config);
write_user_skill(&extra_root, "x", "extra-skill", "from extra root");
@@ -335,7 +360,8 @@ mod tests {
.await
.expect("defaults for test should always succeed");
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf());
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager);
let _ = skills_manager.skills_for_config(&config);
write_user_skill(&extra_root_a, "x", "extra-skill-a", "from extra root a");
-1
View File
@@ -16,7 +16,6 @@ pub(crate) use injection::build_skill_injections;
pub(crate) use injection::collect_explicit_skill_mentions;
pub(crate) use invocation_utils::build_implicit_skill_path_indexes;
pub(crate) use invocation_utils::maybe_emit_implicit_skill_invocation;
pub use loader::load_skills;
pub use manager::SkillsManager;
pub use model::SkillError;
pub use model::SkillLoadOutcome;
+4
View File
@@ -9,8 +9,10 @@ use crate::client::ModelClient;
use crate::config::StartedNetworkProxy;
use crate::exec_policy::ExecPolicyManager;
use crate::file_watcher::FileWatcher;
use crate::mcp::McpManager;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::models_manager::manager::ModelsManager;
use crate::plugins::PluginsManager;
use crate::skills::SkillsManager;
use crate::state_db::StateDbHandle;
use crate::tools::network_approval::NetworkApprovalService;
@@ -48,6 +50,8 @@ pub(crate) struct SessionServices {
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) execve_session_approvals: RwLock<HashMap<AbsolutePathBuf, ExecveSessionApproval>>,
pub(crate) skills_manager: Arc<SkillsManager>,
pub(crate) plugins_manager: Arc<PluginsManager>,
pub(crate) mcp_manager: Arc<McpManager>,
pub(crate) file_watcher: Arc<FileWatcher>,
pub(crate) agent_control: AgentControl,
pub(crate) network_proxy: Option<StartedNetworkProxy>,
+33 -3
View File
@@ -11,8 +11,10 @@ use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::file_watcher::FileWatcher;
use crate::file_watcher::FileWatcherEvent;
use crate::mcp::McpManager;
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use crate::models_manager::manager::ModelsManager;
use crate::plugins::PluginsManager;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::SessionConfiguredEvent;
@@ -132,6 +134,8 @@ pub(crate) struct ThreadManagerState {
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
skills_manager: Arc<SkillsManager>,
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
file_watcher: Arc<FileWatcher>,
session_source: SessionSource,
// Captures submitted ops for testing purpose when test mode is enabled.
@@ -147,7 +151,12 @@ impl ThreadManager {
collaboration_modes_config: CollaborationModesConfig,
) -> Self {
let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY);
let skills_manager = Arc::new(SkillsManager::new(codex_home.clone()));
let plugins_manager = Arc::new(PluginsManager::new(codex_home.clone()));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_manager = Arc::new(SkillsManager::new(
codex_home.clone(),
Arc::clone(&plugins_manager),
));
let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager));
Self {
state: Arc::new(ThreadManagerState {
@@ -160,6 +169,8 @@ impl ThreadManager {
collaboration_modes_config,
)),
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
auth_manager,
session_source,
@@ -199,7 +210,12 @@ impl ThreadManager {
set_thread_manager_test_mode_for_tests(true);
let auth_manager = AuthManager::from_auth_for_testing(auth);
let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY);
let skills_manager = Arc::new(SkillsManager::new(codex_home.clone()));
let plugins_manager = Arc::new(PluginsManager::new(codex_home.clone()));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_manager = Arc::new(SkillsManager::new(
codex_home.clone(),
Arc::clone(&plugins_manager),
));
let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager));
Self {
state: Arc::new(ThreadManagerState {
@@ -211,6 +227,8 @@ impl ThreadManager {
provider,
)),
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
auth_manager,
session_source: SessionSource::Exec,
@@ -229,6 +247,14 @@ impl ThreadManager {
self.state.skills_manager.clone()
}
pub fn plugins_manager(&self) -> Arc<PluginsManager> {
self.state.plugins_manager.clone()
}
pub fn mcp_manager(&self) -> Arc<McpManager> {
self.state.mcp_manager.clone()
}
pub fn subscribe_file_watcher(&self) -> broadcast::Receiver<FileWatcherEvent> {
self.state.file_watcher.subscribe()
}
@@ -557,7 +583,9 @@ impl ThreadManagerState {
persist_extended_history: bool,
metrics_service_name: Option<String>,
) -> CodexResult<NewThread> {
let watch_registration = self.file_watcher.register_config(&config);
let watch_registration = self
.file_watcher
.register_config(&config, self.skills_manager.as_ref());
let CodexSpawnOk {
codex, thread_id, ..
} = Codex::spawn(
@@ -565,6 +593,8 @@ impl ThreadManagerState {
auth_manager,
Arc::clone(&self.models_manager),
Arc::clone(&self.skills_manager),
Arc::clone(&self.plugins_manager),
Arc::clone(&self.mcp_manager),
Arc::clone(&self.file_watcher),
initial_history,
session_source,
+1
View File
@@ -95,6 +95,7 @@ mod pending_input;
mod permissions_messages;
mod personality;
mod personality_migration;
mod plugins;
mod prompt_caching;
mod quota_exceeded;
mod read_file;
+182
View File
@@ -0,0 +1,182 @@
#![cfg(not(target_os = "windows"))]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use anyhow::Result;
use codex_core::CodexAuth;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::stdio_server_bin;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
use dunce::canonicalize as normalize_path;
use tempfile::TempDir;
use wiremock::MockServer;
fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
let plugin_root = home.path().join("plugins/sample");
let skill_dir = plugin_root.join("skills/sample-search");
std::fs::create_dir_all(skill_dir.as_path()).expect("create plugin skill dir");
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write plugin manifest");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\ndescription: inspect sample data\n---\n\n# body\n",
)
.expect("write plugin skill");
std::fs::write(
home.path().join("config.toml"),
format!(
"[features]\nplugins = true\n\n[plugins.sample]\nenabled = true\npath = \"{}\"\n",
plugin_root.display()
),
)
.expect("write config");
skill_dir.join("SKILL.md")
}
fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
let plugin_root = home.path().join("plugins/sample");
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write plugin manifest");
std::fs::write(
plugin_root.join(".mcp.json"),
format!(
r#"{{
"mcpServers": {{
"sample": {{
"command": "{command}"
}}
}}
}}"#
),
)
.expect("write plugin mcp config");
std::fs::write(
home.path().join("config.toml"),
format!(
"[features]\nplugins = true\n\n[plugins.sample]\nenabled = true\npath = \"{}\"\n",
plugin_root.display()
),
)
.expect("write config");
}
async fn build_plugin_test_codex(
server: &MockServer,
codex_home: Arc<TempDir>,
) -> Result<Arc<codex_core::CodexThread>> {
let mut builder = test_codex()
.with_home(codex_home)
.with_auth(CodexAuth::from_api_key("Test API Key"));
Ok(builder
.build(server)
.await
.expect("create new conversation")
.codex)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plugin_skills_append_to_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
let resp_mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let codex_home = Arc::new(TempDir::new()?);
let skill_path = write_plugin_skill_plugin(codex_home.as_ref());
let codex = build_plugin_test_codex(&server, Arc::clone(&codex_home)).await?;
codex
.submit(Op::UserInput {
items: vec![codex_protocol::user_input::UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = resp_mock.single_request();
let request_body = request.body_json();
let instructions_text = request_body["input"][1]["content"][0]["text"]
.as_str()
.expect("instructions text");
assert!(
instructions_text.contains("## Skills"),
"expected skills section present"
);
assert!(
instructions_text.contains("sample:sample-search: inspect sample data"),
"expected namespaced plugin skill summary"
);
let expected_path = normalize_path(skill_path)?;
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
assert!(
instructions_text.contains(&expected_path_str),
"expected path {expected_path_str} in instructions"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn plugin_mcp_tools_are_listed() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let codex_home = Arc::new(TempDir::new()?);
let rmcp_test_server_bin = stdio_server_bin()?;
write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin);
let codex = build_plugin_test_codex(&server, codex_home).await?;
let tools_ready_deadline = Instant::now() + Duration::from_secs(30);
loop {
codex.submit(Op::ListMcpTools).await?;
let list_event = wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::McpListToolsResponse(_)),
Duration::from_secs(10),
)
.await;
let EventMsg::McpListToolsResponse(tool_list) = list_event else {
unreachable!("event guard guarantees McpListToolsResponse");
};
if tool_list.tools.contains_key("mcp__sample__echo")
&& tool_list.tools.contains_key("mcp__sample__image")
{
break;
}
let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect();
if Instant::now() >= tools_ready_deadline {
panic!("timed out waiting for plugin MCP tools; discovered tools: {available_tools:?}");
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
Ok(())
}
+6 -1
View File
@@ -63,7 +63,9 @@ use codex_core::find_thread_name_by_id;
use codex_core::git_info::current_branch_name;
use codex_core::git_info::get_git_repo_root;
use codex_core::git_info::local_git_branches;
use codex_core::mcp::McpManager;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::plugins::PluginsManager;
use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
use codex_core::skills::model::SkillMetadata;
use codex_core::terminal::TerminalName;
@@ -7183,7 +7185,10 @@ impl ChatWidget {
}
pub(crate) fn add_mcp_output(&mut self) {
if self.config.mcp_servers.is_empty() {
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
self.config.codex_home.clone(),
)));
if mcp_manager.effective_servers(&self.config, None).is_empty() {
self.add_to_history(history_cell::empty_mcp_output());
} else {
self.submit_op(Op::ListMcpTools);
+6 -1
View File
@@ -39,6 +39,8 @@ use crate::wrapping::adaptive_wrap_lines;
use base64::Engine;
use codex_core::config::Config;
use codex_core::config::types::McpServerTransportConfig;
use codex_core::mcp::McpManager;
use codex_core::plugins::PluginsManager;
use codex_core::web_search::web_search_detail;
use codex_otel::RuntimeMetricsSummary;
use codex_protocol::account::PlanType;
@@ -73,6 +75,7 @@ use std::collections::HashMap;
use std::io::Cursor;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tracing::error;
@@ -1713,7 +1716,9 @@ pub(crate) fn new_mcp_tools_output(
lines.push("".into());
}
let mut servers: Vec<_> = config.mcp_servers.iter().collect();
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
let effective_servers = mcp_manager.effective_servers(config, None);
let mut servers: Vec<_> = effective_servers.iter().collect();
servers.sort_by(|(a, _), (b, _)| a.cmp(b));
for (server, cfg) in servers {