mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 07:41:52 +08:00
Add first MCP client transport scaffolding
Add a minimal runtime MCP client bootstrap layer that turns typed MCP configs into concrete transport targets with normalized names, tool prefixes, signatures, and auth requirements. This is intentionally scaffolding rather than a live connection manager: it creates the real data model the runtime will need to launch stdio, remote, websocket, sdk, and claude.ai proxy clients without prematurely coupling the code to any specific async transport implementation. Constraint: Keep the slice real and minimal without adding connection lifecycle complexity yet Constraint: Runtime verification must stay green under fmt, clippy, and tests Rejected: Implement live connection/session orchestration in the same commit | too much surface area for a clean foundational slice Rejected: Leave bootstrap shaping implicit in future transport code | would duplicate transport mapping and weaken testability Confidence: high Scope-risk: narrow Reversibility: clean Directive: Build future MCP launch/execution code by consuming McpClientBootstrap/McpClientTransport rather than re-parsing config enums ad hoc Tested: cargo fmt --all; cargo clippy -p runtime --all-targets -- -D warnings; cargo test -p runtime Not-tested: live MCP server processes; remote stream handshakes; tool/resource enumeration against real servers
This commit is contained in:
@@ -6,6 +6,7 @@ mod conversation;
|
||||
mod file_ops;
|
||||
mod json;
|
||||
mod mcp;
|
||||
mod mcp_client;
|
||||
mod oauth;
|
||||
mod permissions;
|
||||
mod prompt;
|
||||
@@ -38,6 +39,10 @@ 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 mcp_client::{
|
||||
McpClaudeAiProxyTransport, McpClientAuth, McpClientBootstrap, McpClientTransport,
|
||||
McpRemoteTransport, McpSdkTransport, McpStdioTransport,
|
||||
};
|
||||
pub use oauth::{
|
||||
code_challenge_s256, generate_pkce_pair, generate_state, loopback_redirect_uri,
|
||||
OAuthAuthorizationRequest, OAuthRefreshRequest, OAuthTokenExchangeRequest, OAuthTokenSet,
|
||||
|
||||
236
rust/crates/runtime/src/mcp_client.rs
Normal file
236
rust/crates/runtime/src/mcp_client.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::config::{McpOAuthConfig, McpServerConfig, ScopedMcpServerConfig};
|
||||
use crate::mcp::{mcp_server_signature, mcp_tool_prefix, normalize_name_for_mcp};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum McpClientTransport {
|
||||
Stdio(McpStdioTransport),
|
||||
Sse(McpRemoteTransport),
|
||||
Http(McpRemoteTransport),
|
||||
WebSocket(McpRemoteTransport),
|
||||
Sdk(McpSdkTransport),
|
||||
ClaudeAiProxy(McpClaudeAiProxyTransport),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpStdioTransport {
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpRemoteTransport {
|
||||
pub url: String,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub headers_helper: Option<String>,
|
||||
pub auth: McpClientAuth,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpSdkTransport {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpClaudeAiProxyTransport {
|
||||
pub url: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum McpClientAuth {
|
||||
None,
|
||||
OAuth(McpOAuthConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpClientBootstrap {
|
||||
pub server_name: String,
|
||||
pub normalized_name: String,
|
||||
pub tool_prefix: String,
|
||||
pub signature: Option<String>,
|
||||
pub transport: McpClientTransport,
|
||||
}
|
||||
|
||||
impl McpClientBootstrap {
|
||||
#[must_use]
|
||||
pub fn from_scoped_config(server_name: &str, config: &ScopedMcpServerConfig) -> Self {
|
||||
Self {
|
||||
server_name: server_name.to_string(),
|
||||
normalized_name: normalize_name_for_mcp(server_name),
|
||||
tool_prefix: mcp_tool_prefix(server_name),
|
||||
signature: mcp_server_signature(&config.config),
|
||||
transport: McpClientTransport::from_config(&config.config),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl McpClientTransport {
|
||||
#[must_use]
|
||||
pub fn from_config(config: &McpServerConfig) -> Self {
|
||||
match config {
|
||||
McpServerConfig::Stdio(config) => Self::Stdio(McpStdioTransport {
|
||||
command: config.command.clone(),
|
||||
args: config.args.clone(),
|
||||
env: config.env.clone(),
|
||||
}),
|
||||
McpServerConfig::Sse(config) => Self::Sse(McpRemoteTransport {
|
||||
url: config.url.clone(),
|
||||
headers: config.headers.clone(),
|
||||
headers_helper: config.headers_helper.clone(),
|
||||
auth: McpClientAuth::from_oauth(config.oauth.clone()),
|
||||
}),
|
||||
McpServerConfig::Http(config) => Self::Http(McpRemoteTransport {
|
||||
url: config.url.clone(),
|
||||
headers: config.headers.clone(),
|
||||
headers_helper: config.headers_helper.clone(),
|
||||
auth: McpClientAuth::from_oauth(config.oauth.clone()),
|
||||
}),
|
||||
McpServerConfig::Ws(config) => Self::WebSocket(McpRemoteTransport {
|
||||
url: config.url.clone(),
|
||||
headers: config.headers.clone(),
|
||||
headers_helper: config.headers_helper.clone(),
|
||||
auth: McpClientAuth::None,
|
||||
}),
|
||||
McpServerConfig::Sdk(config) => Self::Sdk(McpSdkTransport {
|
||||
name: config.name.clone(),
|
||||
}),
|
||||
McpServerConfig::ClaudeAiProxy(config) => {
|
||||
Self::ClaudeAiProxy(McpClaudeAiProxyTransport {
|
||||
url: config.url.clone(),
|
||||
id: config.id.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl McpClientAuth {
|
||||
#[must_use]
|
||||
pub fn from_oauth(oauth: Option<McpOAuthConfig>) -> Self {
|
||||
oauth.map_or(Self::None, Self::OAuth)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn requires_user_auth(&self) -> bool {
|
||||
matches!(self, Self::OAuth(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::config::{
|
||||
ConfigSource, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig,
|
||||
McpStdioServerConfig, McpWebSocketServerConfig, ScopedMcpServerConfig,
|
||||
};
|
||||
|
||||
use super::{McpClientAuth, McpClientBootstrap, McpClientTransport};
|
||||
|
||||
#[test]
|
||||
fn bootstraps_stdio_servers_into_transport_targets() {
|
||||
let config = ScopedMcpServerConfig {
|
||||
scope: ConfigSource::User,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: "uvx".to_string(),
|
||||
args: vec!["mcp-server".to_string()],
|
||||
env: BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]),
|
||||
}),
|
||||
};
|
||||
|
||||
let bootstrap = McpClientBootstrap::from_scoped_config("stdio-server", &config);
|
||||
assert_eq!(bootstrap.normalized_name, "stdio-server");
|
||||
assert_eq!(bootstrap.tool_prefix, "mcp__stdio-server__");
|
||||
assert_eq!(
|
||||
bootstrap.signature.as_deref(),
|
||||
Some("stdio:[uvx|mcp-server]")
|
||||
);
|
||||
match bootstrap.transport {
|
||||
McpClientTransport::Stdio(transport) => {
|
||||
assert_eq!(transport.command, "uvx");
|
||||
assert_eq!(transport.args, vec!["mcp-server"]);
|
||||
assert_eq!(
|
||||
transport.env.get("TOKEN").map(String::as_str),
|
||||
Some("secret")
|
||||
);
|
||||
}
|
||||
other => panic!("expected stdio transport, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstraps_remote_servers_with_oauth_auth() {
|
||||
let config = ScopedMcpServerConfig {
|
||||
scope: ConfigSource::Project,
|
||||
config: McpServerConfig::Http(McpRemoteServerConfig {
|
||||
url: "https://vendor.example/mcp".to_string(),
|
||||
headers: BTreeMap::from([("X-Test".to_string(), "1".to_string())]),
|
||||
headers_helper: Some("helper.sh".to_string()),
|
||||
oauth: Some(McpOAuthConfig {
|
||||
client_id: Some("client-id".to_string()),
|
||||
callback_port: Some(7777),
|
||||
auth_server_metadata_url: Some(
|
||||
"https://issuer.example/.well-known/oauth-authorization-server".to_string(),
|
||||
),
|
||||
xaa: Some(true),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
let bootstrap = McpClientBootstrap::from_scoped_config("remote server", &config);
|
||||
assert_eq!(bootstrap.normalized_name, "remote_server");
|
||||
match bootstrap.transport {
|
||||
McpClientTransport::Http(transport) => {
|
||||
assert_eq!(transport.url, "https://vendor.example/mcp");
|
||||
assert_eq!(transport.headers_helper.as_deref(), Some("helper.sh"));
|
||||
assert!(transport.auth.requires_user_auth());
|
||||
match transport.auth {
|
||||
McpClientAuth::OAuth(oauth) => {
|
||||
assert_eq!(oauth.client_id.as_deref(), Some("client-id"));
|
||||
}
|
||||
other @ McpClientAuth::None => panic!("expected oauth auth, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected http transport, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstraps_websocket_and_sdk_transports_without_oauth() {
|
||||
let ws = ScopedMcpServerConfig {
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Ws(McpWebSocketServerConfig {
|
||||
url: "wss://vendor.example/mcp".to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
headers_helper: None,
|
||||
}),
|
||||
};
|
||||
let sdk = ScopedMcpServerConfig {
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Sdk(McpSdkServerConfig {
|
||||
name: "sdk-server".to_string(),
|
||||
}),
|
||||
};
|
||||
|
||||
let ws_bootstrap = McpClientBootstrap::from_scoped_config("ws server", &ws);
|
||||
match ws_bootstrap.transport {
|
||||
McpClientTransport::WebSocket(transport) => {
|
||||
assert_eq!(transport.url, "wss://vendor.example/mcp");
|
||||
assert!(!transport.auth.requires_user_auth());
|
||||
}
|
||||
other => panic!("expected websocket transport, got {other:?}"),
|
||||
}
|
||||
|
||||
let sdk_bootstrap = McpClientBootstrap::from_scoped_config("sdk server", &sdk);
|
||||
assert_eq!(sdk_bootstrap.signature, None);
|
||||
match sdk_bootstrap.transport {
|
||||
McpClientTransport::Sdk(transport) => {
|
||||
assert_eq!(transport.name, "sdk-server");
|
||||
}
|
||||
other => panic!("expected sdk transport, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user