Skip to content

Commit a43ae86

Browse files
authored
[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 ```
1 parent 496cb80 commit a43ae86

File tree

7 files changed

+372
-75
lines changed

7 files changed

+372
-75
lines changed

codex-rs/Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/cli/src/mcp_cmd.rs

Lines changed: 116 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use anyhow::Context;
44
use anyhow::Result;
55
use anyhow::anyhow;
66
use anyhow::bail;
7+
use clap::ArgGroup;
78
use codex_common::CliConfigOverrides;
89
use codex_core::config::Config;
910
use codex_core::config::ConfigOverrides;
@@ -77,13 +78,61 @@ pub struct AddArgs {
7778
/// Name for the MCP server configuration.
7879
pub name: String,
7980

80-
/// Environment variables to set when launching the server.
81-
#[arg(long, value_parser = parse_env_pair, value_name = "KEY=VALUE")]
82-
pub env: Vec<(String, String)>,
81+
#[command(flatten)]
82+
pub transport_args: AddMcpTransportArgs,
83+
}
8384

85+
#[derive(Debug, clap::Args)]
86+
#[command(
87+
group(
88+
ArgGroup::new("transport")
89+
.args(["command", "url"])
90+
.required(true)
91+
.multiple(false)
92+
)
93+
)]
94+
pub struct AddMcpTransportArgs {
95+
#[command(flatten)]
96+
pub stdio: Option<AddMcpStdioArgs>,
97+
98+
#[command(flatten)]
99+
pub streamable_http: Option<AddMcpStreamableHttpArgs>,
100+
}
101+
102+
#[derive(Debug, clap::Args)]
103+
pub struct AddMcpStdioArgs {
84104
/// Command to launch the MCP server.
85-
#[arg(trailing_var_arg = true, num_args = 1..)]
105+
/// Use --url for a streamable HTTP server.
106+
#[arg(
107+
trailing_var_arg = true,
108+
num_args = 0..,
109+
)]
86110
pub command: Vec<String>,
111+
112+
/// Environment variables to set when launching the server.
113+
/// Only valid with stdio servers.
114+
#[arg(
115+
long,
116+
value_parser = parse_env_pair,
117+
value_name = "KEY=VALUE",
118+
)]
119+
pub env: Vec<(String, String)>,
120+
}
121+
122+
#[derive(Debug, clap::Args)]
123+
pub struct AddMcpStreamableHttpArgs {
124+
/// URL for a streamable HTTP MCP server.
125+
#[arg(long)]
126+
pub url: String,
127+
128+
/// Optional environment variable to read for a bearer token.
129+
/// Only valid with streamable HTTP servers.
130+
#[arg(
131+
long = "bearer-token-env-var",
132+
value_name = "ENV_VAR",
133+
requires = "url"
134+
)]
135+
pub bearer_token_env_var: Option<String>,
87136
}
88137

89138
#[derive(Debug, clap::Parser)]
@@ -140,37 +189,51 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
140189
// Validate any provided overrides even though they are not currently applied.
141190
config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
142191

143-
let AddArgs { name, env, command } = add_args;
192+
let AddArgs {
193+
name,
194+
transport_args,
195+
} = add_args;
144196

145197
validate_server_name(&name)?;
146198

147-
let mut command_parts = command.into_iter();
148-
let command_bin = command_parts
149-
.next()
150-
.ok_or_else(|| anyhow!("command is required"))?;
151-
let command_args: Vec<String> = command_parts.collect();
152-
153-
let env_map = if env.is_empty() {
154-
None
155-
} else {
156-
let mut map = HashMap::new();
157-
for (key, value) in env {
158-
map.insert(key, value);
159-
}
160-
Some(map)
161-
};
162-
163199
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
164200
let mut servers = load_global_mcp_servers(&codex_home)
165201
.await
166202
.with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;
167203

168-
let new_entry = McpServerConfig {
169-
transport: McpServerTransportConfig::Stdio {
170-
command: command_bin,
171-
args: command_args,
172-
env: env_map,
204+
let transport = match transport_args {
205+
AddMcpTransportArgs {
206+
stdio: Some(stdio), ..
207+
} => {
208+
let mut command_parts = stdio.command.into_iter();
209+
let command_bin = command_parts
210+
.next()
211+
.ok_or_else(|| anyhow!("command is required"))?;
212+
let command_args: Vec<String> = command_parts.collect();
213+
214+
let env_map = if stdio.env.is_empty() {
215+
None
216+
} else {
217+
Some(stdio.env.into_iter().collect::<HashMap<_, _>>())
218+
};
219+
McpServerTransportConfig::Stdio {
220+
command: command_bin,
221+
args: command_args,
222+
env: env_map,
223+
}
224+
}
225+
AddMcpTransportArgs {
226+
streamable_http: Some(streamable_http),
227+
..
228+
} => McpServerTransportConfig::StreamableHttp {
229+
url: streamable_http.url,
230+
bearer_token_env_var: streamable_http.bearer_token_env_var,
173231
},
232+
AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"),
233+
};
234+
235+
let new_entry = McpServerConfig {
236+
transport,
174237
startup_timeout_sec: None,
175238
tool_timeout_sec: None,
176239
};
@@ -288,11 +351,14 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
288351
"args": args,
289352
"env": env,
290353
}),
291-
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
354+
McpServerTransportConfig::StreamableHttp {
355+
url,
356+
bearer_token_env_var,
357+
} => {
292358
serde_json::json!({
293359
"type": "streamable_http",
294360
"url": url,
295-
"bearer_token": bearer_token,
361+
"bearer_token_env_var": bearer_token_env_var,
296362
})
297363
}
298364
};
@@ -345,13 +411,15 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
345411
};
346412
stdio_rows.push([name.clone(), command.clone(), args_display, env_display]);
347413
}
348-
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
349-
let has_bearer = if bearer_token.is_some() {
350-
"True"
351-
} else {
352-
"False"
353-
};
354-
http_rows.push([name.clone(), url.clone(), has_bearer.into()]);
414+
McpServerTransportConfig::StreamableHttp {
415+
url,
416+
bearer_token_env_var,
417+
} => {
418+
http_rows.push([
419+
name.clone(),
420+
url.clone(),
421+
bearer_token_env_var.clone().unwrap_or("-".to_string()),
422+
]);
355423
}
356424
}
357425
}
@@ -396,7 +464,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
396464
}
397465

398466
if !http_rows.is_empty() {
399-
let mut widths = ["Name".len(), "Url".len(), "Has Bearer Token".len()];
467+
let mut widths = ["Name".len(), "Url".len(), "Bearer Token Env Var".len()];
400468
for row in &http_rows {
401469
for (i, cell) in row.iter().enumerate() {
402470
widths[i] = widths[i].max(cell.len());
@@ -407,7 +475,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
407475
"{:<name_w$} {:<url_w$} {:<token_w$}",
408476
"Name",
409477
"Url",
410-
"Has Bearer Token",
478+
"Bearer Token Env Var",
411479
name_w = widths[0],
412480
url_w = widths[1],
413481
token_w = widths[2],
@@ -447,10 +515,13 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
447515
"args": args,
448516
"env": env,
449517
}),
450-
McpServerTransportConfig::StreamableHttp { url, bearer_token } => serde_json::json!({
518+
McpServerTransportConfig::StreamableHttp {
519+
url,
520+
bearer_token_env_var,
521+
} => serde_json::json!({
451522
"type": "streamable_http",
452523
"url": url,
453-
"bearer_token": bearer_token,
524+
"bearer_token_env_var": bearer_token_env_var,
454525
}),
455526
};
456527
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
493564
};
494565
println!(" env: {env_display}");
495566
}
496-
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
567+
McpServerTransportConfig::StreamableHttp {
568+
url,
569+
bearer_token_env_var,
570+
} => {
497571
println!(" transport: streamable_http");
498572
println!(" url: {url}");
499-
let bearer = bearer_token.as_deref().unwrap_or("-");
500-
println!(" bearer_token: {bearer}");
573+
let env_var = bearer_token_env_var.as_deref().unwrap_or("-");
574+
println!(" bearer_token_env_var: {env_var}");
501575
}
502576
}
503577
if let Some(timeout) = server.startup_timeout_sec {

codex-rs/cli/tests/mcp_add_remove.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,116 @@ async fn add_with_env_preserves_key_order_and_values() -> Result<()> {
9393

9494
Ok(())
9595
}
96+
97+
#[tokio::test]
98+
async fn add_streamable_http_without_manual_token() -> Result<()> {
99+
let codex_home = TempDir::new()?;
100+
101+
let mut add_cmd = codex_command(codex_home.path())?;
102+
add_cmd
103+
.args(["mcp", "add", "github", "--url", "https://example.com/mcp"])
104+
.assert()
105+
.success();
106+
107+
let servers = load_global_mcp_servers(codex_home.path()).await?;
108+
let github = servers.get("github").expect("github server should exist");
109+
match &github.transport {
110+
McpServerTransportConfig::StreamableHttp {
111+
url,
112+
bearer_token_env_var,
113+
} => {
114+
assert_eq!(url, "https://example.com/mcp");
115+
assert!(bearer_token_env_var.is_none());
116+
}
117+
other => panic!("unexpected transport: {other:?}"),
118+
}
119+
120+
assert!(!codex_home.path().join(".credentials.json").exists());
121+
assert!(!codex_home.path().join(".env").exists());
122+
123+
Ok(())
124+
}
125+
126+
#[tokio::test]
127+
async fn add_streamable_http_with_custom_env_var() -> Result<()> {
128+
let codex_home = TempDir::new()?;
129+
130+
let mut add_cmd = codex_command(codex_home.path())?;
131+
add_cmd
132+
.args([
133+
"mcp",
134+
"add",
135+
"issues",
136+
"--url",
137+
"https://example.com/issues",
138+
"--bearer-token-env-var",
139+
"GITHUB_TOKEN",
140+
])
141+
.assert()
142+
.success();
143+
144+
let servers = load_global_mcp_servers(codex_home.path()).await?;
145+
let issues = servers.get("issues").expect("issues server should exist");
146+
match &issues.transport {
147+
McpServerTransportConfig::StreamableHttp {
148+
url,
149+
bearer_token_env_var,
150+
} => {
151+
assert_eq!(url, "https://example.com/issues");
152+
assert_eq!(bearer_token_env_var.as_deref(), Some("GITHUB_TOKEN"));
153+
}
154+
other => panic!("unexpected transport: {other:?}"),
155+
}
156+
Ok(())
157+
}
158+
159+
#[tokio::test]
160+
async fn add_streamable_http_rejects_removed_flag() -> Result<()> {
161+
let codex_home = TempDir::new()?;
162+
163+
let mut add_cmd = codex_command(codex_home.path())?;
164+
add_cmd
165+
.args([
166+
"mcp",
167+
"add",
168+
"github",
169+
"--url",
170+
"https://example.com/mcp",
171+
"--with-bearer-token",
172+
])
173+
.assert()
174+
.failure()
175+
.stderr(contains("--with-bearer-token"));
176+
177+
let servers = load_global_mcp_servers(codex_home.path()).await?;
178+
assert!(servers.is_empty());
179+
180+
Ok(())
181+
}
182+
183+
#[tokio::test]
184+
async fn add_cant_add_command_and_url() -> Result<()> {
185+
let codex_home = TempDir::new()?;
186+
187+
let mut add_cmd = codex_command(codex_home.path())?;
188+
add_cmd
189+
.args([
190+
"mcp",
191+
"add",
192+
"github",
193+
"--url",
194+
"https://example.com/mcp",
195+
"--command",
196+
"--",
197+
"echo",
198+
"hello",
199+
])
200+
.assert()
201+
.failure()
202+
.stderr(contains("unexpected argument '--command' found"));
203+
204+
let servers = load_global_mcp_servers(codex_home.path()).await?;
205+
assert!(servers.is_empty());
206+
207+
Ok(())
208+
}

0 commit comments

Comments
 (0)