diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 4939557..559ae6a 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -24,6 +24,95 @@ pub struct ConfigEntry { pub struct RuntimeConfig { merged: BTreeMap, loaded_entries: Vec, + feature_config: RuntimeFeatureConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RuntimeFeatureConfig { + mcp: McpConfigCollection, + oauth: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct McpConfigCollection { + servers: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScopedMcpServerConfig { + pub scope: ConfigSource, + pub config: McpServerConfig, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpTransport { + Stdio, + Sse, + Http, + Ws, + Sdk, + ClaudeAiProxy, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum McpServerConfig { + Stdio(McpStdioServerConfig), + Sse(McpRemoteServerConfig), + Http(McpRemoteServerConfig), + Ws(McpWebSocketServerConfig), + Sdk(McpSdkServerConfig), + ClaudeAiProxy(McpClaudeAiProxyServerConfig), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpStdioServerConfig { + pub command: String, + pub args: Vec, + pub env: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpRemoteServerConfig { + pub url: String, + pub headers: BTreeMap, + pub headers_helper: Option, + pub oauth: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpWebSocketServerConfig { + pub url: String, + pub headers: BTreeMap, + pub headers_helper: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpSdkServerConfig { + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpClaudeAiProxyServerConfig { + pub url: String, + pub id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpOAuthConfig { + pub client_id: Option, + pub callback_port: Option, + pub auth_server_metadata_url: Option, + pub xaa: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OAuthConfig { + pub client_id: String, + pub authorize_url: String, + pub token_url: String, + pub callback_port: Option, + pub manual_redirect_url: Option, + pub scopes: Vec, } #[derive(Debug)] @@ -95,18 +184,31 @@ impl ConfigLoader { pub fn load(&self) -> Result { let mut merged = BTreeMap::new(); let mut loaded_entries = Vec::new(); + let mut mcp_servers = BTreeMap::new(); for entry in self.discover() { let Some(value) = read_optional_json_object(&entry.path)? else { continue; }; + merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?; deep_merge_objects(&mut merged, &value); loaded_entries.push(entry); } + let feature_config = RuntimeFeatureConfig { + mcp: McpConfigCollection { + servers: mcp_servers, + }, + oauth: parse_optional_oauth_config( + &JsonValue::Object(merged.clone()), + "merged settings.oauth", + )?, + }; + Ok(RuntimeConfig { merged, loaded_entries, + feature_config, }) } } @@ -117,6 +219,7 @@ impl RuntimeConfig { Self { merged: BTreeMap::new(), loaded_entries: Vec::new(), + feature_config: RuntimeFeatureConfig::default(), } } @@ -139,6 +242,66 @@ impl RuntimeConfig { pub fn as_json(&self) -> JsonValue { JsonValue::Object(self.merged.clone()) } + + #[must_use] + pub fn feature_config(&self) -> &RuntimeFeatureConfig { + &self.feature_config + } + + #[must_use] + pub fn mcp(&self) -> &McpConfigCollection { + &self.feature_config.mcp + } + + #[must_use] + pub fn oauth(&self) -> Option<&OAuthConfig> { + self.feature_config.oauth.as_ref() + } +} + +impl RuntimeFeatureConfig { + #[must_use] + pub fn mcp(&self) -> &McpConfigCollection { + &self.mcp + } + + #[must_use] + pub fn oauth(&self) -> Option<&OAuthConfig> { + self.oauth.as_ref() + } +} + +impl McpConfigCollection { + #[must_use] + pub fn servers(&self) -> &BTreeMap { + &self.servers + } + + #[must_use] + pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> { + self.servers.get(name) + } +} + +impl ScopedMcpServerConfig { + #[must_use] + pub fn transport(&self) -> McpTransport { + self.config.transport() + } +} + +impl McpServerConfig { + #[must_use] + pub fn transport(&self) -> McpTransport { + match self { + Self::Stdio(_) => McpTransport::Stdio, + Self::Sse(_) => McpTransport::Sse, + Self::Http(_) => McpTransport::Http, + Self::Ws(_) => McpTransport::Ws, + Self::Sdk(_) => McpTransport::Sdk, + Self::ClaudeAiProxy(_) => McpTransport::ClaudeAiProxy, + } + } } fn read_optional_json_object( @@ -165,6 +328,253 @@ fn read_optional_json_object( Ok(Some(object.clone())) } +fn merge_mcp_servers( + target: &mut BTreeMap, + source: ConfigSource, + root: &BTreeMap, + path: &Path, +) -> Result<(), ConfigError> { + let Some(mcp_servers) = root.get("mcpServers") else { + return Ok(()); + }; + let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?; + for (name, value) in servers { + let parsed = parse_mcp_server_config( + name, + value, + &format!("{}: mcpServers.{name}", path.display()), + )?; + target.insert( + name.clone(), + ScopedMcpServerConfig { + scope: source, + config: parsed, + }, + ); + } + Ok(()) +} + +fn parse_optional_oauth_config( + root: &JsonValue, + context: &str, +) -> Result, ConfigError> { + let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else { + return Ok(None); + }; + let object = expect_object(oauth_value, context)?; + let client_id = expect_string(object, "clientId", context)?.to_string(); + let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string(); + let token_url = expect_string(object, "tokenUrl", context)?.to_string(); + let callback_port = optional_u16(object, "callbackPort", context)?; + let manual_redirect_url = + optional_string(object, "manualRedirectUrl", context)?.map(str::to_string); + let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default(); + Ok(Some(OAuthConfig { + client_id, + authorize_url, + token_url, + callback_port, + manual_redirect_url, + scopes, + })) +} + +fn parse_mcp_server_config( + server_name: &str, + value: &JsonValue, + context: &str, +) -> Result { + let object = expect_object(value, context)?; + let server_type = optional_string(object, "type", context)?.unwrap_or("stdio"); + match server_type { + "stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig { + command: expect_string(object, "command", context)?.to_string(), + args: optional_string_array(object, "args", context)?.unwrap_or_default(), + env: optional_string_map(object, "env", context)?.unwrap_or_default(), + })), + "sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config( + object, context, + )?)), + "http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config( + object, context, + )?)), + "ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig { + url: expect_string(object, "url", context)?.to_string(), + headers: optional_string_map(object, "headers", context)?.unwrap_or_default(), + headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string), + })), + "sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig { + name: expect_string(object, "name", context)?.to_string(), + })), + "claudeai-proxy" => Ok(McpServerConfig::ClaudeAiProxy( + McpClaudeAiProxyServerConfig { + url: expect_string(object, "url", context)?.to_string(), + id: expect_string(object, "id", context)?.to_string(), + }, + )), + other => Err(ConfigError::Parse(format!( + "{context}: unsupported MCP server type for {server_name}: {other}" + ))), + } +} + +fn parse_mcp_remote_server_config( + object: &BTreeMap, + context: &str, +) -> Result { + Ok(McpRemoteServerConfig { + url: expect_string(object, "url", context)?.to_string(), + headers: optional_string_map(object, "headers", context)?.unwrap_or_default(), + headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string), + oauth: parse_optional_mcp_oauth_config(object, context)?, + }) +} + +fn parse_optional_mcp_oauth_config( + object: &BTreeMap, + context: &str, +) -> Result, ConfigError> { + let Some(value) = object.get("oauth") else { + return Ok(None); + }; + let oauth = expect_object(value, &format!("{context}.oauth"))?; + Ok(Some(McpOAuthConfig { + client_id: optional_string(oauth, "clientId", context)?.map(str::to_string), + callback_port: optional_u16(oauth, "callbackPort", context)?, + auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)? + .map(str::to_string), + xaa: optional_bool(oauth, "xaa", context)?, + })) +} + +fn expect_object<'a>( + value: &'a JsonValue, + context: &str, +) -> Result<&'a BTreeMap, ConfigError> { + value + .as_object() + .ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object"))) +} + +fn expect_string<'a>( + object: &'a BTreeMap, + key: &str, + context: &str, +) -> Result<&'a str, ConfigError> { + object + .get(key) + .and_then(JsonValue::as_str) + .ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}"))) +} + +fn optional_string<'a>( + object: &'a BTreeMap, + key: &str, + context: &str, +) -> Result, ConfigError> { + match object.get(key) { + Some(value) => value + .as_str() + .map(Some) + .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))), + None => Ok(None), + } +} + +fn optional_bool( + object: &BTreeMap, + key: &str, + context: &str, +) -> Result, ConfigError> { + match object.get(key) { + Some(value) => value + .as_bool() + .map(Some) + .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))), + None => Ok(None), + } +} + +fn optional_u16( + object: &BTreeMap, + key: &str, + context: &str, +) -> Result, ConfigError> { + match object.get(key) { + Some(value) => { + let Some(number) = value.as_i64() else { + return Err(ConfigError::Parse(format!( + "{context}: field {key} must be an integer" + ))); + }; + let number = u16::try_from(number).map_err(|_| { + ConfigError::Parse(format!("{context}: field {key} is out of range")) + })?; + Ok(Some(number)) + } + None => Ok(None), + } +} + +fn optional_string_array( + object: &BTreeMap, + key: &str, + context: &str, +) -> Result>, ConfigError> { + match object.get(key) { + Some(value) => { + let Some(array) = value.as_array() else { + return Err(ConfigError::Parse(format!( + "{context}: field {key} must be an array" + ))); + }; + array + .iter() + .map(|item| { + item.as_str().map(ToOwned::to_owned).ok_or_else(|| { + ConfigError::Parse(format!( + "{context}: field {key} must contain only strings" + )) + }) + }) + .collect::, _>>() + .map(Some) + } + None => Ok(None), + } +} + +fn optional_string_map( + object: &BTreeMap, + key: &str, + context: &str, +) -> Result>, ConfigError> { + match object.get(key) { + Some(value) => { + let Some(map) = value.as_object() else { + return Err(ConfigError::Parse(format!( + "{context}: field {key} must be an object" + ))); + }; + map.iter() + .map(|(entry_key, entry_value)| { + entry_value + .as_str() + .map(|text| (entry_key.clone(), text.to_string())) + .ok_or_else(|| { + ConfigError::Parse(format!( + "{context}: field {key} must contain only string values" + )) + }) + }) + .collect::, _>>() + .map(Some) + } + None => Ok(None), + } +} + fn deep_merge_objects( target: &mut BTreeMap, source: &BTreeMap, @@ -183,7 +593,9 @@ fn deep_merge_objects( #[cfg(test)] mod tests { - use super::{ConfigLoader, ConfigSource, CLAUDE_CODE_SETTINGS_SCHEMA_NAME}; + use super::{ + ConfigLoader, ConfigSource, McpServerConfig, McpTransport, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, + }; use crate::json::JsonValue; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; @@ -266,4 +678,118 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + + #[test] + fn parses_typed_mcp_and_oauth_config() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claude"); + fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); + fs::create_dir_all(&home).expect("home config dir"); + + fs::write( + home.join("settings.json"), + r#"{ + "mcpServers": { + "stdio-server": { + "command": "uvx", + "args": ["mcp-server"], + "env": {"TOKEN": "secret"} + }, + "remote-server": { + "type": "http", + "url": "https://example.test/mcp", + "headers": {"Authorization": "Bearer token"}, + "headersHelper": "helper.sh", + "oauth": { + "clientId": "mcp-client", + "callbackPort": 7777, + "authServerMetadataUrl": "https://issuer.test/.well-known/oauth-authorization-server", + "xaa": true + } + } + }, + "oauth": { + "clientId": "runtime-client", + "authorizeUrl": "https://console.test/oauth/authorize", + "tokenUrl": "https://console.test/oauth/token", + "callbackPort": 54545, + "manualRedirectUrl": "https://console.test/oauth/callback", + "scopes": ["org:read", "user:write"] + } + }"#, + ) + .expect("write user settings"); + fs::write( + cwd.join(".claude").join("settings.local.json"), + r#"{ + "mcpServers": { + "remote-server": { + "type": "ws", + "url": "wss://override.test/mcp", + "headers": {"X-Env": "local"} + } + } + }"#, + ) + .expect("write local settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + let stdio_server = loaded + .mcp() + .get("stdio-server") + .expect("stdio server should exist"); + assert_eq!(stdio_server.scope, ConfigSource::User); + assert_eq!(stdio_server.transport(), McpTransport::Stdio); + + let remote_server = loaded + .mcp() + .get("remote-server") + .expect("remote server should exist"); + assert_eq!(remote_server.scope, ConfigSource::Local); + assert_eq!(remote_server.transport(), McpTransport::Ws); + match &remote_server.config { + McpServerConfig::Ws(config) => { + assert_eq!(config.url, "wss://override.test/mcp"); + assert_eq!( + config.headers.get("X-Env").map(String::as_str), + Some("local") + ); + } + other => panic!("expected ws config, got {other:?}"), + } + + let oauth = loaded.oauth().expect("oauth config should exist"); + assert_eq!(oauth.client_id, "runtime-client"); + assert_eq!(oauth.callback_port, Some(54_545)); + assert_eq!(oauth.scopes, vec!["org:read", "user:write"]); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn rejects_invalid_mcp_server_shapes() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claude"); + fs::create_dir_all(&home).expect("home config dir"); + fs::create_dir_all(&cwd).expect("project dir"); + fs::write( + home.join("settings.json"), + r#"{"mcpServers":{"broken":{"type":"http","url":123}}}"#, + ) + .expect("write broken settings"); + + let error = ConfigLoader::new(&cwd, &home) + .load() + .expect_err("config should fail"); + assert!(error + .to_string() + .contains("mcpServers.broken: missing string field url")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } } diff --git a/rust/crates/runtime/src/file_ops.rs b/rust/crates/runtime/src/file_ops.rs index 42b3bab..e18bed7 100644 --- a/rust/crates/runtime/src/file_ops.rs +++ b/rust/crates/runtime/src/file_ops.rs @@ -138,9 +138,9 @@ pub fn read_file( let content = fs::read_to_string(&absolute_path)?; let lines: Vec<&str> = content.lines().collect(); let start_index = offset.unwrap_or(0).min(lines.len()); - let end_index = limit - .map(|limit| start_index.saturating_add(limit).min(lines.len())) - .unwrap_or(lines.len()); + let end_index = limit.map_or(lines.len(), |limit| { + start_index.saturating_add(limit).min(lines.len()) + }); let selected = lines[start_index..end_index].join("\n"); Ok(ReadFileOutput { @@ -296,12 +296,12 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result { continue; } - let Ok(content) = fs::read_to_string(&file_path) else { + let Ok(file_content) = fs::read_to_string(&file_path) else { continue; }; if output_mode == "count" { - let count = regex.find_iter(&content).count(); + let count = regex.find_iter(&file_content).count(); if count > 0 { filenames.push(file_path.to_string_lossy().into_owned()); total_matches += count; @@ -309,7 +309,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result { continue; } - let lines: Vec<&str> = content.lines().collect(); + let lines: Vec<&str> = file_content.lines().collect(); let mut matched_lines = Vec::new(); for (index, line) in lines.iter().enumerate() { if regex.is_match(line) { @@ -327,13 +327,13 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result { for index in matched_lines { let start = index.saturating_sub(input.before.unwrap_or(context)); let end = (index + input.after.unwrap_or(context) + 1).min(lines.len()); - for current in start..end { + for (current, line_content) in lines.iter().enumerate().take(end).skip(start) { let prefix = if input.line_numbers.unwrap_or(true) { format!("{}:{}:", file_path.to_string_lossy(), current + 1) } else { format!("{}:", file_path.to_string_lossy()) }; - content_lines.push(format!("{prefix}{}", lines[current])); + content_lines.push(format!("{prefix}{line_content}")); } } } @@ -341,7 +341,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result { let (filenames, applied_limit, applied_offset) = apply_limit(filenames, input.head_limit, input.offset); - let content = if output_mode == "content" { + let rendered_content = if output_mode == "content" { let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset); return Ok(GrepSearchOutput { mode: Some(output_mode), @@ -361,7 +361,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result { mode: Some(output_mode.clone()), num_files: filenames.len(), filenames, - content, + content: rendered_content, num_lines: None, num_matches: (output_mode == "count").then_some(total_matches), applied_limit, @@ -376,8 +376,7 @@ fn collect_search_files(base_path: &Path) -> io::Result> { let mut files = Vec::new(); for entry in WalkDir::new(base_path) { - let entry = - entry.map_err(|error| io::Error::new(io::ErrorKind::Other, error.to_string()))?; + let entry = entry.map_err(|error| io::Error::other(error.to_string()))?; if entry.file_type().is_file() { files.push(entry.path().to_path_buf()); } diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 0cb5814..358d367 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -17,8 +17,10 @@ pub use compact::{ get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult, }; pub use config::{ - ConfigEntry, ConfigError, ConfigLoader, ConfigSource, RuntimeConfig, - CLAUDE_CODE_SETTINGS_SCHEMA_NAME, + ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig, + McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, + McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, + RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, }; pub use conversation::{ ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,