pub const WEBMCP_TOOLS_HELP: &str = "WebMCP tools: workspace.list_files, workspace.read_file, patch.propose, patch.apply, checks.run, patch.revert, turn.request, cancel";
pub const WEBMCP_ROOTS_HELP: &str =
"Headless WebMCP roots are configured with [webmcp].allowed_roots or --allowed-root.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WebmcpSlashCommand {
Status,
Help,
Tools,
Roots,
ConnectHelp,
Connect {
origin: String,
replace: bool,
},
Unpair,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebmcpSlashCommandParseError {
UnknownCommand,
InvalidConnectionArguments,
}
pub fn parse_webmcp_slash_command(arguments: &str) -> Result<WebmcpSlashCommand, WebmcpSlashCommandParseError> {
let arguments = arguments.trim();
if arguments.is_empty() {
return Ok(WebmcpSlashCommand::Status);
}
let (verb, tail) = arguments
.split_once(char::is_whitespace)
.map_or((arguments, ""), |(verb, tail)| (verb, tail.trim()));
match verb {
"status" if tail.is_empty() => Ok(WebmcpSlashCommand::Status),
"help" if tail.is_empty() => Ok(WebmcpSlashCommand::Help),
"tools" if tail.is_empty() => Ok(WebmcpSlashCommand::Tools),
"roots" if tail.is_empty() => Ok(WebmcpSlashCommand::Roots),
"unpair" | "stop" if tail.is_empty() => Ok(WebmcpSlashCommand::Unpair),
"connect" | "pair" => parse_connection_arguments(tail),
_ => Err(WebmcpSlashCommandParseError::UnknownCommand),
}
}
fn parse_connection_arguments(arguments: &str) -> Result<WebmcpSlashCommand, WebmcpSlashCommandParseError> {
if arguments.is_empty() {
return Ok(WebmcpSlashCommand::ConnectHelp);
}
let mut tokens = arguments.split_whitespace();
let mut origin = None;
let mut replace = false;
while let Some(token) = tokens.next() {
match token {
"--replace" if !replace => replace = true,
"--origin" if origin.is_none() => {
let Some(value) = tokens.next() else {
return Err(WebmcpSlashCommandParseError::InvalidConnectionArguments);
};
if value.starts_with('-') {
return Err(WebmcpSlashCommandParseError::InvalidConnectionArguments);
}
origin = Some(value);
}
value if origin.is_none() && !value.starts_with('-') => origin = Some(value),
_ => return Err(WebmcpSlashCommandParseError::InvalidConnectionArguments),
}
}
let Some(origin) = origin else {
return Err(WebmcpSlashCommandParseError::InvalidConnectionArguments);
};
Ok(WebmcpSlashCommand::Connect { origin: origin.to_owned(), replace })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_status_help_and_webmcp_catalog_commands() {
assert_eq!(parse_webmcp_slash_command(""), Ok(WebmcpSlashCommand::Status));
assert_eq!(parse_webmcp_slash_command(" status "), Ok(WebmcpSlashCommand::Status));
assert_eq!(parse_webmcp_slash_command("help"), Ok(WebmcpSlashCommand::Help));
assert_eq!(parse_webmcp_slash_command("tools"), Ok(WebmcpSlashCommand::Tools));
assert_eq!(parse_webmcp_slash_command("roots"), Ok(WebmcpSlashCommand::Roots));
}
#[test]
fn parses_connect_aliases_and_options_in_either_order() {
assert_eq!(
parse_webmcp_slash_command("connect https://example.test"),
Ok(WebmcpSlashCommand::Connect {
origin: "https://example.test".into(),
replace: false,
})
);
assert_eq!(
parse_webmcp_slash_command("pair --replace --origin http://localhost:5173"),
Ok(WebmcpSlashCommand::Connect {
origin: "http://localhost:5173".into(),
replace: true,
})
);
assert_eq!(
parse_webmcp_slash_command("connect --origin http://localhost:5173 --replace"),
Ok(WebmcpSlashCommand::Connect {
origin: "http://localhost:5173".into(),
replace: true,
})
);
assert_eq!(parse_webmcp_slash_command("pair"), Ok(WebmcpSlashCommand::ConnectHelp));
}
#[test]
fn parses_both_disconnect_verbs() {
assert_eq!(parse_webmcp_slash_command("unpair"), Ok(WebmcpSlashCommand::Unpair));
assert_eq!(parse_webmcp_slash_command("stop"), Ok(WebmcpSlashCommand::Unpair));
}
#[test]
fn rejects_ambiguous_or_malformed_commands() {
for input in [
"status extra",
"unknown",
"pair https://a.test https://b.test",
"pair --unknown https://a.test",
"pair --replace --replace https://a.test",
"pair --origin --replace",
"connect --replace",
] {
assert!(parse_webmcp_slash_command(input).is_err(), "accepted {input:?}");
}
}
}