Skip to content

Commit 898336e

Browse files
authored
Support MCP servers for third-party harnesses. (#10341)
## Description <!-- Please remember to add your design buddy onto the PR for review, if it contains any UI changes! --> This PR adds support for MCP servers for third-party harnesses with cloud agents. When setting up the driver for a third-party task, we add a new step `resolve_mcp_specs_to_json`, which converts MCP server config data from the task into a JSON format by applying any secret values to the templated MCP. We thread this list of `JSONMCPServer`s into each harness's environment setup, where: - Claude writes them to a temp file, which we pass a path to via `--mcp-config` flag - Codex writes them to a section of `config.toml` In this PR, I also remove `prepare_environment_config` from the `ThirdPartyHarness` trait and move its implementation into `build_runner`. Across Claude Code and Codex we started to do different parts of the environment setup process in each place (e.g. Codex would seed the system prompt in `prepare_environment_config`, while CC needed to do it in `build_runner` so we could hold onto the temp file), which resulted in adding the params to both functions that each harness would ignore in one place and user in the other. We always call `prepare_environment_config`, and `build_runner` back to back; now we fold `prepare_environment_config` responsibilities within `build_runner`. ## Testing <!-- How did you test this change? What automated tests did you add? If you didn't add any new tests, what's your justification for not adding any? Manual testing is required for changes that can be manually tested, and almost all changes can be manually tested. If your change can be manually tested, please include screenshots or a screen recording that show it working end to end. You can run the app locally using `./script/run` - see WARP.md for more details on how to get set up. --> Tested locally for both `codex` and `claude` with `./script/oz-local`, Loom available in Slack. - [x] Just Linear (single MCP, stdio) - [x] Linear and Sentry (multiple MCP servers, stdio) - [x] Context7 (single MCP, http) ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode
1 parent 10e2bae commit 898336e

10 files changed

Lines changed: 606 additions & 107 deletions

File tree

app/src/ai/agent_sdk/driver.rs

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use std::{
1717
use crate::ai::blocklist::task_status_sync_model::TaskStatusSyncModel;
1818
use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentModelEvent};
1919
use crate::ai::llms::{LLMId, LLMPreferences};
20-
use crate::ai::mcp::MCPServerState;
20+
use crate::ai::mcp::{JSONMCPServer, MCPServerState};
2121
use crate::ai::skills::{SkillManager, SkillWatcher};
2222
use crate::ai::{
2323
agent::conversation::AIConversationId,
@@ -53,7 +53,7 @@ use crate::{
5353
execution_profiles::profiles::AIExecutionProfilesModel,
5454
mcp::{
5555
file_based_manager::{FileBasedMCPManager, FileBasedMCPManagerEvent},
56-
parsing::{normalize_mcp_json, ParsedTemplatableMCPServerResult},
56+
parsing::{normalize_mcp_json, resolve_json, ParsedTemplatableMCPServerResult},
5757
templatable_manager::TemplatableMCPServerManagerEvent,
5858
TemplatableMCPServerInstallation, TemplatableMCPServerManager,
5959
},
@@ -260,9 +260,9 @@ pub struct AgentDriver {
260260
secrets: Arc<HashMap<String, ManagedSecretValue>>,
261261

262262
/// Env vars passed to the terminal session, including resolved secrets, cloud
263-
/// provider vars, task vars, and sandbox flags. Shared with
264-
/// `prepare_environment_config` so harnesses can look up resolved secret
265-
/// values without re-deriving precedence.
263+
/// provider vars, task vars, and sandbox flags. Passed to
264+
/// `build_runner` so harnesses can look up resolved secret values
265+
/// without re-deriving precedence.
266266
resolved_env_vars: Arc<HashMap<OsString, OsString>>,
267267

268268
output_format: OutputFormat,
@@ -790,6 +790,44 @@ impl AgentDriver {
790790
}
791791
}
792792

793+
/// Resolve MCP specs into a map of MCP name to `JSONMCPServer` for use in
794+
/// third-party harnesses. Each spec is fully resolved (secrets applied, templates
795+
/// rendered) so harnesses can serialize directly into their native config format.
796+
fn resolve_mcp_specs_to_json(
797+
specs: &[MCPSpec],
798+
secrets: &HashMap<String, ManagedSecretValue>,
799+
ctx: &ModelContext<Self>,
800+
) -> Result<HashMap<String, JSONMCPServer>, AgentDriverError> {
801+
let (existing_uuids, mut ephemeral_installations) = Self::resolve_mcp_specs(specs)?;
802+
let mut result = HashMap::new();
803+
804+
// Resolve UUID-referenced servers from the TemplatableMCPServerManager.
805+
let manager = TemplatableMCPServerManager::as_ref(ctx);
806+
for uuid in &existing_uuids {
807+
let installation = manager
808+
.get_installed_server(uuid)
809+
.ok_or(AgentDriverError::MCPServerNotFound(*uuid))?
810+
.clone();
811+
let mut installation = installation;
812+
installation.apply_secrets(secrets);
813+
let resolved = resolve_json(&installation);
814+
let servers: HashMap<String, JSONMCPServer> = serde_json::from_str(&resolved)
815+
.map_err(|e| AgentDriverError::MCPJsonParseError(e.to_string()))?;
816+
result.extend(servers);
817+
}
818+
819+
// Resolve ephemeral (inline JSON) servers.
820+
for installation in ephemeral_installations.iter_mut() {
821+
installation.apply_secrets(secrets);
822+
let resolved = resolve_json(installation);
823+
let servers: HashMap<String, JSONMCPServer> = serde_json::from_str(&resolved)
824+
.map_err(|e| AgentDriverError::MCPJsonParseError(e.to_string()))?;
825+
result.extend(servers);
826+
}
827+
828+
Ok(result)
829+
}
830+
793831
/// Resolve MCP specs into UUIDs for existing servers and ephemeral installations for inline specs.
794832
///
795833
/// Returns (existing_server_uuids, ephemeral_installations)
@@ -1451,8 +1489,13 @@ impl AgentDriver {
14511489
}
14521490
HarnessKind::ThirdParty(harness) => {
14531491
let harness_exit_rx = Self::setup_harness(harness.as_ref(), &foreground).await?;
1454-
let runner =
1455-
Self::prepare_harness(&task.prompt, harness.as_ref(), &foreground).await?;
1492+
let runner = Self::prepare_harness(
1493+
&task.prompt,
1494+
&task.mcp_specs,
1495+
harness.as_ref(),
1496+
&foreground,
1497+
)
1498+
.await?;
14561499

14571500
if let Some(task_id) = task_id_for_refresh {
14581501
let harness_fut =
@@ -1514,6 +1557,7 @@ impl AgentDriver {
15141557
/// return a handle to the harness runner.
15151558
async fn prepare_harness(
15161559
prompt: &AgentRunPrompt,
1560+
mcp_specs: &[MCPSpec],
15171561
harness: &dyn ThirdPartyHarness,
15181562
foreground: &ModelSpawner<Self>,
15191563
) -> Result<Arc<dyn harness::HarnessRunner>, AgentDriverError> {
@@ -1570,18 +1614,29 @@ impl AgentDriver {
15701614
}
15711615
};
15721616

1573-
// Prepare harness config files (onboarding, trust dialog, API-key approval, etc.).
1574-
// Pass the terminal env vars (which include the resolved secrets) so harnesses
1575-
// can look up auth keys without re-deriving precedence.
1617+
let secrets = foreground
1618+
.spawn(|me, _| Arc::clone(&me.secrets))
1619+
.await
1620+
.map_err(|_| AgentDriverError::InvalidRuntimeState)?;
1621+
1622+
// Resolve MCP specs into harness-native JSON format.
1623+
let mcp_specs = mcp_specs.to_vec();
1624+
let resolved_mcp_servers = foreground
1625+
.spawn(move |_, ctx| Self::resolve_mcp_specs_to_json(&mcp_specs, &secrets, ctx))
1626+
.await
1627+
.map_err(|_| AgentDriverError::InvalidRuntimeState)?;
1628+
let resolved_mcp_servers = resolved_mcp_servers?;
1629+
if !resolved_mcp_servers.is_empty() {
1630+
log::info!(
1631+
"Resolved {} MCP server(s) for third-party harness",
1632+
resolved_mcp_servers.len()
1633+
);
1634+
}
1635+
15761636
let resolved_env_vars = foreground
15771637
.spawn(|me, _| Arc::clone(&me.resolved_env_vars))
15781638
.await
15791639
.map_err(|_| AgentDriverError::InvalidRuntimeState)?;
1580-
harness.prepare_environment_config(
1581-
&working_dir,
1582-
system_prompt.as_deref(),
1583-
&resolved_env_vars,
1584-
)?;
15851640
let resume = foreground
15861641
.spawn(|me, _| me.resume_payload.take())
15871642
.await
@@ -1597,6 +1652,8 @@ impl AgentDriver {
15971652
server_api,
15981653
terminal_driver,
15991654
resume,
1655+
&resolved_env_vars,
1656+
&resolved_mcp_servers,
16001657
)?
16011658
.into();
16021659

app/src/ai/agent_sdk/driver/harness/claude_code.rs

Lines changed: 105 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use warpui::{ModelHandle, ModelSpawner};
1616

1717
use crate::ai::agent::conversation::AIConversationId;
1818
use crate::ai::ambient_agents::AmbientAgentTaskId;
19+
use crate::ai::mcp::JSONTransportType;
1920
use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient};
2021
use crate::server::server_api::ServerApi;
2122
use crate::terminal::model::block::BlockId;
@@ -31,7 +32,7 @@ use super::claude_transcript::{
3132
use super::json_utils::{read_json_file_or_default, write_json_file};
3233
use super::{
3334
cli_agent_session_status, write_temp_file, HarnessCleanupDisposition, HarnessRunner,
34-
ResumePayload, SavePoint, ThirdPartyHarness,
35+
JSONMCPServer, ResumePayload, SavePoint, ThirdPartyHarness,
3536
};
3637
mod parent_bridge;
3738
mod wake_driver;
@@ -70,20 +71,6 @@ impl ThirdPartyHarness for ClaudeHarness {
7071
Some("https://code.claude.com/docs/en/quickstart")
7172
}
7273

73-
fn prepare_environment_config(
74-
&self,
75-
working_dir: &Path,
76-
_system_prompt: Option<&str>,
77-
resolved_env_vars: &HashMap<OsString, OsString>,
78-
) -> Result<(), AgentDriverError> {
79-
prepare_claude_environment_config(working_dir, resolved_env_vars).map_err(|error| {
80-
AgentDriverError::HarnessConfigSetupFailed {
81-
harness: self.cli_agent().command_prefix().to_owned(),
82-
error,
83-
}
84-
})
85-
}
86-
8774
/// Fetch the Claude Code transcript for the current task's conversation and wrap it
8875
/// into a [`ResumePayload::Claude`]. Maps a server 404 to
8976
/// [`AgentDriverError::ConversationResumeStateMissing`] tagged as the `claude` harness
@@ -114,7 +101,17 @@ impl ThirdPartyHarness for ClaudeHarness {
114101
server_api: Arc<ServerApi>,
115102
terminal_driver: ModelHandle<TerminalDriver>,
116103
resume: Option<ResumePayload>,
104+
resolved_env_vars: &HashMap<OsString, OsString>,
105+
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
117106
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
107+
// Prepare the environment config files.
108+
prepare_claude_environment_config(working_dir, resolved_env_vars).map_err(|error| {
109+
AgentDriverError::HarnessConfigSetupFailed {
110+
harness: self.cli_agent().command_prefix().to_owned(),
111+
error,
112+
}
113+
})?;
114+
118115
// The ResumePayload shouldn't contain non-Claude information, error if it does.
119116
let claude_resume = resume.map(ClaudeResumeInfo::try_from).transpose()?;
120117
// Claude treats the user-turn message as immediate intent, so the resumption preamble
@@ -132,6 +129,7 @@ impl ThirdPartyHarness for ClaudeHarness {
132129
server_api,
133130
terminal_driver,
134131
claude_resume,
132+
resolved_mcp_servers,
135133
)?))
136134
}
137135
}
@@ -153,13 +151,17 @@ fn claude_command(
153151
session_id: &Uuid,
154152
prompt_path: &str,
155153
system_prompt_path: Option<&str>,
154+
mcp_config_path: Option<&str>,
156155
resuming: bool,
157156
) -> String {
158157
let flag = if resuming { "--resume" } else { "--session-id" };
159158
let mut cmd = format!("{cli_name} {flag} {session_id} --dangerously-skip-permissions");
160159
if let Some(sp_path) = system_prompt_path {
161160
let _ = write!(cmd, " --append-system-prompt-file '{sp_path}'");
162161
}
162+
if let Some(mcp_path) = mcp_config_path {
163+
let _ = write!(cmd, " --mcp-config '{mcp_path}'");
164+
}
163165
format!("{cmd} < '{prompt_path}'")
164166
}
165167

@@ -182,6 +184,8 @@ struct ClaudeHarnessRunner {
182184
_temp_prompt_file: NamedTempFile,
183185
/// Held so the system prompt temp file is cleaned up when the runner is dropped.
184186
_temp_system_prompt_file: Option<NamedTempFile>,
187+
/// Held so the MCP config temp file lives until the CLI exits.
188+
_temp_mcp_config_file: Option<NamedTempFile>,
185189
client: Arc<dyn HarnessSupportClient>,
186190
server_api: Arc<ServerApi>,
187191
terminal_driver: ModelHandle<TerminalDriver>,
@@ -208,10 +212,11 @@ impl ClaudeHarnessRunner {
208212
server_api: Arc<ServerApi>,
209213
terminal_driver: ModelHandle<TerminalDriver>,
210214
resume: Option<ClaudeResumeInfo>,
215+
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
211216
) -> Result<Self, AgentDriverError> {
212217
// Write the prompt to a temp file so we can feed it via stdin redirect,
213218
// avoiding shell-quoting issues with complex content (e.g. skill instructions).
214-
let temp_file = write_temp_file("oz_prompt_", prompt)?;
219+
let temp_file = write_temp_file("oz_prompt_", prompt, ".txt")?;
215220
let prompt_path = temp_file.path().display().to_string();
216221

217222
let (session_id, preexisting_conversation_id) = match resume {
@@ -246,11 +251,23 @@ impl ClaudeHarnessRunner {
246251
};
247252

248253
let temp_system_prompt_file = system_prompt
249-
.map(|sp| write_temp_file("oz_system_prompt_", sp))
254+
.map(|sp| write_temp_file("oz_system_prompt_", sp, ".txt"))
250255
.transpose()?;
251256
let system_prompt_path = temp_system_prompt_file
252257
.as_ref()
253258
.map(|f| f.path().display().to_string());
259+
260+
let temp_mcp_config_file = (!resolved_mcp_servers.is_empty())
261+
.then(|| {
262+
let mcp_json = serialize_claude_mcp_config(resolved_mcp_servers)
263+
.map_err(AgentDriverError::ConfigBuildFailed)?;
264+
write_temp_file("oz_mcp_config_", &mcp_json, ".json")
265+
})
266+
.transpose()?;
267+
let mcp_config_path = temp_mcp_config_file
268+
.as_ref()
269+
.map(|f| f.path().display().to_string());
270+
254271
let parent_bridge = task_id
255272
.map(|task_id| MessageBridge::new(task_id.to_string(), session_id))
256273
.transpose()
@@ -263,11 +280,13 @@ impl ClaudeHarnessRunner {
263280
&session_id,
264281
&prompt_path,
265282
system_prompt_path.as_deref(),
283+
mcp_config_path.as_deref(),
266284
preexisting_conversation_id.is_some(),
267285
),
268286
cli_name: cli_command.to_string(),
269287
_temp_prompt_file: temp_file,
270288
_temp_system_prompt_file: temp_system_prompt_file,
289+
_temp_mcp_config_file: temp_mcp_config_file,
271290
client,
272291
server_api,
273292
terminal_driver,
@@ -541,7 +560,7 @@ async fn upload_transcript(
541560
.with_context(|| format!("Failed to get transcript upload target for {conversation_id}"))?;
542561
upload_to_target(client.http_client(), &target, body).await
543562
}
544-
fn prepare_claude_environment_config(
563+
pub(crate) fn prepare_claude_environment_config(
545564
working_dir: &Path,
546565
resolved_env_vars: &HashMap<OsString, OsString>,
547566
) -> Result<()> {
@@ -679,6 +698,74 @@ fn suffix_of(key: &str) -> Option<&str> {
679698
}
680699
}
681700

701+
#[derive(Serialize)]
702+
#[serde(rename_all = "camelCase")]
703+
struct ClaudeMcpConfig {
704+
mcp_servers: HashMap<String, ClaudeMcpServerEntry>,
705+
}
706+
707+
#[derive(Serialize)]
708+
#[serde(tag = "type")]
709+
enum ClaudeMcpServerEntry {
710+
#[serde(rename = "stdio")]
711+
Stdio {
712+
command: String,
713+
args: Vec<String>,
714+
#[serde(skip_serializing_if = "HashMap::is_empty")]
715+
env: HashMap<String, String>,
716+
#[serde(skip_serializing_if = "Option::is_none")]
717+
cwd: Option<String>,
718+
},
719+
#[serde(rename = "http")]
720+
Http {
721+
url: String,
722+
#[serde(skip_serializing_if = "HashMap::is_empty")]
723+
headers: HashMap<String, String>,
724+
},
725+
}
726+
727+
impl ClaudeMcpServerEntry {
728+
fn from_json_mcp_server(server: &JSONMCPServer) -> Self {
729+
match &server.transport_type {
730+
JSONTransportType::CLIServer {
731+
command,
732+
args,
733+
env,
734+
working_directory,
735+
} => Self::Stdio {
736+
command: command.clone(),
737+
args: args.clone(),
738+
env: env.clone(),
739+
cwd: working_directory.clone(),
740+
},
741+
JSONTransportType::SSEServer { url, headers } => Self::Http {
742+
url: url.clone(),
743+
headers: headers.clone(),
744+
},
745+
}
746+
}
747+
}
748+
749+
/// Serialize resolved MCP servers into Claude Code's `--mcp-config` JSON format.
750+
///
751+
/// Produces `{ "mcpServers": { "name": { "type": "stdio"|"http", ... }, ... } }`.
752+
pub(crate) fn serialize_claude_mcp_config(
753+
servers: &HashMap<String, JSONMCPServer>,
754+
) -> Result<String> {
755+
let config = ClaudeMcpConfig {
756+
mcp_servers: servers
757+
.iter()
758+
.map(|(name, server)| {
759+
(
760+
name.clone(),
761+
ClaudeMcpServerEntry::from_json_mcp_server(server),
762+
)
763+
})
764+
.collect(),
765+
};
766+
serde_json::to_string_pretty(&config).context("Failed to serialize Claude MCP config")
767+
}
768+
682769
#[cfg(test)]
683770
#[path = "claude_code_tests.rs"]
684771
mod tests;

app/src/ai/agent_sdk/driver/harness/claude_code/wake_driver.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ impl ClaudeHarness {
218218
&remote.session_id,
219219
&prompt_path.display().to_string(),
220220
None,
221+
None,
221222
true,
222223
);
223224
let env_vars = local_wake_task_env_vars(Some(&task_id), parent_run_id.as_deref());

0 commit comments

Comments
 (0)