feat: MCP support for agentic CLI providers (#6972)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Adrian Cole
2026-02-12 07:37:52 +08:00
committed by GitHub
parent c6e27b8f84
commit 8d59c2dced
57 changed files with 1196 additions and 254 deletions
+200 -4
View File
@@ -3,8 +3,10 @@ use async_trait::async_trait;
use futures::future::BoxFuture;
use rmcp::model::Role;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tempfile::NamedTempFile;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
@@ -12,8 +14,9 @@ use super::base::{ConfigKey, Provider, ProviderDef, ProviderMetadata, ProviderUs
use super::errors::ProviderError;
use super::utils::{filter_extensions_from_system_prompt, RequestLog};
use crate::config::base::ClaudeCodeCommand;
use crate::config::paths::Paths;
use crate::config::search_path::SearchPaths;
use crate::config::{Config, GooseMode};
use crate::config::{Config, ExtensionConfig, GooseMode};
use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig;
use crate::subprocess::configure_subprocess;
@@ -145,6 +148,9 @@ pub struct ClaudeCodeProvider {
model: ModelConfig,
#[serde(skip)]
name: String,
/// Temp file holding MCP config JSON (auto-deleted on drop).
#[serde(skip)]
mcp_config_file: Option<NamedTempFile>,
#[serde(skip)]
cli_process: tokio::sync::OnceCell<tokio::sync::Mutex<CliProcess>>,
}
@@ -374,6 +380,10 @@ impl ClaudeCodeProvider {
.cli_process
.get_or_try_init(|| async {
let mut cmd = self.build_stream_json_command();
if let Some(f) = &self.mcp_config_file {
cmd.arg("--mcp-config").arg(f.path());
cmd.arg("--strict-mcp-config");
}
// System prompt is set once at process start and cannot be updated at runtime.
cmd.arg("--system-prompt").arg(&filtered_system);
@@ -577,7 +587,71 @@ fn build_stream_json_input(content_blocks: &[Value], session_id: &str) -> String
serde_json::to_string(&msg).expect("serializing JSON content blocks cannot fail")
}
#[async_trait]
fn claude_mcp_config_json(extensions: &[ExtensionConfig]) -> Option<String> {
let mut mcp_servers = serde_json::Map::new();
for extension in extensions {
match extension {
ExtensionConfig::StreamableHttp { uri, headers, .. } => {
let key = extension.key();
let mut config = serde_json::Map::new();
config.insert("type".to_string(), json!("http"));
config.insert("url".to_string(), json!(uri));
if !headers.is_empty() {
config.insert("headers".to_string(), json!(headers));
}
mcp_servers.insert(key, Value::Object(config));
}
ExtensionConfig::Stdio {
cmd, args, envs, ..
} => {
let key = extension.key();
let mut config = serde_json::Map::new();
config.insert("type".to_string(), json!("stdio"));
config.insert("command".to_string(), json!(cmd));
if !args.is_empty() {
config.insert("args".to_string(), json!(args));
}
let env_map = envs.get_env();
if !env_map.is_empty() {
config.insert("env".to_string(), json!(env_map));
}
mcp_servers.insert(key, Value::Object(config));
}
ExtensionConfig::Sse { name, .. } => {
tracing::debug!(name, "skipping SSE extension, migrate to streamable_http");
}
_ => {}
}
}
if mcp_servers.is_empty() {
return None;
}
serde_json::to_string(&json!({ "mcpServers": mcp_servers })).ok()
}
/// Write the MCP config JSON to a temp file with restricted permissions
/// so secrets (headers, env vars) are not leaked via process argv.
fn write_mcp_config_file(state_dir: &Path, json: &str) -> Result<NamedTempFile, anyhow::Error> {
let dir = state_dir.join("claude-code");
std::fs::create_dir_all(&dir)?;
let prefix = format!("mcp-config-{}_", chrono::Utc::now().format("%Y%m%d"));
let mut tmp = tempfile::Builder::new()
.prefix(&prefix)
.suffix(".json")
.tempfile_in(&dir)?;
tmp.write_all(json.as_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tmp.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
Ok(tmp)
}
impl ProviderDef for ClaudeCodeProvider {
type Provider = Self;
@@ -598,16 +672,29 @@ impl ProviderDef for ClaudeCodeProvider {
.with_unlisted_models()
}
fn from_env(model: ModelConfig) -> BoxFuture<'static, Result<Self::Provider>> {
fn from_env(
model: ModelConfig,
extensions: Vec<ExtensionConfig>,
) -> BoxFuture<'static, Result<Self::Provider>> {
Box::pin(async move {
let config = crate::config::Config::global();
let command: String = config.get_claude_code_command().unwrap_or_default().into();
let resolved_command = SearchPaths::builder().with_npm().resolve(command)?;
let mut resolved = Vec::with_capacity(extensions.len());
for ext in extensions {
resolved.push(ext.resolve(config).await?);
}
let mcp_config_file = claude_mcp_config_json(&resolved)
.map(|json| write_mcp_config_file(&Paths::state_dir(), &json))
.transpose()?;
Ok(Self {
command: resolved_command,
model,
name: CLAUDE_CODE_PROVIDER_NAME.to_string(),
mcp_config_file,
cli_process: tokio::sync::OnceCell::new(),
})
})
@@ -737,8 +824,13 @@ impl Provider for ClaudeCodeProvider {
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::extension::Envs;
use chrono::Utc;
use goose_test_support::session::TEST_SESSION_ID;
use serde_json::json;
use std::collections::HashMap;
use std::fs;
use tempfile::tempdir;
use test_case::test_case;
/// (role, text, optional (image_data, mime_type))
@@ -940,11 +1032,115 @@ mod tests {
assert_eq!(result, expected);
}
#[test_case(
vec![],
None
; "empty_extensions_returns_none"
)]
#[test_case(
vec![ExtensionConfig::Sse {
name: "legacy".into(),
description: String::new(),
uri: Some("http://localhost/sse".into()),
}],
None
; "sse_only_returns_none"
)]
#[test_case(
vec![ExtensionConfig::Stdio {
name: "lookup".into(),
description: String::new(),
cmd: "node".into(),
args: vec!["server.js".into()],
envs: Envs::new([("API_KEY".into(), "secret".into())].into()),
env_keys: vec![],
timeout: None,
bundled: Some(false),
available_tools: vec![],
}],
Some(json!({ "mcpServers": {
"lookup": {
"type": "stdio",
"command": "node",
"args": ["server.js"],
"env": { "API_KEY": "secret" }
}
}}))
; "stdio_converts_to_mcp_config_json"
)]
#[test_case(
vec![ExtensionConfig::StreamableHttp {
name: "lookup".into(),
description: String::new(),
uri: "http://localhost/mcp".into(),
envs: Envs::default(),
env_keys: vec![],
headers: HashMap::from([("Authorization".into(), "Bearer token".into())]),
timeout: None,
bundled: Some(false),
available_tools: vec![],
}],
Some(json!({ "mcpServers": {
"lookup": {
"type": "http",
"url": "http://localhost/mcp",
"headers": { "Authorization": "Bearer token" }
}
}}))
; "streamable_http_converts_to_mcp_config_json"
)]
#[test_case(
vec![ExtensionConfig::StreamableHttp {
name: "mcp_kiwi_com".into(),
description: String::new(),
uri: "https://mcp.kiwi.com".into(),
envs: Envs::default(),
env_keys: vec![],
headers: HashMap::new(),
timeout: None,
bundled: None,
available_tools: vec![],
}],
Some(json!({ "mcpServers": {
"mcp_kiwi_com": {
"type": "http",
"url": "https://mcp.kiwi.com"
}
}}))
; "resolved_name_used_as_key"
)]
fn test_claude_mcp_config_json(extensions: Vec<ExtensionConfig>, expected: Option<Value>) {
let result = claude_mcp_config_json(&extensions)
.map(|json| serde_json::from_str::<Value>(&json).unwrap());
assert_eq!(result, expected);
}
#[test]
fn test_write_mcp_config_file() {
let state_dir = tempdir().unwrap();
let json = r#"{"mcpServers":{}}"#;
let tmp = write_mcp_config_file(state_dir.path(), json).unwrap();
assert_eq!(fs::read_to_string(tmp.path()).unwrap(), json);
let norm_path = tmp.path().to_string_lossy().replace('\\', "/");
let expected_prefix = format!("claude-code/mcp-config-{}_", Utc::now().format("%Y%m%d"));
assert!(norm_path.contains(&expected_prefix));
assert!(norm_path.ends_with(".json"));
}
#[test]
fn test_write_mcp_config_file_invalid_state_dir() {
assert!(write_mcp_config_file(Path::new("/dev/null"), "{}").is_err());
}
fn make_provider() -> ClaudeCodeProvider {
ClaudeCodeProvider {
command: PathBuf::from("claude"),
model: ModelConfig::new(CLAUDE_CODE_DEFAULT_MODEL).unwrap(),
name: "claude-code".to_string(),
mcp_config_file: None,
cli_process: tokio::sync::OnceCell::new(),
}
}