Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 71 additions & 36 deletions crates/agent-runner/src/runner/mcp_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ pub(super) struct AdditionalMcpServer {
pub(super) command: String,
pub(super) args: Vec<String>,
pub(super) env: HashMap<String, String>,
/// HTTP endpoint URL. When set, this server uses HTTP transport.
pub(super) url: Option<String>,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -162,8 +164,14 @@ pub(super) fn resolve_mcp_tool_enforcement(runtime_contract: Option<&serde_json:
e.iter().filter_map(|(k, v)| v.as_str().map(|val| (k.clone(), val.to_string()))).collect()
})
.unwrap_or_default(),
url: entry
.get("url")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|u| !u.is_empty())
.map(ToString::to_string),
})
.filter(|s| !s.command.is_empty())
.filter(|s| !s.command.is_empty() || s.url.is_some())
.collect()
})
.unwrap_or_default();
Expand Down Expand Up @@ -307,13 +315,20 @@ fn apply_claude_native_mcp_lockdown(
let mut mcp_servers = serde_json::Map::new();
mcp_servers.insert(agent_id.to_string(), primary);
for server in additional_servers {
let mut config = serde_json::Map::new();
config.insert("command".to_string(), serde_json::Value::String(server.command.clone()));
config.insert("args".to_string(), serde_json::to_value(&server.args).expect("server args should serialize"));
if !server.env.is_empty() {
config.insert("env".to_string(), serde_json::to_value(&server.env).expect("server env should serialize"));
}
mcp_servers.insert(server.name.clone(), serde_json::Value::Object(config));
let config = if let Some(url) = &server.url {
serde_json::json!({ "type": "http", "url": url })
} else {
let mut config = serde_json::Map::new();
config.insert("command".to_string(), serde_json::Value::String(server.command.clone()));
config
.insert("args".to_string(), serde_json::to_value(&server.args).expect("server args should serialize"));
if !server.env.is_empty() {
config
.insert("env".to_string(), serde_json::to_value(&server.env).expect("server env should serialize"));
}
serde_json::Value::Object(config)
};
mcp_servers.insert(server.name.clone(), config);
}
let config = serde_json::json!({ "mcpServers": mcp_servers }).to_string();
ensure_flag(args, "--strict-mcp-config", 0);
Expand Down Expand Up @@ -355,11 +370,16 @@ fn apply_codex_native_mcp_lockdown(

for server in additional_servers {
let sbase = format!("mcp_servers.{}", server.name);
ensure_codex_config_override(args, &format!("{sbase}.command"), &toml_string(&server.command));
let toml_args = format!("[{}]", server.args.iter().map(|arg| toml_string(arg)).collect::<Vec<_>>().join(", "));
ensure_codex_config_override(args, &format!("{sbase}.args"), &toml_args);
for (key, value) in &server.env {
ensure_codex_config_override(args, &format!("{sbase}.env.{key}"), &toml_string(value));
if let Some(url) = &server.url {
ensure_codex_config_override(args, &format!("{sbase}.url"), &toml_string(url));
} else {
ensure_codex_config_override(args, &format!("{sbase}.command"), &toml_string(&server.command));
let toml_args =
format!("[{}]", server.args.iter().map(|arg| toml_string(arg)).collect::<Vec<_>>().join(", "));
ensure_codex_config_override(args, &format!("{sbase}.args"), &toml_args);
for (key, value) in &server.env {
ensure_codex_config_override(args, &format!("{sbase}.env.{key}"), &toml_string(value));
}
}
ensure_codex_config_override(args, &format!("{sbase}.enabled"), "true");
}
Expand Down Expand Up @@ -397,14 +417,21 @@ fn apply_gemini_native_mcp_lockdown(
let mut mcp_servers = serde_json::Map::new();
mcp_servers.insert(agent_id.to_string(), primary);
for server in additional_servers {
let mut config = serde_json::Map::new();
config.insert("type".to_string(), serde_json::Value::String("stdio".to_string()));
config.insert("command".to_string(), serde_json::Value::String(server.command.clone()));
config.insert("args".to_string(), serde_json::to_value(&server.args).expect("server args should serialize"));
if !server.env.is_empty() {
config.insert("env".to_string(), serde_json::to_value(&server.env).expect("server env should serialize"));
}
mcp_servers.insert(server.name.clone(), serde_json::Value::Object(config));
let config = if let Some(url) = &server.url {
serde_json::json!({ "type": "http", "url": url })
} else {
let mut config = serde_json::Map::new();
config.insert("type".to_string(), serde_json::Value::String("stdio".to_string()));
config.insert("command".to_string(), serde_json::Value::String(server.command.clone()));
config
.insert("args".to_string(), serde_json::to_value(&server.args).expect("server args should serialize"));
if !server.env.is_empty() {
config
.insert("env".to_string(), serde_json::to_value(&server.env).expect("server env should serialize"));
}
serde_json::Value::Object(config)
};
mcp_servers.insert(server.name.clone(), config);
}
let settings = serde_json::json!({
"tools": {
Expand Down Expand Up @@ -448,20 +475,26 @@ fn apply_opencode_native_mcp_lockdown(
let mut mcp_entries = serde_json::Map::new();
mcp_entries.insert(agent_id.to_string(), primary);
for server in additional_servers {
let mut command_with_args = Vec::with_capacity(server.args.len() + 1);
command_with_args.push(server.command.clone());
command_with_args.extend(server.args.iter().cloned());
let mut config = serde_json::Map::new();
config.insert("type".to_string(), serde_json::Value::String("local".to_string()));
config.insert(
"command".to_string(),
serde_json::to_value(command_with_args).expect("server command should serialize"),
);
config.insert("enabled".to_string(), serde_json::Value::Bool(true));
if !server.env.is_empty() {
config.insert("env".to_string(), serde_json::to_value(&server.env).expect("server env should serialize"));
}
mcp_entries.insert(server.name.clone(), serde_json::Value::Object(config));
let config = if let Some(url) = &server.url {
serde_json::json!({ "type": "remote", "url": url, "enabled": true })
} else {
let mut command_with_args = Vec::with_capacity(server.args.len() + 1);
command_with_args.push(server.command.clone());
command_with_args.extend(server.args.iter().cloned());
let mut config = serde_json::Map::new();
config.insert("type".to_string(), serde_json::Value::String("local".to_string()));
config.insert(
"command".to_string(),
serde_json::to_value(command_with_args).expect("server command should serialize"),
);
config.insert("enabled".to_string(), serde_json::Value::Bool(true));
if !server.env.is_empty() {
config
.insert("env".to_string(), serde_json::to_value(&server.env).expect("server env should serialize"));
}
serde_json::Value::Object(config)
};
mcp_entries.insert(server.name.clone(), config);
}
let config = serde_json::json!({ "mcp": mcp_entries });
env.insert("OPENCODE_CONFIG_CONTENT".to_string(), config.to_string());
Expand All @@ -472,7 +505,9 @@ fn apply_oai_runner_native_mcp_lockdown(args: &mut Vec<String>, transport: McpSe
McpServerTransport::Stdio { command, args: stdio_args } => {
serde_json::json!([{ "command": command, "args": stdio_args }])
}
McpServerTransport::Http(_) => return,
McpServerTransport::Http(endpoint) => {
serde_json::json!([{ "url": endpoint, "transport": "http" }])
}
};
let insert_at = args.iter().position(|entry| entry == "run").map(|index| index + 1).unwrap_or(0);
ensure_flag_value(args, "--mcp-config", &config.to_string(), insert_at);
Expand Down
2 changes: 2 additions & 0 deletions crates/agent-runner/src/runner/mcp_policy/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ fn native_mcp_policy_preserves_primary_server_when_additional_server_name_collid
command: "ao".to_string(),
args: vec!["mcp".to_string(), "serve".to_string()],
env: HashMap::new(),
url: None,
}],
};
let mut env = HashMap::new();
Expand Down Expand Up @@ -574,6 +575,7 @@ fn claude_lockdown_includes_additional_servers() {
command: "/usr/local/bin/db-mcp".to_string(),
args: vec!["--port".to_string(), "5432".to_string()],
env: HashMap::from([("DB_HOST".to_string(), "localhost".to_string())]),
url: None,
}];
apply_claude_native_mcp_lockdown(
&mut args,
Expand Down
4 changes: 2 additions & 2 deletions crates/oai-runner/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time", "process"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
reqwest = { version = "0.13", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
Expand All @@ -21,7 +21,7 @@ eventsource-stream = "0.2"
tiktoken-rs = "0.9"
tokio-util = "0.7"
chrono = { version = "0.4", features = ["serde"] }
rmcp = { version = "1.2", features = ["client", "transport-child-process"] }
rmcp = { version = "1.2", features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest"] }
protocol = { path = "../protocol" }

[dev-dependencies]
Expand Down
26 changes: 26 additions & 0 deletions crates/oai-runner/src/tools/mcp_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use anyhow::{Context, Result};
use rmcp::model::{CallToolRequestParams, RawContent};
use rmcp::service::RunningService;
use rmcp::transport::child_process::TokioChildProcess;
use rmcp::transport::streamable_http_client::{StreamableHttpClientTransport, StreamableHttpClientTransportConfig};
use rmcp::{RoleClient, ServiceExt};
use serde::Deserialize;
use std::borrow::Cow;
Expand All @@ -12,9 +13,16 @@ use crate::api::types::{FunctionSchema, ToolDefinition};

#[derive(Debug, Clone, Deserialize)]
pub struct McpServerConfig {
#[serde(default)]
pub command: String,
#[serde(default)]
pub args: Vec<String>,
/// HTTP endpoint URL. When set, uses HTTP/SSE transport instead of stdio.
#[serde(default)]
pub url: Option<String>,
/// Transport type hint ("stdio" or "http"). Presence of `url` takes precedence.
#[serde(default)]
pub transport: Option<String>,
}

pub struct McpClient {
Expand All @@ -23,6 +31,24 @@ pub struct McpClient {
}

pub async fn connect(config: &McpServerConfig) -> Result<McpClient> {
// Use HTTP transport when a URL is provided or transport is explicitly "http".
let use_http = config.url.is_some() || config.transport.as_deref().is_some_and(|t| t.eq_ignore_ascii_case("http"));

if use_http {
let url = config
.url
.as_deref()
.filter(|u| !u.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("HTTP MCP server config is missing 'url'"))?;
let transport = StreamableHttpClientTransport::with_client(
reqwest::Client::new(),
StreamableHttpClientTransportConfig::with_uri(url),
);
let service: RunningService<RoleClient, ()> =
().serve(transport).await.map_err(|e| anyhow::anyhow!("failed to initialize HTTP MCP session: {}", e))?;
return Ok(McpClient { service, tool_names: Vec::new() });
}

let mut cmd = Command::new(&config.command);
for arg in &config.args {
cmd.arg(arg);
Expand Down
1 change: 1 addition & 0 deletions crates/orchestrator-config/src/pack_config/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ pub fn load_pack_mcp_overlay(pack: &LoadedPackManifest) -> Result<PackMcpOverlay
command: descriptor.command,
args: descriptor.args,
transport: descriptor.transport,
url: None,
config,
tools: descriptor.tools,
env: descriptor.env,
Expand Down
1 change: 1 addition & 0 deletions crates/orchestrator-config/src/workflow_config/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ pub(crate) fn builtin_workflow_config_base() -> WorkflowConfig {
command: "ao".to_string(),
args: vec!["mcp".to_string(), "serve".to_string()],
transport: Some("stdio".to_string()),
url: None,
config: BTreeMap::new(),
tools: Vec::new(),
env: BTreeMap::new(),
Expand Down
1 change: 1 addition & 0 deletions crates/orchestrator-config/src/workflow_config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1757,6 +1757,7 @@ fn validate_rejects_invalid_unified_sections() {
command: "".to_string(),
args: vec!["".to_string()],
transport: Some(" ".to_string()),
url: None,
config: BTreeMap::new(),
tools: vec!["".to_string()],
env: BTreeMap::from([("".to_string(), "value".to_string())]),
Expand Down
Loading
Loading