pub mod config;
pub mod error;
pub mod manager;
pub mod oauth;
use crate::{
APP_NAME,
core::{
extensions::{
self, Extension, ExtensionCommand, ExtensionMode, ExtensionTool, ExtensionUiRequest,
ForkProjectInfo, SubcommandDef, ToolAnnotations, ToolExecCtx, ToolExposure,
UiNotifyLevel,
},
project_trust::is_project_trusted,
provider::AgentMessage,
session_manager::Session,
settings_manager::agent_dir,
tools::{ToolError, ToolExecutionMode, ToolResult, ToolResultAttachment},
},
extensions::{
EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_MCP, command_arg, util::notify_plain,
},
modes::interactive::{
app::{App, MsgLevel},
handlers::register_slash_command,
},
utils::{glyphs::DEF_ELLIPSIS, paths::cwd, truncate::truncate_chars_within},
};
use config::{AuthMode, LoadedConfig, McpExposure, OAuthGrantType, ServerEntry};
use error::McpError;
use futures_util::future::BoxFuture;
use manager::{CachedTool, McpManager};
use rmcp::model::ContentBlock as McpContent;
use rustls::crypto::{CryptoProvider, aws_lc_rs};
use serde::Serialize;
use serde_json::{Value, json};
use std::{
collections::BTreeMap,
future::Future,
path::{Path, PathBuf},
sync::{
Arc, Mutex, Once, OnceLock,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use tokio::{
io::{AsyncReadExt as _, AsyncWriteExt as _},
net::TcpListener,
};
use tokio_util::sync::CancellationToken;
use url::Url;
pub const EXT: &str = "mcp";
pub const TOOL: &str = "mcp";
const CMD: &str = "mcp";
const MAX_TEXT_BYTES: usize = 100_000;
const SERVER_DESCRIPTION_CHARS: usize = 120;
const MANAGER_TTL: Duration = Duration::from_secs(3);
pub const MCP_SERVERS_SECTION: &str = "mcp_servers";
pub const MAX_SERVERS_SECTION_CHARS: usize = 4096;
pub const MAX_SERVER_DESCRIPTION_CHARS: usize = 250;
const SUBCOMMAND_NAMES: &str = "status|list|tools|test|login|logout|reload";
const SUBCOMMANDS: &[SubcommandDef] = &[
SubcommandDef {
name: "status",
description: "Show per-server state and cached tool/resource/prompt counts",
},
SubcommandDef {
name: "list",
description: "List configured MCP servers and their sources",
},
SubcommandDef {
name: "tools",
description: "List cached tools: /mcp tools [server]",
},
SubcommandDef {
name: "test",
description: "Connect and list a server's tools: /mcp test <server>",
},
SubcommandDef {
name: "login",
description: "OAuth login: /mcp login <server> [redirect-url]",
},
SubcommandDef {
name: "logout",
description: "Remove saved OAuth credentials: /mcp logout <server>",
},
SubcommandDef {
name: "reload",
description: "Re-read MCP config files and drop cached connections",
},
SubcommandDef {
name: "enable",
description: "Enable a server: /mcp enable <server> [user|project] (default project)",
},
SubcommandDef {
name: "disable",
description: "Disable a server: /mcp disable <server> [user|project] (default project)",
},
];
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static MCP_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_MCP,
make: || -> Arc<dyn Extension> { Arc::new(Mcp) },
};
pub fn ensure_tls_crypto_provider() {
static INSTALL: Once = Once::new();
INSTALL.call_once(|| {
if CryptoProvider::get_default().is_none() {
_ = aws_lc_rs::default_provider().install_default();
}
});
}
struct CachedManager {
cwd: PathBuf,
trusted: bool,
manager: McpManager,
loaded_at: Instant,
}
fn manager_cache() -> &'static Mutex<Option<CachedManager>> {
static C: OnceLock<Mutex<Option<CachedManager>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(None))
}
pub fn invalidate_manager() {
*manager_cache().lock().unwrap_or_else(|e| e.into_inner()) = None;
}
fn manager_for(cwd: &str) -> Result<McpManager, ToolError> {
let cwd = PathBuf::from(if cwd.is_empty() { "." } else { cwd });
let trusted = is_project_trusted(&cwd, &agent_dir());
{
let guard = manager_cache().lock().unwrap_or_else(|e| e.into_inner());
if let Some(cached) = guard.as_ref()
&& cached.cwd == cwd
&& cached.trusted == trusted
&& cached.loaded_at.elapsed() < MANAGER_TTL
{
return Ok(cached.manager.clone());
}
}
let loaded = load_config_with_declarations(&cwd, trusted).map_err(ToolError)?;
let manager = McpManager::new(loaded).map_err(|e| ToolError(e.to_string()))?;
let mut guard = manager_cache().lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(CachedManager {
cwd,
trusted,
manager: manager.clone(),
loaded_at: Instant::now(),
});
Ok(manager)
}
pub struct Mcp;
impl Extension for Mcp {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
concat!(
"Model Context Protocol client. Proxies configured MCP servers' tools, resources, and \
prompts through one `mcp` agent tool, with lazy connections, metadata caching, and OAuth 2.1 \
login (/mcp). Config is read from ~/.config/mcp, ~/.agents, agent_dir()/extensions/mcp/mcp.json, and \
(trusted) project .mcp.json / ",
".",
env!("CARGO_PKG_NAME"),
"/mcp.json."
)
}
fn fork_project(&self) -> Option<ForkProjectInfo> {
Some(ForkProjectInfo {
plugin_name: "kiss-mcp".to_string(),
plugin_version: "0.0.18".to_string(),
url: "https://github.com/racetozero/kiss".to_string(),
})
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn default_enabled(&self) -> bool {
false
}
fn tools(&self) -> Vec<ExtensionTool> {
match enabled_servers() {
Some(servers) => vec![mcp_tool(
aggregate_exposure(servers.iter().map(|(_, server)| server)),
&server_summary(&servers),
)],
None => Vec::new(),
}
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![ExtensionCommand {
name: CMD.to_string(),
description:
"/mcp status|list|tools|test|login|logout|reload|enable|disable — manage MCP servers"
.to_string(),
busy_safe: true, subcommands: SUBCOMMANDS.to_vec(),
}]
}
fn on_registered(&self) {
register_slash_command(EXT, CMD, command_mcp);
}
fn on_enabled_changed(&self, _enabled: bool) {
invalidate_manager();
}
fn on_session_start(&self, cwd: &str, _messages: &[AgentMessage], _session: Option<&Session>) {
auto_enable_codemode();
if let Some(message) = startup_message(cwd) {
notify_plain(message, UiNotifyLevel::Info);
}
}
fn execute_tool_async(
&self,
name: String,
args: Value,
ctx: ToolExecCtx,
) -> BoxFuture<'static, Result<ToolResult, ToolError>> {
Box::pin(async move {
if name != TOOL {
return Err(ToolError(format!("mcp: unknown tool: {name}")));
}
dispatch(args, ctx).await
})
}
}
fn server_entries(cwd: &str) -> BTreeMap<String, ServerEntry> {
let path = Path::new(if cwd.is_empty() { "." } else { cwd });
let trusted = is_project_trusted(path, &agent_dir());
load_config_with_declarations(path, trusted)
.map(|loaded| loaded.config.mcp_servers)
.unwrap_or_default()
}
fn tool_visible(
servers: &BTreeMap<String, ServerEntry>,
script_call: bool,
server: &str,
tool: &str,
) -> bool {
match servers.get(server).map(|entry| entry.exposure_for(tool)) {
Some(config::McpExposure::Hidden) => false,
Some(config::McpExposure::Codemode | config::McpExposure::CodemodeDeferred) => script_call,
_ => true,
}
}
fn require_visible(
servers: &BTreeMap<String, ServerEntry>,
script_call: bool,
server: &str,
tool: &str,
) -> Result<(), ToolError> {
if tool_visible(servers, script_call, server, tool) {
return Ok(());
}
let exposure = servers
.get(server)
.map(|entry| entry.exposure_for(tool).as_str())
.unwrap_or("unknown");
Err(ToolError(format!(
"MCP tool `{server}/{tool}` is not visible in this context (exposure: {exposure})"
)))
}
fn startup_message(cwd: &str) -> Option<String> {
let cwd = PathBuf::from(if cwd.is_empty() { "." } else { cwd });
let trusted = is_project_trusted(&cwd, &agent_dir());
let enabled = load_config_with_declarations(&cwd, trusted)
.ok()?
.enabled_server_count();
let blocked = if trusted {
0
} else {
load_config_with_declarations(&cwd, true)
.map(|all| all.enabled_server_count().saturating_sub(enabled))
.unwrap_or(0)
};
if enabled == 0 && blocked == 0 {
return None;
}
Some(if blocked == 0 {
format!("MCP: {enabled} servers enabled")
} else {
format!("MCP: {enabled} servers enabled ({blocked} blocked by project trust)")
})
}
fn auto_enable_codemode() {
let cwd = cwd();
let trusted = is_project_trusted(&cwd, &agent_dir());
let Ok(loaded) = load_config_with_declarations(&cwd, trusted) else {
return;
};
if loaded.config.auto_enable_codemode == Some(false) {
return;
}
let wants_codemode = loaded
.config
.mcp_servers
.values()
.any(|server| !server.disabled && server.uses_codemode_exposure());
if !wants_codemode {
return;
}
if !extensions::is_extension_enabled("codemode") {
extensions::set_extension_enabled("codemode", true);
}
}
fn enabled_servers() -> Option<Vec<(String, ServerEntry)>> {
let cwd = cwd();
let trusted = is_project_trusted(&cwd, &agent_dir());
let loaded = load_config_with_declarations(&cwd, trusted).ok()?;
let servers: Vec<(String, ServerEntry)> = loaded
.config
.mcp_servers
.iter()
.filter(|(_, server)| !server.disabled)
.map(|(name, server)| (name.clone(), server.clone()))
.collect();
if servers.is_empty() {
None
} else {
Some(servers)
}
}
pub fn servers_section() -> Option<String> {
if !extensions::is_extension_enabled(EXT) {
return None;
}
render_servers_section(&enabled_servers()?)
}
fn render_servers_section(servers: &[(String, ServerEntry)]) -> Option<String> {
let mut listed: Vec<(String, ServerEntry)> = servers
.iter()
.filter(|(_, server)| has_indirect_tools(server))
.cloned()
.collect();
if listed.is_empty() {
return None;
}
listed.sort_by(|a, b| a.0.cmp(&b.0));
let reaches: Vec<&str> = listed
.iter()
.map(|(_, server)| {
if server.uses_codemode_exposure() {
"codemode"
} else {
"tool_search"
}
})
.collect();
let mut intro = "MCP servers whose tools are not declared to you.".to_string();
if reaches.contains(&"codemode") {
intro.push_str(" Call the tools of `codemode` servers from codemode scripts.");
}
if reaches.contains(&"tool_search") {
intro.push_str(" Load the tools of `tool_search` servers with `tool_search`.");
}
let head = |index: usize| format!("- {} ({})", listed[index].0, reaches[index]);
let size = |kept: usize| {
let mut parts = vec![intro.clone()];
parts.extend((0..kept).map(head));
parts.extend(omitted_line(listed.len() - kept));
parts.join("\n").chars().count()
};
let mut kept = listed.len();
while kept > 0 && size(kept) > MAX_SERVERS_SECTION_CHARS {
kept -= 1;
}
if kept == 0 {
return None;
}
let per_server = ((MAX_SERVERS_SECTION_CHARS.saturating_sub(size(kept)) / kept)
.saturating_sub(2))
.min(MAX_SERVER_DESCRIPTION_CHARS);
let mut lines: Vec<String> = Vec::with_capacity(kept);
for (index, head) in (0..kept).map(head).enumerate() {
let summary = listed[index]
.1
.description
.as_deref()
.map(first_line)
.filter(|s| !s.is_empty() && per_server > 0)
.map(|s| truncate_chars_within(&s, per_server, DEF_ELLIPSIS))
.unwrap_or_default();
lines.push(if summary.is_empty() {
head
} else {
format!("{head}: {summary}")
});
}
let mut parts = vec![intro];
parts.extend(lines);
parts.extend(omitted_line(listed.len() - kept));
Some(format!(
"<{MCP_SERVERS_SECTION}>\n{}\n</{MCP_SERVERS_SECTION}>",
parts.join("\n")
))
}
fn has_indirect_tools(server: &ServerEntry) -> bool {
let indirect = |exposure: McpExposure| {
matches!(
exposure,
McpExposure::Codemode | McpExposure::CodemodeDeferred | McpExposure::Deferred
)
};
indirect(server.exposure.unwrap_or_default())
|| server.tool_exposure.values().any(|e| indirect(*e))
}
fn omitted_line(omitted: usize) -> Option<String> {
(omitted > 0).then(|| {
format!(
"- … {omitted} more server{}; find their tools with searchTools()",
if omitted == 1 { "" } else { "s" }
)
})
}
fn first_line(text: &str) -> String {
text.lines().next().unwrap_or_default().trim().to_string()
}
fn server_summary(servers: &[(String, ServerEntry)]) -> String {
let described: Vec<String> = servers
.iter()
.filter_map(|(name, server)| {
let description = server.description.as_deref()?.trim();
if description.is_empty() {
return None;
}
Some(format!(
"{name} — {}",
description
.chars()
.take(SERVER_DESCRIPTION_CHARS)
.collect::<String>()
))
})
.collect();
if described.is_empty() {
String::new()
} else {
format!("Configured servers: {}", described.join("; "))
}
}
fn aggregate_exposure<'a>(servers: impl IntoIterator<Item = &'a ServerEntry>) -> ToolExposure {
let rank = |exposure: McpExposure| match exposure {
McpExposure::Direct => 0,
McpExposure::Deferred => 1,
McpExposure::Codemode | McpExposure::CodemodeDeferred => 2,
McpExposure::Hidden => 3,
};
let least = servers
.into_iter()
.map(|server| server.exposure.unwrap_or_default())
.min_by_key(|exposure| rank(*exposure))
.unwrap_or_default();
match least {
McpExposure::Direct => ToolExposure::Direct,
McpExposure::Deferred => ToolExposure::Deferred,
McpExposure::Codemode | McpExposure::CodemodeDeferred => ToolExposure::Codemode,
McpExposure::Hidden => ToolExposure::Hidden,
}
}
fn mcp_tool(exposure: ToolExposure, summary: &str) -> ExtensionTool {
let mut description = "Find and use tools, resources, and prompts from configured MCP servers. \
Start with search or list. Use describe before a tool call when you do not \
know its input schema."
.to_string();
let mut snippet = "Use `mcp` to search/list/describe/call tools, resources, and prompts from configured MCP servers.".to_string();
if !summary.is_empty() {
description.push_str("\n\n");
description.push_str(summary);
snippet.push(' ');
snippet.push_str(summary);
}
ExtensionTool {
exposure,
namespace: None,
annotations: ToolAnnotations::default(),
output_schema: None,
name: TOOL.to_string(),
description,
label: Some("MCP".to_string()),
parameters: json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["status", "list", "search", "describe", "call", "resources", "read_resource", "prompts", "get_prompt"],
"description": "The MCP operation."
},
"server": {"type": "string", "description": "The configured MCP server name."},
"name": {"type": "string", "description": "The tool or prompt name."},
"query": {"type": "string", "description": "Text used to search tool names and descriptions."},
"uri": {"type": "string", "description": "The resource URI."},
"arguments": {"type": "object", "description": "Arguments for a tool or prompt."}
},
"required": ["action"],
"additionalProperties": false
}),
snippet,
prompt_guidelines: Vec::new(),
constrained_sampling: false,
render_shell: None,
execution_mode: Some(ToolExecutionMode::Parallel),
prepare_arguments: None,
grammar_sampling: None,
}
}
async fn dispatch(args: Value, ctx: ToolExecCtx) -> Result<ToolResult, ToolError> {
let action = required_string(&args, "action")?.to_string();
let manager = manager_for(&ctx.cwd)?;
let cancel = CancellationToken::new();
let abort = ctx.parent_abort.clone();
let servers = server_entries(&ctx.cwd);
let script_call = ctx.script_call;
match action.as_str() {
"status" => json_result(manager.status().await),
"list" => {
let server = optional_string(&args, "server").map(str::to_string);
let mut tools = run_cancellable(
&cancel,
abort,
manager.list_tools(server.as_deref(), &cancel),
)
.await
.map_err(tool_error)?;
tools.retain(|t| tool_visible(&servers, script_call, &t.server, &t.name));
json_result(tools)
}
"search" => {
let query = required_string(&args, "query")?.to_string();
let server = optional_string(&args, "server").map(str::to_string);
let mut tools = run_cancellable(
&cancel,
abort,
manager.search_tools(&query, server.as_deref(), &cancel),
)
.await
.map_err(tool_error)?;
tools.retain(|t| tool_visible(&servers, script_call, &t.server, &t.name));
json_result(tools)
}
"describe" => {
let server = required_string(&args, "server")?.to_string();
let name = required_string(&args, "name")?.to_string();
require_visible(&servers, script_call, &server, &name)?;
let tool = run_cancellable(
&cancel,
abort,
manager.describe_tool(&server, &name, &cancel),
)
.await
.map_err(tool_error)?;
json_result(tool)
}
"call" => {
let server = required_string(&args, "server")?.to_string();
let name = required_string(&args, "name")?.to_string();
require_visible(&servers, script_call, &server, &name)?;
let arguments = args.get("arguments").cloned().unwrap_or(Value::Null);
let result = run_cancellable(
&cancel,
abort,
manager.call_tool(&server, &name, arguments, &cancel),
)
.await
.map_err(tool_error)?;
let is_error = result.is_error.unwrap_or(false);
let details = json!({
"action": action,
"server": server,
"name": name,
"isError": is_error,
"meta": result.meta,
});
let (text, attachments) = convert_content(result.content);
if is_error {
return Err(ToolError(format!(
"MCP tool `{server}/{name}` failed: {text}"
)));
}
Ok(ToolResult {
text,
structured_content: result.structured_content,
details: Some(details),
attachments,
..Default::default()
})
}
"resources" => {
let server = required_string(&args, "server")?.to_string();
let resources =
run_cancellable(&cancel, abort, manager.list_resources(&server, &cancel))
.await
.map_err(tool_error)?;
json_result(resources)
}
"read_resource" => {
let server = required_string(&args, "server")?.to_string();
let uri = required_string(&args, "uri")?.to_string();
let value = run_cancellable(
&cancel,
abort,
manager.read_resource(&server, &uri, &cancel),
)
.await
.map_err(tool_error)?;
json_result(value)
}
"prompts" => {
let server = required_string(&args, "server")?.to_string();
let prompts = run_cancellable(&cancel, abort, manager.list_prompts(&server, &cancel))
.await
.map_err(tool_error)?;
json_result(prompts)
}
"get_prompt" => {
let server = required_string(&args, "server")?.to_string();
let name = required_string(&args, "name")?.to_string();
let arguments = args.get("arguments").cloned().unwrap_or(Value::Null);
let value = run_cancellable(
&cancel,
abort,
manager.get_prompt(&server, &name, arguments, &cancel),
)
.await
.map_err(tool_error)?;
json_result(value)
}
other => Err(ToolError(format!("unknown MCP action `{other}`"))),
}
}
async fn run_cancellable<F, T>(
cancel: &CancellationToken,
abort: Arc<AtomicBool>,
future: F,
) -> Result<T, McpError>
where
F: Future<Output = Result<T, McpError>>,
{
let mut future = Box::pin(future);
tokio::select! {
result = &mut future => result,
_ = wait_abort(abort) => {
cancel.cancel();
future.await
}
}
}
async fn wait_abort(abort: Arc<AtomicBool>) {
loop {
if abort.load(Ordering::Relaxed) {
return;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn tool_error(error: McpError) -> ToolError {
ToolError(error.to_string())
}
fn required_string<'a>(args: &'a Value, key: &str) -> Result<&'a str, ToolError> {
args.get(key)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.ok_or_else(|| ToolError(format!("MCP action needs `{key}`")))
}
fn optional_string<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
args.get(key)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
}
fn json_result(value: impl Serialize) -> Result<ToolResult, ToolError> {
let details = serde_json::to_value(&value).map_err(|e| ToolError(e.to_string()))?;
let text = serde_json::to_string_pretty(&value).map_err(|e| ToolError(e.to_string()))?;
Ok(ToolResult {
text: limit_text(text),
details: Some(details),
..Default::default()
})
}
fn convert_content(content: Vec<McpContent>) -> (String, Vec<ToolResultAttachment>) {
let mut texts: Vec<String> = Vec::with_capacity(content.len());
let mut attachments = Vec::new();
for block in content {
match block {
McpContent::Text(text) => texts.push(limit_text(text.text)),
McpContent::Image(image) => attachments.push(ToolResultAttachment {
data_base64: image.data,
mime_type: image.mime_type,
original_size: None,
converted_from: None,
}),
McpContent::Audio(audio) => texts.push(format!(
"[MCP audio content: {}; {} base64 bytes]",
audio.mime_type,
audio.data.len()
)),
McpContent::Resource(resource) => {
texts.push(limit_text(
serde_json::to_string_pretty(&resource).unwrap_or_default(),
));
}
McpContent::ResourceLink(resource) => {
texts.push(format!(
"MCP resource: {} ({})",
resource.name, resource.uri
));
}
other => {
texts.push(limit_text(
serde_json::to_string_pretty(&other).unwrap_or_default(),
));
}
}
}
if texts.is_empty() && attachments.is_empty() {
texts.push("MCP tool completed with no content.".to_string());
}
let mut parts = Vec::new();
for attachment in &attachments {
parts.push(format!("[image: {}]", attachment.mime_type));
}
parts.extend(texts);
(parts.join("\n"), attachments)
}
fn limit_text(mut text: String) -> String {
if text.len() <= MAX_TEXT_BYTES {
return text;
}
let mut end = MAX_TEXT_BYTES;
while !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
text.push_str(&format!("\n\n[MCP output was truncated by {APP_NAME}.]\n"));
text
}
fn lookup_server<'a>(loaded: &'a LoadedConfig, name: &str) -> Result<&'a ServerEntry, String> {
loaded
.config
.mcp_servers
.get(name)
.ok_or_else(|| format!("MCP server `{name}` is not configured"))
}
fn http_server<'a>(loaded: &'a LoadedConfig, name: &str) -> Result<&'a ServerEntry, String> {
let server = lookup_server(loaded, name)?;
if server.url.is_none() {
return Err(format!(
"MCP server `{name}` is a stdio server and does not use OAuth"
));
}
Ok(server)
}
fn load_config() -> Result<LoadedConfig, String> {
let cwd = cwd();
let trusted = is_project_trusted(&cwd, &agent_dir());
load_config_with_declarations(&cwd, trusted)
}
fn load_config_with_declarations(cwd: &Path, trusted: bool) -> Result<LoadedConfig, String> {
let mut loaded = config::load(cwd, trusted).map_err(|e| e.to_string())?;
let errors = config::apply_declared_servers(&mut loaded, extensions::declared_mcp_servers());
if !errors.is_empty() {
notify_plain(errors.join("; "), UiNotifyLevel::Warning);
}
Ok(loaded)
}
fn command_mcp(st: &mut App, raw: &str) -> bool {
let arg = command_arg(raw).trim();
let (sub, rest) = match arg.split_once(char::is_whitespace) {
Some((a, b)) => (a, b.trim()),
None => (arg, ""),
};
match sub {
"" | "status" => {
st.push_msg(render_status(), MsgLevel::Info);
}
"list" => {
st.push_msg(render_list(), MsgLevel::Info);
}
"tools" => {
let server = (!rest.is_empty()).then_some(rest);
st.push_msg(render_cached_tools(server), MsgLevel::Info);
}
"reload" => {
invalidate_manager();
extensions::request_ui(ExtensionUiRequest::RebuildTools);
st.push_msg(
"MCP config reloaded; cached connections dropped.".to_string(),
MsgLevel::Success,
);
}
"enable" | "disable" => {
let enabled = sub == "enable";
let mut parts = rest.split_whitespace();
let Some(server) = parts.next() else {
st.push_msg(
format!("Usage: /mcp {sub} <server> [user|project]"),
MsgLevel::Warning,
);
return false;
};
let scope = match parts.next() {
None | Some("project") => config::ConfigScope::Project,
Some("user") => config::ConfigScope::User,
Some(other) => {
st.push_msg(
format!("Unknown scope `{other}`; use user or project."),
MsgLevel::Warning,
);
return false;
}
};
let loaded = match load_config() {
Ok(loaded) => loaded,
Err(error) => {
st.push_msg(format!("MCP config error: {error}"), MsgLevel::Error);
return false;
}
};
if !loaded.config.mcp_servers.contains_key(server) {
st.push_msg(
format!("MCP server `{server}` is not configured."),
MsgLevel::Warning,
);
return false;
}
if let Err(error) = config::set_disabled(&loaded.paths, scope, server, !enabled) {
st.push_msg(format!("MCP config error: {error}"), MsgLevel::Error);
return false;
}
invalidate_manager();
extensions::request_ui(ExtensionUiRequest::RebuildTools);
let scope_label = match scope {
config::ConfigScope::User => "user config",
config::ConfigScope::Project => "project config",
};
if scope == config::ConfigScope::Project && !is_project_trusted(&cwd(), &agent_dir()) {
st.push_msg(
"This project is not trusted; the override will not take effect until you approve it."
.to_string(),
MsgLevel::Warning,
);
}
st.push_msg(
format!(
"MCP server `{server}` {} in {scope_label}.",
if enabled { "enabled" } else { "disabled" }
),
MsgLevel::Success,
);
}
"test" => {
if rest.is_empty() {
st.push_msg("Usage: /mcp test <server>".to_string(), MsgLevel::Warning);
return false;
}
spawn_test(rest.to_string());
st.push_msg(
format!("Connecting to MCP server `{rest}`…"),
MsgLevel::Info,
);
}
"logout" => {
if rest.is_empty() {
st.push_msg("Usage: /mcp logout <server>".to_string(), MsgLevel::Warning);
return false;
}
spawn_logout(rest.to_string());
st.push_msg(
format!("Removing MCP OAuth credentials for `{rest}`…"),
MsgLevel::Info,
);
}
"login" => {
let (server, callback) = match rest.split_once(char::is_whitespace) {
Some((s, c)) => (s.trim(), Some(c.trim().to_string())),
None => (rest, None),
};
if server.is_empty() {
st.push_msg(
"Usage: /mcp login <server> [redirect-url]".to_string(),
MsgLevel::Warning,
);
return false;
}
match http_server(&load_config().unwrap_or_else(|_| empty_loaded()), server) {
Ok(entry) => {
if entry.auth_mode() != Some(AuthMode::OAuth) {
st.push_msg(
format!(
"MCP server `{server}` does not use OAuth (set auth=\"oauth\" or an oauth block)."
),
MsgLevel::Warning,
);
return false;
}
}
Err(error) => {
st.push_msg(error, MsgLevel::Error);
return false;
}
}
spawn_login(server.to_string(), callback);
st.push_msg(
format!("Starting MCP OAuth login for `{server}`…"),
MsgLevel::Info,
);
}
other => {
st.push_msg(
format!("Unknown MCP subcommand `{other}`. Usage: /mcp [{SUBCOMMAND_NAMES}]"),
MsgLevel::Warning,
);
}
}
false
}
fn empty_loaded() -> LoadedConfig {
let mut loaded = LoadedConfig {
config: config::McpConfig::default(),
sources: Default::default(),
paths: config::ConfigPaths::discover(&cwd()),
};
_ = config::apply_declared_servers(&mut loaded, extensions::declared_mcp_servers());
loaded
}
fn render_status() -> String {
let loaded = match load_config() {
Ok(loaded) => loaded,
Err(error) => return format!("MCP config error: {error}"),
};
if loaded.config.mcp_servers.is_empty() {
return "No MCP servers are configured. Add one to ~/.agents/mcp.json or agent_dir()/extensions/mcp/mcp.json (project .mcp.json also works when trusted).".to_string();
}
let cache = manager::cache_counts(&config::cache_path());
let mut lines = vec!["MCP servers:".to_string()];
for (name, server) in &loaded.config.mcp_servers {
let transport = server
.url
.as_deref()
.map(|url| format!("http {url}"))
.unwrap_or_else(|| format!("stdio {}", server.command.as_deref().unwrap_or("")));
let state = if server.disabled {
"disabled"
} else {
"enabled"
};
let counts = cache.get(name).copied().unwrap_or_default();
let exposure = server.exposure.unwrap_or_default().as_str();
let overrides: Vec<String> = server
.tool_exposure
.iter()
.map(|(tool, value)| format!("{tool}={}", value.as_str()))
.collect();
let overrides = if overrides.is_empty() {
String::new()
} else {
format!(" [{}]", overrides.join(" "))
};
lines.push(format!(
" {name}\t{state}\t{transport}\texposure={exposure}{overrides}\ttools={} resources={} prompts={}{}",
counts.0,
counts.1,
counts.2,
described(server)
));
}
lines.push("Use the `mcp` tool (or `/mcp test <server>`) to connect and refresh.".to_string());
lines.join("\n")
}
fn render_list() -> String {
let loaded = match load_config() {
Ok(loaded) => loaded,
Err(error) => return format!("MCP config error: {error}"),
};
if loaded.config.mcp_servers.is_empty() {
return "No MCP servers are configured.".to_string();
}
let mut lines = Vec::new();
for (name, server) in &loaded.config.mcp_servers {
let transport = server
.url
.as_deref()
.map(|url| format!("http {url}"))
.unwrap_or_else(|| format!("stdio {}", server.command.as_deref().unwrap_or("")));
lines.push(format!(
"{name} [{}] {transport}{}",
if server.disabled {
"disabled"
} else {
"enabled"
},
described(server)
));
for source in loaded.source_labels(name) {
lines.push(format!(" from {source}"));
}
}
lines.join("\n")
}
fn described(server: &ServerEntry) -> String {
match server.description.as_deref().map(str::trim) {
Some(description) if !description.is_empty() => format!("\t{description}"),
_ => String::new(),
}
}
fn render_cached_tools(server: Option<&str>) -> String {
let cache = manager::cache_entries(&config::cache_path());
let mut tools: Vec<CachedTool> = cache
.into_values()
.flatten()
.filter(|tool| server.is_none_or(|name| tool.server == name))
.collect();
if tools.is_empty() {
return match server {
Some(name) => format!(
"No cached tools for `{name}`. Run `/mcp test {name}` or use the `mcp` tool's `list` action."
),
None => "No cached MCP tools yet. Use the `mcp` tool's `list`/`search` action to populate the cache.".to_string(),
};
}
tools.sort_by_key(|tool| (tool.server.clone(), tool.name.clone()));
tools
.into_iter()
.map(|tool| {
format!(
"{}/{} — {}",
tool.server,
tool.name,
tool.description.as_deref().unwrap_or("(no description)")
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn spawn_test(server: String) {
tokio::spawn(async move {
let cwd = cwd();
let result = async {
let trusted = is_project_trusted(&cwd, &agent_dir());
let loaded = load_config_with_declarations(&cwd, trusted)?;
lookup_server(&loaded, &server)?;
let manager = McpManager::new(loaded).map_err(|e| e.to_string())?;
let cancel = CancellationToken::new();
let tools = manager
.list_tools(Some(&server), &cancel)
.await
.map_err(|e| e.to_string())?;
manager.disconnect_all().await;
Ok::<usize, String>(tools.len())
}
.await;
match result {
Ok(count) => notify_plain(
format!("Connected to MCP server `{server}`. Found {count} tools."),
UiNotifyLevel::Success,
),
Err(error) => notify_plain(
format!("MCP server `{server}` failed: {error}"),
UiNotifyLevel::Error,
),
}
});
}
fn spawn_logout(server: String) {
tokio::spawn(async move {
let result = async {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let trusted = is_project_trusted(&cwd, &agent_dir());
let loaded = load_config_with_declarations(&cwd, trusted)?;
let entry = http_server(&loaded, &server)?;
let url = entry
.url
.clone()
.ok_or_else(|| "MCP URL is missing".to_string())?;
let existed = oauth::logout(&server, &url)
.await
.map_err(|e| e.to_string())?;
Ok::<bool, String>(existed)
}
.await;
match result {
Ok(true) => notify_plain(
format!("Removed MCP OAuth credentials for `{server}`."),
UiNotifyLevel::Success,
),
Ok(false) => notify_plain(
format!("No MCP OAuth credentials are saved for `{server}`."),
UiNotifyLevel::Info,
),
Err(error) => notify_plain(error, UiNotifyLevel::Error),
}
});
}
fn spawn_login(server: String, callback: Option<String>) {
let cancel = CancellationToken::new();
let _guard =
extensions::register_background_cancel(format!("mcp-login:{server}"), cancel.clone());
tokio::spawn(async move {
let result = match callback {
Some(callback) => finish_login_from_url(&server, &callback).await,
None => login_with_browser(&server, &cancel).await,
};
if cancel.is_cancelled() {
notify_plain(
format!("MCP OAuth sign-in for `{server}` was cancelled."),
UiNotifyLevel::Info,
);
return;
}
match result {
Ok(()) => notify_plain(
format!("Saved OAuth credentials for MCP server `{server}`."),
UiNotifyLevel::Success,
),
Err(error) => notify_plain(error, UiNotifyLevel::Error),
}
});
}
async fn login_with_browser(server: &str, cancel: &CancellationToken) -> Result<(), String> {
let cwd = cwd();
let trusted = is_project_trusted(&cwd, &agent_dir());
let loaded = load_config_with_declarations(&cwd, trusted)?;
let entry = http_server(&loaded, server)?;
let url = entry
.url
.clone()
.ok_or_else(|| "MCP URL is missing".to_string())?;
let oauth_config = entry.oauth.clone().unwrap_or_default();
if oauth_config.grant_type == config::OAuthGrantType::ClientCredentials {
return oauth::login_client_credentials_with_cancel(
server,
&url,
&oauth_config,
None,
Some(cancel.clone()),
)
.await
.map_err(|e| e.to_string());
}
let challenge = tokio::select! {
biased;
_ = cancel.cancelled() => return Err("MCP OAuth sign-in was cancelled".to_string()),
result = tokio::time::timeout(
Duration::from_secs(15),
manager::probe_oauth_challenge(entry),
) => result,
}
.map_err(|_| "MCP OAuth discovery timed out".to_string())?
.map_err(|e| e.to_string())?;
let pending = oauth::begin_login_with_cancel(
server,
&url,
&oauth_config,
challenge.as_deref(),
None,
Some(cancel.clone()),
)
.await
.map_err(|e| e.to_string())?;
let redirect_uri = pending.redirect_uri.clone();
notify_plain(
format!(
"Open this URL to sign in to `{server}`:\n{}",
pending.authorization_url
),
UiNotifyLevel::Info,
);
let Some(listener) = callback_listener(&redirect_uri).await? else {
return Err(format!(
"Cannot listen on `{redirect_uri}` for the OAuth callback. Re-run as `/mcp login {server} <redirect-url>` after signing in."
));
};
if webbrowser::open(&pending.authorization_url).is_err() {
notify_plain(
"The browser did not open. Open the URL manually.",
UiNotifyLevel::Warning,
);
}
let callback = receive_callback(listener, &redirect_uri, cancel).await?;
oauth::finish_login(pending, &callback)
.await
.map_err(|e| e.to_string())
}
async fn finish_login_from_url(server: &str, _callback: &str) -> Result<(), String> {
let cwd = cwd();
let trusted = is_project_trusted(&cwd, &agent_dir());
let loaded = load_config_with_declarations(&cwd, trusted)?;
let entry = http_server(&loaded, server)?;
let url = entry
.url
.clone()
.ok_or_else(|| "MCP URL is missing".to_string())?;
let oauth_config = entry.oauth.clone().unwrap_or_default();
if oauth_config.grant_type == OAuthGrantType::ClientCredentials {
return oauth::login_client_credentials(server, &url, &oauth_config)
.await
.map_err(|e| e.to_string());
}
Err(format!(
"Manual redirect-URL completion is not supported for authorization_code (PKCE state is in-memory). Run `/mcp login {server}` and let the local callback listener complete it."
))
}
pub async fn callback_listener(redirect_uri: &str) -> Result<Option<TcpListener>, String> {
let uri = url::Url::parse(redirect_uri).map_err(|e| e.to_string())?;
let Some(host) = uri.host_str() else {
return Ok(None);
};
if !matches!(host, "localhost" | "127.0.0.1" | "::1") {
return Ok(None);
}
let port = uri
.port_or_known_default()
.ok_or_else(|| "OAuth redirect URI has no port".to_string())?;
let address = if host == "::1" {
format!("[::1]:{port}")
} else {
format!("127.0.0.1:{port}")
};
let listener = TcpListener::bind(address)
.await
.map_err(|e| format!("bind {redirect_uri}: {e}"))?;
Ok(Some(listener))
}
pub async fn receive_callback(
listener: TcpListener,
redirect_uri: &str,
cancel: &CancellationToken,
) -> Result<String, String> {
let accepted = tokio::select! {
biased;
_ = cancel.cancelled() => return Err("MCP OAuth sign-in was cancelled".to_string()),
result = tokio::time::timeout(Duration::from_secs(600), listener.accept()) => result,
};
let (mut socket, _) = accepted
.map_err(|_| "MCP OAuth callback timed out".to_string())?
.map_err(|e| e.to_string())?;
let mut buffer = vec![0_u8; 16 * 1024];
let length = socket.read(&mut buffer).await.map_err(|e| e.to_string())?;
let request =
std::str::from_utf8(&buffer[..length]).map_err(|_| "invalid OAuth callback".to_string())?;
let target = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.ok_or_else(|| "invalid OAuth callback request line".to_string())?;
let base = Url::parse(redirect_uri).map_err(|e| e.to_string())?;
let callback = base.join(target).map_err(|e| e.to_string())?.to_string();
_ = socket
.write_all(
&format!("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n<!doctype html><title>{APP_NAME} MCP login</title><p>Login is complete. You can close this page.</p>").into_bytes()
)
.await;
Ok(callback)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{AgentDirGuard, HomeGuard};
fn lock() -> std::sync::MutexGuard<'static, ()> {
crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner())
}
#[test]
fn gateway_tool_name_matches_core_constant() {
assert_eq!(TOOL, crate::core::tool_names::MCP_GATEWAY_TOOL_NAME);
}
#[tokio::test]
async fn receive_callback_aborts_on_cancel() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let cancel = CancellationToken::new();
cancel.cancel();
let error = receive_callback(listener, "http://127.0.0.1:1/callback", &cancel)
.await
.unwrap_err();
assert!(error.contains("cancelled"), "{error}");
}
#[test]
fn text_limit_keeps_utf8_valid() {
let text = "é".repeat(MAX_TEXT_BYTES);
let limited = limit_text(text);
assert!(limited.is_char_boundary(limited.len()));
assert!(limited.contains("truncated"));
}
#[test]
fn aggregate_exposure_picks_most_visible() {
let entry = |exposure: Option<config::McpExposure>| {
let mut server = ServerEntry {
url: Some("https://example.com/mcp".to_string()),
..Default::default()
};
server.exposure = exposure;
server
};
assert_eq!(
aggregate_exposure(&[entry(None)]),
ToolExposure::Direct,
"未配置 exposure 时保持 direct(prux 既有行为)"
);
assert_eq!(
aggregate_exposure(&[
entry(Some(config::McpExposure::Codemode)),
entry(Some(config::McpExposure::Direct)),
]),
ToolExposure::Direct
);
assert_eq!(
aggregate_exposure(&[
entry(Some(config::McpExposure::Codemode)),
entry(Some(config::McpExposure::Deferred)),
]),
ToolExposure::Deferred
);
assert_eq!(
aggregate_exposure(&[entry(Some(config::McpExposure::CodemodeDeferred))]),
ToolExposure::Codemode
);
assert_eq!(
aggregate_exposure(&[entry(Some(config::McpExposure::Hidden))]),
ToolExposure::Hidden
);
}
#[test]
fn tool_visibility_follows_exposure_and_caller() {
let mut server = ServerEntry {
url: Some("https://example.com/mcp".to_string()),
..Default::default()
};
server
.tool_exposure
.insert("read_*".to_string(), config::McpExposure::Hidden);
server
.tool_exposure
.insert("write".to_string(), config::McpExposure::Codemode);
let servers = BTreeMap::from([("docs".to_string(), server)]);
assert!(tool_visible(&servers, false, "docs", "search"));
assert!(!tool_visible(&servers, true, "docs", "read_file"));
assert!(!tool_visible(&servers, false, "docs", "write"));
assert!(tool_visible(&servers, true, "docs", "write"));
assert!(tool_visible(&servers, false, "other", "anything"));
let err = require_visible(&servers, false, "docs", "write").unwrap_err();
assert!(err.0.contains("exposure: codemode"), "got: {}", err.0);
assert!(require_visible(&servers, true, "docs", "write").is_ok());
}
#[test]
fn required_values_are_checked() {
let input = json!({"action": "search", "query": "files"});
assert_eq!(required_string(&input, "action").unwrap(), "search");
assert_eq!(required_string(&input, "query").unwrap(), "files");
assert!(required_string(&input, "server").is_err());
assert_eq!(optional_string(&input, "server"), None);
}
#[test]
fn content_conversion_marks_images_and_audio() {
use rmcp::model::{AudioContent, ContentBlock, ImageContent, TextContent};
let content = vec![
ContentBlock::Text(TextContent::new("hello")),
ContentBlock::Image(ImageContent::new("ZGF0YQ==", "image/png")),
ContentBlock::Audio(AudioContent::new("YXVkaW8=", "audio/wav")),
];
let (text, attachments) = convert_content(content);
assert!(text.contains("hello"));
assert!(text.contains("[image: image/png]"));
assert!(text.contains("MCP audio content"));
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].mime_type, "image/png");
}
#[test]
fn server_summary_lists_described_servers() {
let described = |description: Option<&str>| ServerEntry {
description: description.map(str::to_string),
..Default::default()
};
let servers = vec![
("a".to_string(), described(Some("Alpha tools"))),
("b".to_string(), described(None)),
("c".to_string(), described(Some(" "))),
];
assert_eq!(
server_summary(&servers),
"Configured servers: a — Alpha tools"
);
assert!(server_summary(&[("a".to_string(), described(None))]).is_empty());
let long = "x".repeat(SERVER_DESCRIPTION_CHARS + 50);
let summary = server_summary(&[("a".to_string(), described(Some(&long)))]);
assert_eq!(
summary.chars().filter(|c| *c == 'x').count(),
SERVER_DESCRIPTION_CHARS
);
}
#[test]
fn servers_section_lists_indirect_servers() {
let entry = |exposure: Option<McpExposure>, description: Option<&str>| ServerEntry {
command: Some("echo".to_string()),
exposure,
description: description.map(str::to_string),
..Default::default()
};
let named = |name: &str, server: ServerEntry| (name.to_string(), server);
let direct = vec![named(
"direct-one",
entry(None, Some("Hidden from the section")),
)];
assert_eq!(render_servers_section(&direct), None);
let servers = vec![
named("direct-one", entry(None, None)),
named(
"zeta",
entry(
Some(McpExposure::Codemode),
Some("Zeta tools\nsecond line is dropped"),
),
),
named("alpha", entry(Some(McpExposure::Deferred), None)),
named(
"mixed",
ServerEntry {
command: Some("echo".to_string()),
tool_exposure: [("x".to_string(), McpExposure::Codemode)].into(),
..Default::default()
},
),
named("hidden-one", entry(Some(McpExposure::Hidden), None)),
];
let section = render_servers_section(&servers).unwrap();
assert!(section.starts_with("<mcp_servers>\n"), "{section}");
assert!(section.ends_with("</mcp_servers>"), "{section}");
assert!(
section.contains(
"MCP servers whose tools are not declared to you. Call the tools of `codemode` servers from codemode scripts. Load the tools of `tool_search` servers with `tool_search`."
),
"{section}"
);
assert!(section.contains("- alpha (tool_search)"), "{section}");
assert!(section.contains("- mixed (codemode)"), "{section}");
assert!(
section.contains("- zeta (codemode): Zeta tools"),
"{section}"
);
assert!(
!section.contains("second line") && !section.contains("direct-one"),
"只取说明第一行、且不列 direct 服务器: {section}"
);
assert!(
!section.contains("hidden-one"),
"hidden 服务器不列: {section}"
);
assert!(
section.find("- alpha").unwrap() < section.find("- mixed").unwrap()
&& section.find("- mixed").unwrap() < section.find("- zeta").unwrap(),
"按名字排序: {section}"
);
assert!(omitted_line(0).is_none());
assert!(omitted_line(1).unwrap().contains("1 more server;"));
assert!(omitted_line(3).unwrap().contains("3 more servers;"));
let long = "x".repeat(600);
let section = render_servers_section(&[named(
"wide",
entry(Some(McpExposure::Codemode), Some(&long)),
)])
.unwrap();
assert_eq!(
section.matches('x').count(),
MAX_SERVER_DESCRIPTION_CHARS - 1,
"超长说明截断到单台上限(末尾一个字符换成省略号): {section}"
);
assert!(section.contains('…'), "{section}");
}
#[test]
fn command_enable_disable_writes_scope_override() {
let _lock = lock();
let _dir = AgentDirGuard::temp();
let _home = HomeGuard::temp();
let paths = config::ConfigPaths::discover(Path::new("."));
config::add_server(
&paths,
config::ConfigScope::User,
"demo",
ServerEntry {
command: Some("echo".to_string()),
..Default::default()
},
)
.unwrap();
let mut st = App::new();
let last = |st: &App| {
st.system_messages
.last()
.map(|(_, s)| crate::modes::interactive::app::sys_spans_text(&s.spans))
.unwrap_or_default()
};
assert!(!command_mcp(&mut st, "mcp disable demo user"));
let disabled_msg = last(&st);
let written = std::fs::read_to_string(&paths.app_global).unwrap();
assert!(!command_mcp(&mut st, "mcp enable demo user"));
let written_enabled = std::fs::read_to_string(&paths.app_global).unwrap();
assert!(!command_mcp(&mut st, "mcp disable nope user"));
let not_configured_msg = last(&st);
let written_after = std::fs::read_to_string(&paths.app_global).unwrap();
assert!(!command_mcp(&mut st, "mcp disable demo bogus"));
let unknown_scope_msg = last(&st);
while crate::core::extensions::take_pending_ui().is_some() {}
assert!(
disabled_msg.contains("disabled in user config"),
"{disabled_msg}"
);
assert!(written.contains("\"disabled\": true"), "{written}");
assert!(
written_enabled.contains("\"disabled\": false"),
"{written_enabled}"
);
assert!(
not_configured_msg.contains("is not configured"),
"{not_configured_msg}"
);
assert!(!written_after.contains("nope"), "{written_after}");
assert!(
unknown_scope_msg.contains("Unknown scope"),
"{unknown_scope_msg}"
);
}
#[test]
fn command_usage_is_reported() {
let _dir = AgentDirGuard::temp();
let mut st = App::new();
command_mcp(&mut st, "mcp bogus");
let last = st
.system_messages
.last()
.map(|(_, s)| crate::modes::interactive::app::sys_spans_text(&s.spans))
.unwrap_or_default();
assert!(last.contains("Unknown MCP subcommand"), "got: {last}");
}
#[test]
fn status_and_list_handle_empty_config() {
let _lock = lock();
let _dir = AgentDirGuard::temp();
let _home = HomeGuard::temp();
assert!(render_status().contains("No MCP servers are configured"));
assert!(render_list().contains("No MCP servers are configured"));
}
#[test]
fn startup_message_reports_enabled_and_blocked_counts() {
let _lock = lock();
let _dir = AgentDirGuard::temp();
let _home = HomeGuard::temp();
let project = tempfile::tempdir().unwrap();
let cwd = project.path();
let paths = config::ConfigPaths::discover(cwd);
config::add_server(
&paths,
config::ConfigScope::User,
"global",
ServerEntry {
command: Some("echo".to_string()),
..Default::default()
},
)
.unwrap();
crate::core::project_trust::set_session_trust(cwd, false);
std::fs::write(
&paths.shared_project,
r#"{"mcpServers":{"local":{"command":"echo"}}}"#,
)
.unwrap();
let message = startup_message(cwd.to_str().unwrap()).unwrap();
assert_eq!(
message,
"MCP: 1 servers enabled (1 blocked by project trust)"
);
crate::core::project_trust::clear_session_trust(cwd);
}
#[test]
fn startup_message_is_silent_without_servers() {
let _lock = lock();
let _dir = AgentDirGuard::temp();
let _home = HomeGuard::temp();
let project = tempfile::tempdir().unwrap();
let cwd = project.path();
crate::core::project_trust::set_session_trust(cwd, true);
assert!(startup_message(cwd.to_str().unwrap()).is_none());
crate::core::project_trust::clear_session_trust(cwd);
}
#[test]
fn tool_is_hidden_without_configured_servers() {
let _lock = lock();
let _dir = AgentDirGuard::temp();
let _home = HomeGuard::temp();
assert!(Mcp.tools().is_empty(), "无服务器时应不注入 mcp 工具");
let cwd = std::env::current_dir().unwrap();
let paths = config::ConfigPaths::discover(&cwd);
config::add_server(
&paths,
config::ConfigScope::User,
"demo",
ServerEntry {
command: Some("echo".to_string()),
..Default::default()
},
)
.unwrap();
let tools = Mcp.tools();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "mcp");
}
#[test]
fn extension_declared_servers_join_config_and_listing() {
let _lock = lock();
let _dir = AgentDirGuard::temp();
let _home = HomeGuard::temp();
let cwd = std::env::current_dir().unwrap();
crate::core::extensions::register_extension(DeclaredServerProbe);
assert!(crate::core::extensions::set_extension_enabled(
PROBE_EXT, true
));
let servers = server_entries(cwd.to_str().unwrap());
let declared = servers.get("probe-srv").expect("声明的服务器进连接列表");
assert_eq!(declared.command.as_deref(), Some("echo"));
assert_eq!(Mcp.tools().len(), 1, "有(声明的)服务器时注入 mcp 工具");
let list = render_list();
assert!(list.contains("probe-srv"), "got: {list}");
assert!(list.contains("from extension probe-ext"), "got: {list}");
assert!(crate::core::extensions::set_extension_enabled(
PROBE_EXT, false
));
assert!(!server_entries(cwd.to_str().unwrap()).contains_key("probe-srv"));
assert!(!render_list().contains("probe-srv"), "禁用后不应列出");
assert!(crate::core::extensions::unregister_extension(PROBE_EXT));
}
struct DeclaredServerProbe;
impl Extension for DeclaredServerProbe {
fn name(&self) -> &str {
PROBE_EXT
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn mcp_servers(&self) -> Vec<crate::core::extensions::DeclaredMcpServer> {
vec![(
"probe-srv".to_string(),
serde_json::json!({ "command": "echo", "args": ["hello"] }),
)]
}
}
const PROBE_EXT: &str = "probe-ext";
#[test]
fn status_lists_configured_server() {
let _dir = AgentDirGuard::temp();
let _home = HomeGuard::temp();
let cwd = std::env::current_dir().unwrap();
let paths = config::ConfigPaths::discover(&cwd);
config::add_server(
&paths,
config::ConfigScope::User,
"demo",
config::ServerEntry {
command: Some("echo".to_string()),
..Default::default()
},
)
.unwrap();
let status = render_status();
assert!(status.contains("demo"), "got: {status}");
assert!(status.contains("stdio echo"), "got: {status}");
let list = render_list();
assert!(list.contains("demo"), "got: {list}");
assert!(
list.contains(&format!("{} global", crate::APP_NAME)),
"got: {list}"
);
}
}