diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 486abf8e5f..e92a25a3e7 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -78,6 +78,9 @@ macro_rules! for_each_schema_type { $macro!(crate::LoginChatGptResponse); $macro!(crate::LogoutChatGptParams); $macro!(crate::LogoutChatGptResponse); + $macro!(crate::McpOAuthCredentialsStoreMode); + $macro!(crate::McpServerConfig); + $macro!(crate::McpServerTransportConfig); $macro!(crate::NewConversationParams); $macro!(crate::NewConversationResponse); $macro!(crate::Profile); diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index ddc6003297..3e4134b93b 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -134,13 +134,15 @@ client_request_definitions! { #[ts(rename = "config/read")] GetConfig { params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: Config, + response: UserSavedConfig, }, #[serde(rename = "config/update")] #[ts(rename = "config/update")] UpdateConfig { params: UpdateConfigParams, + // TODO(owen): or should we return UserSavedConfig directly? + // First, figure out how we want to represent errors. response: UpdateConfigResponse, }, @@ -426,13 +428,13 @@ pub struct LogoutAccountResponse {} #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct UpdateConfigParams { - pub config: Config, + pub config: UserSavedConfig, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct UpdateConfigResponse { - pub config: Config, + pub config: UserSavedConfig, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -652,6 +654,12 @@ pub struct UserSavedConfig { #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option, + /// MCP servers + #[serde(default)] + pub mcp_servers: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_oauth_credentials_store: Option, + /// Profiles #[serde(skip_serializing_if = "Option::is_none")] pub profile: Option, @@ -673,6 +681,58 @@ pub struct Profile { pub model_verbosity: Option, pub chatgpt_base_url: Option, } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpServerConfig { + pub transport: McpServerTransportConfig, + #[serde(default)] + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub startup_timeout_sec: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_timeout_sec: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled_tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_tools: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum McpServerTransportConfig { + #[serde(rename_all = "camelCase")] + Stdio { + command: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + env: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + env_vars: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + cwd: Option, + }, + #[serde(rename_all = "camelCase")] + StreamableHttp { + url: String, + #[serde(skip_serializing_if = "Option::is_none")] + bearer_token_env_var: Option, + #[serde(skip_serializing_if = "Option::is_none")] + http_headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + env_http_headers: Option>, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, Default)] +#[serde(rename_all = "lowercase")] +pub enum McpOAuthCredentialsStoreMode { + #[default] + Auto, + File, + Keyring, +} /// MCP representation of a [`codex_core::config::ToolsToml`]. #[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] diff --git a/codex-rs/app-server/tests/suite/config.rs b/codex-rs/app-server/tests/suite/config.rs index 2809d68ca4..68edd5f87d 100644 --- a/codex-rs/app-server/tests/suite/config.rs +++ b/codex-rs/app-server/tests/suite/config.rs @@ -5,6 +5,9 @@ use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::GetUserSavedConfigResponse; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpOAuthCredentialsStoreMode; +use codex_app_server_protocol::McpServerConfig as ProtocolMcpServerConfig; +use codex_app_server_protocol::McpServerTransportConfig as ProtocolMcpServerTransportConfig; use codex_app_server_protocol::Profile; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxSettings; @@ -36,6 +39,7 @@ model_verbosity = "medium" profile = "test" forced_chatgpt_workspace_id = "12345678-0000-0000-0000-000000000000" forced_login_method = "chatgpt" +mcp_oauth_credentials_store = "keyring" [sandbox_workspace_write] writable_roots = ["/tmp"] @@ -55,6 +59,32 @@ model_reasoning_summary = "detailed" model_verbosity = "medium" model_provider = "openai" chatgpt_base_url = "https://api.chatgpt.com" + +[mcp_servers.docs] +command = "codex-docs" +args = ["serve"] +env_vars = ["DOCS_TOKEN"] +cwd = "/tmp/docs" +startup_timeout_sec = 12.5 +tool_timeout_sec = 42.0 +enabled = false +enabled_tools = ["read_docs"] +disabled_tools = ["delete_docs"] + +[mcp_servers.docs.env] +PLAN = "gold" + +[mcp_servers.issues] +url = "https://example.com/mcp" +bearer_token_env_var = "MCP_TOKEN" +startup_timeout_sec = 30.0 +tool_timeout_sec = 15.0 + +[mcp_servers.issues.http_headers] +"X-Test" = "42" + +[mcp_servers.issues.env_http_headers] +"X-Token" = "TOKEN_ENV" "#, ) } @@ -105,6 +135,45 @@ async fn get_config_toml_parses_all_fields() { web_search: Some(false), view_image: Some(true), }), + mcp_servers: HashMap::from([ + ( + "docs".into(), + ProtocolMcpServerConfig { + transport: ProtocolMcpServerTransportConfig::Stdio { + command: "codex-docs".into(), + args: vec!["serve".into()], + env: Some(HashMap::from([("PLAN".into(), "gold".into())])), + env_vars: vec!["DOCS_TOKEN".into()], + cwd: Some("/tmp/docs".into()), + }, + enabled: false, + startup_timeout_sec: Some(12.5), + tool_timeout_sec: Some(42.0), + enabled_tools: Some(vec!["read_docs".into()]), + disabled_tools: Some(vec!["delete_docs".into()]), + }, + ), + ( + "issues".into(), + ProtocolMcpServerConfig { + transport: ProtocolMcpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".into(), + bearer_token_env_var: Some("MCP_TOKEN".into()), + http_headers: Some(HashMap::from([("X-Test".into(), "42".into())])), + env_http_headers: Some(HashMap::from([( + "X-Token".into(), + "TOKEN_ENV".into(), + )])), + }, + enabled: true, + startup_timeout_sec: Some(30.0), + tool_timeout_sec: Some(15.0), + enabled_tools: None, + disabled_tools: None, + }, + ), + ]), + mcp_oauth_credentials_store: Some(McpOAuthCredentialsStoreMode::Keyring), profile: Some("test".to_string()), profiles: HashMap::from([( "test".into(), @@ -161,6 +230,8 @@ async fn get_config_toml_empty() { model_reasoning_summary: None, model_verbosity: None, tools: None, + mcp_servers: HashMap::new(), + mcp_oauth_credentials_store: None, profile: None, profiles: HashMap::new(), }, diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 0af01f6857..88fb0c83c5 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -967,6 +967,11 @@ impl From for UserSavedConfig { .into_iter() .map(|(k, v)| (k, v.into())) .collect(); + let mcp_servers = config_toml + .mcp_servers + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect(); Self { approval_policy: config_toml.approval_policy, @@ -979,12 +984,24 @@ impl From for UserSavedConfig { model_reasoning_summary: config_toml.model_reasoning_summary, model_verbosity: config_toml.model_verbosity, tools: config_toml.tools.map(From::from), + mcp_servers, + mcp_oauth_credentials_store: config_toml.mcp_oauth_credentials_store.map(From::from), profile: config_toml.profile, profiles, } } } +impl From for codex_app_server_protocol::McpOAuthCredentialsStoreMode { + fn from(mode: OAuthCredentialsStoreMode) -> Self { + match mode { + OAuthCredentialsStoreMode::Auto => Self::Auto, + OAuthCredentialsStoreMode::File => Self::File, + OAuthCredentialsStoreMode::Keyring => Self::Keyring, + } + } +} + #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] pub struct ProjectConfig { pub trust_level: Option, diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 2f4be2b6da..1d34dffb0e 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -388,6 +388,52 @@ impl From for codex_app_server_protocol::SandboxSettings } } +impl From for codex_app_server_protocol::McpServerConfig { + fn from(cfg: McpServerConfig) -> Self { + Self { + transport: cfg.transport.into(), + enabled: cfg.enabled, + startup_timeout_sec: cfg + .startup_timeout_sec + .map(|duration| duration.as_secs_f64()), + tool_timeout_sec: cfg.tool_timeout_sec.map(|duration| duration.as_secs_f64()), + enabled_tools: cfg.enabled_tools, + disabled_tools: cfg.disabled_tools, + } + } +} + +impl From for codex_app_server_protocol::McpServerTransportConfig { + fn from(cfg: McpServerTransportConfig) -> Self { + match cfg { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => Self::Stdio { + command, + args, + env, + env_vars, + cwd, + }, + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => Self::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + }, + } + } +} + #[derive(Deserialize, Debug, Clone, PartialEq, Default)] #[serde(rename_all = "kebab-case")] pub enum ShellEnvironmentPolicyInherit {