use std::collections::HashMap;
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use async_trait::async_trait;
use nu_ansi_term::{Color, Style};
use tower_mcp::client::{ClientHandler, NotificationHandler, ServerNotification};
use tower_mcp::error::JsonRpcError;
use tower_mcp::protocol::{
CreateMessageParams, CreateMessageResult, ElicitAction, ElicitFieldValue, ElicitFormParams,
ElicitRequestParams, ElicitResult, PrimitiveSchemaDefinition,
};
use crate::output::AsyncOutput;
use crate::sampling::{self, SamplingMode};
use crate::style::{paint, sanitize, tag};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum ElicitationMode {
#[default]
Prompt,
Decline,
}
impl ElicitationMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Prompt => "prompt",
Self::Decline => "decline",
}
}
}
pub fn resolve(flag: Option<ElicitationMode>, one_shot: bool) -> ElicitationMode {
match flag {
Some(mode) => mode,
None if one_shot => ElicitationMode::Decline,
None => ElicitationMode::Prompt,
}
}
static MODE: std::sync::OnceLock<ElicitationMode> = std::sync::OnceLock::new();
pub fn init(mode: ElicitationMode) {
let _ = MODE.set(mode);
}
pub fn mode() -> ElicitationMode {
*MODE.get().unwrap_or(&ElicitationMode::Prompt)
}
pub type ServerLabel = Arc<RwLock<String>>;
pub struct ReplClientHandler {
notifications: NotificationHandler,
at_prompt: Arc<AtomicBool>,
server: ServerLabel,
output: AsyncOutput,
}
impl ReplClientHandler {
pub fn new(
notifications: NotificationHandler,
at_prompt: Arc<AtomicBool>,
server: ServerLabel,
output: AsyncOutput,
) -> Self {
Self {
notifications,
at_prompt,
server,
output,
}
}
fn server_name(&self) -> String {
self.server
.read()
.map(|name| name.clone())
.unwrap_or_default()
}
fn note(&self, message: String) {
self.output.line(message);
}
}
#[async_trait]
impl ClientHandler for ReplClientHandler {
async fn handle_create_message(
&self,
params: CreateMessageParams,
) -> Result<CreateMessageResult, JsonRpcError> {
match sampling::mode() {
SamplingMode::Decline => Err(sampling::declined("--sampling decline")),
SamplingMode::Canned => {
eprintln!(
"{} answered with the canned reply",
tag(Style::new().fg(Color::Purple), "sampling")
);
Ok(sampling::canned(¶ms))
}
SamplingMode::Prompt => {
if self.at_prompt.load(Ordering::SeqCst) {
self.note(format!(
"{} declined a completion request from the server (arrived while at \
the prompt; run the tool in the foreground to answer it)",
tag(Style::new().fg(Color::Purple), "sampling"),
));
return Err(sampling::declined(
"arrived while the editor held the terminal",
));
}
tokio::task::spawn_blocking(move || sampling::prompt(¶ms))
.await
.map_err(|e| JsonRpcError::internal_error(e.to_string()))?
}
}
}
async fn handle_elicit(
&self,
params: ElicitRequestParams,
) -> Result<ElicitResult, JsonRpcError> {
if mode() == ElicitationMode::Decline {
self.note(format!(
"{} declined a request from the server (--elicitation decline)",
tag(Style::new().fg(Color::Purple), "elicit"),
));
return Ok(ElicitResult::decline());
}
if self.at_prompt.load(Ordering::SeqCst) {
self.note(format!(
"{} declined a request from the server (arrived while at the prompt; run the \
tool in the foreground to answer it)",
tag(Style::new().fg(Color::Purple), "elicit"),
));
return Ok(ElicitResult::decline());
}
let server = self.server_name();
match params {
ElicitRequestParams::Url(url) => {
if !is_web_url(&url.url) {
self.note(format!(
"{} declined a request to open {} (only http and https are shown)",
tag(Style::new().fg(Color::Purple), "elicit"),
sanitize(&url.url)
));
return Ok(ElicitResult::decline());
}
let (message, link) = (url.message.clone(), url.url.clone());
tokio::task::spawn_blocking(move || confirm_url(&server, &message, &link))
.await
.map_err(|e| JsonRpcError::internal_error(e.to_string()))
}
ElicitRequestParams::Form(form) => {
tokio::task::spawn_blocking(move || prompt_form(&server, &form))
.await
.map_err(|e| JsonRpcError::internal_error(e.to_string()))
}
_ => Ok(ElicitResult::decline()),
}
}
async fn on_notification(&self, notification: ServerNotification) {
self.notifications.on_notification(notification).await;
}
}
pub async fn answer_in_foreground(server: &str, params: ElicitRequestParams) -> ElicitResult {
if mode() == ElicitationMode::Decline {
eprintln!(
"{} declined (--elicitation decline)",
tag(Style::new().fg(Color::Purple), "elicit")
);
return ElicitResult::decline();
}
let server = server.to_string();
let answered = match params {
ElicitRequestParams::Url(url) => {
if !is_web_url(&url.url) {
eprintln!(
"{} declined a request to open {} (only http and https are shown)",
tag(Style::new().fg(Color::Purple), "elicit"),
sanitize(&url.url)
);
return ElicitResult::decline();
}
let (message, link) = (url.message.clone(), url.url.clone());
tokio::task::spawn_blocking(move || confirm_url(&server, &message, &link)).await
}
ElicitRequestParams::Form(form) => {
tokio::task::spawn_blocking(move || prompt_form(&server, &form)).await
}
_ => return ElicitResult::decline(),
};
answered.unwrap_or_else(|_| ElicitResult::decline())
}
fn is_web_url(url: &str) -> bool {
let scheme = url
.split_once("://")
.map(|(scheme, _)| scheme)
.unwrap_or("");
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
}
fn provenance(server: &str) -> String {
let who = if server.is_empty() {
"the server".to_string()
} else {
format!("server {}", sanitize(server))
};
format!(
"{} {who} is asking:",
tag(Style::new().fg(Color::Purple), "elicit")
)
}
fn read_line() -> Option<String> {
let mut buf = String::new();
let read = {
let mut lock = std::io::stdin().lock();
std::io::BufRead::read_line(&mut lock, &mut buf)
};
match read {
Ok(0) | Err(_) => None,
Ok(_) => Some(buf),
}
}
fn confirm_url(server: &str, message: &str, url: &str) -> ElicitResult {
eprintln!("{}", provenance(server));
eprintln!(" {}", sanitize(message));
eprintln!(
" open: {}",
paint(Style::new().underline(), &sanitize(url))
);
eprint!(" confirm you completed this [y/N]> ");
let _ = std::io::stderr().flush();
match read_line() {
Some(answer) if matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") => ElicitResult {
action: ElicitAction::Accept,
content: None,
meta: None,
},
Some(_) => ElicitResult::decline(),
None => ElicitResult::cancel(),
}
}
fn prompt_form(server: &str, form: &ElicitFormParams) -> ElicitResult {
eprintln!("{}", provenance(server));
eprintln!(" {}", sanitize(&form.message));
let mut content: HashMap<String, ElicitFieldValue> = HashMap::new();
for (name, schema) in &form.requested_schema.properties {
let required = form.requested_schema.required.iter().any(|r| r == name);
let (ty, detail, default) = describe_field(schema);
let display_name = sanitize(name);
let mut prompt_line = format!(
" {} ({}",
paint(Style::new().fg(Color::Cyan), &display_name),
sanitize(&ty)
);
if required {
prompt_line.push_str(", required");
}
if let Some(d) = &default {
prompt_line.push_str(&format!(", default {}", sanitize(d)));
}
prompt_line.push(')');
if let Some(detail) = detail {
prompt_line.push_str(&format!(
" {}",
paint(Style::new().dimmed(), &sanitize(&detail))
));
}
eprintln!("{prompt_line}");
if crate::wire::looks_like_credential(name) {
eprintln!(
" {} this field name looks like a credential; mcp-repl sends the answer to \
the server as typed",
paint(Style::new().fg(Color::Yellow).bold(), "warning:")
);
}
loop {
eprint!(" {display_name}> ");
let _ = std::io::stderr().flush();
let mut buf = String::new();
let read = {
let mut lock = std::io::stdin().lock();
std::io::BufRead::read_line(&mut lock, &mut buf)
};
match read {
Ok(0) | Err(_) => return ElicitResult::cancel(),
Ok(_) => {}
}
let raw = buf.trim();
if raw.is_empty() {
match (&default, required) {
(Some(d), _) => {
content.insert(name.clone(), coerce_field(schema, d));
break;
}
(None, false) => break,
(None, true) => {
eprintln!(" (required)");
continue;
}
}
}
content.insert(name.clone(), coerce_field(schema, raw));
break;
}
}
ElicitResult::accept(content)
}
fn describe_field(schema: &PrimitiveSchemaDefinition) -> (String, Option<String>, Option<String>) {
let raw = field_json(schema);
let description = raw
.get("description")
.and_then(|d| d.as_str())
.map(str::to_string);
let default = raw.get("default").map(render_default);
let choices = raw.get("enum").or_else(|| raw.pointer("/items/enum"));
if let Some(values) = choices.and_then(|e| e.as_array()) {
let choices: Vec<String> = values.iter().map(render_default).collect();
let label = match raw.get("type").and_then(|t| t.as_str()) {
Some("array") => format!("any of {}, comma-separated", choices.join("|")),
_ => format!("one of {}", choices.join("|")),
};
return (label, description, default);
}
let label = match raw.get("type").and_then(|t| t.as_str()) {
Some("array") => "comma-separated list".to_string(),
Some(other) => other.to_string(),
None => "value".to_string(),
};
(label, description, default)
}
fn field_json(schema: &PrimitiveSchemaDefinition) -> serde_json::Value {
serde_json::to_value(schema).unwrap_or_else(|_| serde_json::json!({}))
}
fn render_default(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn coerce_field(schema: &PrimitiveSchemaDefinition, raw: &str) -> ElicitFieldValue {
let json = field_json(schema);
match json.get("type").and_then(|t| t.as_str()) {
Some("integer") => raw
.parse::<i64>()
.map(ElicitFieldValue::Integer)
.unwrap_or_else(|_| ElicitFieldValue::String(raw.to_string())),
Some("number") => raw
.parse::<f64>()
.map(ElicitFieldValue::Number)
.unwrap_or_else(|_| ElicitFieldValue::String(raw.to_string())),
Some("boolean") => match raw.to_ascii_lowercase().as_str() {
"true" | "yes" | "y" | "1" => ElicitFieldValue::Boolean(true),
"false" | "no" | "n" | "0" => ElicitFieldValue::Boolean(false),
_ => ElicitFieldValue::String(raw.to_string()),
},
Some("array") => {
ElicitFieldValue::StringArray(raw.split(',').map(|s| s.trim().to_string()).collect())
}
_ => ElicitFieldValue::String(raw.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn field_from_wire(json: serde_json::Value) -> PrimitiveSchemaDefinition {
serde_json::from_value(json).expect("field schema")
}
#[test]
fn a_declared_type_survives_the_untagged_union() {
let (label, _, _) = describe_field(&field_from_wire(
serde_json::json!({"type": "boolean", "description": "Stay signed in"}),
));
assert_eq!(label, "boolean");
let (label, description, default) = describe_field(&field_from_wire(
serde_json::json!({"type": "integer", "description": "How many", "default": 3}),
));
assert_eq!(label, "integer");
assert_eq!(description.as_deref(), Some("How many"));
assert_eq!(default.as_deref(), Some("3"));
}
fn coerced(field: serde_json::Value, raw: &str) -> serde_json::Value {
serde_json::to_value(coerce_field(&field_from_wire(field), raw)).expect("field value")
}
#[test]
fn an_answer_is_coerced_to_the_declared_type() {
assert_eq!(coerced(serde_json::json!({"type": "integer"}), "5"), 5);
assert_eq!(coerced(serde_json::json!({"type": "number"}), "1.5"), 1.5);
assert_eq!(coerced(serde_json::json!({"type": "boolean"}), "yes"), true);
assert_eq!(
coerced(
serde_json::json!({
"type": "array",
"items": {"type": "string", "enum": ["a", "b", "c"]},
}),
"a, b"
),
serde_json::json!(["a", "b"])
);
assert_eq!(
coerced(serde_json::json!({"type": "integer"}), "many"),
"many"
);
}
#[test]
fn enum_choices_reach_the_prompt() {
let (label, _, _) = describe_field(&field_from_wire(serde_json::json!({
"type": "string",
"enum": ["staging", "production"],
})));
assert_eq!(label, "one of staging|production");
let (label, _, _) = describe_field(&field_from_wire(serde_json::json!({
"type": "array",
"items": {"type": "string", "enum": ["read", "write"]},
})));
assert_eq!(label, "any of read|write, comma-separated");
}
#[test]
fn a_string_field_stays_a_string() {
assert_eq!(coerced(serde_json::json!({"type": "string"}), "5"), "5");
}
#[test]
fn a_script_declines_elicitation_unless_it_asked_for_it() {
assert_eq!(resolve(None, true), ElicitationMode::Decline);
assert_eq!(resolve(None, false), ElicitationMode::Prompt);
assert_eq!(
resolve(Some(ElicitationMode::Prompt), true),
ElicitationMode::Prompt
);
assert_eq!(
resolve(Some(ElicitationMode::Decline), false),
ElicitationMode::Decline
);
}
#[test]
fn only_web_links_are_shown() {
assert!(is_web_url("https://example.com/authorize?x=1"));
assert!(is_web_url("http://127.0.0.1:8080/cb"));
assert!(is_web_url("HTTPS://EXAMPLE.COM"));
assert!(!is_web_url("javascript:alert(1)"));
assert!(!is_web_url("file:///etc/passwd"));
assert!(!is_web_url("data:text/html;base64,PHNjcmlwdD4="));
assert!(!is_web_url("not a url"));
assert!(!is_web_url(""));
}
#[test]
fn the_provenance_line_names_the_server() {
let line = provenance("cratesio-mcp");
assert!(line.contains("cratesio-mcp"));
assert!(line.contains("is asking"));
assert!(provenance("").contains("the server"));
}
#[test]
fn a_hostile_server_name_cannot_repaint_the_provenance_line() {
let line = provenance("evil\u{1b}[2K\rmcp-repl");
assert!(!line.contains('\u{1b}'));
assert!(line.contains('\u{FFFD}'));
}
#[test]
fn credential_shaped_field_names_are_flagged() {
for name in [
"api_key",
"apiKey",
"password",
"github_token",
"AWS_SECRET_ACCESS_KEY",
"passphrase",
] {
assert!(
crate::wire::looks_like_credential(name),
"{name} should be flagged before the operator types a value"
);
}
for name in ["city", "message", "count", "email"] {
assert!(!crate::wire::looks_like_credential(name), "{name}");
}
}
}