mcp.rs 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. use crate::config::{McpServerConfig, ScopedMcpServerConfig};
  2. const CLAUDEAI_SERVER_PREFIX: &str = "claude.ai ";
  3. const CCR_PROXY_PATH_MARKERS: [&str; 2] = ["/v2/session_ingress/shttp/mcp/", "/v2/ccr-sessions/"];
  4. #[must_use]
  5. pub fn normalize_name_for_mcp(name: &str) -> String {
  6. let mut normalized = name
  7. .chars()
  8. .map(|ch| match ch {
  9. 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' => ch,
  10. _ => '_',
  11. })
  12. .collect::<String>();
  13. if name.starts_with(CLAUDEAI_SERVER_PREFIX) {
  14. normalized = collapse_underscores(&normalized)
  15. .trim_matches('_')
  16. .to_string();
  17. }
  18. normalized
  19. }
  20. #[must_use]
  21. pub fn mcp_tool_prefix(server_name: &str) -> String {
  22. format!("mcp__{}__", normalize_name_for_mcp(server_name))
  23. }
  24. #[must_use]
  25. pub fn mcp_tool_name(server_name: &str, tool_name: &str) -> String {
  26. format!(
  27. "{}{}",
  28. mcp_tool_prefix(server_name),
  29. normalize_name_for_mcp(tool_name)
  30. )
  31. }
  32. #[must_use]
  33. pub fn unwrap_ccr_proxy_url(url: &str) -> String {
  34. if !CCR_PROXY_PATH_MARKERS
  35. .iter()
  36. .any(|marker| url.contains(marker))
  37. {
  38. return url.to_string();
  39. }
  40. let Some(query_start) = url.find('?') else {
  41. return url.to_string();
  42. };
  43. let query = &url[query_start + 1..];
  44. for pair in query.split('&') {
  45. let mut parts = pair.splitn(2, '=');
  46. if matches!(parts.next(), Some("mcp_url")) {
  47. if let Some(value) = parts.next() {
  48. return percent_decode(value);
  49. }
  50. }
  51. }
  52. url.to_string()
  53. }
  54. #[must_use]
  55. pub fn mcp_server_signature(config: &McpServerConfig) -> Option<String> {
  56. match config {
  57. McpServerConfig::Stdio(config) => {
  58. let mut command = vec![config.command.clone()];
  59. command.extend(config.args.clone());
  60. Some(format!("stdio:{}", render_command_signature(&command)))
  61. }
  62. McpServerConfig::Sse(config) | McpServerConfig::Http(config) => {
  63. Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url)))
  64. }
  65. McpServerConfig::Ws(config) => Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url))),
  66. McpServerConfig::ManagedProxy(config) => {
  67. Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url)))
  68. }
  69. McpServerConfig::Sdk(_) => None,
  70. }
  71. }
  72. #[must_use]
  73. pub fn scoped_mcp_config_hash(config: &ScopedMcpServerConfig) -> String {
  74. let rendered = match &config.config {
  75. McpServerConfig::Stdio(stdio) => format!(
  76. "stdio|{}|{}|{}|{}",
  77. stdio.command,
  78. render_command_signature(&stdio.args),
  79. render_env_signature(&stdio.env),
  80. stdio
  81. .tool_call_timeout_ms
  82. .map_or_else(String::new, |timeout_ms| timeout_ms.to_string())
  83. ),
  84. McpServerConfig::Sse(remote) => format!(
  85. "sse|{}|{}|{}|{}",
  86. remote.url,
  87. render_env_signature(&remote.headers),
  88. remote.headers_helper.as_deref().unwrap_or(""),
  89. render_oauth_signature(remote.oauth.as_ref())
  90. ),
  91. McpServerConfig::Http(remote) => format!(
  92. "http|{}|{}|{}|{}",
  93. remote.url,
  94. render_env_signature(&remote.headers),
  95. remote.headers_helper.as_deref().unwrap_or(""),
  96. render_oauth_signature(remote.oauth.as_ref())
  97. ),
  98. McpServerConfig::Ws(ws) => format!(
  99. "ws|{}|{}|{}",
  100. ws.url,
  101. render_env_signature(&ws.headers),
  102. ws.headers_helper.as_deref().unwrap_or("")
  103. ),
  104. McpServerConfig::Sdk(sdk) => format!("sdk|{}", sdk.name),
  105. McpServerConfig::ManagedProxy(proxy) => {
  106. format!("claudeai-proxy|{}|{}", proxy.url, proxy.id)
  107. }
  108. };
  109. stable_hex_hash(&rendered)
  110. }
  111. fn render_command_signature(command: &[String]) -> String {
  112. let escaped = command
  113. .iter()
  114. .map(|part| part.replace('\\', "\\\\").replace('|', "\\|"))
  115. .collect::<Vec<_>>();
  116. format!("[{}]", escaped.join("|"))
  117. }
  118. fn render_env_signature(map: &std::collections::BTreeMap<String, String>) -> String {
  119. map.iter()
  120. .map(|(key, value)| format!("{key}={value}"))
  121. .collect::<Vec<_>>()
  122. .join(";")
  123. }
  124. fn render_oauth_signature(oauth: Option<&crate::config::McpOAuthConfig>) -> String {
  125. oauth.map_or_else(String::new, |oauth| {
  126. format!(
  127. "{}|{}|{}|{}",
  128. oauth.client_id.as_deref().unwrap_or(""),
  129. oauth
  130. .callback_port
  131. .map_or_else(String::new, |port| port.to_string()),
  132. oauth.auth_server_metadata_url.as_deref().unwrap_or(""),
  133. oauth.xaa.map_or_else(String::new, |flag| flag.to_string())
  134. )
  135. })
  136. }
  137. fn stable_hex_hash(value: &str) -> String {
  138. let mut hash = 0xcbf2_9ce4_8422_2325_u64;
  139. for byte in value.as_bytes() {
  140. hash ^= u64::from(*byte);
  141. hash = hash.wrapping_mul(0x0100_0000_01b3);
  142. }
  143. format!("{hash:016x}")
  144. }
  145. fn collapse_underscores(value: &str) -> String {
  146. let mut collapsed = String::with_capacity(value.len());
  147. let mut last_was_underscore = false;
  148. for ch in value.chars() {
  149. if ch == '_' {
  150. if !last_was_underscore {
  151. collapsed.push(ch);
  152. }
  153. last_was_underscore = true;
  154. } else {
  155. collapsed.push(ch);
  156. last_was_underscore = false;
  157. }
  158. }
  159. collapsed
  160. }
  161. fn percent_decode(value: &str) -> String {
  162. let bytes = value.as_bytes();
  163. let mut decoded = Vec::with_capacity(bytes.len());
  164. let mut index = 0;
  165. while index < bytes.len() {
  166. match bytes[index] {
  167. b'%' if index + 2 < bytes.len() => {
  168. let hex = &value[index + 1..index + 3];
  169. if let Ok(byte) = u8::from_str_radix(hex, 16) {
  170. decoded.push(byte);
  171. index += 3;
  172. continue;
  173. }
  174. decoded.push(bytes[index]);
  175. index += 1;
  176. }
  177. b'+' => {
  178. decoded.push(b' ');
  179. index += 1;
  180. }
  181. byte => {
  182. decoded.push(byte);
  183. index += 1;
  184. }
  185. }
  186. }
  187. String::from_utf8_lossy(&decoded).into_owned()
  188. }
  189. #[cfg(test)]
  190. mod tests {
  191. use std::collections::BTreeMap;
  192. use crate::config::{
  193. ConfigSource, McpRemoteServerConfig, McpServerConfig, McpStdioServerConfig,
  194. McpWebSocketServerConfig, ScopedMcpServerConfig,
  195. };
  196. use super::{
  197. mcp_server_signature, mcp_tool_name, normalize_name_for_mcp, scoped_mcp_config_hash,
  198. unwrap_ccr_proxy_url,
  199. };
  200. #[test]
  201. fn normalizes_server_names_for_mcp_tooling() {
  202. assert_eq!(normalize_name_for_mcp("github.com"), "github_com");
  203. assert_eq!(normalize_name_for_mcp("tool name!"), "tool_name_");
  204. assert_eq!(
  205. normalize_name_for_mcp("claude.ai Example Server!!"),
  206. "claude_ai_Example_Server"
  207. );
  208. assert_eq!(
  209. mcp_tool_name("claude.ai Example Server", "weather tool"),
  210. "mcp__claude_ai_Example_Server__weather_tool"
  211. );
  212. }
  213. #[test]
  214. fn unwraps_ccr_proxy_urls_for_signature_matching() {
  215. let wrapped = "https://api.anthropic.com/v2/session_ingress/shttp/mcp/123?mcp_url=https%3A%2F%2Fvendor.example%2Fmcp&other=1";
  216. assert_eq!(unwrap_ccr_proxy_url(wrapped), "https://vendor.example/mcp");
  217. assert_eq!(
  218. unwrap_ccr_proxy_url("https://vendor.example/mcp"),
  219. "https://vendor.example/mcp"
  220. );
  221. }
  222. #[test]
  223. fn computes_signatures_for_stdio_and_remote_servers() {
  224. let stdio = McpServerConfig::Stdio(McpStdioServerConfig {
  225. command: "uvx".to_string(),
  226. args: vec!["mcp-server".to_string()],
  227. env: BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]),
  228. tool_call_timeout_ms: None,
  229. });
  230. assert_eq!(
  231. mcp_server_signature(&stdio),
  232. Some("stdio:[uvx|mcp-server]".to_string())
  233. );
  234. let remote = McpServerConfig::Ws(McpWebSocketServerConfig {
  235. url: "https://api.anthropic.com/v2/ccr-sessions/1?mcp_url=wss%3A%2F%2Fvendor.example%2Fmcp".to_string(),
  236. headers: BTreeMap::new(),
  237. headers_helper: None,
  238. });
  239. assert_eq!(
  240. mcp_server_signature(&remote),
  241. Some("url:wss://vendor.example/mcp".to_string())
  242. );
  243. }
  244. #[test]
  245. fn scoped_hash_ignores_scope_but_tracks_config_content() {
  246. let base_config = McpServerConfig::Http(McpRemoteServerConfig {
  247. url: "https://vendor.example/mcp".to_string(),
  248. headers: BTreeMap::from([("Authorization".to_string(), "Bearer token".to_string())]),
  249. headers_helper: Some("helper.sh".to_string()),
  250. oauth: None,
  251. });
  252. let user = ScopedMcpServerConfig {
  253. scope: ConfigSource::User,
  254. config: base_config.clone(),
  255. };
  256. let local = ScopedMcpServerConfig {
  257. scope: ConfigSource::Local,
  258. config: base_config,
  259. };
  260. assert_eq!(
  261. scoped_mcp_config_hash(&user),
  262. scoped_mcp_config_hash(&local)
  263. );
  264. let changed = ScopedMcpServerConfig {
  265. scope: ConfigSource::Local,
  266. config: McpServerConfig::Http(McpRemoteServerConfig {
  267. url: "https://vendor.example/v2/mcp".to_string(),
  268. headers: BTreeMap::new(),
  269. headers_helper: None,
  270. oauth: None,
  271. }),
  272. };
  273. assert_ne!(
  274. scoped_mcp_config_hash(&user),
  275. scoped_mcp_config_hash(&changed)
  276. );
  277. }
  278. }