mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
6a673e7339
commit
752402c4fe
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ®istered_roots {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user