From 12050b2a1e9a20b737ab18869f416c0685e27add Mon Sep 17 00:00:00 2001 From: Sami Shukri Date: Wed, 1 Apr 2026 00:26:55 -0600 Subject: [PATCH] feat(mcp): implement HTTP/remote MCP server support (closes #96) Add first-class HTTP/remote MCP server support across the config, runtime contract, and agent lockdown layers. ## Changes ### Config types - `protocol::ProjectMcpServerEntry`: add `transport` (Option) and `url` (Option) fields; `command` now has `#[serde(default)]` so HTTP-only servers don't need an empty command string. - `orchestrator_config::McpServerDefinition`: same additions. ### Validation - `transport` must be "stdio" or "http" when set. - `transport = "http"` requires a non-empty `url`. - `transport = "stdio"` (or absent) continues to require a non-empty `command`. ### Runtime contract injection - All three injection sites (`inject_project_mcp_servers`, `inject_workflow_mcp_servers`, `inject_named_mcp_servers`) now forward `transport` and `url` into the `additional_servers` JSON passed to agent runners. ### Agent lockdown (agent-runner) - `AdditionalMcpServer` gains a `url: Option` field. - Parsing in `resolve_mcp_tool_enforcement` reads `url`; filters allow servers with a URL even if `command` is empty. - Claude, Codex, Gemini, and OpenCode lockdown functions each emit the correct vendor-native HTTP config when `url` is set: - Claude: `{ "type": "http", "url": "..." }` - Codex: `mcp_servers..url = "..."` - Gemini: `{ "type": "http", "url": "..." }` - OpenCode: `{ "type": "remote", "url": "...", "enabled": true }` - `apply_oai_runner_native_mcp_lockdown` now emits an HTTP entry (`[{ "url": "...", "transport": "http" }]`) instead of a no-op return. ### OAI runner - `McpServerConfig` adds `url` and `transport` fields. - `connect()` branches on HTTP when `url` is set or `transport = "http"`, using `rmcp::StreamableHttpClientTransport`. - Enable `transport-streamable-http-client-reqwest` rmcp feature. - Upgrade `reqwest` from 0.12 to 0.13 to match rmcp's internal reqwest. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 31 ++++- crates/agent-runner/src/runner/mcp_policy.rs | 107 ++++++++++++------ .../src/runner/mcp_policy/tests.rs | 2 + crates/oai-runner/Cargo.toml | 4 +- crates/oai-runner/src/tools/mcp_client.rs | 26 +++++ .../src/pack_config/mcp.rs | 1 + .../src/workflow_config/builtins.rs | 1 + .../src/workflow_config/tests.rs | 1 + .../src/workflow_config/types.rs | 5 + .../src/workflow_config/validation.rs | 27 ++++- crates/protocol/src/config.rs | 7 ++ .../src/runtime_contract.rs | 81 +++++++------ .../workflow-runner-v2/src/skill_dispatch.rs | 1 + 13 files changed, 213 insertions(+), 81 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf744c604..179c04342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2320,7 +2320,7 @@ dependencies = [ "glob", "jsonschema", "protocol", - "reqwest 0.12.28", + "reqwest 0.13.2", "rmcp", "serde", "serde_json", @@ -2924,6 +2924,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -3106,7 +3107,6 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "futures-util", "h2", "http", "http-body", @@ -3131,14 +3131,12 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", - "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", "webpki-roots", ] @@ -3151,6 +3149,7 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", @@ -3163,8 +3162,10 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -3173,12 +3174,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] @@ -3206,13 +3209,16 @@ dependencies = [ "base64", "chrono", "futures", + "http", "pastey", "pin-project-lite", "process-wrap", + "reqwest 0.13.2", "rmcp-macros", "schemars 1.2.1", "serde", "serde_json", + "sse-stream", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -3701,6 +3707,19 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4489,9 +4508,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", diff --git a/crates/agent-runner/src/runner/mcp_policy.rs b/crates/agent-runner/src/runner/mcp_policy.rs index 19f469472..a299f1f89 100644 --- a/crates/agent-runner/src/runner/mcp_policy.rs +++ b/crates/agent-runner/src/runner/mcp_policy.rs @@ -18,6 +18,8 @@ pub(super) struct AdditionalMcpServer { pub(super) command: String, pub(super) args: Vec, pub(super) env: HashMap, + /// HTTP endpoint URL. When set, this server uses HTTP transport. + pub(super) url: Option, } #[derive(Debug, Clone)] @@ -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(); @@ -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); @@ -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::>().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::>().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"); } @@ -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": { @@ -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()); @@ -472,7 +505,9 @@ fn apply_oai_runner_native_mcp_lockdown(args: &mut Vec, 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); diff --git a/crates/agent-runner/src/runner/mcp_policy/tests.rs b/crates/agent-runner/src/runner/mcp_policy/tests.rs index 0dfeb29cf..59f7c8984 100644 --- a/crates/agent-runner/src/runner/mcp_policy/tests.rs +++ b/crates/agent-runner/src/runner/mcp_policy/tests.rs @@ -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(); @@ -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, diff --git a/crates/oai-runner/Cargo.toml b/crates/oai-runner/Cargo.toml index f632b88a3..380e22789 100644 --- a/crates/oai-runner/Cargo.toml +++ b/crates/oai-runner/Cargo.toml @@ -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" @@ -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] diff --git a/crates/oai-runner/src/tools/mcp_client.rs b/crates/oai-runner/src/tools/mcp_client.rs index 6170deba2..49f2416aa 100644 --- a/crates/oai-runner/src/tools/mcp_client.rs +++ b/crates/oai-runner/src/tools/mcp_client.rs @@ -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; @@ -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, + /// HTTP endpoint URL. When set, uses HTTP/SSE transport instead of stdio. + #[serde(default)] + pub url: Option, + /// Transport type hint ("stdio" or "http"). Presence of `url` takes precedence. + #[serde(default)] + pub transport: Option, } pub struct McpClient { @@ -23,6 +31,24 @@ pub struct McpClient { } pub async fn connect(config: &McpServerConfig) -> Result { + // 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 = + ().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); diff --git a/crates/orchestrator-config/src/pack_config/mcp.rs b/crates/orchestrator-config/src/pack_config/mcp.rs index 791238ecd..2d96734ba 100644 --- a/crates/orchestrator-config/src/pack_config/mcp.rs +++ b/crates/orchestrator-config/src/pack_config/mcp.rs @@ -101,6 +101,7 @@ pub fn load_pack_mcp_overlay(pack: &LoadedPackManifest) -> Result 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(), diff --git a/crates/orchestrator-config/src/workflow_config/tests.rs b/crates/orchestrator-config/src/workflow_config/tests.rs index 7d3a281d0..5647f4173 100644 --- a/crates/orchestrator-config/src/workflow_config/tests.rs +++ b/crates/orchestrator-config/src/workflow_config/tests.rs @@ -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())]), diff --git a/crates/orchestrator-config/src/workflow_config/types.rs b/crates/orchestrator-config/src/workflow_config/types.rs index c30a4354e..40d2537ee 100644 --- a/crates/orchestrator-config/src/workflow_config/types.rs +++ b/crates/orchestrator-config/src/workflow_config/types.rs @@ -329,11 +329,16 @@ impl Default for WorkflowCheckpointRetentionConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpServerDefinition { + #[serde(default)] pub command: String, #[serde(default)] pub args: Vec, + /// Transport type: "stdio" (default) or "http". #[serde(default)] pub transport: Option, + /// HTTP endpoint URL. Required when transport is "http". + #[serde(default)] + pub url: Option, #[serde(default)] pub config: BTreeMap, #[serde(default)] diff --git a/crates/orchestrator-config/src/workflow_config/validation.rs b/crates/orchestrator-config/src/workflow_config/validation.rs index 6abb9e373..668566e9e 100644 --- a/crates/orchestrator-config/src/workflow_config/validation.rs +++ b/crates/orchestrator-config/src/workflow_config/validation.rs @@ -453,8 +453,28 @@ pub fn validate_workflow_config_with_project_root(config: &WorkflowConfig, proje errors.push("mcp_servers contains an empty server name".to_string()); continue; } - if definition.command.trim().is_empty() { - errors.push(format!("mcp_servers['{}'].command must not be empty", name)); + let transport = definition.transport.as_deref().map(str::trim).filter(|t| !t.is_empty()); + match transport { + Some("http") => { + if definition.url.as_deref().is_none_or(|u| u.trim().is_empty()) { + errors.push(format!("mcp_servers['{}'].url is required when transport is \"http\"", name)); + } + } + Some(other) if other != "stdio" => { + errors.push(format!( + "mcp_servers['{}'].transport must be \"stdio\" or \"http\", got \"{}\"", + name, other + )); + } + _ => { + // stdio (explicit or default): command is required + if definition.command.trim().is_empty() { + errors.push(format!("mcp_servers['{}'].command must not be empty", name)); + } + } + } + if definition.transport.as_deref().is_some_and(|transport| transport.trim().is_empty()) { + errors.push(format!("mcp_servers['{}'].transport must not be empty when set", name)); } if definition.args.iter().any(|arg| arg.trim().is_empty()) { errors.push(format!("mcp_servers['{}'].args must not contain empty values", name)); @@ -462,9 +482,6 @@ pub fn validate_workflow_config_with_project_root(config: &WorkflowConfig, proje if definition.tools.iter().any(|tool| tool.trim().is_empty()) { errors.push(format!("mcp_servers['{}'].tools must not contain empty values", name)); } - if definition.transport.as_deref().is_some_and(|transport| transport.trim().is_empty()) { - errors.push(format!("mcp_servers['{}'].transport must not be empty when set", name)); - } if definition.env.iter().any(|(key, value)| key.trim().is_empty() || value.trim().is_empty()) { errors.push(format!("mcp_servers['{}'].env must not contain empty keys or values", name)); } diff --git a/crates/protocol/src/config.rs b/crates/protocol/src/config.rs index 6e5847675..755b68455 100644 --- a/crates/protocol/src/config.rs +++ b/crates/protocol/src/config.rs @@ -7,6 +7,7 @@ use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProjectMcpServerEntry { + #[serde(default)] pub command: String, #[serde(default)] pub args: Vec, @@ -14,6 +15,12 @@ pub struct ProjectMcpServerEntry { pub env: BTreeMap, #[serde(default)] pub assign_to: Vec, + /// Transport type: "stdio" (default) or "http". + #[serde(default)] + pub transport: Option, + /// HTTP endpoint URL. Required when transport is "http". + #[serde(default)] + pub url: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/workflow-runner-v2/src/runtime_contract.rs b/crates/workflow-runner-v2/src/runtime_contract.rs index 25b1dafb4..02bffef71 100644 --- a/crates/workflow-runner-v2/src/runtime_contract.rs +++ b/crates/workflow-runner-v2/src/runtime_contract.rs @@ -409,14 +409,18 @@ pub fn inject_project_mcp_servers( if !assigned { continue; } - servers.insert( - name.clone(), - serde_json::json!({ - "command": entry.command, - "args": entry.args, - "env": entry.env, - }), - ); + let mut entry_json = serde_json::json!({ + "command": entry.command, + "args": entry.args, + "env": entry.env, + }); + if let Some(transport) = &entry.transport { + entry_json["transport"] = serde_json::Value::String(transport.clone()); + } + if let Some(url) = &entry.url { + entry_json["url"] = serde_json::Value::String(url.clone()); + } + servers.insert(name.clone(), entry_json); } let servers = remove_additional_mcp_server_collisions(runtime_contract, servers); if servers.is_empty() { @@ -465,14 +469,18 @@ pub fn inject_workflow_mcp_servers(runtime_contract: &mut Value, ctx: &RuntimeCo if !allowed_servers.is_empty() && !allowed_servers.contains(name) { continue; } - servers.insert( - name.clone(), - serde_json::json!({ - "command": definition.command, - "args": definition.args, - "env": definition.env, - }), - ); + let mut entry_json = serde_json::json!({ + "command": definition.command, + "args": definition.args, + "env": definition.env, + }); + if let Some(transport) = &definition.transport { + entry_json["transport"] = serde_json::Value::String(transport.clone()); + } + if let Some(url) = &definition.url { + entry_json["url"] = serde_json::Value::String(url.clone()); + } + servers.insert(name.clone(), entry_json); } let servers = remove_additional_mcp_server_collisions(runtime_contract, servers); if servers.is_empty() { @@ -507,26 +515,34 @@ pub fn inject_named_mcp_servers( } if let Some(definition) = ctx.workflow_config.config.mcp_servers.get(name) { - servers.insert( - name.to_string(), - serde_json::json!({ - "command": definition.command, - "args": definition.args, - "env": definition.env, - }), - ); + let mut entry_json = serde_json::json!({ + "command": definition.command, + "args": definition.args, + "env": definition.env, + }); + if let Some(transport) = &definition.transport { + entry_json["transport"] = serde_json::Value::String(transport.clone()); + } + if let Some(url) = &definition.url { + entry_json["url"] = serde_json::Value::String(url.clone()); + } + servers.insert(name.to_string(), entry_json); continue; } if let Some(definition) = project_config.mcp_servers.get(name) { - servers.insert( - name.to_string(), - serde_json::json!({ - "command": definition.command, - "args": definition.args, - "env": definition.env, - }), - ); + let mut entry_json = serde_json::json!({ + "command": definition.command, + "args": definition.args, + "env": definition.env, + }); + if let Some(transport) = &definition.transport { + entry_json["transport"] = serde_json::Value::String(transport.clone()); + } + if let Some(url) = &definition.url { + entry_json["url"] = serde_json::Value::String(url.clone()); + } + servers.insert(name.to_string(), entry_json); continue; } @@ -569,6 +585,7 @@ mod tests { command: "node".to_string(), args: vec!["server.js".to_string()], transport: Some("stdio".to_string()), + url: None, config: BTreeMap::new(), tools: Vec::new(), env: BTreeMap::new(), diff --git a/crates/workflow-runner-v2/src/skill_dispatch.rs b/crates/workflow-runner-v2/src/skill_dispatch.rs index cf2d8922f..b814ea489 100644 --- a/crates/workflow-runner-v2/src/skill_dispatch.rs +++ b/crates/workflow-runner-v2/src/skill_dispatch.rs @@ -246,6 +246,7 @@ mod tests { command: "docs-mcp".to_string(), args: vec!["--serve".to_string()], transport: None, + url: None, config: BTreeMap::new(), tools: Vec::new(), env: BTreeMap::from([("DOCS_TOKEN".to_string(), "abc123".to_string())]),