mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 07:41:52 +08:00
Add MCP normalization and config identity helpers
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
This commit is contained in:
@@ -5,6 +5,7 @@ mod config;
|
||||
mod conversation;
|
||||
mod file_ops;
|
||||
mod json;
|
||||
mod mcp;
|
||||
mod oauth;
|
||||
mod permissions;
|
||||
mod prompt;
|
||||
@@ -33,6 +34,10 @@ pub use file_ops::{
|
||||
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
|
||||
WriteFileOutput,
|
||||
};
|
||||
pub use mcp::{
|
||||
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
|
||||
scoped_mcp_config_hash, unwrap_ccr_proxy_url,
|
||||
};
|
||||
pub use oauth::{
|
||||
code_challenge_s256, generate_pkce_pair, generate_state, loopback_redirect_uri,
|
||||
OAuthAuthorizationRequest, OAuthRefreshRequest, OAuthTokenExchangeRequest, OAuthTokenSet,
|
||||
|
||||
300
rust/crates/runtime/src/mcp.rs
Normal file
300
rust/crates/runtime/src/mcp.rs
Normal file
@@ -0,0 +1,300 @@
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user