Add runtime MCP helpers for name normalization, tool naming, CCR proxy URL unwrapping, config signatures, and stable scope-independent config hashing. This is the fastest clean parity-unblocking MCP slice because it creates real reusable behavior needed by future client/transport work without forcing a transport boundary prematurely. The helpers mirror key upstream semantics around normalized tool names and dedup/config-change detection. Constraint: Must land a real MCP foundation without pulling transport management into the same commit Constraint: Runtime verification must pass with fmt, clippy, and tests Rejected: Start with transport/client scaffolding first | would need more design surface and more unverified edges Rejected: Leave normalization/signature logic implicit in later client code | would duplicate behavior and complicate testing Confidence: high Scope-risk: narrow Reversibility: clean Directive: Reuse these helpers for future MCP tool naming, dedup, and reconnect/change-detection work instead of re-encoding the rules ad hoc Tested: cargo fmt --all; cargo clippy -p runtime --all-targets -- -D warnings; cargo test -p runtime Not-tested: live MCP transport connections; plugin reload integration; full connector dedup flows
301 lines
9.5 KiB
Rust
301 lines
9.5 KiB
Rust
use crate::config::{McpServerConfig, ScopedMcpServerConfig};
|
|
|
|
const CLAUDEAI_SERVER_PREFIX: &str = "claude.ai ";
|
|
const CCR_PROXY_PATH_MARKERS: [&str; 2] = ["/v2/session_ingress/shttp/mcp/", "/v2/ccr-sessions/"];
|
|
|
|
#[must_use]
|
|
pub fn normalize_name_for_mcp(name: &str) -> String {
|
|
let mut normalized = name
|
|
.chars()
|
|
.map(|ch| match ch {
|
|
'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' => ch,
|
|
_ => '_',
|
|
})
|
|
.collect::<String>();
|
|
|
|
if name.starts_with(CLAUDEAI_SERVER_PREFIX) {
|
|
normalized = collapse_underscores(&normalized)
|
|
.trim_matches('_')
|
|
.to_string();
|
|
}
|
|
|
|
normalized
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn mcp_tool_prefix(server_name: &str) -> String {
|
|
format!("mcp__{}__", normalize_name_for_mcp(server_name))
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn mcp_tool_name(server_name: &str, tool_name: &str) -> String {
|
|
format!(
|
|
"{}{}",
|
|
mcp_tool_prefix(server_name),
|
|
normalize_name_for_mcp(tool_name)
|
|
)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn unwrap_ccr_proxy_url(url: &str) -> String {
|
|
if !CCR_PROXY_PATH_MARKERS
|
|
.iter()
|
|
.any(|marker| url.contains(marker))
|
|
{
|
|
return url.to_string();
|
|
}
|
|
|
|
let Some(query_start) = url.find('?') else {
|
|
return url.to_string();
|
|
};
|
|
let query = &url[query_start + 1..];
|
|
for pair in query.split('&') {
|
|
let mut parts = pair.splitn(2, '=');
|
|
if matches!(parts.next(), Some("mcp_url")) {
|
|
if let Some(value) = parts.next() {
|
|
return percent_decode(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
url.to_string()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn mcp_server_signature(config: &McpServerConfig) -> Option<String> {
|
|
match config {
|
|
McpServerConfig::Stdio(config) => {
|
|
let mut command = vec![config.command.clone()];
|
|
command.extend(config.args.clone());
|
|
Some(format!("stdio:{}", render_command_signature(&command)))
|
|
}
|
|
McpServerConfig::Sse(config) | McpServerConfig::Http(config) => {
|
|
Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url)))
|
|
}
|
|
McpServerConfig::Ws(config) => Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url))),
|
|
McpServerConfig::ClaudeAiProxy(config) => {
|
|
Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url)))
|
|
}
|
|
McpServerConfig::Sdk(_) => None,
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn scoped_mcp_config_hash(config: &ScopedMcpServerConfig) -> String {
|
|
let rendered = match &config.config {
|
|
McpServerConfig::Stdio(stdio) => format!(
|
|
"stdio|{}|{}|{}",
|
|
stdio.command,
|
|
render_command_signature(&stdio.args),
|
|
render_env_signature(&stdio.env)
|
|
),
|
|
McpServerConfig::Sse(remote) => format!(
|
|
"sse|{}|{}|{}|{}",
|
|
remote.url,
|
|
render_env_signature(&remote.headers),
|
|
remote.headers_helper.as_deref().unwrap_or(""),
|
|
render_oauth_signature(remote.oauth.as_ref())
|
|
),
|
|
McpServerConfig::Http(remote) => format!(
|
|
"http|{}|{}|{}|{}",
|
|
remote.url,
|
|
render_env_signature(&remote.headers),
|
|
remote.headers_helper.as_deref().unwrap_or(""),
|
|
render_oauth_signature(remote.oauth.as_ref())
|
|
),
|
|
McpServerConfig::Ws(ws) => format!(
|
|
"ws|{}|{}|{}",
|
|
ws.url,
|
|
render_env_signature(&ws.headers),
|
|
ws.headers_helper.as_deref().unwrap_or("")
|
|
),
|
|
McpServerConfig::Sdk(sdk) => format!("sdk|{}", sdk.name),
|
|
McpServerConfig::ClaudeAiProxy(proxy) => {
|
|
format!("claudeai-proxy|{}|{}", proxy.url, proxy.id)
|
|
}
|
|
};
|
|
stable_hex_hash(&rendered)
|
|
}
|
|
|
|
fn render_command_signature(command: &[String]) -> String {
|
|
let escaped = command
|
|
.iter()
|
|
.map(|part| part.replace('\\', "\\\\").replace('|', "\\|"))
|
|
.collect::<Vec<_>>();
|
|
format!("[{}]", escaped.join("|"))
|
|
}
|
|
|
|
fn render_env_signature(map: &std::collections::BTreeMap<String, String>) -> String {
|
|
map.iter()
|
|
.map(|(key, value)| format!("{key}={value}"))
|
|
.collect::<Vec<_>>()
|
|
.join(";")
|
|
}
|
|
|
|
fn render_oauth_signature(oauth: Option<&crate::config::McpOAuthConfig>) -> String {
|
|
oauth.map_or_else(String::new, |oauth| {
|
|
format!(
|
|
"{}|{}|{}|{}",
|
|
oauth.client_id.as_deref().unwrap_or(""),
|
|
oauth
|
|
.callback_port
|
|
.map_or_else(String::new, |port| port.to_string()),
|
|
oauth.auth_server_metadata_url.as_deref().unwrap_or(""),
|
|
oauth.xaa.map_or_else(String::new, |flag| flag.to_string())
|
|
)
|
|
})
|
|
}
|
|
|
|
fn stable_hex_hash(value: &str) -> String {
|
|
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
|
|
for byte in value.as_bytes() {
|
|
hash ^= u64::from(*byte);
|
|
hash = hash.wrapping_mul(0x0100_0000_01b3);
|
|
}
|
|
format!("{hash:016x}")
|
|
}
|
|
|
|
fn collapse_underscores(value: &str) -> String {
|
|
let mut collapsed = String::with_capacity(value.len());
|
|
let mut last_was_underscore = false;
|
|
for ch in value.chars() {
|
|
if ch == '_' {
|
|
if !last_was_underscore {
|
|
collapsed.push(ch);
|
|
}
|
|
last_was_underscore = true;
|
|
} else {
|
|
collapsed.push(ch);
|
|
last_was_underscore = false;
|
|
}
|
|
}
|
|
collapsed
|
|
}
|
|
|
|
fn percent_decode(value: &str) -> String {
|
|
let bytes = value.as_bytes();
|
|
let mut decoded = Vec::with_capacity(bytes.len());
|
|
let mut index = 0;
|
|
while index < bytes.len() {
|
|
match bytes[index] {
|
|
b'%' if index + 2 < bytes.len() => {
|
|
let hex = &value[index + 1..index + 3];
|
|
if let Ok(byte) = u8::from_str_radix(hex, 16) {
|
|
decoded.push(byte);
|
|
index += 3;
|
|
continue;
|
|
}
|
|
decoded.push(bytes[index]);
|
|
index += 1;
|
|
}
|
|
b'+' => {
|
|
decoded.push(b' ');
|
|
index += 1;
|
|
}
|
|
byte => {
|
|
decoded.push(byte);
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
String::from_utf8_lossy(&decoded).into_owned()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::BTreeMap;
|
|
|
|
use crate::config::{
|
|
ConfigSource, McpRemoteServerConfig, McpServerConfig, McpStdioServerConfig,
|
|
McpWebSocketServerConfig, ScopedMcpServerConfig,
|
|
};
|
|
|
|
use super::{
|
|
mcp_server_signature, mcp_tool_name, normalize_name_for_mcp, scoped_mcp_config_hash,
|
|
unwrap_ccr_proxy_url,
|
|
};
|
|
|
|
#[test]
|
|
fn normalizes_server_names_for_mcp_tooling() {
|
|
assert_eq!(normalize_name_for_mcp("github.com"), "github_com");
|
|
assert_eq!(normalize_name_for_mcp("tool name!"), "tool_name_");
|
|
assert_eq!(
|
|
normalize_name_for_mcp("claude.ai Example Server!!"),
|
|
"claude_ai_Example_Server"
|
|
);
|
|
assert_eq!(
|
|
mcp_tool_name("claude.ai Example Server", "weather tool"),
|
|
"mcp__claude_ai_Example_Server__weather_tool"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unwraps_ccr_proxy_urls_for_signature_matching() {
|
|
let wrapped = "https://api.anthropic.com/v2/session_ingress/shttp/mcp/123?mcp_url=https%3A%2F%2Fvendor.example%2Fmcp&other=1";
|
|
assert_eq!(unwrap_ccr_proxy_url(wrapped), "https://vendor.example/mcp");
|
|
assert_eq!(
|
|
unwrap_ccr_proxy_url("https://vendor.example/mcp"),
|
|
"https://vendor.example/mcp"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn computes_signatures_for_stdio_and_remote_servers() {
|
|
let stdio = McpServerConfig::Stdio(McpStdioServerConfig {
|
|
command: "uvx".to_string(),
|
|
args: vec!["mcp-server".to_string()],
|
|
env: BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]),
|
|
});
|
|
assert_eq!(
|
|
mcp_server_signature(&stdio),
|
|
Some("stdio:[uvx|mcp-server]".to_string())
|
|
);
|
|
|
|
let remote = McpServerConfig::Ws(McpWebSocketServerConfig {
|
|
url: "https://api.anthropic.com/v2/ccr-sessions/1?mcp_url=wss%3A%2F%2Fvendor.example%2Fmcp".to_string(),
|
|
headers: BTreeMap::new(),
|
|
headers_helper: None,
|
|
});
|
|
assert_eq!(
|
|
mcp_server_signature(&remote),
|
|
Some("url:wss://vendor.example/mcp".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scoped_hash_ignores_scope_but_tracks_config_content() {
|
|
let base_config = McpServerConfig::Http(McpRemoteServerConfig {
|
|
url: "https://vendor.example/mcp".to_string(),
|
|
headers: BTreeMap::from([("Authorization".to_string(), "Bearer token".to_string())]),
|
|
headers_helper: Some("helper.sh".to_string()),
|
|
oauth: None,
|
|
});
|
|
let user = ScopedMcpServerConfig {
|
|
scope: ConfigSource::User,
|
|
config: base_config.clone(),
|
|
};
|
|
let local = ScopedMcpServerConfig {
|
|
scope: ConfigSource::Local,
|
|
config: base_config,
|
|
};
|
|
assert_eq!(
|
|
scoped_mcp_config_hash(&user),
|
|
scoped_mcp_config_hash(&local)
|
|
);
|
|
|
|
let changed = ScopedMcpServerConfig {
|
|
scope: ConfigSource::Local,
|
|
config: McpServerConfig::Http(McpRemoteServerConfig {
|
|
url: "https://vendor.example/v2/mcp".to_string(),
|
|
headers: BTreeMap::new(),
|
|
headers_helper: None,
|
|
oauth: None,
|
|
}),
|
|
};
|
|
assert_ne!(
|
|
scoped_mcp_config_hash(&user),
|
|
scoped_mcp_config_hash(&changed)
|
|
);
|
|
}
|
|
}
|