mcp.rs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  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::ClaudeAiProxy(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. ),
  81. McpServerConfig::Sse(remote) => format!(
  82. "sse|{}|{}|{}|{}",
  83. remote.url,
  84. render_env_signature(&remote.headers),
  85. remote.headers_helper.as_deref().unwrap_or(""),
  86. render_oauth_signature(remote.oauth.as_ref())
  87. ),
  88. McpServerConfig::Http(remote) => format!(
  89. "http|{}|{}|{}|{}",
  90. remote.url,
  91. render_env_signature(&remote.headers),
  92. remote.headers_helper.as_deref().unwrap_or(""),
  93. render_oauth_signature(remote.oauth.as_ref())
  94. ),
  95. McpServerConfig::Ws(ws) => format!(
  96. "ws|{}|{}|{}",
  97. ws.url,
  98. render_env_signature(&ws.headers),
  99. ws.headers_helper.as_deref().unwrap_or("")
  100. ),
  101. McpServerConfig::Sdk(sdk) => format!("sdk|{}", sdk.name),
  102. McpServerConfig::ClaudeAiProxy(proxy) => {
  103. format!("claudeai-proxy|{}|{}", proxy.url, proxy.id)
  104. }
  105. };
  106. stable_hex_hash(&rendered)
  107. }
  108. fn render_command_signature(command: &[String]) -> String {
  109. let escaped = command
  110. .iter()
  111. .map(|part| part.replace('\\', "\\\\").replace('|', "\\|"))
  112. .collect::<Vec<_>>();
  113. format!("[{}]", escaped.join("|"))
  114. }
  115. fn render_env_signature(map: &std::collections::BTreeMap<String, String>) -> String {
  116. map.iter()
  117. .map(|(key, value)| format!("{key}={value}"))
  118. .collect::<Vec<_>>()
  119. .join(";")
  120. }
  121. fn render_oauth_signature(oauth: Option<&crate::config::McpOAuthConfig>) -> String {
  122. oauth.map_or_else(String::new, |oauth| {
  123. format!(
  124. "{}|{}|{}|{}",
  125. oauth.client_id.as_deref().unwrap_or(""),
  126. oauth
  127. .callback_port
  128. .map_or_else(String::new, |port| port.to_string()),
  129. oauth.auth_server_metadata_url.as_deref().unwrap_or(""),
  130. oauth.xaa.map_or_else(String::new, |flag| flag.to_string())
  131. )
  132. })
  133. }
  134. fn stable_hex_hash(value: &str) -> String {
  135. let mut hash = 0xcbf2_9ce4_8422_2325_u64;
  136. for byte in value.as_bytes() {
  137. hash ^= u64::from(*byte);
  138. hash = hash.wrapping_mul(0x0100_0000_01b3);
  139. }
  140. format!("{hash:016x}")
  141. }
  142. fn collapse_underscores(value: &str) -> String {
  143. let mut collapsed = String::with_capacity(value.len());
  144. let mut last_was_underscore = false;
  145. for ch in value.chars() {
  146. if ch == '_' {
  147. if !last_was_underscore {
  148. collapsed.push(ch);
  149. }
  150. last_was_underscore = true;
  151. } else {
  152. collapsed.push(ch);
  153. last_was_underscore = false;
  154. }
  155. }
  156. collapsed
  157. }
  158. fn percent_decode(value: &str) -> String {
  159. let bytes = value.as_bytes();
  160. let mut decoded = Vec::with_capacity(bytes.len());
  161. let mut index = 0;
  162. while index < bytes.len() {
  163. match bytes[index] {
  164. b'%' if index + 2 < bytes.len() => {
  165. let hex = &value[index + 1..index + 3];
  166. if let Ok(byte) = u8::from_str_radix(hex, 16) {
  167. decoded.push(byte);
  168. index += 3;
  169. continue;
  170. }
  171. decoded.push(bytes[index]);
  172. index += 1;
  173. }
  174. b'+' => {
  175. decoded.push(b' ');
  176. index += 1;
  177. }
  178. byte => {
  179. decoded.push(byte);
  180. index += 1;
  181. }
  182. }
  183. }
  184. String::from_utf8_lossy(&decoded).into_owned()
  185. }
  186. #[cfg(test)]
  187. mod tests {
  188. use std::collections::BTreeMap;
  189. use crate::config::{
  190. ConfigSource, McpRemoteServerConfig, McpServerConfig, McpStdioServerConfig,
  191. McpWebSocketServerConfig, ScopedMcpServerConfig,
  192. };
  193. use super::{
  194. mcp_server_signature, mcp_tool_name, normalize_name_for_mcp, scoped_mcp_config_hash,
  195. unwrap_ccr_proxy_url,
  196. };
  197. #[test]
  198. fn normalizes_server_names_for_mcp_tooling() {
  199. assert_eq!(normalize_name_for_mcp("github.com"), "github_com");
  200. assert_eq!(normalize_name_for_mcp("tool name!"), "tool_name_");
  201. assert_eq!(
  202. normalize_name_for_mcp("claude.ai Example Server!!"),
  203. "claude_ai_Example_Server"
  204. );
  205. assert_eq!(
  206. mcp_tool_name("claude.ai Example Server", "weather tool"),
  207. "mcp__claude_ai_Example_Server__weather_tool"
  208. );
  209. }
  210. #[test]
  211. fn unwraps_ccr_proxy_urls_for_signature_matching() {
  212. let wrapped = "https://api.anthropic.com/v2/session_ingress/shttp/mcp/123?mcp_url=https%3A%2F%2Fvendor.example%2Fmcp&other=1";
  213. assert_eq!(unwrap_ccr_proxy_url(wrapped), "https://vendor.example/mcp");
  214. assert_eq!(
  215. unwrap_ccr_proxy_url("https://vendor.example/mcp"),
  216. "https://vendor.example/mcp"
  217. );
  218. }
  219. #[test]
  220. fn computes_signatures_for_stdio_and_remote_servers() {
  221. let stdio = McpServerConfig::Stdio(McpStdioServerConfig {
  222. command: "uvx".to_string(),
  223. args: vec!["mcp-server".to_string()],
  224. env: BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]),
  225. });
  226. assert_eq!(
  227. mcp_server_signature(&stdio),
  228. Some("stdio:[uvx|mcp-server]".to_string())
  229. );
  230. let remote = McpServerConfig::Ws(McpWebSocketServerConfig {
  231. url: "https://api.anthropic.com/v2/ccr-sessions/1?mcp_url=wss%3A%2F%2Fvendor.example%2Fmcp".to_string(),
  232. headers: BTreeMap::new(),
  233. headers_helper: None,
  234. });
  235. assert_eq!(
  236. mcp_server_signature(&remote),
  237. Some("url:wss://vendor.example/mcp".to_string())
  238. );
  239. }
  240. #[test]
  241. fn scoped_hash_ignores_scope_but_tracks_config_content() {
  242. let base_config = McpServerConfig::Http(McpRemoteServerConfig {
  243. url: "https://vendor.example/mcp".to_string(),
  244. headers: BTreeMap::from([("Authorization".to_string(), "Bearer token".to_string())]),
  245. headers_helper: Some("helper.sh".to_string()),
  246. oauth: None,
  247. });
  248. let user = ScopedMcpServerConfig {
  249. scope: ConfigSource::User,
  250. config: base_config.clone(),
  251. };
  252. let local = ScopedMcpServerConfig {
  253. scope: ConfigSource::Local,
  254. config: base_config,
  255. };
  256. assert_eq!(
  257. scoped_mcp_config_hash(&user),
  258. scoped_mcp_config_hash(&local)
  259. );
  260. let changed = ScopedMcpServerConfig {
  261. scope: ConfigSource::Local,
  262. config: McpServerConfig::Http(McpRemoteServerConfig {
  263. url: "https://vendor.example/v2/mcp".to_string(),
  264. headers: BTreeMap::new(),
  265. headers_helper: None,
  266. oauth: None,
  267. }),
  268. };
  269. assert_ne!(
  270. scoped_mcp_config_hash(&user),
  271. scoped_mcp_config_hash(&changed)
  272. );
  273. }
  274. }