|
@@ -5,7 +5,10 @@ use std::fs;
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
use plugins::{PluginError, PluginManager, PluginSummary};
|
|
use plugins::{PluginError, PluginManager, PluginSummary};
|
|
|
-use runtime::{compact_session, CompactionConfig, Session};
|
|
|
|
|
|
|
+use runtime::{
|
|
|
|
|
+ compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig,
|
|
|
|
|
+ ScopedMcpServerConfig, Session,
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
pub struct CommandManifestEntry {
|
|
pub struct CommandManifestEntry {
|
|
@@ -117,6 +120,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|
|
argument_hint: Some("[env|hooks|model|plugins]"),
|
|
argument_hint: Some("[env|hooks|model|plugins]"),
|
|
|
resume_supported: true,
|
|
resume_supported: true,
|
|
|
},
|
|
},
|
|
|
|
|
+ SlashCommandSpec {
|
|
|
|
|
+ name: "mcp",
|
|
|
|
|
+ aliases: &[],
|
|
|
|
|
+ summary: "Inspect configured MCP servers",
|
|
|
|
|
+ argument_hint: Some("[list|show <server>|help]"),
|
|
|
|
|
+ resume_supported: true,
|
|
|
|
|
+ },
|
|
|
SlashCommandSpec {
|
|
SlashCommandSpec {
|
|
|
name: "memory",
|
|
name: "memory",
|
|
|
aliases: &[],
|
|
aliases: &[],
|
|
@@ -272,6 +282,10 @@ pub enum SlashCommand {
|
|
|
Config {
|
|
Config {
|
|
|
section: Option<String>,
|
|
section: Option<String>,
|
|
|
},
|
|
},
|
|
|
|
|
+ Mcp {
|
|
|
|
|
+ action: Option<String>,
|
|
|
|
|
+ target: Option<String>,
|
|
|
|
|
+ },
|
|
|
Memory,
|
|
Memory,
|
|
|
Init,
|
|
Init,
|
|
|
Diff,
|
|
Diff,
|
|
@@ -393,6 +407,7 @@ pub fn validate_slash_command_input(
|
|
|
"config" => SlashCommand::Config {
|
|
"config" => SlashCommand::Config {
|
|
|
section: parse_config_section(&args)?,
|
|
section: parse_config_section(&args)?,
|
|
|
},
|
|
},
|
|
|
|
|
+ "mcp" => parse_mcp_command(&args)?,
|
|
|
"memory" => {
|
|
"memory" => {
|
|
|
validate_no_args(command, &args)?;
|
|
validate_no_args(command, &args)?;
|
|
|
SlashCommand::Memory
|
|
SlashCommand::Memory
|
|
@@ -551,6 +566,39 @@ fn parse_session_command(args: &[&str]) -> Result<SlashCommand, SlashCommandPars
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+fn parse_mcp_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
|
|
|
|
|
+ match args {
|
|
|
|
|
+ [] => Ok(SlashCommand::Mcp {
|
|
|
|
|
+ action: None,
|
|
|
|
|
+ target: None,
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["list"] => Ok(SlashCommand::Mcp {
|
|
|
|
|
+ action: Some("list".to_string()),
|
|
|
|
|
+ target: None,
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["list", ..] => Err(usage_error("mcp list", "")),
|
|
|
|
|
+ ["show"] => Err(usage_error("mcp show", "<server>")),
|
|
|
|
|
+ ["show", target] => Ok(SlashCommand::Mcp {
|
|
|
|
|
+ action: Some("show".to_string()),
|
|
|
|
|
+ target: Some((*target).to_string()),
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["show", ..] => Err(command_error(
|
|
|
|
|
+ "Unexpected arguments for /mcp show.",
|
|
|
|
|
+ "mcp",
|
|
|
|
|
+ "/mcp show <server>",
|
|
|
|
|
+ )),
|
|
|
|
|
+ ["help"] | ["-h"] | ["--help"] => Ok(SlashCommand::Mcp {
|
|
|
|
|
+ action: Some("help".to_string()),
|
|
|
|
|
+ target: None,
|
|
|
|
|
+ }),
|
|
|
|
|
+ [action, ..] => Err(command_error(
|
|
|
|
|
+ &format!("Unknown /mcp action '{action}'. Use list, show <server>, or help."),
|
|
|
|
|
+ "mcp",
|
|
|
|
|
+ "/mcp [list|show <server>|help]",
|
|
|
|
|
+ )),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
fn parse_plugin_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
|
|
fn parse_plugin_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
|
|
|
match args {
|
|
match args {
|
|
|
[] => Ok(SlashCommand::Plugins {
|
|
[] => Ok(SlashCommand::Plugins {
|
|
@@ -760,7 +808,7 @@ fn slash_command_category(name: &str) -> &'static str {
|
|
|
| "version" => "Session & visibility",
|
|
| "version" => "Session & visibility",
|
|
|
"compact" | "clear" | "config" | "memory" | "init" | "diff" | "commit" | "pr" | "issue"
|
|
"compact" | "clear" | "config" | "memory" | "init" | "diff" | "commit" | "pr" | "issue"
|
|
|
| "export" | "plugin" => "Workspace & git",
|
|
| "export" | "plugin" => "Workspace & git",
|
|
|
- "agents" | "skills" | "teleport" | "debug-tool-call" => "Discovery & debugging",
|
|
|
|
|
|
|
+ "agents" | "skills" | "teleport" | "debug-tool-call" | "mcp" => "Discovery & debugging",
|
|
|
"bughunter" | "ultraplan" => "Analysis & automation",
|
|
"bughunter" | "ultraplan" => "Analysis & automation",
|
|
|
_ => "Other",
|
|
_ => "Other",
|
|
|
}
|
|
}
|
|
@@ -1113,6 +1161,14 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+pub fn handle_mcp_slash_command(
|
|
|
|
|
+ args: Option<&str>,
|
|
|
|
|
+ cwd: &Path,
|
|
|
|
|
+) -> Result<String, runtime::ConfigError> {
|
|
|
|
|
+ let loader = ConfigLoader::default_for(cwd);
|
|
|
|
|
+ render_mcp_report_for(&loader, cwd, args)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
|
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
|
|
match normalize_optional_args(args) {
|
|
match normalize_optional_args(args) {
|
|
|
None | Some("list") => {
|
|
None | Some("list") => {
|
|
@@ -1134,6 +1190,41 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+fn render_mcp_report_for(
|
|
|
|
|
+ loader: &ConfigLoader,
|
|
|
|
|
+ cwd: &Path,
|
|
|
|
|
+ args: Option<&str>,
|
|
|
|
|
+) -> Result<String, runtime::ConfigError> {
|
|
|
|
|
+ match normalize_optional_args(args) {
|
|
|
|
|
+ None | Some("list") => {
|
|
|
|
|
+ let runtime_config = loader.load()?;
|
|
|
|
|
+ Ok(render_mcp_summary_report(
|
|
|
|
|
+ cwd,
|
|
|
|
|
+ runtime_config.mcp().servers(),
|
|
|
|
|
+ ))
|
|
|
|
|
+ }
|
|
|
|
|
+ Some("-h" | "--help" | "help") => Ok(render_mcp_usage(None)),
|
|
|
|
|
+ Some("show") => Ok(render_mcp_usage(Some("show"))),
|
|
|
|
|
+ Some(args) if args.split_whitespace().next() == Some("show") => {
|
|
|
|
|
+ let mut parts = args.split_whitespace();
|
|
|
|
|
+ let _ = parts.next();
|
|
|
|
|
+ let Some(server_name) = parts.next() else {
|
|
|
|
|
+ return Ok(render_mcp_usage(Some("show")));
|
|
|
|
|
+ };
|
|
|
|
|
+ if parts.next().is_some() {
|
|
|
|
|
+ return Ok(render_mcp_usage(Some(args)));
|
|
|
|
|
+ }
|
|
|
|
|
+ let runtime_config = loader.load()?;
|
|
|
|
|
+ Ok(render_mcp_server_report(
|
|
|
|
|
+ cwd,
|
|
|
|
|
+ server_name,
|
|
|
|
|
+ runtime_config.mcp().get(server_name),
|
|
|
|
|
+ ))
|
|
|
|
|
+ }
|
|
|
|
|
+ Some(args) => Ok(render_mcp_usage(Some(args))),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
#[must_use]
|
|
#[must_use]
|
|
|
pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
|
pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
|
|
let mut lines = vec!["Plugins".to_string()];
|
|
let mut lines = vec!["Plugins".to_string()];
|
|
@@ -1844,6 +1935,111 @@ fn render_skill_install_report(skill: &InstalledSkill) -> String {
|
|
|
lines.join("\n")
|
|
lines.join("\n")
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+fn render_mcp_summary_report(
|
|
|
|
|
+ cwd: &Path,
|
|
|
|
|
+ servers: &BTreeMap<String, ScopedMcpServerConfig>,
|
|
|
|
|
+) -> String {
|
|
|
|
|
+ let mut lines = vec![
|
|
|
|
|
+ "MCP".to_string(),
|
|
|
|
|
+ format!(" Working directory {}", cwd.display()),
|
|
|
|
|
+ format!(" Configured servers {}", servers.len()),
|
|
|
|
|
+ ];
|
|
|
|
|
+ if servers.is_empty() {
|
|
|
|
|
+ lines.push(" No MCP servers configured.".to_string());
|
|
|
|
|
+ return lines.join("\n");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ lines.push(String::new());
|
|
|
|
|
+ for (name, server) in servers {
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ " {name:<16} {transport:<13} {scope:<7} {summary}",
|
|
|
|
|
+ transport = mcp_transport_label(&server.config),
|
|
|
|
|
+ scope = config_source_label(server.scope),
|
|
|
|
|
+ summary = mcp_server_summary(&server.config)
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ lines.join("\n")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn render_mcp_server_report(
|
|
|
|
|
+ cwd: &Path,
|
|
|
|
|
+ server_name: &str,
|
|
|
|
|
+ server: Option<&ScopedMcpServerConfig>,
|
|
|
|
|
+) -> String {
|
|
|
|
|
+ let Some(server) = server else {
|
|
|
|
|
+ return format!(
|
|
|
|
|
+ "MCP\n Working directory {}\n Result server `{server_name}` is not configured",
|
|
|
|
|
+ cwd.display()
|
|
|
|
|
+ );
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let mut lines = vec![
|
|
|
|
|
+ "MCP".to_string(),
|
|
|
|
|
+ format!(" Working directory {}", cwd.display()),
|
|
|
|
|
+ format!(" Name {server_name}"),
|
|
|
|
|
+ format!(" Scope {}", config_source_label(server.scope)),
|
|
|
|
|
+ format!(
|
|
|
|
|
+ " Transport {}",
|
|
|
|
|
+ mcp_transport_label(&server.config)
|
|
|
|
|
+ ),
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ match &server.config {
|
|
|
|
|
+ McpServerConfig::Stdio(config) => {
|
|
|
|
|
+ lines.push(format!(" Command {}", config.command));
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ " Args {}",
|
|
|
|
|
+ format_optional_list(&config.args)
|
|
|
|
|
+ ));
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ " Env keys {}",
|
|
|
|
|
+ format_optional_keys(config.env.keys().cloned().collect())
|
|
|
|
|
+ ));
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ " Tool timeout {}",
|
|
|
|
|
+ config
|
|
|
|
|
+ .tool_call_timeout_ms
|
|
|
|
|
+ .map_or_else(|| "<default>".to_string(), |value| format!("{value} ms"))
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ McpServerConfig::Sse(config) | McpServerConfig::Http(config) => {
|
|
|
|
|
+ lines.push(format!(" URL {}", config.url));
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ " Header keys {}",
|
|
|
|
|
+ format_optional_keys(config.headers.keys().cloned().collect())
|
|
|
|
|
+ ));
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ " Header helper {}",
|
|
|
|
|
+ config.headers_helper.as_deref().unwrap_or("<none>")
|
|
|
|
|
+ ));
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ " OAuth {}",
|
|
|
|
|
+ format_mcp_oauth(config.oauth.as_ref())
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ McpServerConfig::Ws(config) => {
|
|
|
|
|
+ lines.push(format!(" URL {}", config.url));
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ " Header keys {}",
|
|
|
|
|
+ format_optional_keys(config.headers.keys().cloned().collect())
|
|
|
|
|
+ ));
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ " Header helper {}",
|
|
|
|
|
+ config.headers_helper.as_deref().unwrap_or("<none>")
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ McpServerConfig::Sdk(config) => {
|
|
|
|
|
+ lines.push(format!(" SDK name {}", config.name));
|
|
|
|
|
+ }
|
|
|
|
|
+ McpServerConfig::ManagedProxy(config) => {
|
|
|
|
|
+ lines.push(format!(" URL {}", config.url));
|
|
|
|
|
+ lines.push(format!(" Proxy id {}", config.id));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ lines.join("\n")
|
|
|
|
|
+}
|
|
|
fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
|
|
fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
|
|
|
args.map(str::trim).filter(|value| !value.is_empty())
|
|
args.map(str::trim).filter(|value| !value.is_empty())
|
|
|
}
|
|
}
|
|
@@ -1875,6 +2071,95 @@ fn render_skills_usage(unexpected: Option<&str>) -> String {
|
|
|
lines.join("\n")
|
|
lines.join("\n")
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+fn render_mcp_usage(unexpected: Option<&str>) -> String {
|
|
|
|
|
+ let mut lines = vec![
|
|
|
|
|
+ "MCP".to_string(),
|
|
|
|
|
+ " Usage /mcp [list|show <server>|help]".to_string(),
|
|
|
|
|
+ " Direct CLI claw mcp [list|show <server>|help]".to_string(),
|
|
|
|
|
+ " Sources .claw/settings.json, .claw/settings.local.json".to_string(),
|
|
|
|
|
+ ];
|
|
|
|
|
+ if let Some(args) = unexpected {
|
|
|
|
|
+ lines.push(format!(" Unexpected {args}"));
|
|
|
|
|
+ }
|
|
|
|
|
+ lines.join("\n")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn config_source_label(source: ConfigSource) -> &'static str {
|
|
|
|
|
+ match source {
|
|
|
|
|
+ ConfigSource::User => "user",
|
|
|
|
|
+ ConfigSource::Project => "project",
|
|
|
|
|
+ ConfigSource::Local => "local",
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn mcp_transport_label(config: &McpServerConfig) -> &'static str {
|
|
|
|
|
+ match config {
|
|
|
|
|
+ McpServerConfig::Stdio(_) => "stdio",
|
|
|
|
|
+ McpServerConfig::Sse(_) => "sse",
|
|
|
|
|
+ McpServerConfig::Http(_) => "http",
|
|
|
|
|
+ McpServerConfig::Ws(_) => "ws",
|
|
|
|
|
+ McpServerConfig::Sdk(_) => "sdk",
|
|
|
|
|
+ McpServerConfig::ManagedProxy(_) => "managed-proxy",
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn mcp_server_summary(config: &McpServerConfig) -> String {
|
|
|
|
|
+ match config {
|
|
|
|
|
+ McpServerConfig::Stdio(config) => {
|
|
|
|
|
+ if config.args.is_empty() {
|
|
|
|
|
+ config.command.clone()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ format!("{} {}", config.command, config.args.join(" "))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ McpServerConfig::Sse(config) | McpServerConfig::Http(config) => config.url.clone(),
|
|
|
|
|
+ McpServerConfig::Ws(config) => config.url.clone(),
|
|
|
|
|
+ McpServerConfig::Sdk(config) => config.name.clone(),
|
|
|
|
|
+ McpServerConfig::ManagedProxy(config) => format!("{} ({})", config.id, config.url),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn format_optional_list(values: &[String]) -> String {
|
|
|
|
|
+ if values.is_empty() {
|
|
|
|
|
+ "<none>".to_string()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ values.join(" ")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn format_optional_keys(mut keys: Vec<String>) -> String {
|
|
|
|
|
+ if keys.is_empty() {
|
|
|
|
|
+ return "<none>".to_string();
|
|
|
|
|
+ }
|
|
|
|
|
+ keys.sort();
|
|
|
|
|
+ keys.join(", ")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn format_mcp_oauth(oauth: Option<&McpOAuthConfig>) -> String {
|
|
|
|
|
+ let Some(oauth) = oauth else {
|
|
|
|
|
+ return "<none>".to_string();
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let mut parts = Vec::new();
|
|
|
|
|
+ if let Some(client_id) = &oauth.client_id {
|
|
|
|
|
+ parts.push(format!("client_id={client_id}"));
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Some(port) = oauth.callback_port {
|
|
|
|
|
+ parts.push(format!("callback_port={port}"));
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Some(url) = &oauth.auth_server_metadata_url {
|
|
|
|
|
+ parts.push(format!("metadata_url={url}"));
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Some(xaa) = oauth.xaa {
|
|
|
|
|
+ parts.push(format!("xaa={xaa}"));
|
|
|
|
|
+ }
|
|
|
|
|
+ if parts.is_empty() {
|
|
|
|
|
+ "enabled".to_string()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ parts.join(", ")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
#[must_use]
|
|
#[must_use]
|
|
|
pub fn handle_slash_command(
|
|
pub fn handle_slash_command(
|
|
|
input: &str,
|
|
input: &str,
|
|
@@ -1927,6 +2212,7 @@ pub fn handle_slash_command(
|
|
|
| SlashCommand::Cost
|
|
| SlashCommand::Cost
|
|
|
| SlashCommand::Resume { .. }
|
|
| SlashCommand::Resume { .. }
|
|
|
| SlashCommand::Config { .. }
|
|
| SlashCommand::Config { .. }
|
|
|
|
|
+ | SlashCommand::Mcp { .. }
|
|
|
| SlashCommand::Memory
|
|
| SlashCommand::Memory
|
|
|
| SlashCommand::Init
|
|
| SlashCommand::Init
|
|
|
| SlashCommand::Diff
|
|
| SlashCommand::Diff
|
|
@@ -1950,7 +2236,9 @@ mod tests {
|
|
|
validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
|
|
validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
|
|
|
};
|
|
};
|
|
|
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
|
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
|
|
- use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
|
|
|
|
|
|
+ use runtime::{
|
|
|
|
|
+ CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session,
|
|
|
|
|
+ };
|
|
|
use std::fs;
|
|
use std::fs;
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::path::{Path, PathBuf};
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
@@ -2151,6 +2439,20 @@ mod tests {
|
|
|
section: Some("env".to_string())
|
|
section: Some("env".to_string())
|
|
|
}))
|
|
}))
|
|
|
);
|
|
);
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ SlashCommand::parse("/mcp"),
|
|
|
|
|
+ Ok(Some(SlashCommand::Mcp {
|
|
|
|
|
+ action: None,
|
|
|
|
|
+ target: None
|
|
|
|
|
+ }))
|
|
|
|
|
+ );
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ SlashCommand::parse("/mcp show remote"),
|
|
|
|
|
+ Ok(Some(SlashCommand::Mcp {
|
|
|
|
|
+ action: Some("show".to_string()),
|
|
|
|
|
+ target: Some("remote".to_string())
|
|
|
|
|
+ }))
|
|
|
|
|
+ );
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/memory"),
|
|
SlashCommand::parse("/memory"),
|
|
|
Ok(Some(SlashCommand::Memory))
|
|
Ok(Some(SlashCommand::Memory))
|
|
@@ -2299,6 +2601,18 @@ mod tests {
|
|
|
assert!(skills_error.contains(" Usage /skills [list|install <path>|help]"));
|
|
assert!(skills_error.contains(" Usage /skills [list|install <path>|help]"));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn rejects_invalid_mcp_arguments() {
|
|
|
|
|
+ let show_error = parse_error_message("/mcp show alpha beta");
|
|
|
|
|
+ assert!(show_error.contains("Unexpected arguments for /mcp show."));
|
|
|
|
|
+ assert!(show_error.contains(" Usage /mcp show <server>"));
|
|
|
|
|
+
|
|
|
|
|
+ let action_error = parse_error_message("/mcp inspect alpha");
|
|
|
|
|
+ assert!(action_error
|
|
|
|
|
+ .contains("Unknown /mcp action 'inspect'. Use list, show <server>, or help."));
|
|
|
|
|
+ assert!(action_error.contains(" Usage /mcp [list|show <server>|help]"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn renders_help_from_shared_specs() {
|
|
fn renders_help_from_shared_specs() {
|
|
|
let help = render_slash_command_help();
|
|
let help = render_slash_command_help();
|
|
@@ -2325,6 +2639,7 @@ mod tests {
|
|
|
assert!(help.contains("/cost"));
|
|
assert!(help.contains("/cost"));
|
|
|
assert!(help.contains("/resume <session-path>"));
|
|
assert!(help.contains("/resume <session-path>"));
|
|
|
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
|
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
|
|
|
|
+ assert!(help.contains("/mcp [list|show <server>|help]"));
|
|
|
assert!(help.contains("/memory"));
|
|
assert!(help.contains("/memory"));
|
|
|
assert!(help.contains("/init"));
|
|
assert!(help.contains("/init"));
|
|
|
assert!(help.contains("/diff"));
|
|
assert!(help.contains("/diff"));
|
|
@@ -2338,8 +2653,8 @@ mod tests {
|
|
|
assert!(help.contains("aliases: /plugins, /marketplace"));
|
|
assert!(help.contains("aliases: /plugins, /marketplace"));
|
|
|
assert!(help.contains("/agents [list|help]"));
|
|
assert!(help.contains("/agents [list|help]"));
|
|
|
assert!(help.contains("/skills [list|install <path>|help]"));
|
|
assert!(help.contains("/skills [list|install <path>|help]"));
|
|
|
- assert_eq!(slash_command_specs().len(), 26);
|
|
|
|
|
- assert_eq!(resume_supported_slash_commands().len(), 14);
|
|
|
|
|
|
|
+ assert_eq!(slash_command_specs().len(), 27);
|
|
|
|
|
+ assert_eq!(resume_supported_slash_commands().len(), 15);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
@@ -2357,6 +2672,15 @@ mod tests {
|
|
|
assert!(help.contains("Category Workspace & git"));
|
|
assert!(help.contains("Category Workspace & git"));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn renders_per_command_help_detail_for_mcp() {
|
|
|
|
|
+ let help = render_slash_command_help_detail("mcp").expect("detail help should exist");
|
|
|
|
|
+ assert!(help.contains("/mcp"));
|
|
|
|
|
+ assert!(help.contains("Summary Inspect configured MCP servers"));
|
|
|
|
|
+ assert!(help.contains("Category Discovery & debugging"));
|
|
|
|
|
+ assert!(help.contains("Resume Supported with --resume SESSION.jsonl"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn validate_slash_command_input_rejects_extra_single_value_arguments() {
|
|
fn validate_slash_command_input_rejects_extra_single_value_arguments() {
|
|
|
// given
|
|
// given
|
|
@@ -2491,6 +2815,7 @@ mod tests {
|
|
|
assert!(
|
|
assert!(
|
|
|
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
|
|
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
|
|
|
);
|
|
);
|
|
|
|
|
+ assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
|
|
|
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
|
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
|
|
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
|
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
|
|
assert!(
|
|
assert!(
|
|
@@ -2665,6 +2990,98 @@ mod tests {
|
|
|
let _ = fs::remove_dir_all(cwd);
|
|
let _ = fs::remove_dir_all(cwd);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn mcp_usage_supports_help_and_unexpected_args() {
|
|
|
|
|
+ let cwd = temp_dir("mcp-usage");
|
|
|
|
|
+
|
|
|
|
|
+ let help = super::handle_mcp_slash_command(Some("help"), &cwd).expect("mcp help");
|
|
|
|
|
+ assert!(help.contains("Usage /mcp [list|show <server>|help]"));
|
|
|
|
|
+ assert!(help.contains("Direct CLI claw mcp [list|show <server>|help]"));
|
|
|
|
|
+
|
|
|
|
|
+ let unexpected =
|
|
|
|
|
+ super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
|
|
|
|
|
+ assert!(unexpected.contains("Unexpected show alpha beta"));
|
|
|
|
|
+
|
|
|
|
|
+ let _ = fs::remove_dir_all(cwd);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn renders_mcp_reports_from_loaded_config() {
|
|
|
|
|
+ let workspace = temp_dir("mcp-config-workspace");
|
|
|
|
|
+ let config_home = temp_dir("mcp-config-home");
|
|
|
|
|
+ fs::create_dir_all(workspace.join(".claw")).expect("workspace config dir");
|
|
|
|
|
+ fs::create_dir_all(&config_home).expect("config home");
|
|
|
|
|
+ fs::write(
|
|
|
|
|
+ workspace.join(".claw").join("settings.json"),
|
|
|
|
|
+ r#"{
|
|
|
|
|
+ "mcpServers": {
|
|
|
|
|
+ "alpha": {
|
|
|
|
|
+ "command": "uvx",
|
|
|
|
|
+ "args": ["alpha-server"],
|
|
|
|
|
+ "env": {"ALPHA_TOKEN": "secret"},
|
|
|
|
|
+ "toolCallTimeoutMs": 1200
|
|
|
|
|
+ },
|
|
|
|
|
+ "remote": {
|
|
|
|
|
+ "type": "http",
|
|
|
|
|
+ "url": "https://remote.example/mcp",
|
|
|
|
|
+ "headers": {"Authorization": "Bearer secret"},
|
|
|
|
|
+ "headersHelper": "./bin/headers",
|
|
|
|
|
+ "oauth": {
|
|
|
|
|
+ "clientId": "remote-client",
|
|
|
|
|
+ "callbackPort": 7878
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }"#,
|
|
|
|
|
+ )
|
|
|
|
|
+ .expect("write settings");
|
|
|
|
|
+ fs::write(
|
|
|
|
|
+ workspace.join(".claw").join("settings.local.json"),
|
|
|
|
|
+ r#"{
|
|
|
|
|
+ "mcpServers": {
|
|
|
|
|
+ "remote": {
|
|
|
|
|
+ "type": "ws",
|
|
|
|
|
+ "url": "wss://remote.example/mcp"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }"#,
|
|
|
|
|
+ )
|
|
|
|
|
+ .expect("write local settings");
|
|
|
|
|
+
|
|
|
|
|
+ let loader = ConfigLoader::new(&workspace, &config_home);
|
|
|
|
|
+ let list = super::render_mcp_report_for(&loader, &workspace, None)
|
|
|
|
|
+ .expect("mcp list report should render");
|
|
|
|
|
+ assert!(list.contains("Configured servers 2"));
|
|
|
|
|
+ assert!(list.contains("alpha"));
|
|
|
|
|
+ assert!(list.contains("stdio"));
|
|
|
|
|
+ assert!(list.contains("project"));
|
|
|
|
|
+ assert!(list.contains("uvx alpha-server"));
|
|
|
|
|
+ assert!(list.contains("remote"));
|
|
|
|
|
+ assert!(list.contains("ws"));
|
|
|
|
|
+ assert!(list.contains("local"));
|
|
|
|
|
+ assert!(list.contains("wss://remote.example/mcp"));
|
|
|
|
|
+
|
|
|
|
|
+ let show = super::render_mcp_report_for(&loader, &workspace, Some("show alpha"))
|
|
|
|
|
+ .expect("mcp show report should render");
|
|
|
|
|
+ assert!(show.contains("Name alpha"));
|
|
|
|
|
+ assert!(show.contains("Command uvx"));
|
|
|
|
|
+ assert!(show.contains("Args alpha-server"));
|
|
|
|
|
+ assert!(show.contains("Env keys ALPHA_TOKEN"));
|
|
|
|
|
+ assert!(show.contains("Tool timeout 1200 ms"));
|
|
|
|
|
+
|
|
|
|
|
+ let remote = super::render_mcp_report_for(&loader, &workspace, Some("show remote"))
|
|
|
|
|
+ .expect("mcp show remote report should render");
|
|
|
|
|
+ assert!(remote.contains("Transport ws"));
|
|
|
|
|
+ assert!(remote.contains("URL wss://remote.example/mcp"));
|
|
|
|
|
+
|
|
|
|
|
+ let missing = super::render_mcp_report_for(&loader, &workspace, Some("show missing"))
|
|
|
|
|
+ .expect("missing report should render");
|
|
|
|
|
+ assert!(missing.contains("server `missing` is not configured"));
|
|
|
|
|
+
|
|
|
|
|
+ let _ = fs::remove_dir_all(workspace);
|
|
|
|
|
+ let _ = fs::remove_dir_all(config_home);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn parses_quoted_skill_frontmatter_values() {
|
|
fn parses_quoted_skill_frontmatter_values() {
|
|
|
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
|
|
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
|