[MCP] Add support for streamable http servers with codex mcp add and replace bearer token handling (#4904)
1. You can now add streamable http servers via the CLI 2. As part of this, I'm also changing the existing bearer_token plain text config field with ane env var ``` mcp add github --url https://api.githubcopilot.com/mcp/ --bearer-token-env-var=GITHUB_PAT ```
This commit is contained in:
8
codex-rs/Cargo.lock
generated
8
codex-rs/Cargo.lock
generated
@@ -4772,9 +4772,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rmcp"
|
name = "rmcp"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "583d060e99feb3a3683fb48a1e4bf5f8d4a50951f429726f330ee5ff548837f8"
|
checksum = "6f35acda8f89fca5fd8c96cae3c6d5b4c38ea0072df4c8030915f3b5ff469c1c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -4806,9 +4806,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rmcp-macros"
|
name = "rmcp-macros"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "421d8b0ba302f479214889486f9550e63feca3af310f1190efcf6e2016802693"
|
checksum = "c9f1d5220aaa23b79c3d02e18f7a554403b3ccea544bbb6c69d6bcb3e854a274"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling 0.21.3",
|
"darling 0.21.3",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use anyhow::Context;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use anyhow::bail;
|
use anyhow::bail;
|
||||||
|
use clap::ArgGroup;
|
||||||
use codex_common::CliConfigOverrides;
|
use codex_common::CliConfigOverrides;
|
||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use codex_core::config::ConfigOverrides;
|
use codex_core::config::ConfigOverrides;
|
||||||
@@ -77,13 +78,61 @@ pub struct AddArgs {
|
|||||||
/// Name for the MCP server configuration.
|
/// Name for the MCP server configuration.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
||||||
/// Environment variables to set when launching the server.
|
#[command(flatten)]
|
||||||
#[arg(long, value_parser = parse_env_pair, value_name = "KEY=VALUE")]
|
pub transport_args: AddMcpTransportArgs,
|
||||||
pub env: Vec<(String, String)>,
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
#[command(
|
||||||
|
group(
|
||||||
|
ArgGroup::new("transport")
|
||||||
|
.args(["command", "url"])
|
||||||
|
.required(true)
|
||||||
|
.multiple(false)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub struct AddMcpTransportArgs {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub stdio: Option<AddMcpStdioArgs>,
|
||||||
|
|
||||||
|
#[command(flatten)]
|
||||||
|
pub streamable_http: Option<AddMcpStreamableHttpArgs>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
pub struct AddMcpStdioArgs {
|
||||||
/// Command to launch the MCP server.
|
/// Command to launch the MCP server.
|
||||||
#[arg(trailing_var_arg = true, num_args = 1..)]
|
/// Use --url for a streamable HTTP server.
|
||||||
|
#[arg(
|
||||||
|
trailing_var_arg = true,
|
||||||
|
num_args = 0..,
|
||||||
|
)]
|
||||||
pub command: Vec<String>,
|
pub command: Vec<String>,
|
||||||
|
|
||||||
|
/// Environment variables to set when launching the server.
|
||||||
|
/// Only valid with stdio servers.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_parser = parse_env_pair,
|
||||||
|
value_name = "KEY=VALUE",
|
||||||
|
)]
|
||||||
|
pub env: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
pub struct AddMcpStreamableHttpArgs {
|
||||||
|
/// URL for a streamable HTTP MCP server.
|
||||||
|
#[arg(long)]
|
||||||
|
pub url: String,
|
||||||
|
|
||||||
|
/// Optional environment variable to read for a bearer token.
|
||||||
|
/// Only valid with streamable HTTP servers.
|
||||||
|
#[arg(
|
||||||
|
long = "bearer-token-env-var",
|
||||||
|
value_name = "ENV_VAR",
|
||||||
|
requires = "url"
|
||||||
|
)]
|
||||||
|
pub bearer_token_env_var: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, clap::Parser)]
|
#[derive(Debug, clap::Parser)]
|
||||||
@@ -140,37 +189,51 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
|
|||||||
// Validate any provided overrides even though they are not currently applied.
|
// Validate any provided overrides even though they are not currently applied.
|
||||||
config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
||||||
|
|
||||||
let AddArgs { name, env, command } = add_args;
|
let AddArgs {
|
||||||
|
name,
|
||||||
|
transport_args,
|
||||||
|
} = add_args;
|
||||||
|
|
||||||
validate_server_name(&name)?;
|
validate_server_name(&name)?;
|
||||||
|
|
||||||
let mut command_parts = command.into_iter();
|
|
||||||
let command_bin = command_parts
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| anyhow!("command is required"))?;
|
|
||||||
let command_args: Vec<String> = command_parts.collect();
|
|
||||||
|
|
||||||
let env_map = if env.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let mut map = HashMap::new();
|
|
||||||
for (key, value) in env {
|
|
||||||
map.insert(key, value);
|
|
||||||
}
|
|
||||||
Some(map)
|
|
||||||
};
|
|
||||||
|
|
||||||
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
||||||
let mut servers = load_global_mcp_servers(&codex_home)
|
let mut servers = load_global_mcp_servers(&codex_home)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;
|
.with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;
|
||||||
|
|
||||||
let new_entry = McpServerConfig {
|
let transport = match transport_args {
|
||||||
transport: McpServerTransportConfig::Stdio {
|
AddMcpTransportArgs {
|
||||||
command: command_bin,
|
stdio: Some(stdio), ..
|
||||||
args: command_args,
|
} => {
|
||||||
env: env_map,
|
let mut command_parts = stdio.command.into_iter();
|
||||||
|
let command_bin = command_parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow!("command is required"))?;
|
||||||
|
let command_args: Vec<String> = command_parts.collect();
|
||||||
|
|
||||||
|
let env_map = if stdio.env.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(stdio.env.into_iter().collect::<HashMap<_, _>>())
|
||||||
|
};
|
||||||
|
McpServerTransportConfig::Stdio {
|
||||||
|
command: command_bin,
|
||||||
|
args: command_args,
|
||||||
|
env: env_map,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddMcpTransportArgs {
|
||||||
|
streamable_http: Some(streamable_http),
|
||||||
|
..
|
||||||
|
} => McpServerTransportConfig::StreamableHttp {
|
||||||
|
url: streamable_http.url,
|
||||||
|
bearer_token_env_var: streamable_http.bearer_token_env_var,
|
||||||
},
|
},
|
||||||
|
AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_entry = McpServerConfig {
|
||||||
|
transport,
|
||||||
startup_timeout_sec: None,
|
startup_timeout_sec: None,
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
};
|
};
|
||||||
@@ -288,11 +351,14 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
|||||||
"args": args,
|
"args": args,
|
||||||
"env": env,
|
"env": env,
|
||||||
}),
|
}),
|
||||||
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
|
McpServerTransportConfig::StreamableHttp {
|
||||||
|
url,
|
||||||
|
bearer_token_env_var,
|
||||||
|
} => {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"type": "streamable_http",
|
"type": "streamable_http",
|
||||||
"url": url,
|
"url": url,
|
||||||
"bearer_token": bearer_token,
|
"bearer_token_env_var": bearer_token_env_var,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -345,13 +411,15 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
|||||||
};
|
};
|
||||||
stdio_rows.push([name.clone(), command.clone(), args_display, env_display]);
|
stdio_rows.push([name.clone(), command.clone(), args_display, env_display]);
|
||||||
}
|
}
|
||||||
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
|
McpServerTransportConfig::StreamableHttp {
|
||||||
let has_bearer = if bearer_token.is_some() {
|
url,
|
||||||
"True"
|
bearer_token_env_var,
|
||||||
} else {
|
} => {
|
||||||
"False"
|
http_rows.push([
|
||||||
};
|
name.clone(),
|
||||||
http_rows.push([name.clone(), url.clone(), has_bearer.into()]);
|
url.clone(),
|
||||||
|
bearer_token_env_var.clone().unwrap_or("-".to_string()),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,7 +464,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !http_rows.is_empty() {
|
if !http_rows.is_empty() {
|
||||||
let mut widths = ["Name".len(), "Url".len(), "Has Bearer Token".len()];
|
let mut widths = ["Name".len(), "Url".len(), "Bearer Token Env Var".len()];
|
||||||
for row in &http_rows {
|
for row in &http_rows {
|
||||||
for (i, cell) in row.iter().enumerate() {
|
for (i, cell) in row.iter().enumerate() {
|
||||||
widths[i] = widths[i].max(cell.len());
|
widths[i] = widths[i].max(cell.len());
|
||||||
@@ -407,7 +475,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
|||||||
"{:<name_w$} {:<url_w$} {:<token_w$}",
|
"{:<name_w$} {:<url_w$} {:<token_w$}",
|
||||||
"Name",
|
"Name",
|
||||||
"Url",
|
"Url",
|
||||||
"Has Bearer Token",
|
"Bearer Token Env Var",
|
||||||
name_w = widths[0],
|
name_w = widths[0],
|
||||||
url_w = widths[1],
|
url_w = widths[1],
|
||||||
token_w = widths[2],
|
token_w = widths[2],
|
||||||
@@ -447,10 +515,13 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
|
|||||||
"args": args,
|
"args": args,
|
||||||
"env": env,
|
"env": env,
|
||||||
}),
|
}),
|
||||||
McpServerTransportConfig::StreamableHttp { url, bearer_token } => serde_json::json!({
|
McpServerTransportConfig::StreamableHttp {
|
||||||
|
url,
|
||||||
|
bearer_token_env_var,
|
||||||
|
} => serde_json::json!({
|
||||||
"type": "streamable_http",
|
"type": "streamable_http",
|
||||||
"url": url,
|
"url": url,
|
||||||
"bearer_token": bearer_token,
|
"bearer_token_env_var": bearer_token_env_var,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
let output = serde_json::to_string_pretty(&serde_json::json!({
|
let output = serde_json::to_string_pretty(&serde_json::json!({
|
||||||
@@ -493,11 +564,14 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
|
|||||||
};
|
};
|
||||||
println!(" env: {env_display}");
|
println!(" env: {env_display}");
|
||||||
}
|
}
|
||||||
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
|
McpServerTransportConfig::StreamableHttp {
|
||||||
|
url,
|
||||||
|
bearer_token_env_var,
|
||||||
|
} => {
|
||||||
println!(" transport: streamable_http");
|
println!(" transport: streamable_http");
|
||||||
println!(" url: {url}");
|
println!(" url: {url}");
|
||||||
let bearer = bearer_token.as_deref().unwrap_or("-");
|
let env_var = bearer_token_env_var.as_deref().unwrap_or("-");
|
||||||
println!(" bearer_token: {bearer}");
|
println!(" bearer_token_env_var: {env_var}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(timeout) = server.startup_timeout_sec {
|
if let Some(timeout) = server.startup_timeout_sec {
|
||||||
|
|||||||
@@ -93,3 +93,116 @@ async fn add_with_env_preserves_key_order_and_values() -> Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn add_streamable_http_without_manual_token() -> Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
|
||||||
|
let mut add_cmd = codex_command(codex_home.path())?;
|
||||||
|
add_cmd
|
||||||
|
.args(["mcp", "add", "github", "--url", "https://example.com/mcp"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let servers = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
|
let github = servers.get("github").expect("github server should exist");
|
||||||
|
match &github.transport {
|
||||||
|
McpServerTransportConfig::StreamableHttp {
|
||||||
|
url,
|
||||||
|
bearer_token_env_var,
|
||||||
|
} => {
|
||||||
|
assert_eq!(url, "https://example.com/mcp");
|
||||||
|
assert!(bearer_token_env_var.is_none());
|
||||||
|
}
|
||||||
|
other => panic!("unexpected transport: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(!codex_home.path().join(".credentials.json").exists());
|
||||||
|
assert!(!codex_home.path().join(".env").exists());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn add_streamable_http_with_custom_env_var() -> Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
|
||||||
|
let mut add_cmd = codex_command(codex_home.path())?;
|
||||||
|
add_cmd
|
||||||
|
.args([
|
||||||
|
"mcp",
|
||||||
|
"add",
|
||||||
|
"issues",
|
||||||
|
"--url",
|
||||||
|
"https://example.com/issues",
|
||||||
|
"--bearer-token-env-var",
|
||||||
|
"GITHUB_TOKEN",
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let servers = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
|
let issues = servers.get("issues").expect("issues server should exist");
|
||||||
|
match &issues.transport {
|
||||||
|
McpServerTransportConfig::StreamableHttp {
|
||||||
|
url,
|
||||||
|
bearer_token_env_var,
|
||||||
|
} => {
|
||||||
|
assert_eq!(url, "https://example.com/issues");
|
||||||
|
assert_eq!(bearer_token_env_var.as_deref(), Some("GITHUB_TOKEN"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected transport: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn add_streamable_http_rejects_removed_flag() -> Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
|
||||||
|
let mut add_cmd = codex_command(codex_home.path())?;
|
||||||
|
add_cmd
|
||||||
|
.args([
|
||||||
|
"mcp",
|
||||||
|
"add",
|
||||||
|
"github",
|
||||||
|
"--url",
|
||||||
|
"https://example.com/mcp",
|
||||||
|
"--with-bearer-token",
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(contains("--with-bearer-token"));
|
||||||
|
|
||||||
|
let servers = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
|
assert!(servers.is_empty());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn add_cant_add_command_and_url() -> Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
|
||||||
|
let mut add_cmd = codex_command(codex_home.path())?;
|
||||||
|
add_cmd
|
||||||
|
.args([
|
||||||
|
"mcp",
|
||||||
|
"add",
|
||||||
|
"github",
|
||||||
|
"--url",
|
||||||
|
"https://example.com/mcp",
|
||||||
|
"--command",
|
||||||
|
"--",
|
||||||
|
"echo",
|
||||||
|
"hello",
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(contains("unexpected argument '--command' found"));
|
||||||
|
|
||||||
|
let servers = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
|
assert!(servers.is_empty());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,8 +38,10 @@ use dirs::home_dir;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::io::ErrorKind;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
use toml::Value as TomlValue;
|
use toml::Value as TomlValue;
|
||||||
use toml_edit::Array as TomlArray;
|
use toml_edit::Array as TomlArray;
|
||||||
@@ -311,12 +313,35 @@ pub async fn load_global_mcp_servers(
|
|||||||
return Ok(BTreeMap::new());
|
return Ok(BTreeMap::new());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ensure_no_inline_bearer_tokens(servers_value)?;
|
||||||
|
|
||||||
servers_value
|
servers_value
|
||||||
.clone()
|
.clone()
|
||||||
.try_into()
|
.try_into()
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// We briefly allowed plain text bearer_token fields in MCP server configs.
|
||||||
|
/// We want to warn people who recently added these fields but can remove this after a few months.
|
||||||
|
fn ensure_no_inline_bearer_tokens(value: &TomlValue) -> std::io::Result<()> {
|
||||||
|
let Some(servers_table) = value.as_table() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
for (server_name, server_value) in servers_table {
|
||||||
|
if let Some(server_table) = server_value.as_table()
|
||||||
|
&& server_table.contains_key("bearer_token")
|
||||||
|
{
|
||||||
|
let message = format!(
|
||||||
|
"mcp_servers.{server_name} uses unsupported `bearer_token`; set `bearer_token_env_var`."
|
||||||
|
);
|
||||||
|
return Err(std::io::Error::new(ErrorKind::InvalidData, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn write_global_mcp_servers(
|
pub fn write_global_mcp_servers(
|
||||||
codex_home: &Path,
|
codex_home: &Path,
|
||||||
servers: &BTreeMap<String, McpServerConfig>,
|
servers: &BTreeMap<String, McpServerConfig>,
|
||||||
@@ -365,10 +390,13 @@ pub fn write_global_mcp_servers(
|
|||||||
entry["env"] = TomlItem::Table(env_table);
|
entry["env"] = TomlItem::Table(env_table);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
|
McpServerTransportConfig::StreamableHttp {
|
||||||
|
url,
|
||||||
|
bearer_token_env_var,
|
||||||
|
} => {
|
||||||
entry["url"] = toml_edit::value(url.clone());
|
entry["url"] = toml_edit::value(url.clone());
|
||||||
if let Some(token) = bearer_token {
|
if let Some(env_var) = bearer_token_env_var {
|
||||||
entry["bearer_token"] = toml_edit::value(token.clone());
|
entry["bearer_token_env_var"] = toml_edit::value(env_var.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1571,6 +1599,31 @@ startup_timeout_ms = 2500
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
&config_path,
|
||||||
|
r#"
|
||||||
|
[mcp_servers.docs]
|
||||||
|
url = "https://example.com/mcp"
|
||||||
|
bearer_token = "secret"
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let err = load_global_mcp_servers(codex_home.path())
|
||||||
|
.await
|
||||||
|
.expect_err("bearer_token entries should be rejected");
|
||||||
|
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||||
|
assert!(err.to_string().contains("bearer_token"));
|
||||||
|
assert!(err.to_string().contains("bearer_token_env_var"));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn write_global_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
|
async fn write_global_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
@@ -1634,7 +1687,7 @@ ZIG_VAR = "3"
|
|||||||
McpServerConfig {
|
McpServerConfig {
|
||||||
transport: McpServerTransportConfig::StreamableHttp {
|
transport: McpServerTransportConfig::StreamableHttp {
|
||||||
url: "https://example.com/mcp".to_string(),
|
url: "https://example.com/mcp".to_string(),
|
||||||
bearer_token: Some("secret-token".to_string()),
|
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
|
||||||
},
|
},
|
||||||
startup_timeout_sec: Some(Duration::from_secs(2)),
|
startup_timeout_sec: Some(Duration::from_secs(2)),
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
@@ -1649,7 +1702,7 @@ ZIG_VAR = "3"
|
|||||||
serialized,
|
serialized,
|
||||||
r#"[mcp_servers.docs]
|
r#"[mcp_servers.docs]
|
||||||
url = "https://example.com/mcp"
|
url = "https://example.com/mcp"
|
||||||
bearer_token = "secret-token"
|
bearer_token_env_var = "MCP_TOKEN"
|
||||||
startup_timeout_sec = 2.0
|
startup_timeout_sec = 2.0
|
||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
@@ -1657,9 +1710,12 @@ startup_timeout_sec = 2.0
|
|||||||
let loaded = load_global_mcp_servers(codex_home.path()).await?;
|
let loaded = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
let docs = loaded.get("docs").expect("docs entry");
|
let docs = loaded.get("docs").expect("docs entry");
|
||||||
match &docs.transport {
|
match &docs.transport {
|
||||||
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
|
McpServerTransportConfig::StreamableHttp {
|
||||||
|
url,
|
||||||
|
bearer_token_env_var,
|
||||||
|
} => {
|
||||||
assert_eq!(url, "https://example.com/mcp");
|
assert_eq!(url, "https://example.com/mcp");
|
||||||
assert_eq!(bearer_token.as_deref(), Some("secret-token"));
|
assert_eq!(bearer_token_env_var.as_deref(), Some("MCP_TOKEN"));
|
||||||
}
|
}
|
||||||
other => panic!("unexpected transport {other:?}"),
|
other => panic!("unexpected transport {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -1670,7 +1726,7 @@ startup_timeout_sec = 2.0
|
|||||||
McpServerConfig {
|
McpServerConfig {
|
||||||
transport: McpServerTransportConfig::StreamableHttp {
|
transport: McpServerTransportConfig::StreamableHttp {
|
||||||
url: "https://example.com/mcp".to_string(),
|
url: "https://example.com/mcp".to_string(),
|
||||||
bearer_token: None,
|
bearer_token_env_var: None,
|
||||||
},
|
},
|
||||||
startup_timeout_sec: None,
|
startup_timeout_sec: None,
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
@@ -1689,9 +1745,12 @@ url = "https://example.com/mcp"
|
|||||||
let loaded = load_global_mcp_servers(codex_home.path()).await?;
|
let loaded = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
let docs = loaded.get("docs").expect("docs entry");
|
let docs = loaded.get("docs").expect("docs entry");
|
||||||
match &docs.transport {
|
match &docs.transport {
|
||||||
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
|
McpServerTransportConfig::StreamableHttp {
|
||||||
|
url,
|
||||||
|
bearer_token_env_var,
|
||||||
|
} => {
|
||||||
assert_eq!(url, "https://example.com/mcp");
|
assert_eq!(url, "https://example.com/mcp");
|
||||||
assert!(bearer_token.is_none());
|
assert!(bearer_token_env_var.is_none());
|
||||||
}
|
}
|
||||||
other => panic!("unexpected transport {other:?}"),
|
other => panic!("unexpected transport {other:?}"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
|||||||
|
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
bearer_token: Option<String>,
|
bearer_token: Option<String>,
|
||||||
|
bearer_token_env_var: Option<String>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
startup_timeout_sec: Option<f64>,
|
startup_timeout_sec: Option<f64>,
|
||||||
@@ -86,11 +87,15 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
|||||||
args,
|
args,
|
||||||
env,
|
env,
|
||||||
url,
|
url,
|
||||||
bearer_token,
|
bearer_token_env_var,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
throw_if_set("stdio", "url", url.as_ref())?;
|
throw_if_set("stdio", "url", url.as_ref())?;
|
||||||
throw_if_set("stdio", "bearer_token", bearer_token.as_ref())?;
|
throw_if_set(
|
||||||
|
"stdio",
|
||||||
|
"bearer_token_env_var",
|
||||||
|
bearer_token_env_var.as_ref(),
|
||||||
|
)?;
|
||||||
McpServerTransportConfig::Stdio {
|
McpServerTransportConfig::Stdio {
|
||||||
command,
|
command,
|
||||||
args: args.unwrap_or_default(),
|
args: args.unwrap_or_default(),
|
||||||
@@ -100,6 +105,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
|||||||
RawMcpServerConfig {
|
RawMcpServerConfig {
|
||||||
url: Some(url),
|
url: Some(url),
|
||||||
bearer_token,
|
bearer_token,
|
||||||
|
bearer_token_env_var,
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
env,
|
env,
|
||||||
@@ -108,7 +114,11 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
|||||||
throw_if_set("streamable_http", "command", command.as_ref())?;
|
throw_if_set("streamable_http", "command", command.as_ref())?;
|
||||||
throw_if_set("streamable_http", "args", args.as_ref())?;
|
throw_if_set("streamable_http", "args", args.as_ref())?;
|
||||||
throw_if_set("streamable_http", "env", env.as_ref())?;
|
throw_if_set("streamable_http", "env", env.as_ref())?;
|
||||||
McpServerTransportConfig::StreamableHttp { url, bearer_token }
|
throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?;
|
||||||
|
McpServerTransportConfig::StreamableHttp {
|
||||||
|
url,
|
||||||
|
bearer_token_env_var,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => return Err(SerdeError::custom("invalid transport")),
|
_ => return Err(SerdeError::custom("invalid transport")),
|
||||||
};
|
};
|
||||||
@@ -135,11 +145,11 @@ pub enum McpServerTransportConfig {
|
|||||||
/// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http
|
/// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http
|
||||||
StreamableHttp {
|
StreamableHttp {
|
||||||
url: String,
|
url: String,
|
||||||
/// A plain text bearer token to use for authentication.
|
/// Name of the environment variable to read for an HTTP bearer token.
|
||||||
/// This bearer token will be included in the HTTP request header as an `Authorization: Bearer <token>` header.
|
/// When set, requests will include the token via `Authorization: Bearer <token>`.
|
||||||
/// This should be used with caution because it lives on disk in clear text.
|
/// The actual secret value must be provided via the environment.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
bearer_token: Option<String>,
|
bearer_token_env_var: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,17 +516,17 @@ mod tests {
|
|||||||
cfg.transport,
|
cfg.transport,
|
||||||
McpServerTransportConfig::StreamableHttp {
|
McpServerTransportConfig::StreamableHttp {
|
||||||
url: "https://example.com/mcp".to_string(),
|
url: "https://example.com/mcp".to_string(),
|
||||||
bearer_token: None
|
bearer_token_env_var: None
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deserialize_streamable_http_server_config_with_bearer_token() {
|
fn deserialize_streamable_http_server_config_with_env_var() {
|
||||||
let cfg: McpServerConfig = toml::from_str(
|
let cfg: McpServerConfig = toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
url = "https://example.com/mcp"
|
url = "https://example.com/mcp"
|
||||||
bearer_token = "secret"
|
bearer_token_env_var = "GITHUB_TOKEN"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.expect("should deserialize http config");
|
.expect("should deserialize http config");
|
||||||
@@ -525,7 +535,7 @@ mod tests {
|
|||||||
cfg.transport,
|
cfg.transport,
|
||||||
McpServerTransportConfig::StreamableHttp {
|
McpServerTransportConfig::StreamableHttp {
|
||||||
url: "https://example.com/mcp".to_string(),
|
url: "https://example.com/mcp".to_string(),
|
||||||
bearer_token: Some("secret".to_string())
|
bearer_token_env_var: Some("GITHUB_TOKEN".to_string())
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -553,13 +563,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deserialize_rejects_bearer_token_for_stdio_transport() {
|
fn deserialize_rejects_inline_bearer_token_field() {
|
||||||
toml::from_str::<McpServerConfig>(
|
let err = toml::from_str::<McpServerConfig>(
|
||||||
r#"
|
r#"
|
||||||
command = "echo"
|
url = "https://example.com"
|
||||||
bearer_token = "secret"
|
bearer_token = "secret"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.expect_err("should reject bearer token for stdio transport");
|
.expect_err("should reject bearer_token field");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("bearer_token is not supported"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::env;
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -209,6 +210,14 @@ impl McpConnectionManager {
|
|||||||
let startup_timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
|
let startup_timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
|
||||||
let tool_timeout = cfg.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT);
|
let tool_timeout = cfg.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT);
|
||||||
|
|
||||||
|
let resolved_bearer_token = match &cfg.transport {
|
||||||
|
McpServerTransportConfig::StreamableHttp {
|
||||||
|
bearer_token_env_var,
|
||||||
|
..
|
||||||
|
} => resolve_bearer_token(&server_name, bearer_token_env_var.as_deref()),
|
||||||
|
_ => Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
join_set.spawn(async move {
|
join_set.spawn(async move {
|
||||||
let McpServerConfig { transport, .. } = cfg;
|
let McpServerConfig { transport, .. } = cfg;
|
||||||
let params = mcp_types::InitializeRequestParams {
|
let params = mcp_types::InitializeRequestParams {
|
||||||
@@ -246,11 +255,11 @@ impl McpConnectionManager {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
|
McpServerTransportConfig::StreamableHttp { url, .. } => {
|
||||||
McpClientAdapter::new_streamable_http_client(
|
McpClientAdapter::new_streamable_http_client(
|
||||||
server_name.clone(),
|
server_name.clone(),
|
||||||
url,
|
url,
|
||||||
bearer_token,
|
resolved_bearer_token.unwrap_or_default(),
|
||||||
params,
|
params,
|
||||||
startup_timeout,
|
startup_timeout,
|
||||||
store_mode,
|
store_mode,
|
||||||
@@ -341,6 +350,33 @@ impl McpConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_bearer_token(
|
||||||
|
server_name: &str,
|
||||||
|
bearer_token_env_var: Option<&str>,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
let Some(env_var) = bearer_token_env_var else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
match env::var(env_var) {
|
||||||
|
Ok(value) => {
|
||||||
|
if value.is_empty() {
|
||||||
|
Err(anyhow!(
|
||||||
|
"Environment variable {env_var} for MCP server '{server_name}' is empty"
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(Some(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(env::VarError::NotPresent) => Err(anyhow!(
|
||||||
|
"Environment variable {env_var} for MCP server '{server_name}' is not set"
|
||||||
|
)),
|
||||||
|
Err(env::VarError::NotUnicode(_)) => Err(anyhow!(
|
||||||
|
"Environment variable {env_var} for MCP server '{server_name}' contains invalid Unicode"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Query every server for its available tools and return a single map that
|
/// Query every server for its available tools and return a single map that
|
||||||
/// contains **all** tools. Each key is the fully-qualified name for the tool.
|
/// contains **all** tools. Each key is the fully-qualified name for the tool.
|
||||||
async fn list_all_tools(clients: &HashMap<String, ManagedClient>) -> Result<Vec<ToolInfo>> {
|
async fn list_all_tools(clients: &HashMap<String, ManagedClient>) -> Result<Vec<ToolInfo>> {
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
|
|||||||
McpServerConfig {
|
McpServerConfig {
|
||||||
transport: McpServerTransportConfig::StreamableHttp {
|
transport: McpServerTransportConfig::StreamableHttp {
|
||||||
url: server_url,
|
url: server_url,
|
||||||
bearer_token: None,
|
bearer_token_env_var: None,
|
||||||
},
|
},
|
||||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
@@ -412,7 +412,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> {
|
|||||||
McpServerConfig {
|
McpServerConfig {
|
||||||
transport: McpServerTransportConfig::StreamableHttp {
|
transport: McpServerTransportConfig::StreamableHttp {
|
||||||
url: server_url,
|
url: server_url,
|
||||||
bearer_token: None,
|
bearer_token_env_var: None,
|
||||||
},
|
},
|
||||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
|||||||
Reference in New Issue
Block a user