1mod alias;
44mod bench;
45mod command;
46pub mod config;
47mod editor;
48mod elicit;
49mod exit_status;
50mod find;
51pub mod import_config;
52mod import_trust;
53mod jobs;
54pub mod lifecycle;
55pub mod oauth_profile;
56mod output;
57mod sampling;
58mod schema_contract;
59mod secure_file;
60mod session;
61mod style;
62mod subscribe;
63mod surface_subscription;
64mod vars;
65mod wire;
66
67use std::collections::HashMap;
68use std::future::Future;
69use std::sync::atomic::{AtomicBool, Ordering};
70use std::sync::{Arc, RwLock};
71use std::time::Duration;
72
73use clap::{Parser, ValueEnum};
74use nu_ansi_term::{Color, Style};
75
76use tokio::io::{AsyncBufReadExt, BufReader};
77use tower_mcp::client::{
78 ChannelTransport, HttpClientConfig, HttpClientTransport, McpClient, McpClientBuilder,
79 NotificationHandler, OAuthAuthorizationFlow, OAuthAuthorizationStart, OAuthClientError,
80 OAuthScopeEscalationConfig, StdioClientTransport,
81};
82use tower_mcp::protocol::{
83 Content, DiscoverResult, Implementation, InitializeResult, LogLevel, PromptDefinition,
84 ResourceDefinition, ResourceTemplateDefinition, ServerCapabilities, SubscriptionFilter,
85 TaskObject, ToolDefinition,
86};
87use tower_mcp::{ProtocolSupport, ProtocolSupportError};
88
89use alias::Aliases;
90use elicit::ReplClientHandler;
91use exit_status::ExitStatus;
92use jobs::Jobs;
93use output::AsyncOutput;
94use session::{Connector, Session, is_not_initialized, is_session_lost};
95use style::{json_pretty, paint, sanitize, tag, task_status_style};
96use wire::{TracingTransport, wire};
97
98#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
104enum ProtocolMode {
105 #[default]
106 Stable,
107 #[value(name = "2026-07-28", alias = "final")]
108 Final,
109}
110
111impl ProtocolMode {
112 fn support(self) -> Result<ProtocolSupport, ProtocolSupportError> {
113 match self {
114 Self::Stable => Ok(ProtocolSupport::stable()),
115 Self::Final => ProtocolSupport::try_new(["2026-07-28"]),
116 }
117 }
118}
119
120#[derive(Parser)]
121#[command(
122 name = "mcp-repl",
123 version,
124 about = "Interactive MCP client REPL",
125 long_about = "\
126An interactive terminal REPL for any MCP server. The server's surface is the \
127command set: every tool becomes a top-level command with schema-coerced \
128key=value arguments, prompts and resources get built-ins, tab completion is \
129powered by the server itself where the protocol allows, and the command table \
130refreshes when the server's surface changes.
131
132Connects over stdio or streamable HTTP, reads the JSON config files other MCP \
133clients use, and keeps named profiles of its own.",
134 trailing_var_arg = true,
135 after_help = "\
138EXAMPLES:
139 mcp-repl --demo the bundled demo server
140 mcp-repl --http https://example/mcp a streamable HTTP server
141 mcp-repl -- ./my-server --stdio spawn a stdio server
142 mcp-repl .mcp.json:local an entry from a client config
143 mcp-repl --server prod a saved profile
144
145 mcp-repl --demo -e 'echo message=hi' run one command and exit
146 mcp-repl --demo --json -e tools | jq NDJSON for scripts
147
148Inside the REPL, `help` lists the built-ins and `help <command>` explains one."
149)]
150struct Args {
151 #[arg(long, value_enum, default_value = "stable")]
154 protocol: ProtocolMode,
155
156 #[arg(long)]
159 http: Option<String>,
160
161 #[arg(long, conflicts_with_all = ["http", "command", "server"])]
163 demo: bool,
164
165 #[arg(long, value_name = "NAME")]
169 server: Option<String>,
170
171 #[arg(long, value_name = "PATH")]
174 config: Option<String>,
175
176 #[arg(long)]
178 list_servers: bool,
179
180 #[arg(long)]
186 scan: bool,
187
188 #[arg(long, value_name = "SHELL")]
193 completions: Option<clap_complete::Shell>,
194
195 #[arg(long)]
199 man: bool,
200
201 #[arg(long, value_enum, default_value = "auto")]
203 color: style::ColorMode,
204
205 #[arg(long)]
210 bearer: Option<String>,
211
212 #[arg(long = "header", value_name = "NAME: VALUE")]
215 headers: Vec<String>,
216
217 #[arg(long, value_name = "NAME")]
219 oauth: Option<String>,
220
221 #[arg(long, value_name = "NAME", conflicts_with = "logout")]
224 login: Option<String>,
225
226 #[arg(long, value_name = "NAME", conflicts_with = "login")]
229 logout: Option<String>,
230
231 #[arg(long = "oauth-scope", value_name = "SCOPE")]
234 oauth_scopes: Vec<String>,
235
236 #[arg(long, value_name = "URL")]
239 oauth_client_id_metadata_document: Option<String>,
240
241 #[arg(long, value_name = "ISSUER")]
244 oauth_authorization_server: Option<String>,
245
246 #[arg(long)]
249 no_browser: bool,
250
251 #[arg(short = 'e', long = "exec", value_name = "COMMAND")]
256 exec: Vec<String>,
257
258 #[arg(long)]
261 json: bool,
262
263 #[arg(long)]
266 verbose: bool,
267
268 #[arg(long = "schema-contract", value_name = "PATH")]
271 schema_contracts: Vec<std::path::PathBuf>,
272
273 #[arg(long, value_enum, default_value = "compatible")]
275 schema_mode: schema_contract::ValidationMode,
276
277 #[arg(long, value_enum, value_name = "STRATEGY")]
282 sampling: Option<sampling::SamplingMode>,
283
284 #[arg(long, value_enum, value_name = "STRATEGY")]
289 elicitation: Option<elicit::ElicitationMode>,
290
291 #[arg(long)]
296 trust_import: bool,
297
298 #[arg(long)]
300 no_history: bool,
301
302 #[arg(long)]
306 no_reconnect: bool,
307
308 #[arg(long)]
311 trace: bool,
312
313 #[arg(long, value_name = "SECONDS")]
320 timeout: Option<u64>,
321
322 command: Vec<String>,
324}
325
326static JSON_OUTPUT: AtomicBool = AtomicBool::new(false);
328
329fn json_output() -> bool {
330 JSON_OUTPUT.load(Ordering::Relaxed)
331}
332
333static COMMAND_RAN: AtomicBool = AtomicBool::new(false);
336
337pub(crate) const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 120;
339
340static REQUEST_TIMEOUT_SECS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
342
343fn request_timeout() -> Option<Duration> {
344 match REQUEST_TIMEOUT_SECS.load(Ordering::Relaxed) {
345 0 => None,
346 secs => Some(Duration::from_secs(secs)),
347 }
348}
349
350async fn with_deadline<T, Fut>(fut: Fut) -> Result<T, tower_mcp::Error>
357where
358 Fut: Future<Output = Result<T, tower_mcp::Error>>,
359{
360 let Some(limit) = request_timeout() else {
361 return fut.await;
362 };
363 match tokio::time::timeout(limit, fut).await {
364 Ok(result) => result,
365 Err(_) => Err(tower_mcp::Error::Transport(format!(
366 "no response after {}s (--timeout); the request may still be running on the server",
367 limit.as_secs()
368 ))),
369 }
370}
371
372fn note_error(status: ExitStatus) {
373 exit_status::record(status);
374}
375
376fn automatic_task_updates(one_shot: bool, json: bool) -> bool {
377 !one_shot && !json
378}
379
380fn print_json(value: &serde_json::Value) {
384 println!("{value}");
385}
386
387fn error_json(status: ExitStatus, message: &str) -> serde_json::Value {
389 serde_json::json!({
390 "error": message,
391 "kind": status.label(),
392 "exitStatus": status.code(),
393 })
394}
395
396fn report_error(status: ExitStatus, message: &str) {
406 report_error_with_hint(status, message, None);
407}
408
409fn report_error_with_hint(status: ExitStatus, message: &str, hint: Option<&str>) {
411 note_error(status);
412 if json_output() {
413 let mut value = error_json(status, message);
414 if let Some(hint) = hint {
415 value["didYouMean"] = serde_json::json!(hint);
416 }
417 print_json(&value);
418 return;
419 }
420 let mut line = format!("{}: {}", style::error_prefix(), sanitize(message));
423 if let Some(hint) = hint {
424 line.push_str(&format!(
425 "; did you mean `{}`?",
426 paint(Style::new().fg(Color::Green), &sanitize(hint))
427 ));
428 }
429 eprintln!("{line}");
430}
431
432fn report_mcp_error(error: &tower_mcp::Error) {
433 report_error(
434 ExitStatus::from_mcp_error(error),
435 &describe_mcp_error(error),
436 );
437}
438
439fn unwrap_nested(message: &str) -> String {
450 let Some(start) = message.find('{') else {
451 return message.to_string();
452 };
453 let Ok(value) = serde_json::from_str::<serde_json::Value>(&message[start..]) else {
454 return message.to_string();
455 };
456 match value.get("message").and_then(serde_json::Value::as_str) {
457 Some(inner) if !inner.is_empty() => unwrap_nested(inner),
459 _ => message.to_string(),
460 }
461}
462
463fn describe_mcp_error(error: &tower_mcp::Error) -> String {
475 let tower_mcp::Error::JsonRpc(rpc) = error else {
476 return collapse_repeated_label(&error.to_string()).to_string();
477 };
478 let mut described = format!(
479 "{} (code {})",
480 sanitize(&unwrap_nested(&rpc.message)),
481 rpc.code
482 );
483 if let Some(data) = &rpc.data {
487 let detail = match data {
488 serde_json::Value::String(text) => text.clone(),
489 other => other.to_string(),
490 };
491 if !detail.is_empty() && detail != "null" {
492 described.push_str(&format!(": {}", sanitize(&detail)));
493 }
494 }
495 described
496}
497
498fn init_tracing(args: &Args) {
500 let ansi = match args.color {
504 style::ColorMode::Always => true,
505 style::ColorMode::Never => false,
506 style::ColorMode::Auto => {
507 std::env::var_os("NO_COLOR").is_none()
508 && std::io::IsTerminal::is_terminal(&std::io::stderr())
509 }
510 };
511 tracing_subscriber::fmt()
512 .with_writer(std::io::stderr)
513 .with_ansi(ansi)
514 .with_env_filter(
515 tracing_subscriber::EnvFilter::try_from_default_env()
522 .unwrap_or_else(|_| "warn,tower_mcp::client=off".into()),
523 )
524 .init();
525}
526
527fn collapse_repeated_label(message: &str) -> &str {
536 let Some((label, _)) = message.split_once(": ") else {
537 return message;
538 };
539 if label.is_empty() {
542 return message;
543 }
544 let prefix = format!("{label}: ");
545 let mut collapsed = message;
546 while let Some(rest) = collapsed.strip_prefix(&prefix) {
547 if !rest.starts_with(&prefix) {
548 break;
549 }
550 collapsed = rest;
551 }
552 collapsed
553}
554
555fn exit_with_error(status: ExitStatus, message: &str) -> ! {
556 if json_output() {
557 print_json(&error_json(status, message));
558 } else {
559 eprintln!("error: {}", sanitize(message));
560 }
561 std::process::exit(status.code());
562}
563
564#[derive(Default)]
567pub(crate) struct Surface {
568 pub tools: Vec<ToolDefinition>,
569 pub prompts: Vec<PromptDefinition>,
570 pub resources: Vec<ResourceDefinition>,
571 pub templates: Vec<ResourceTemplateDefinition>,
572 pub unavailable: Vec<&'static str>,
579}
580
581impl Surface {
582 pub fn is_unavailable(&self, what: &str) -> bool {
584 self.unavailable.contains(&what)
585 }
586}
587
588pub(crate) const BUILTINS: &[(&str, &str)] = &[
591 ("help", "list built-ins and the server's tools"),
592 ("tools", "list tools"),
593 ("prompts", "list prompts"),
594 ("resources", "list resources"),
595 ("templates", "list resource templates"),
596 ("find", "search the surface by keyword"),
597 ("describe", "show schemas and metadata for a name"),
598 ("snapshot", "export a tool or prompt schema contract"),
599 ("validate", "compare the surface with a schema snapshot"),
600 ("read", "read a resource"),
601 ("subscribe", "watch a resource for updates"),
602 ("unsubscribe", "stop watching a resource"),
603 ("subscriptions", "list active resource subscriptions"),
604 ("prompt", "get a prompt"),
605 ("call", "call a tool with raw JSON"),
606 ("bench", "time repeated calls to a tool"),
607 ("jobs", "list background tasks"),
608 ("task", "show a background task"),
609 ("wait", "wait for background tasks"),
610 ("cancel", "cancel a background task"),
611 ("alias", "define, list, or show a command alias"),
612 ("unalias", "remove a command alias"),
613 ("ping", "check the server is answering"),
614 ("loglevel", "set the server's log verbosity"),
615 ("refresh", "re-fetch the server surface"),
616 ("info", "replay the connection banner plus capabilities"),
617 ("wire", "toggle raw JSON-RPC frame tracing (on|off)"),
618 ("last", "reprint the previous request and response"),
619 ("history", "list recent command history"),
620 ("vars", "list captured variables"),
621 ("unset", "clear a captured variable"),
622 ("quit", "exit"),
623 ("exit", "exit"),
624];
625
626const BUILTIN_HELP: &[(&str, &str, &str)] = &[
630 (
631 "help",
632 "help [command]",
633 "With no argument, list the built-ins and the server's tools. With one, explain that command.",
634 ),
635 (
636 "tools",
637 "tools [--full]",
638 "List the server's tools. Every tool is also a command: `<tool> [k=v...]`. \
639 A long list is trimmed to the window; `--full` prints all of it.",
640 ),
641 ("prompts", "prompts [--full]", "List the server's prompts."),
642 (
643 "resources",
644 "resources [--full]",
645 "List concrete resources. Parameterized ones are under `templates`.",
646 ),
647 (
648 "templates",
649 "templates [--full]",
650 "List resource templates: URIs with `{variable}` parts, completed by the server.",
651 ),
652 (
653 "find",
654 "find [-E] [-m N] [--case-sensitive] [--tools|--prompts|--resources|--templates|--builtins] <keyword>",
655 "Search names and descriptions across the surface and the built-ins. Kind flags narrow \
656 it, `-m` caps the results, `-E` treats the keyword as a regular expression, and it \
657 exits non-zero when nothing matches, like grep.",
658 ),
659 (
660 "describe",
661 "describe <name>",
662 "Show a tool's schemas, a prompt's arguments, or a resource's metadata, plus an example invocation.",
663 ),
664 (
665 "snapshot",
666 "snapshot <name> [path]",
667 "Export a tool or prompt's schema as a versioned contract. Without a path, print it.",
668 ),
669 (
670 "validate",
671 "validate <path> [strict|compatible|ignore]",
672 "Compare a saved snapshot with the live surface. No request is sent.",
673 ),
674 (
675 "read",
676 "read <uri> [--out <path>] [--force]",
677 "Read a resource. Tab completes URIs, and template variables via the server. \
678 `--out` writes the content to a file, decoding a binary resource, instead of \
679 printing it.",
680 ),
681 (
682 "subscribe",
683 "subscribe <uri>",
684 "Ask the server to report updates to a resource. Updates print inline.",
685 ),
686 (
687 "unsubscribe",
688 "unsubscribe <uri>",
689 "Stop receiving updates for a resource.",
690 ),
691 (
692 "subscriptions",
693 "subscriptions",
694 "List the resources the server is currently reporting updates for.",
695 ),
696 (
697 "prompt",
698 "prompt <name> [k=v...]",
699 "Retrieve a prompt. Argument values tab-complete through the server.",
700 ),
701 (
702 "call",
703 "call <tool> <json>",
704 "Call a tool with a raw JSON argument object, for when `k=v` coercion is not enough.",
705 ),
706 (
707 "bench",
708 "bench <tool> [k=v...] [--n N] [--concurrency C]",
709 "Time repeated calls and report the latency distribution. Any failure exits non-zero.",
710 ),
711 (
712 "jobs",
713 "jobs",
714 "List the background tasks this session started, with their current status.",
715 ),
716 (
717 "task",
718 "task <task> [respond]",
719 "Show one background task. Takes the short number from `jobs`, `last`, or the server's \
720 id. \
721 `respond` answers what an `input_required` task is waiting for and lets its handler \
722 resume; it needs --protocol 2026-07-28.",
723 ),
724 (
725 "wait",
726 "wait [<task>] [--timeout <seconds>]",
727 "Block until a task settles. With no task, waits for every task this session started, \
728 in start order, which is how an --exec script waits for work whose id it never saw. \
729 `last` names the most recent. A task that failed or was cancelled sets a non-zero exit \
730 status. Ctrl-C interrupts; the global --timeout does not apply, and this one applies \
731 per task.",
732 ),
733 (
734 "cancel",
735 "cancel <task>",
736 "Ask the server to cancel a task. `last` names the most recent.",
737 ),
738 (
739 "alias",
740 "alias [--global] [<name>=<expansion>]",
741 "Define, list, or show a command alias. Saved to the config file.",
742 ),
743 (
744 "unalias",
745 "unalias [--global] <name>",
746 "Remove the alias that is in effect for a name.",
747 ),
748 (
749 "loglevel",
750 "loglevel <debug|info|notice|warning|error|critical|alert|emergency>",
751 "Ask the server to change how much it logs, via `logging/setLevel`. Levels are the \
752 syslog severities the MCP spec uses, least severe first: a level means that one and \
753 everything more severe. Needs the server to declare the `logging` capability.",
754 ),
755 (
756 "ping",
757 "ping",
758 "Send an empty request and report the round trip. Exits non-zero if the server does not answer.",
759 ),
760 (
761 "refresh",
762 "refresh",
763 "Re-fetch the surface. Usually unnecessary: list_changed notifications refresh it live.",
764 ),
765 (
766 "info",
767 "info",
768 "Replay the connection banner and show the server's capabilities.",
769 ),
770 (
771 "wire",
772 "wire [on|off]",
773 "Trace raw JSON-RPC frames to stderr. Bare `wire` reports the current state.",
774 ),
775 (
776 "last",
777 "last",
778 "Reprint the previous request and response. Frames are recorded whether or not tracing is on.",
779 ),
780 (
781 "history",
782 "history [count]",
783 "List recent commands from previous sessions. Ctrl-R searches them interactively.",
784 ),
785 (
786 "vars",
787 "vars",
788 "List captured variables. Capture with `name = <command>`.",
789 ),
790 ("unset", "unset <name>", "Clear one captured variable."),
791 ("quit", "quit", "Close the session and exit."),
792 ("exit", "exit", "Close the session and exit."),
793];
794
795fn builtin_help(name: &str) -> Option<(&'static str, &'static str)> {
797 BUILTIN_HELP
798 .iter()
799 .find(|(builtin, _, _)| *builtin == name)
800 .map(|(_, usage, detail)| (*usage, *detail))
801}
802
803fn coerce_arg(schema: &serde_json::Value, key: &str, raw: &str) -> serde_json::Value {
805 let ty = schema
806 .get("properties")
807 .and_then(|p| p.get(key))
808 .and_then(|s| s.get("type"))
809 .and_then(|t| t.as_str());
810 match ty {
811 Some("integer") => raw
812 .parse::<i64>()
813 .map(Into::into)
814 .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
815 Some("number") => raw
816 .parse::<f64>()
817 .ok()
818 .and_then(|n| serde_json::Number::from_f64(n).map(serde_json::Value::Number))
819 .unwrap_or_else(|| serde_json::Value::String(raw.to_string())),
820 Some("boolean") => raw
821 .parse::<bool>()
822 .map(serde_json::Value::Bool)
823 .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
824 Some("array") | Some("object") => {
825 serde_json::from_str(raw).unwrap_or_else(|_| serde_json::Value::String(raw.to_string()))
826 }
827 _ => {
828 serde_json::from_str(raw).unwrap_or_else(|_| serde_json::Value::String(raw.to_string()))
830 }
831 }
832}
833
834fn parse_kv_args(schema: &serde_json::Value, tokens: &[&str]) -> serde_json::Value {
835 if tokens.len() == 1
837 && tokens[0].starts_with('{')
838 && let Ok(v) = serde_json::from_str::<serde_json::Value>(tokens[0])
839 {
840 return v;
841 }
842 let mut map = serde_json::Map::new();
843 for t in tokens {
844 if let Some((k, v)) = t.split_once('=') {
845 map.insert(k.to_string(), coerce_arg(schema, k, v));
846 }
847 }
848 serde_json::Value::Object(map)
849}
850
851fn render_content(content: &[Content]) {
852 for c in content {
853 match c {
854 Content::Text { text, .. } => {
855 if style::colors_enabled() && style::looks_like_markdown(text) {
856 println!("{}", style::render_markdown(text));
857 } else {
858 println!("{}", sanitize(text));
859 }
860 }
861 other => {
862 let v = serde_json::to_value(other).unwrap_or_default();
863 let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("content");
864 match ty {
865 "image" | "audio" => {
866 let mime = v.get("mimeType").and_then(|m| m.as_str()).unwrap_or("?");
867 let len = v.get("data").and_then(|d| d.as_str()).map_or(0, str::len);
868 println!(
869 "{}",
870 tag(
871 Style::new(),
872 &format!("{ty} {}, {len} base64 chars", sanitize(mime))
873 )
874 );
875 }
876 _ => println!("{}", json_pretty(&v)),
877 }
878 }
879 }
880 }
881}
882
883fn render_task(task: &TaskObject, label: &str) {
884 println!(
885 "task {} status={} {}",
886 paint(Style::new().bold(), &sanitize(label)),
887 paint(task_status_style(task.status), &task.status.to_string()),
888 sanitize(task.status_message.as_deref().unwrap_or(""))
889 );
890 if let Some(result) = &task.result {
891 if result.is_error {
896 println!("{}", tag(Style::new().fg(Color::Red), "tool error"));
897 }
898 render_content(&result.content);
899 }
900 if let Some(err) = &task.error {
901 println!(
902 "{} {}: {}",
903 style::error_prefix(),
904 err.code,
905 sanitize(&err.message)
906 );
907 }
908}
909
910async fn wait_for_one(
912 client: &McpClient,
913 id: &str,
914 limit: Option<Duration>,
915) -> tower_mcp::Result<TaskObject> {
916 match limit {
917 None => client.task_wait(id).await,
918 Some(limit) => match tokio::time::timeout(limit, client.task_wait(id)).await {
919 Ok(result) => result,
920 Err(_) => Err(tower_mcp::Error::Transport(format!(
921 "task {id} was still running after {}s (--timeout)",
922 limit.as_secs()
923 ))),
924 },
925 }
926}
927
928fn note_settled_task(task: &TaskObject) {
935 use tower_mcp::protocol::TaskStatus;
936 if task.error.is_some() || task.result.as_ref().is_some_and(|r| r.is_error) {
941 note_error(ExitStatus::Server);
942 return;
943 }
944 match task.status {
945 TaskStatus::Failed => note_error(ExitStatus::Server),
946 TaskStatus::Cancelled => note_error(ExitStatus::Cancelled),
950 _ => {}
951 }
952}
953
954async fn wait_for_all(
958 client: &McpClient,
959 jobs: &Arc<Jobs>,
960 limit: Option<Duration>,
961 started: std::time::Instant,
962) {
963 let ids = jobs.all_ids();
964 if ids.is_empty() {
965 report_error(
966 ExitStatus::NoMatch,
967 "no tasks in this session to wait for (start one with a trailing `&`)",
968 );
969 return;
970 }
971 let mut settled = Vec::new();
972 for id in &ids {
973 match wait_for_one(client, id, limit).await {
974 Ok(task) => {
975 jobs.sync(id, task.status, task.status_message.clone());
976 note_settled_task(&task);
977 if !json_output() {
978 render_task(&task, &jobs.label_for(&task.task_id));
979 }
980 settled.push(task);
981 }
982 Err(e) => report_mcp_error(&e),
985 }
986 }
987 if json_output() {
988 print_json(&serde_json::to_value(&settled).unwrap_or_default());
991 } else {
992 println!("{}", timing(started.elapsed()));
993 }
994}
995
996async fn respond_to_task(client: &McpClient, id: &str, label: &str) {
1004 use tower_mcp::protocol::{InputRequest, InputResponse, InputResponses};
1005
1006 if client.selected_protocol_version().await.as_deref()
1010 != Some(tower_mcp::protocol::PROTOCOL_VERSION_2026_07_28)
1011 {
1012 report_error(
1013 ExitStatus::Usage,
1014 "`respond` needs --protocol 2026-07-28: only that lifecycle reports what a task is \
1015 waiting for. On the stable lifecycle a server asks by sending `elicitation/create` \
1016 itself, which is declined while the editor holds the terminal, so run the tool in \
1017 the foreground instead of as a task",
1018 );
1019 return;
1020 }
1021 let detailed = match client.task_get_detailed(id).await {
1022 Ok(detailed) => detailed,
1023 Err(e) => {
1024 report_mcp_error(&e);
1025 return;
1026 }
1027 };
1028 let Some(outstanding) = detailed.task.input_requests().filter(|r| !r.is_empty()) else {
1029 report_error(
1030 ExitStatus::NoMatch,
1031 &format!(
1032 "task {label} is not waiting for input (status: {})",
1033 detailed.task.status()
1034 ),
1035 );
1036 return;
1037 };
1038
1039 let server = connection_info(client)
1040 .await
1041 .map(|info| info.server_info.name)
1042 .unwrap_or_default();
1043 let mut responses = InputResponses::new();
1044 for (key, request) in outstanding.clone() {
1045 match request {
1046 InputRequest::Elicit(params) => {
1047 let answer = elicit::answer_in_foreground(&server, params).await;
1048 responses.insert(key, InputResponse::Elicit(answer));
1049 }
1050 InputRequest::CreateMessage(params) => {
1051 match tokio::task::spawn_blocking(move || sampling::prompt(¶ms)).await {
1052 Ok(Ok(result)) => {
1053 responses.insert(key, InputResponse::CreateMessage(result));
1054 }
1055 Ok(Err(e)) => command_error(&format!(
1059 "could not answer `{}`: {}",
1060 sanitize(&key),
1061 sanitize(&e.message)
1062 )),
1063 Err(e) => command_error(&format!("could not answer `{}`: {e}", sanitize(&key))),
1064 }
1065 }
1066 InputRequest::ListRoots(_) => {
1069 println!(
1070 "{} answered `{}` with no roots (mcp-repl declares none)",
1071 tag(Style::new().fg(Color::Purple), "elicit"),
1072 sanitize(&key)
1073 );
1074 responses.insert(
1075 key,
1076 InputResponse::ListRoots(tower_mcp::protocol::ListRootsResult {
1077 roots: Vec::new(),
1078 meta: None,
1079 }),
1080 );
1081 }
1082 other => command_error(&format!(
1083 "cannot answer `{}`: unsupported request {}",
1084 sanitize(&key),
1085 sanitize(other.method_name())
1086 )),
1087 }
1088 }
1089
1090 if responses.is_empty() {
1091 report_error(
1092 ExitStatus::Usage,
1093 &format!("nothing was answered, so task {label} is still waiting"),
1094 );
1095 return;
1096 }
1097 if let Err(e) = client.task_update(id, responses).await {
1098 report_mcp_error(&e);
1099 return;
1100 }
1101 match client.task_get(id).await {
1104 Ok(task) if json_output() => print_json(&serde_json::to_value(&task).unwrap_or_default()),
1105 Ok(task) => render_task(&task, label),
1106 Err(e) => report_mcp_error(&e),
1107 }
1108}
1109
1110#[derive(Clone, Debug)]
1112struct ConnectionInfo {
1113 protocol_version: String,
1114 capabilities: ServerCapabilities,
1115 server_info: Implementation,
1116 instructions: Option<String>,
1117}
1118
1119impl From<InitializeResult> for ConnectionInfo {
1120 fn from(info: InitializeResult) -> Self {
1121 Self {
1122 protocol_version: info.protocol_version,
1123 capabilities: info.capabilities,
1124 server_info: info.server_info,
1125 instructions: info.instructions,
1126 }
1127 }
1128}
1129
1130impl ConnectionInfo {
1131 fn from_discovery(discovery: DiscoverResult, protocol_version: String) -> Self {
1132 let server_info = discovery
1133 .meta
1134 .as_ref()
1135 .and_then(|meta| meta.server_info.clone())
1136 .unwrap_or_else(|| Implementation {
1137 name: "MCP server".to_string(),
1138 version: "unknown".to_string(),
1139 ..Default::default()
1140 });
1141 Self {
1142 protocol_version,
1143 capabilities: discovery.capabilities,
1144 server_info,
1145 instructions: discovery.instructions,
1146 }
1147 }
1148}
1149
1150async fn connection_info(client: &McpClient) -> Option<ConnectionInfo> {
1151 if let Some(info) = client.server_info().await {
1152 return Some(info.into());
1153 }
1154 let discovery = client.discovery().await?;
1155 let protocol_version = client.selected_protocol_version().await?;
1156 Some(ConnectionInfo::from_discovery(discovery, protocol_version))
1157}
1158
1159async fn establish_connection(
1160 client: &McpClient,
1161 protocol: ProtocolMode,
1162) -> tower_mcp::Result<ConnectionInfo> {
1163 match protocol {
1164 ProtocolMode::Stable => client
1165 .initialize("mcp-repl", env!("CARGO_PKG_VERSION"))
1166 .await
1167 .map(Into::into),
1168 ProtocolMode::Final => {
1169 let discovery: DiscoverResult = client
1170 .discover("mcp-repl", env!("CARGO_PKG_VERSION"))
1171 .await?;
1172 let protocol_version = client
1173 .selected_protocol_version()
1174 .await
1175 .unwrap_or_else(|| "2026-07-28".to_string());
1176 Ok(ConnectionInfo::from_discovery(discovery, protocol_version))
1177 }
1178 }
1179}
1180
1181fn client_builder(protocol: ProtocolMode) -> Result<McpClientBuilder, ProtocolSupportError> {
1182 let builder = McpClient::builder()
1183 .protocol_support(protocol.support()?)
1184 .with_elicitation()
1185 .with_sampling()
1186 .request_progress();
1190 Ok(match protocol {
1191 ProtocolMode::Stable => builder,
1192 ProtocolMode::Final => builder.with_tasks(),
1193 })
1194}
1195
1196fn print_banner(info: &ConnectionInfo) {
1200 println!(
1201 "connected: {} v{} {}",
1202 paint(Style::new().bold(), &sanitize(&info.server_info.name)),
1203 sanitize(&info.server_info.version),
1204 paint(
1205 Style::new().dimmed(),
1206 &format!("(protocol {})", sanitize(&info.protocol_version))
1207 )
1208 );
1209 if let Some(instructions) = &info.instructions {
1210 if style::colors_enabled() && style::looks_like_markdown(instructions) {
1211 println!("{}", style::render_markdown(instructions));
1212 } else {
1213 println!("{}", sanitize(instructions));
1214 }
1215 }
1216}
1217
1218pub(crate) fn timing(elapsed: Duration) -> String {
1222 let body = if elapsed.as_millis() < 1000 {
1223 format!("[{}ms]", elapsed.as_millis())
1224 } else {
1225 format!("[{:.2}s]", elapsed.as_secs_f64())
1226 };
1227 paint(Style::new().dimmed(), &body)
1228}
1229
1230fn listing_limit() -> Option<usize> {
1239 if json_output() || !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
1240 return None;
1241 }
1242 const RESERVED: usize = 4;
1244 const FALLBACK_ROWS: usize = 24;
1245 let rows = crossterm::terminal::size()
1246 .map(|(_, rows)| rows as usize)
1247 .unwrap_or(FALLBACK_ROWS);
1248 Some(rows.saturating_sub(RESERVED).max(5))
1250}
1251
1252fn note_truncation(shown: usize, total: usize, full: &str) {
1257 if shown >= total {
1258 return;
1259 }
1260 println!(
1261 "{}",
1262 paint(
1263 Style::new().dimmed(),
1264 &format!(
1265 "... {} more of {total}; `{full}` shows everything",
1266 total - shown
1267 )
1268 )
1269 );
1270}
1271
1272fn print_tool_overview(surface: &Surface) {
1276 if surface.tools.is_empty() {
1277 return;
1278 }
1279 let cap = listing_limit().map_or(surface.tools.len(), |rows| (rows / 2).max(5));
1282 for t in surface.tools.iter().take(cap) {
1283 println!(
1284 "{} {}{}",
1285 style::column(Style::new().fg(Color::Green), &sanitize(&t.name), 24),
1286 sanitize(t.description.as_deref().unwrap_or("")),
1287 tool_tag_suffix(t)
1288 );
1289 }
1290 if surface.tools.len() > cap {
1291 println!(
1292 "{}",
1293 paint(
1294 Style::new().dimmed(),
1295 &format!("... +{} more, type `tools`", surface.tools.len() - cap)
1296 )
1297 );
1298 }
1299}
1300
1301fn print_find(surface: &Surface, query: &find::Query, output: &vars::Output) {
1305 let hits = find::search_query(surface, query);
1306 if !output.is_plain() || json_output() {
1307 let v: Vec<serde_json::Value> = hits
1308 .iter()
1309 .map(|h| {
1310 serde_json::json!({
1311 "kind": h.kind.heading(),
1312 "name": h.name,
1313 "description": h.description,
1314 "score": h.score,
1315 })
1316 })
1317 .collect();
1318 if v.is_empty() {
1321 note_error(ExitStatus::NoMatch);
1322 }
1323 emit_value(serde_json::Value::Array(v), output, || {
1324 unreachable!("plain output handled below")
1325 });
1326 return;
1327 }
1328 if hits.is_empty() {
1329 report_error(ExitStatus::NoMatch, &format!("no match for {}", query.text));
1332 return;
1333 }
1334 let total = hits.len();
1335 for (kind, group) in find::grouped(hits) {
1336 println!("{}:", paint(Style::new().bold(), kind.heading()));
1337 for hit in group {
1338 println!(
1339 " {} {}",
1340 style::column(Style::new().fg(Color::Green), &sanitize(&hit.name), 24),
1341 sanitize(&hit.description)
1342 );
1343 }
1344 }
1345 println!(
1346 "{}",
1347 paint(
1348 Style::new().dimmed(),
1349 &format!("{total} match{}", if total == 1 { "" } else { "es" })
1350 )
1351 );
1352}
1353
1354fn print_counts(surface: &Surface) {
1356 println!(
1357 "{}, {}, {}, {}. Type `help`.",
1358 plural(surface.tools.len(), "tool"),
1359 plural(surface.prompts.len(), "prompt"),
1360 plural(surface.resources.len(), "resource"),
1361 plural(surface.templates.len(), "template")
1362 );
1363}
1364
1365fn print_first_run_hint() {
1369 println!(
1370 "{}",
1371 paint(
1372 Style::new().dimmed(),
1373 "Tab completes · `find <word>` searches · `describe <name>` shows \
1374 schemas · `&` runs a tool as a task"
1375 )
1376 );
1377}
1378
1379fn plural(count: usize, noun: &str) -> String {
1381 if count == 1 {
1382 format!("{count} {noun}")
1383 } else {
1384 format!("{count} {noun}s")
1385 }
1386}
1387
1388async fn with_reconnect<T, F, Fut>(
1400 session: &Session,
1401 surface: &Arc<RwLock<Surface>>,
1402 op: F,
1403) -> Result<T, tower_mcp::Error>
1404where
1405 F: Fn(Arc<McpClient>) -> Fut,
1406 Fut: Future<Output = Result<T, tower_mcp::Error>>,
1407{
1408 let seen = session.generation();
1409 let err = match with_deadline(op(session.client())).await {
1412 Ok(value) => return Ok(value),
1413 Err(e) => e,
1414 };
1415 if !session.can_reconnect() || !is_session_lost(&err) {
1416 return Err(err);
1417 }
1418 if let Err(reconnect_err) = session.reconnect(seen).await {
1419 eprintln!("reconnect failed: {reconnect_err}");
1420 return Err(err);
1421 }
1422 *surface.write().unwrap() = fetch_surface(&session.client()).await;
1427 eprintln!("{}", paint(Style::new().dimmed(), "[reconnected]"));
1430
1431 let retried = with_deadline(op(session.client())).await;
1432 if let Err(e) = &retried
1433 && is_session_lost(e)
1434 {
1435 eprintln!(
1436 "still no session after reconnecting. The server is likely down or \
1437 restart-looping; check its logs, or pass --no-reconnect to see the \
1438 raw errors."
1439 );
1440 }
1441 retried
1442}
1443
1444const MAX_SURFACE_PAGES: usize = 100;
1451const MAX_SURFACE_ITEMS: usize = 10_000;
1452
1453async fn collect_pages<T, F, Fut>(what: &str, mut page: F) -> Result<Vec<T>, tower_mcp::Error>
1459where
1460 F: FnMut(Option<String>) -> Fut,
1461 Fut: Future<Output = Result<(Vec<T>, Option<String>), tower_mcp::Error>>,
1462{
1463 let mut all: Vec<T> = Vec::new();
1464 let mut cursor: Option<String> = None;
1465 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1466 for _ in 0..MAX_SURFACE_PAGES {
1467 let (items, next) = page(cursor).await?;
1468 all.extend(items);
1469 if all.len() >= MAX_SURFACE_ITEMS {
1470 all.truncate(MAX_SURFACE_ITEMS);
1471 eprintln!(
1472 "warning: {what} stopped at {MAX_SURFACE_ITEMS} entries; the server offered more"
1473 );
1474 return Ok(all);
1475 }
1476 match next {
1477 None => return Ok(all),
1478 Some(next) if !seen.insert(next.clone()) => {
1479 eprintln!("warning: {what} paging stopped: the server repeated a cursor");
1480 return Ok(all);
1481 }
1482 Some(next) => cursor = Some(next),
1483 }
1484 }
1485 eprintln!("warning: {what} stopped after {MAX_SURFACE_PAGES} pages; the server offered more");
1486 Ok(all)
1487}
1488
1489async fn fetch_surface_once(client: &McpClient) -> (Surface, bool) {
1492 struct Outcome {
1493 not_initialized: bool,
1494 unavailable: Vec<&'static str>,
1495 }
1496
1497 fn take<T>(
1498 what: &'static str,
1499 r: Option<Result<Vec<T>, tower_mcp::Error>>,
1500 at: &mut Outcome,
1501 ) -> Vec<T> {
1502 match r {
1503 None => Vec::new(),
1506 Some(Ok(v)) => v,
1507 Some(Err(e)) => {
1508 if is_not_initialized(&e) {
1509 at.not_initialized = true;
1510 } else {
1511 eprintln!(
1512 "warning: fetching {what} failed: {}",
1513 describe_mcp_error(&e)
1514 );
1515 note_error(ExitStatus::Transport);
1520 at.unavailable.push(what);
1521 }
1522 Vec::new()
1523 }
1524 }
1525 }
1526
1527 let declared = connection_info(client).await.map(|info| info.capabilities);
1533 let has = |pick: fn(&ServerCapabilities) -> bool| declared.as_ref().is_none_or(pick);
1534 let (want_tools, want_prompts, want_resources) = (
1535 has(|c| c.tools.is_some()),
1536 has(|c| c.prompts.is_some()),
1537 has(|c| c.resources.is_some()),
1538 );
1539 let (tools, prompts, resources, templates) = tokio::join!(
1552 maybe(want_tools, async {
1553 with_deadline(collect_pages("tools", |cursor| async move {
1554 let page = client.list_tools_with_cursor(cursor).await?;
1555 Ok((page.tools, page.next_cursor))
1556 }))
1557 .await
1558 }),
1559 maybe(want_prompts, async {
1560 with_deadline(collect_pages("prompts", |cursor| async move {
1561 let page = client.list_prompts_with_cursor(cursor).await?;
1562 Ok((page.prompts, page.next_cursor))
1563 }))
1564 .await
1565 }),
1566 maybe(want_resources, async {
1567 with_deadline(collect_pages("resources", |cursor| async move {
1568 let page = client.list_resources_with_cursor(cursor).await?;
1569 Ok((page.resources, page.next_cursor))
1570 }))
1571 .await
1572 }),
1573 maybe(want_resources, async {
1576 with_deadline(collect_pages("resource templates", |cursor| async move {
1577 let page = client.list_resource_templates_with_cursor(cursor).await?;
1578 Ok((page.resource_templates, page.next_cursor))
1579 }))
1580 .await
1581 }),
1582 );
1583 let mut at = Outcome {
1584 not_initialized: false,
1585 unavailable: Vec::new(),
1586 };
1587 let surface = Surface {
1588 tools: take("tools", tools, &mut at),
1589 prompts: take("prompts", prompts, &mut at),
1590 resources: take("resources", resources, &mut at),
1591 templates: take("resource templates", templates, &mut at),
1592 unavailable: std::mem::take(&mut at.unavailable),
1593 };
1594 (surface, at.not_initialized)
1595}
1596
1597async fn maybe<T, F: Future<Output = T>>(wanted: bool, work: F) -> Option<T> {
1599 if wanted { Some(work.await) } else { None }
1600}
1601
1602async fn fetch_surface(client: &McpClient) -> Surface {
1603 fetch_surface_once(client).await.0
1604}
1605
1606async fn refresh_surface(session: &Session) -> Surface {
1611 let (fresh, not_initialized) = fetch_surface_once(&session.client()).await;
1612 if !not_initialized || !session.can_reconnect() {
1613 return fresh;
1614 }
1615 let seen = session.generation();
1616 match session.reconnect(seen).await {
1617 Ok(()) => {
1618 eprintln!("{}", paint(Style::new().dimmed(), "[reconnected]"));
1619 fetch_surface(&session.client()).await
1620 }
1621 Err(e) => {
1622 eprintln!("reconnect failed: {e}");
1623 fresh
1624 }
1625 }
1626}
1627
1628async fn fetch_surface_initial(client: &McpClient) -> Surface {
1631 const ATTEMPTS: usize = 4;
1632 for attempt in 1..=ATTEMPTS {
1633 let (surface, not_initialized) = fetch_surface_once(client).await;
1634 if !not_initialized {
1635 return surface;
1636 }
1637 if attempt == ATTEMPTS {
1638 eprintln!(
1639 "warning: the server kept rejecting surface requests as not-initialized \
1640 after {ATTEMPTS} attempts. The session the handshake established is not \
1641 being recognized on follow-up requests. Two common causes: the server runs \
1642 multiple instances without a shared session store, so requests scatter \
1643 across instances; or a single instance restarted (crash, OOM, or redeploy) \
1644 between requests and lost its in-memory sessions. Try `refresh`. A \
1645 persistent session store or the stateless protocol avoids both; if it is a \
1646 single instance, check its logs and resources (an OOM-looping machine \
1647 flaps like this)."
1648 );
1649 return surface;
1650 }
1651 tokio::time::sleep(Duration::from_millis(200 * attempt as u64)).await;
1652 }
1653 unreachable!()
1654}
1655
1656fn build_http_config(
1663 bearer: Option<String>,
1664 headers: &[String],
1665 profile_bearer: Option<String>,
1666 profile_headers: &[(String, String)],
1667) -> Result<HttpClientConfig, String> {
1668 build_http_config_with_env(
1669 bearer,
1670 headers,
1671 profile_bearer,
1672 profile_headers,
1673 std::env::var("MCP_BEARER").ok(),
1674 )
1675}
1676
1677fn build_http_config_with_env(
1678 bearer: Option<String>,
1679 headers: &[String],
1680 profile_bearer: Option<String>,
1681 profile_headers: &[(String, String)],
1682 env_bearer: Option<String>,
1683) -> Result<HttpClientConfig, String> {
1684 let mut config = HttpClientConfig::default();
1685 for (name, value) in profile_headers {
1686 config = config.header(name.as_str(), value.as_str());
1687 }
1688 let selected_has_authorization = profile_headers
1689 .iter()
1690 .any(|(name, _)| name.eq_ignore_ascii_case("authorization"));
1691 if let Some(token) = bearer.or(profile_bearer).or_else(|| {
1692 (!selected_has_authorization)
1693 .then_some(env_bearer)
1694 .flatten()
1695 }) {
1696 config = config.bearer_token(token);
1697 }
1698 for raw in headers {
1699 let (name, value) = raw
1700 .split_once(':')
1701 .ok_or_else(|| format!("invalid --header {raw:?}: expected `Name: Value`"))?;
1702 config = config.header(name.trim(), value.trim());
1703 }
1704 config.request_timeout = request_timeout().unwrap_or(Duration::from_secs(365 * 24 * 60 * 60));
1710 Ok(config)
1711}
1712
1713fn selected_oauth_profile(
1714 cli_oauth: Option<&str>,
1715 profile_oauth: Option<&str>,
1716 cli_bearer: bool,
1717 cli_headers: &[String],
1718) -> Option<String> {
1719 let explicit_authorization = cli_bearer
1720 || cli_headers.iter().any(|header| {
1721 header
1722 .split_once(':')
1723 .is_some_and(|(name, _)| name.trim().eq_ignore_ascii_case("authorization"))
1724 });
1725 (!explicit_authorization)
1726 .then(|| cli_oauth.or(profile_oauth).map(str::to_string))
1727 .flatten()
1728}
1729
1730fn demo_router() -> tower_mcp::McpRouter {
1731 use tower_mcp::context::RequestContext;
1732 use tower_mcp::extract::{Context, Json, RawArgs};
1733 use tower_mcp::protocol::ToolAnnotations;
1734 use tower_mcp::protocol::{
1735 CompleteResult, CompletionReference, ElicitRequestParams, InputRequest, InputRequests,
1736 InputRequiredResult, InputResponse, ReadResourceResult, RequestOutcome,
1737 };
1738 use tower_mcp::resource::ResourceTemplateBuilder;
1739 use tower_mcp::{CallToolResult, PromptBuilder, TaskSupportMode, ToolBuilder};
1740
1741 fn local_read_only() -> ToolAnnotations {
1746 ToolAnnotations {
1747 read_only_hint: true,
1748 idempotent_hint: true,
1749 destructive_hint: false,
1750 open_world_hint: false,
1751 ..Default::default()
1752 }
1753 }
1754
1755 const PIXEL_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
1757
1758 const NOTES: &[(&str, &str)] = &[
1759 ("groceries", "- eggs\n- coffee"),
1760 ("ideas", "# Ideas\n\n- a REPL for MCP servers"),
1761 ("todo", "1. ship it"),
1762 ];
1763
1764 tower_mcp::McpRouter::new()
1765 .server_info("mcp-repl-demo", env!("CARGO_PKG_VERSION"))
1766 .with_tasks()
1767 .prompt(
1768 PromptBuilder::new("greet")
1769 .description("Generate a greeting (name tab-completes via the server)")
1770 .required_arg("name", "The person to greet")
1771 .handler(|args| async move {
1772 let name = args.get("name").map(|s| s.as_str()).unwrap_or("World");
1773 Ok(tower_mcp::GetPromptResult::user_message(format!(
1774 "Please greet {name} warmly."
1775 )))
1776 })
1777 .build(),
1778 )
1779 .resource(
1784 tower_mcp::resource::ResourceBuilder::new("note://status")
1785 .name("Status")
1786 .description("A one-line status note (subscribe to it)")
1787 .mime_type("text/plain")
1788 .handler(|| async {
1789 Ok(ReadResourceResult::text(
1790 "note://status",
1791 "all quiet on the demo server",
1792 ))
1793 })
1794 .build(),
1795 )
1796 .resource(
1800 tower_mcp::resource::ResourceBuilder::new("img://pixel")
1801 .name("Pixel")
1802 .description("A 1x1 transparent PNG (try `read img://pixel --out pixel.png`)")
1803 .mime_type("image/png")
1804 .handler(|| async {
1805 Ok(ReadResourceResult {
1806 contents: vec![tower_mcp::protocol::ResourceContent {
1807 uri: "img://pixel".to_string(),
1808 mime_type: Some("image/png".to_string()),
1809 text: None,
1810 blob: Some(PIXEL_PNG.to_string()),
1811 meta: None,
1812 }],
1813 ..Default::default()
1814 })
1815 })
1816 .build(),
1817 )
1818 .resource_template(
1819 ResourceTemplateBuilder::new("note://{name}")
1820 .name("Notes")
1821 .description("Tiny in-memory notes (name tab-completes via the server)")
1822 .mime_type("text/markdown")
1823 .handler(
1824 |uri: String, vars: std::collections::HashMap<String, String>| async move {
1825 let name = vars.get("name").cloned().unwrap_or_default();
1826 let text = NOTES
1827 .iter()
1828 .find(|(n, _)| *n == name)
1829 .map(|(_, t)| (*t).to_string())
1830 .unwrap_or_else(|| format!("no note named `{name}`"));
1831 Ok(ReadResourceResult::text(uri, text))
1832 },
1833 ),
1834 )
1835 .completion_handler(|params| async move {
1836 let partial = params.argument.value;
1837 let candidates: Vec<String> = match ¶ms.reference {
1838 CompletionReference::Prompt { name } if name == "greet" => {
1839 ["Ada", "Alan", "Grace", "Linus"]
1840 .iter()
1841 .map(|s| s.to_string())
1842 .collect()
1843 }
1844 CompletionReference::Resource { uri } if uri == "note://{name}" => {
1845 NOTES.iter().map(|(n, _)| n.to_string()).collect()
1846 }
1847 _ => Vec::new(),
1848 };
1849 Ok(CompleteResult::new(
1850 candidates
1851 .into_iter()
1852 .filter(|c| c.starts_with(&partial))
1853 .collect::<Vec<_>>(),
1854 ))
1855 })
1856 .tool(
1857 ToolBuilder::new("echo")
1858 .description("Echo a message back")
1859 .annotations(local_read_only())
1860 .handler(|input: EchoInput| async move {
1861 let text = match input.repeat {
1862 1 => input.message,
1863 n => std::iter::repeat_n(input.message.as_str(), n as usize)
1864 .collect::<Vec<_>>()
1865 .join(" "),
1866 };
1867 Ok(CallToolResult::text(text))
1868 })
1869 .build(),
1870 )
1871 .tool(
1872 ToolBuilder::new("about")
1873 .description("Notes about this demo server, in markdown")
1874 .annotations(local_read_only())
1875 .extractor_handler((), |RawArgs(_): RawArgs| async move {
1876 Ok(CallToolResult::text(
1877 "# mcp-repl demo\n\n\
1878 A tiny in-process router for exploring the REPL.\n\n\
1879 - `echo message=hi` echoes back, and `echo <Tab>` completes its arguments\n\
1880 - `convert value=100 to=<Tab>` completes the enum values\n\
1881 - `slow_add a=2 b=3 &` runs **task-augmented**\n\
1882 - `scan steps=5` reports **progress** while it runs\n\
1883 - `sign_in` asks *you* for the answers (elicitation)\n\
1884 - `describe slow_add` shows the tool's schemas\n",
1885 ))
1886 })
1887 .build(),
1888 )
1889 .tool(
1890 ToolBuilder::new("convert")
1891 .description("Convert a temperature between scales")
1892 .annotations(local_read_only())
1893 .handler(|input: ConvertInput| async move {
1894 let celsius = match input.from {
1895 Scale::Celsius => input.value,
1896 Scale::Fahrenheit => (input.value - 32.0) * 5.0 / 9.0,
1897 Scale::Kelvin => input.value - 273.15,
1898 };
1899 let out = match input.to {
1900 Scale::Celsius => celsius,
1901 Scale::Fahrenheit => celsius * 9.0 / 5.0 + 32.0,
1902 Scale::Kelvin => celsius + 273.15,
1903 };
1904 Ok(CallToolResult::text(format!("{out:.2}")))
1905 })
1906 .build(),
1907 )
1908 .tool(
1909 ToolBuilder::new("slow_add")
1910 .description("Add two numbers, slowly")
1911 .task_support(TaskSupportMode::Optional)
1912 .annotations(local_read_only())
1913 .handler(|input: AddInput| async move {
1914 tokio::time::sleep(Duration::from_secs(3)).await;
1915 Ok(CallToolResult::text((input.a + input.b).to_string()))
1916 })
1917 .build(),
1918 )
1919 .tool(
1922 ToolBuilder::new("scan")
1923 .description("Scan slowly, reporting progress")
1924 .annotations(local_read_only())
1925 .extractor_handler(
1926 (),
1927 |ctx: Context, Json(input): Json<ScanInput>| async move {
1928 let steps = input.steps.clamp(1, 20);
1929 for step in 1..=steps {
1930 ctx.report_progress(
1931 f64::from(step),
1932 Some(f64::from(steps)),
1933 Some(&format!("scanned {step} of {steps}")),
1934 )
1935 .await;
1936 tokio::time::sleep(Duration::from_millis(400)).await;
1937 }
1938 Ok(CallToolResult::text(format!("scanned {steps} items")))
1939 },
1940 )
1941 .build(),
1942 )
1943 .tool(
1951 ToolBuilder::new("fail")
1952 .description("Always fails (try `fail &` then `wait`)")
1953 .annotations(local_read_only())
1954 .task_support(TaskSupportMode::Optional)
1955 .extractor_handler((), |_ctx: Context, RawArgs(_): RawArgs| async move {
1956 Ok(CallToolResult::error("the demo `fail` tool always fails"))
1960 })
1961 .build(),
1962 )
1963 .tool(
1966 ToolBuilder::new("sign_in")
1967 .description("Ask you for credentials (elicitation demo)")
1968 .task_support(TaskSupportMode::Optional)
1973 .mrtr_handler(|ctx: RequestContext, _input: SignInInput| async move {
1977 if let Some(responses) = ctx.input_responses() {
1980 let answer = responses.values().find_map(|response| match response {
1981 InputResponse::Elicit(result) => Some(result.clone()),
1982 _ => None,
1983 });
1984 return Ok(RequestOutcome::Complete(CallToolResult::text(
1985 describe_sign_in(answer.as_ref()),
1986 )));
1987 }
1988 if !ctx.can_elicit() {
1989 let mut requests = InputRequests::new();
1996 requests.insert(
1997 "credentials".to_string(),
1998 InputRequest::Elicit(ElicitRequestParams::Form(sign_in_form())),
1999 );
2000 return Ok(RequestOutcome::input_required(
2001 InputRequiredResult::with_requests(requests),
2002 ));
2003 }
2004 let answer = ctx.elicit_form(sign_in_form()).await?;
2006 Ok(RequestOutcome::Complete(CallToolResult::text(
2007 describe_sign_in(Some(&answer)),
2008 )))
2009 })
2010 .build(),
2011 )
2012 .tool(
2016 ToolBuilder::new("summarize")
2017 .description("Ask your client for a one-line summary (sampling demo)")
2018 .annotations(local_read_only())
2019 .mrtr_handler(|ctx: RequestContext, input: SummarizeInput| async move {
2023 if let Some(responses) = ctx.input_responses() {
2024 let answer = responses.values().find_map(|response| match response {
2025 InputResponse::CreateMessage(result) => Some(result.clone()),
2026 _ => None,
2027 });
2028 return Ok(RequestOutcome::Complete(CallToolResult::text(
2029 describe_summary(answer.as_ref()),
2030 )));
2031 }
2032 let params = summarize_request(&input.text);
2033 if !ctx.can_sample() {
2034 let mut requests = InputRequests::new();
2035 requests.insert(
2036 "summary".to_string(),
2037 InputRequest::CreateMessage(params),
2038 );
2039 return Ok(RequestOutcome::input_required(
2040 InputRequiredResult::with_requests(requests),
2041 ));
2042 }
2043 let answer = ctx.sample(params).await?;
2044 Ok(RequestOutcome::Complete(CallToolResult::text(
2045 describe_summary(Some(&answer)),
2046 )))
2047 })
2048 .build(),
2049 )
2050}
2051
2052#[derive(serde::Deserialize, schemars::JsonSchema)]
2054struct SummarizeInput {
2055 text: String,
2057}
2058
2059fn summarize_request(text: &str) -> tower_mcp::protocol::CreateMessageParams {
2061 use tower_mcp::protocol::{
2062 ContentRole, CreateMessageParams, SamplingContent, SamplingContentOrArray, SamplingMessage,
2063 };
2064 CreateMessageParams {
2065 messages: vec![SamplingMessage {
2066 role: ContentRole::User,
2067 content: SamplingContentOrArray::Single(SamplingContent::Text {
2068 text: format!("Summarize this in one line:\n\n{text}"),
2069 annotations: None,
2070 meta: None,
2071 }),
2072 meta: None,
2073 }],
2074 max_tokens: 64,
2075 system_prompt: Some("You write single-line summaries.".to_string()),
2076 temperature: None,
2077 stop_sequences: Vec::new(),
2078 model_preferences: None,
2079 include_context: None,
2080 metadata: None,
2081 tools: None,
2082 tool_choice: None,
2083 task: None,
2084 meta: None,
2085 }
2086}
2087
2088fn describe_summary(answer: Option<&tower_mcp::protocol::CreateMessageResult>) -> String {
2090 use tower_mcp::protocol::SamplingContent;
2091 let Some(answer) = answer else {
2092 return "no summary: the client declined the sampling request".to_string();
2093 };
2094 let text: String = answer
2095 .content
2096 .items()
2097 .iter()
2098 .filter_map(|item| match item {
2099 SamplingContent::Text { text, .. } => Some(text.as_str()),
2100 _ => None,
2101 })
2102 .collect::<Vec<_>>()
2103 .join(" ");
2104 format!("summary ({}): {text}", answer.model)
2105}
2106
2107#[derive(serde::Deserialize, schemars::JsonSchema)]
2109struct SignInInput {}
2110
2111fn sign_in_form() -> tower_mcp::protocol::ElicitFormParams {
2113 tower_mcp::protocol::ElicitFormParams {
2114 mode: None,
2115 message: "The demo server would like to know who you are.".to_string(),
2116 requested_schema: tower_mcp::protocol::ElicitFormSchema::new()
2117 .string_field("username", Some("Any name will do"), true)
2118 .enum_field(
2119 "environment",
2120 Some("Which environment to sign in to"),
2121 vec!["staging".to_string(), "production".to_string()],
2122 false,
2123 )
2124 .boolean_field("remember_me", Some("Stay signed in"), false),
2125 meta: None,
2126 }
2127}
2128
2129fn describe_sign_in(answer: Option<&tower_mcp::protocol::ElicitResult>) -> String {
2131 use tower_mcp::protocol::ElicitAction;
2132 let Some(answer) = answer else {
2133 return "no answer".to_string();
2134 };
2135 match answer.action {
2136 ElicitAction::Accept => {
2137 let content = answer.content.clone().unwrap_or_default();
2138 let username = content
2139 .get("username")
2140 .and_then(|v| serde_json::to_value(v).ok())
2141 .and_then(|v| v.as_str().map(str::to_string))
2142 .unwrap_or_else(|| "(nobody)".to_string());
2143 format!("signed in as {username}")
2144 }
2145 ElicitAction::Decline => "declined".to_string(),
2146 _ => "cancelled".to_string(),
2147 }
2148}
2149
2150#[derive(serde::Deserialize, schemars::JsonSchema)]
2155struct EchoInput {
2156 message: String,
2158 #[serde(default = "one")]
2160 repeat: u8,
2161}
2162
2163fn one() -> u8 {
2164 1
2165}
2166
2167#[derive(serde::Deserialize, schemars::JsonSchema)]
2169struct AddInput {
2170 a: i64,
2172 b: i64,
2174}
2175
2176#[derive(serde::Deserialize, schemars::JsonSchema)]
2178struct ScanInput {
2179 #[serde(default = "five")]
2181 steps: u32,
2182}
2183
2184fn five() -> u32 {
2185 5
2186}
2187
2188#[derive(serde::Deserialize, schemars::JsonSchema)]
2193#[serde(rename_all = "lowercase")]
2194enum Scale {
2195 Celsius,
2196 Fahrenheit,
2197 Kelvin,
2198}
2199
2200#[derive(serde::Deserialize, schemars::JsonSchema)]
2202struct ConvertInput {
2203 value: f64,
2205 from: Scale,
2207 to: Scale,
2209}
2210
2211const SURFACE_REFRESH_DEBOUNCE: Duration = Duration::from_millis(250);
2217
2218type RefreshSignal = Arc<tokio::sync::watch::Sender<u64>>;
2223
2224fn note_surface_change(signal: &RefreshSignal) {
2225 signal.send_modify(|seen| *seen = seen.wrapping_add(1));
2226}
2227
2228fn notification_handler(
2232 refresh: RefreshSignal,
2233 output: AsyncOutput,
2234 jobs: Arc<Jobs>,
2235) -> NotificationHandler {
2236 let t = refresh.clone();
2237 let r = refresh.clone();
2238 let p = refresh;
2239 NotificationHandler::new()
2240 .on_tools_changed(move || note_surface_change(&t))
2241 .on_resources_changed(move || note_surface_change(&r))
2242 .on_prompts_changed(move || note_surface_change(&p))
2243 .on_task_status_changed({
2244 let jobs = jobs.clone();
2245 move |params| jobs.observe_legacy(params)
2246 })
2247 .on_final_task_status_changed(move |params| jobs.observe_final(params))
2248 .on_progress({
2249 let output = output.clone();
2250 move |p| {
2251 let pct = match (p.progress, p.total) {
2252 (done, Some(total)) if total > 0.0 => {
2253 format!(" {:.0}%", 100.0 * done / total)
2254 }
2255 _ => String::new(),
2256 };
2257 output.line(format!(
2258 "{} {}",
2259 tag(Style::new().fg(Color::Cyan), &format!("progress{pct}")),
2260 sanitize(p.message.as_deref().unwrap_or(""))
2261 ));
2262 }
2263 })
2264 .on_resource_updated({
2268 let output = output.clone();
2269 move |uri| {
2270 let known = if subscribe::contains(&uri) {
2271 String::new()
2272 } else {
2273 format!(" {}", paint(Style::new().dimmed(), "(not subscribed here)"))
2274 };
2275 output.line(format!(
2276 "{} {}{known}",
2277 tag(Style::new().fg(Color::Cyan), "resource updated"),
2278 sanitize(&uri)
2279 ));
2280 }
2281 })
2282 .on_log_message(move |m| {
2283 output.line(format!(
2284 "{} {}",
2285 tag(log_level_style(m.level), &format!("log {}", m.level)),
2286 sanitize(&m.data.to_string())
2287 ));
2288 })
2289}
2290
2291fn forward_child_stderr(stderr: tokio::process::ChildStderr, output: AsyncOutput) {
2293 tokio::spawn(async move {
2294 let mut lines = BufReader::new(stderr).lines();
2295 loop {
2296 match lines.next_line().await {
2297 Ok(Some(line)) => output.line(sanitize(&line).into_owned()),
2300 Ok(None) => break,
2301 Err(error) => {
2302 output.line(format!("warning: reading server stderr failed: {error}"));
2303 break;
2304 }
2305 }
2306 }
2307 });
2308}
2309
2310fn watch_task(session: Arc<Session>, jobs: Arc<Jobs>, task_id: String, poll_interval: Option<u64>) {
2315 if !jobs.automatic_updates_enabled() || jobs.is_terminal(&task_id) {
2316 return;
2317 }
2318 tokio::spawn(async move {
2319 let client = session.client();
2320 let _subscription =
2321 if client.selected_protocol_version().await.as_deref() == Some("2026-07-28") {
2322 match client
2323 .listen_subscriptions(SubscriptionFilter {
2324 task_ids: Some(vec![task_id.clone()]),
2325 ..Default::default()
2326 })
2327 .await
2328 {
2329 Ok(mut handle) => match handle.acknowledged().await {
2330 Ok(accepted)
2331 if accepted
2332 .task_ids
2333 .as_ref()
2334 .is_some_and(|ids| ids.iter().any(|id| id == &task_id)) =>
2335 {
2336 Some(handle)
2337 }
2338 _ => None,
2339 },
2340 Err(_) => None,
2341 }
2342 } else {
2343 None
2344 };
2345 let mut interval_ms = poll_interval.unwrap_or(1000).clamp(50, 30_000);
2346 let mut consecutive_errors = 0;
2347 loop {
2348 tokio::time::sleep(Duration::from_millis(interval_ms)).await;
2349 if jobs.is_terminal(&task_id) {
2350 break;
2351 }
2352 match session.client().task_get(&task_id).await {
2353 Ok(task) => {
2354 consecutive_errors = 0;
2355 interval_ms = task.poll_interval.unwrap_or(1000).clamp(50, 30_000);
2356 let terminal = task.status.is_terminal();
2357 jobs.observe_task(&task);
2358 if terminal {
2359 break;
2360 }
2361 }
2362 Err(_) => {
2363 consecutive_errors += 1;
2364 if consecutive_errors >= 3 {
2365 break;
2366 }
2367 }
2368 }
2369 }
2370 });
2371}
2372
2373#[derive(Clone)]
2381struct OAuthRuntime {
2382 flow: OAuthAuthorizationFlow,
2383 scopes: Vec<String>,
2384}
2385
2386fn http_transport(
2387 url: String,
2388 config: HttpClientConfig,
2389 oauth: Option<OAuthRuntime>,
2390) -> HttpClientTransport {
2391 let transport = HttpClientTransport::with_config(url, config);
2392 match oauth {
2393 Some(oauth) => transport.with_scope_aware_token_provider(
2394 oauth.flow,
2395 OAuthScopeEscalationConfig::new(oauth.scopes).max_attempts(2),
2396 ),
2397 None => transport,
2398 }
2399}
2400
2401fn http_connector(
2402 url: String,
2403 config: HttpClientConfig,
2404 oauth: Option<OAuthRuntime>,
2405 make_handler: Arc<dyn Fn() -> ReplClientHandler + Send + Sync>,
2406 protocol: ProtocolMode,
2407) -> Connector {
2408 Box::new(move || {
2409 let (url, config, oauth, handler) =
2410 (url.clone(), config.clone(), oauth.clone(), make_handler());
2411 Box::pin(async move {
2412 let client = client_builder(protocol)
2413 .map_err(|error| tower_mcp::Error::Transport(error.to_string()))?
2414 .connect(
2415 TracingTransport::new(http_transport(url, config, oauth)),
2416 handler,
2417 )
2418 .await?;
2419 establish_connection(&client, protocol).await?;
2420 Ok(client)
2421 })
2422 })
2423}
2424
2425fn load_config(explicit: Option<&str>) -> config::Config {
2428 let Some((path, explicit)) = config::config_path(explicit) else {
2429 return config::Config::default();
2430 };
2431 match config::Config::load(&path, explicit) {
2432 Ok(c) => c,
2433 Err(e) => {
2434 exit_with_error(ExitStatus::Usage, &e);
2435 }
2436 }
2437}
2438
2439async fn handle_oauth_profile_action(
2440 args: &Args,
2441 profiles: &config::Config,
2442 config_file: Option<&std::path::Path>,
2443) -> bool {
2444 let Some(name) = args.login.as_deref().or(args.logout.as_deref()) else {
2445 if !args.oauth_scopes.is_empty()
2446 || args.oauth_client_id_metadata_document.is_some()
2447 || args.oauth_authorization_server.is_some()
2448 {
2449 exit_with_error(
2450 ExitStatus::Usage,
2451 "--oauth-scope, --oauth-client-id-metadata-document, and \
2452 --oauth-authorization-server apply only to --login",
2453 );
2454 }
2455 return false;
2456 };
2457 oauth_profile::validate_name(name)
2458 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
2459 if args.demo
2460 || !args.command.is_empty()
2461 || !args.exec.is_empty()
2462 || args.list_servers
2463 || args.bearer.is_some()
2464 || !args.headers.is_empty()
2465 || args.oauth.is_some()
2466 {
2467 exit_with_error(
2468 ExitStatus::Usage,
2469 "--login/--logout are standalone credential operations; do not combine them with \
2470 a command, --demo, --exec, --list-servers, --bearer, --header, or --oauth \
2471 (--json is allowed, and reports what was created)",
2472 );
2473 }
2474 let path = config_file.unwrap_or_else(|| {
2475 exit_with_error(
2476 ExitStatus::Usage,
2477 "no config file location is available; set HOME/XDG_CONFIG_HOME or pass --config",
2478 )
2479 });
2480
2481 if args.logout.is_some() {
2482 let store = oauth_profile::CredentialStore::keyring(name)
2483 .unwrap_or_else(|error| exit_with_error(ExitStatus::Auth, &error));
2484 store
2485 .clear()
2486 .await
2487 .unwrap_or_else(|error| exit_with_error(ExitStatus::Auth, &error));
2488 oauth_profile::remove_metadata(path, name)
2489 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
2490 if json_output() {
2491 print_json(&serde_json::json!({
2492 "profile": name,
2493 "removed": true,
2494 }));
2495 } else {
2496 println!("removed OAuth profile {name:?} and its stored credentials");
2497 }
2498 return true;
2499 }
2500
2501 let existing = profiles.oauth.get(name).cloned().unwrap_or_default();
2502 let server_url = args.server.as_deref().map(|server_name| {
2503 let profile = profiles
2504 .profile(server_name)
2505 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
2506 match profile.transport() {
2507 Ok(config::Transport::Http) => profile
2508 .url
2509 .clone()
2510 .or_else(|| {
2511 profile
2512 .oauth
2513 .as_deref()
2514 .and_then(|oauth| profiles.oauth.get(oauth))
2515 .map(|metadata| metadata.url.clone())
2516 })
2517 .unwrap_or_else(|| {
2518 exit_with_error(
2519 ExitStatus::Usage,
2520 &format!("server profile {server_name:?} has no HTTP URL"),
2521 )
2522 }),
2523 Ok(config::Transport::Stdio) => exit_with_error(
2524 ExitStatus::Usage,
2525 &format!("server profile {server_name:?} is stdio; OAuth requires HTTP"),
2526 ),
2527 Err(error) => exit_with_error(ExitStatus::Usage, &error),
2528 }
2529 });
2530 let url = args
2531 .http
2532 .clone()
2533 .or(server_url)
2534 .or_else(|| (!existing.url.is_empty()).then(|| existing.url.clone()))
2535 .unwrap_or_else(|| {
2536 exit_with_error(
2537 ExitStatus::Usage,
2538 "a new OAuth profile needs --http URL (or --server with an HTTP profile)",
2539 )
2540 });
2541 let scopes = if args.oauth_scopes.is_empty() {
2542 existing.scopes
2543 } else {
2544 args.oauth_scopes
2545 .iter()
2546 .flat_map(|scope| scope.split_ascii_whitespace())
2547 .map(str::to_string)
2548 .fold(Vec::new(), |mut scopes, scope| {
2549 if !scope.is_empty() && !scopes.contains(&scope) {
2550 scopes.push(scope);
2551 }
2552 scopes
2553 })
2554 };
2555 let metadata = config::OAuthProfile {
2556 url: url.clone(),
2557 scopes,
2558 client_id_metadata_document: args
2559 .oauth_client_id_metadata_document
2560 .clone()
2561 .or(existing.client_id_metadata_document),
2562 authorization_server: args
2563 .oauth_authorization_server
2564 .clone()
2565 .or(existing.authorization_server),
2566 };
2567 let (flow, store) = oauth_profile::build_flow(name, &url, &metadata, true, !args.no_browser)
2568 .unwrap_or_else(|error| exit_with_error(ExitStatus::Auth, &error));
2569 if let Err(error) = flow.authorize(metadata.scopes.clone()).await {
2570 if matches!(error, OAuthClientError::TokenRequest(_)) {
2571 store
2572 .clear_tokens()
2573 .await
2574 .unwrap_or_else(|store_error| exit_with_error(ExitStatus::Auth, &store_error));
2575 let (retry, _) =
2576 oauth_profile::build_flow(name, &url, &metadata, true, !args.no_browser)
2577 .unwrap_or_else(|build_error| exit_with_error(ExitStatus::Auth, &build_error));
2578 retry
2579 .authorize(metadata.scopes.clone())
2580 .await
2581 .unwrap_or_else(|retry_error| {
2582 exit_with_error(ExitStatus::Auth, &retry_error.to_string())
2583 });
2584 } else {
2585 exit_with_error(ExitStatus::Auth, &error.to_string());
2586 }
2587 }
2588 if let Err(error) = oauth_profile::save_metadata(path, name, &metadata) {
2589 let _ = store.clear().await;
2590 exit_with_error(ExitStatus::Usage, &error);
2591 }
2592 if json_output() {
2593 print_json(&saved_profile_json(name, &metadata));
2594 } else {
2595 println!(
2596 "saved OAuth profile {name:?}; credentials are in the operating-system credential store"
2597 );
2598 }
2599 true
2600}
2601
2602fn saved_profile_json(name: &str, metadata: &config::OAuthProfile) -> serde_json::Value {
2609 serde_json::json!({
2610 "profile": name,
2611 "serverUrl": metadata.url,
2612 "scopes": metadata.scopes,
2613 })
2614}
2615
2616fn program_name() -> String {
2620 <Args as clap::CommandFactory>::command()
2621 .get_name()
2622 .to_string()
2623}
2624
2625fn print_completions(shell: clap_complete::Shell) {
2627 let mut command = <Args as clap::CommandFactory>::command();
2628 let name = program_name();
2629 clap_complete::generate(shell, &mut command, name, &mut std::io::stdout());
2630}
2631
2632fn print_man() {
2634 let command = <Args as clap::CommandFactory>::command();
2635 let mut page = Vec::new();
2636 if let Err(error) = clap_mangen::Man::new(command).render(&mut page) {
2637 exit_with_error(
2638 ExitStatus::Usage,
2639 &format!("could not render the man page: {error}"),
2640 );
2641 }
2642 use std::io::Write;
2643 if let Err(error) = std::io::stdout().write_all(&page) {
2644 exit_with_error(
2645 ExitStatus::Usage,
2646 &format!("could not write the man page: {error}"),
2647 );
2648 }
2649}
2650
2651fn print_scan() -> ExitStatus {
2657 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2658 let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
2659 let paths = import_config::candidate_paths(&cwd, home.as_deref());
2660 let scanned = import_config::scan(&paths);
2661
2662 if json_output() {
2663 let files: Vec<serde_json::Value> = scanned
2664 .iter()
2665 .map(|file| match &file.result {
2666 Ok(entries) => serde_json::json!({
2667 "path": file.path.display().to_string(),
2668 "entries": entries.iter().map(|entry| serde_json::json!({
2669 "entry": entry.name,
2670 "selector": format!("{}:{}", file.path.display(), entry.name),
2671 "transport": entry.transport,
2672 "summary": entry.summary,
2673 })).collect::<Vec<_>>(),
2674 }),
2675 Err(error) => serde_json::json!({
2676 "path": file.path.display().to_string(),
2677 "error": error,
2678 }),
2679 })
2680 .collect();
2681 let found = scanned
2682 .iter()
2683 .filter_map(|file| file.result.as_ref().ok())
2684 .map(Vec::len)
2685 .sum::<usize>();
2686 print_json(&serde_json::Value::Array(files));
2687 return no_match_when_empty(found);
2688 }
2689
2690 if scanned.is_empty() {
2691 report_error(
2693 ExitStatus::NoMatch,
2694 "no MCP client configs found (looked for .mcp.json, .vscode/mcp.json, \
2695 .cursor/mcp.json, and the Claude configs under your home directory)",
2696 );
2697 return ExitStatus::NoMatch;
2698 }
2699
2700 let mut total = 0usize;
2701 for file in &scanned {
2702 println!(
2703 "{}",
2704 paint(Style::new().bold(), &file.path.display().to_string())
2705 );
2706 match &file.result {
2707 Err(error) => println!(" {} {}", style::error_prefix(), sanitize(error)),
2710 Ok(entries) if entries.is_empty() => {
2711 println!(" {}", paint(Style::new().dimmed(), "(no servers)"));
2712 }
2713 Ok(entries) => {
2714 let width = entries.iter().map(|e| e.name.len()).max().unwrap_or(0);
2715 for entry in entries {
2716 total += 1;
2717 println!(
2718 " {} {} {}",
2719 style::column(Style::new().fg(Color::Green), &sanitize(&entry.name), width),
2720 paint(Style::new().dimmed(), &format!("{:>5}", entry.transport)),
2721 sanitize(&entry.summary)
2722 );
2723 }
2724 }
2725 }
2726 }
2727 if total > 0 {
2728 println!(
2729 "{}",
2730 paint(
2731 Style::new().dimmed(),
2732 &format!(
2733 "{} in {}. Connect with `mcp-repl <path>:<entry>`.",
2734 plural(total, "server"),
2735 plural(scanned.len(), "file")
2736 )
2737 )
2738 );
2739 }
2740 no_match_when_empty(total)
2741}
2742
2743fn no_match_when_empty(found: usize) -> ExitStatus {
2746 if found == 0 {
2747 ExitStatus::NoMatch
2748 } else {
2749 ExitStatus::Success
2750 }
2751}
2752
2753fn print_servers(config: &config::Config) {
2755 if config.servers.is_empty() {
2756 println!("no server profiles configured");
2757 return;
2758 }
2759 let width = config.names().iter().map(|n| n.len()).max().unwrap_or(0);
2760 for (name, profile) in &config.servers {
2761 println!(
2762 "{} {}",
2763 style::column(Style::new().fg(Color::Cyan), name, width),
2764 paint(Style::new().dimmed(), &profile.summary()),
2765 );
2766 }
2767}
2768
2769fn resolve_profile(args: &Args, config: &config::Config) -> Option<(String, config::Connection)> {
2774 let name = args
2775 .server
2776 .clone()
2777 .or_else(|| match args.command.as_slice() {
2778 [only] if config.servers.contains_key(only) => Some(only.clone()),
2779 _ => None,
2780 })?;
2781 let profile = match config.profile(&name) {
2782 Ok(p) => p,
2783 Err(e) => {
2784 exit_with_error(ExitStatus::Usage, &e);
2785 }
2786 };
2787 if profile.bearer.is_some() {
2788 eprintln!(
2789 "warning: profile {name:?} stores a literal `bearer` token; prefer \
2790 `bearer_env = \"VAR\"` so the token is not kept in the config file"
2791 );
2792 }
2793 match config.resolve_profile_with(&name, |var| std::env::var(var).ok()) {
2794 Ok(connection) => Some((name, connection)),
2795 Err(e) => {
2796 exit_with_error(ExitStatus::Usage, &format!("server profile {name:?}: {e}"));
2797 }
2798 }
2799}
2800
2801fn resolve_import(args: &Args) -> Option<import_config::ImportedConnection> {
2805 let candidate = match args.server.as_deref() {
2806 Some(server) => server,
2807 None => match args.command.as_slice() {
2808 [only] => only,
2809 _ => return None,
2810 },
2811 };
2812 let selector = match import_config::parse_selector(candidate)? {
2813 Ok(selector) => selector,
2814 Err(error) => exit_with_error(ExitStatus::Usage, &error),
2815 };
2816 Some(
2817 import_config::load_with(selector, |variable| std::env::var(variable).ok())
2818 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error)),
2819 )
2820}
2821
2822pub(crate) const LOG_LEVELS: &[&str] = &[
2825 "debug",
2826 "info",
2827 "notice",
2828 "warning",
2829 "error",
2830 "critical",
2831 "alert",
2832 "emergency",
2833];
2834
2835fn parse_log_level(word: &str) -> Option<LogLevel> {
2836 match word.to_ascii_lowercase().as_str() {
2837 "debug" => Some(LogLevel::Debug),
2838 "info" => Some(LogLevel::Info),
2839 "notice" => Some(LogLevel::Notice),
2840 "warning" => Some(LogLevel::Warning),
2841 "error" => Some(LogLevel::Error),
2842 "critical" => Some(LogLevel::Critical),
2843 "alert" => Some(LogLevel::Alert),
2844 "emergency" => Some(LogLevel::Emergency),
2845 _ => None,
2846 }
2847}
2848
2849fn log_level_style(level: LogLevel) -> Style {
2850 match level {
2851 LogLevel::Emergency | LogLevel::Alert | LogLevel::Critical | LogLevel::Error => {
2852 Style::new().fg(Color::Red)
2853 }
2854 LogLevel::Warning => Style::new().fg(Color::Yellow),
2855 LogLevel::Notice | LogLevel::Info => Style::new().fg(Color::Green),
2856 _ => Style::new().dimmed(),
2857 }
2858}
2859
2860#[tokio::main]
2866pub async fn run_cli() {
2867 let args = Args::parse();
2871 init_tracing(&args);
2872
2873 if let Some(shell) = args.completions {
2877 print_completions(shell);
2878 return;
2879 }
2880 if args.man {
2881 print_man();
2882 return;
2883 }
2884
2885 style::init(args.color);
2886 wire::init(args.trace);
2887 JSON_OUTPUT.store(args.json, Ordering::Relaxed);
2888
2889 if let Err(error) = run(args).await {
2890 exit_with_error(
2891 ExitStatus::from_mcp_error(&error),
2892 collapse_repeated_label(&error.to_string()),
2893 );
2894 }
2895}
2896
2897async fn run(args: Args) -> tower_mcp::Result<()> {
2898 let config_file = config::config_path(args.config.as_deref()).map(|(path, _)| path);
2901 let profiles = if args.login.is_some() || args.logout.is_some() {
2902 config_file
2903 .as_deref()
2904 .map(|path| {
2905 config::Config::load(path, false)
2906 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error))
2907 })
2908 .unwrap_or_default()
2909 } else {
2910 load_config(args.config.as_deref())
2911 };
2912 REQUEST_TIMEOUT_SECS.store(
2917 args.timeout
2918 .or(profiles.repl.request_timeout)
2919 .unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS),
2920 Ordering::Relaxed,
2921 );
2922 editor::set_completion_timeout(
2923 profiles
2924 .repl
2925 .completion_timeout_ms
2926 .map(Duration::from_millis)
2927 .unwrap_or(editor::DEFAULT_COMPLETION_TIMEOUT),
2928 );
2929
2930 if handle_oauth_profile_action(&args, &profiles, config_file.as_deref()).await {
2931 return Ok(());
2932 }
2933 if args.list_servers {
2934 print_servers(&profiles);
2935 return Ok(());
2936 }
2937 if args.scan {
2938 std::process::exit(print_scan().code());
2939 }
2940 let schema_contracts =
2941 schema_contract::ContractSet::load(&args.schema_contracts, args.schema_mode)
2942 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
2943 let imported = resolve_import(&args);
2944 let profile = if imported.is_none() {
2945 resolve_profile(&args, &profiles)
2946 } else {
2947 None
2948 };
2949 let one_shot = !args.exec.is_empty();
2952 let quiet = one_shot && (!args.verbose || args.json);
2955
2956 let at_prompt = Arc::new(AtomicBool::new(false));
2960 let async_output = AsyncOutput::new(at_prompt.clone(), !one_shot);
2961 let jobs = Arc::new(Jobs::new(
2965 async_output.clone(),
2966 automatic_task_updates(one_shot, args.json),
2967 ));
2968
2969 let (refresh_tx, mut refresh_rx) = tokio::sync::watch::channel(0u64);
2974 let refresh_tx: RefreshSignal = Arc::new(refresh_tx);
2975
2976 let server_label: elicit::ServerLabel = Arc::new(RwLock::new(String::new()));
2980
2981 let make_handler: Arc<dyn Fn() -> ReplClientHandler + Send + Sync> = {
2984 let refresh_tx = refresh_tx.clone();
2985 let at_prompt = at_prompt.clone();
2986 let async_output = async_output.clone();
2987 let jobs = jobs.clone();
2988 let server_label = server_label.clone();
2989 Arc::new(move || {
2990 ReplClientHandler::new(
2991 notification_handler(refresh_tx.clone(), async_output.clone(), jobs.clone()),
2992 at_prompt.clone(),
2993 server_label.clone(),
2994 async_output.clone(),
2995 )
2996 })
2997 };
2998 sampling::init(sampling::resolve(args.sampling, one_shot));
3002 elicit::init(elicit::resolve(args.elicitation, one_shot));
3005
3006 let (profile_name, import_label, import_selector, connection) = match (imported, profile) {
3010 (Some(imported), _) => (
3011 None,
3012 Some(imported.label()),
3013 Some(imported.selector),
3014 Some(imported.connection),
3015 ),
3016 (None, Some((name, connection))) => (Some(name), None, None, Some(connection)),
3017 (None, None) => (None, None, None, None),
3018 };
3019 let trust_store_config = config_file.clone();
3022
3023 let aliases = Arc::new(RwLock::new(Aliases::new(
3026 profiles.aliases.clone(),
3027 profile_name
3028 .as_ref()
3029 .and_then(|name| profiles.servers.get(name))
3030 .map(|p| p.aliases.clone())
3031 .unwrap_or_default(),
3032 profile_name.clone(),
3033 config_file,
3034 )));
3035
3036 let connection = match (args.http.clone(), connection) {
3037 (
3038 Some(url),
3039 Some(config::Connection::Http {
3040 bearer,
3041 headers,
3042 oauth,
3043 ..
3044 }),
3045 ) => Some(config::Connection::Http {
3046 url,
3047 bearer,
3048 headers,
3049 oauth,
3050 }),
3051 (Some(url), _) => Some(config::Connection::Http {
3052 url,
3053 bearer: None,
3054 headers: Vec::new(),
3055 oauth: None,
3056 }),
3057 (None, Some(c)) => Some(c),
3058 (None, None) if args.command.is_empty() && args.oauth.is_some() => {
3059 let name = args.oauth.as_deref().expect("guarded above");
3060 let metadata = profiles.oauth.get(name).unwrap_or_else(|| {
3061 exit_with_error(
3062 ExitStatus::Usage,
3063 &format!("no OAuth profile named {name:?}; create it with --login"),
3064 )
3065 });
3066 Some(config::Connection::Http {
3067 url: metadata.url.clone(),
3068 bearer: None,
3069 headers: Vec::new(),
3070 oauth: Some(name.to_string()),
3071 })
3072 }
3073 (None, None) if !args.command.is_empty() => Some(config::Connection::Stdio {
3074 command: args.command.clone(),
3075 env: std::collections::BTreeMap::new(),
3076 cwd: None,
3077 }),
3078 (None, None) => None,
3079 };
3080
3081 let over_http = matches!(connection, Some(config::Connection::Http { .. }));
3082 if !over_http && (args.bearer.is_some() || !args.headers.is_empty()) {
3083 eprintln!("warning: --bearer/--header apply only to HTTP servers; ignoring them here");
3084 }
3085 if !over_http && args.oauth.is_some() {
3086 exit_with_error(ExitStatus::Usage, "--oauth applies only to HTTP servers");
3087 }
3088 if let Some(name) = &profile_name
3089 && !quiet
3090 {
3091 println!(
3092 "{}",
3093 tag(Style::new().fg(Color::Cyan), &format!("profile {name}"))
3094 );
3095 } else if let Some(label) = &import_label
3096 && !quiet
3097 {
3098 println!(
3099 "{}",
3100 tag(Style::new().fg(Color::Cyan), &format!("import {label}"))
3101 );
3102 }
3103
3104 let builder = client_builder(args.protocol)
3112 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error.to_string()));
3113 let mut connector: Option<Connector> = None;
3117 let client = if args.demo {
3118 tracing::debug!("connecting to the in-process demo server");
3119 builder
3120 .connect(
3121 TracingTransport::new(ChannelTransport::new(demo_router())),
3122 make_handler(),
3123 )
3124 .await?
3125 } else {
3126 match connection {
3127 Some(config::Connection::Http {
3128 url,
3129 bearer,
3130 headers,
3131 oauth: profile_oauth,
3132 }) => {
3133 let oauth_name = selected_oauth_profile(
3134 args.oauth.as_deref(),
3135 profile_oauth.as_deref(),
3136 args.bearer.is_some(),
3137 &args.headers,
3138 );
3139 let cli_authorization = oauth_name.is_none()
3140 && (args.bearer.is_some()
3141 || args.headers.iter().any(|header| {
3142 header.split_once(':').is_some_and(|(name, _)| {
3143 name.trim().eq_ignore_ascii_case("authorization")
3144 })
3145 }));
3146 if cli_authorization && (args.oauth.is_some() || profile_oauth.is_some()) && !quiet
3147 {
3148 eprintln!(
3149 "warning: explicit --bearer/--header Authorization takes precedence over OAuth"
3150 );
3151 }
3152 let profile_headers = if oauth_name.is_some() {
3153 headers
3154 .into_iter()
3155 .filter(|(name, _)| !name.eq_ignore_ascii_case("authorization"))
3156 .collect::<Vec<_>>()
3157 } else {
3158 headers
3159 };
3160 let config = if oauth_name.is_some() {
3161 build_http_config_with_env(
3162 args.bearer.clone(),
3163 &args.headers,
3164 None,
3165 &profile_headers,
3166 None,
3167 )
3168 } else {
3169 build_http_config(args.bearer.clone(), &args.headers, bearer, &profile_headers)
3170 }
3171 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
3172 let oauth = if let Some(name) = oauth_name {
3173 let metadata = profiles.oauth.get(&name).unwrap_or_else(|| {
3174 exit_with_error(
3175 ExitStatus::Usage,
3176 &format!(
3177 "no OAuth profile named {name:?}; create it with \
3178 `mcp-repl --login {name} --http {url}`"
3179 ),
3180 )
3181 });
3182 let interactive = !one_shot && !args.json;
3183 let (flow, store) = oauth_profile::build_flow(
3184 &name,
3185 &url,
3186 metadata,
3187 interactive,
3188 interactive && !args.no_browser,
3189 )
3190 .unwrap_or_else(|error| exit_with_error(ExitStatus::Auth, &error));
3191 if interactive {
3192 tracing::debug!(profile = %name, "OAuth: interactive authorization");
3193 flow.authorize(metadata.scopes.clone())
3194 .await
3195 .map_err(|error| {
3196 tower_mcp::Error::Transport(format!(
3197 "OAuth authorization failed for profile {name:?}: {error}. \
3198 Run `mcp-repl --login {name} --http {url}` to reauthorize"
3199 ))
3200 })?;
3201 } else {
3202 if !store.has_tokens().await.map_err(|error| {
3203 tower_mcp::Error::Transport(format!(
3204 "OAuth credential restore failed for profile {name:?}: {error}"
3205 ))
3206 })? {
3207 return Err(tower_mcp::Error::Transport(format!(
3208 "OAuth login required for profile {name:?}; run \
3209 `mcp-repl --login {name} --http {url}` before using --exec/--json"
3210 )));
3211 }
3212 match flow.begin(metadata.scopes.clone()).await.map_err(|error| {
3213 tower_mcp::Error::Transport(format!(
3214 "OAuth credential restore failed for profile {name:?}: {error}. \
3215 Run `mcp-repl --login {name} --http {url}` to reauthorize"
3216 ))
3217 })? {
3218 OAuthAuthorizationStart::Authorized { .. } => {
3219 tracing::debug!(
3220 profile = %name,
3221 "OAuth: restored a stored credential"
3222 );
3223 }
3224 OAuthAuthorizationStart::Pending(_) => {
3225 return Err(tower_mcp::Error::Transport(format!(
3226 "OAuth login required for profile {name:?}; run \
3227 `mcp-repl --login {name} --http {url}` before using --exec/--json"
3228 )));
3229 }
3230 _ => {
3231 return Err(tower_mcp::Error::Transport(format!(
3232 "OAuth login required for profile {name:?}; run \
3233 `mcp-repl --login {name} --http {url}` before using --exec/--json"
3234 )));
3235 }
3236 }
3237 }
3238 Some(OAuthRuntime {
3239 flow,
3240 scopes: metadata.scopes.clone(),
3241 })
3242 } else {
3243 None
3244 };
3245 if !args.no_reconnect {
3246 connector = Some(http_connector(
3247 url.clone(),
3248 config.clone(),
3249 oauth.clone(),
3250 make_handler.clone(),
3251 args.protocol,
3252 ));
3253 }
3254 builder
3255 .connect(
3256 TracingTransport::new(http_transport(url, config, oauth)),
3257 make_handler(),
3258 )
3259 .await?
3260 }
3261 Some(config::Connection::Stdio { command, env, cwd }) => {
3262 if let Some(selector) = &import_selector {
3266 let plan = import_trust::SpawnPlan::new(
3267 &selector.path,
3268 &selector.entry,
3269 &command,
3270 cwd.as_deref(),
3271 &env,
3272 );
3273 let interactive =
3274 !one_shot && std::io::IsTerminal::is_terminal(&std::io::stdin());
3275 match import_trust::authorize(
3276 &plan,
3277 trust_store_config.as_deref(),
3278 args.trust_import,
3279 interactive,
3280 ) {
3281 import_trust::Decision::Approved => {}
3282 import_trust::Decision::Refused(reason) => {
3283 exit_with_error(ExitStatus::Usage, &reason);
3284 }
3285 }
3286 }
3287 let mut cmd = tokio::process::Command::new(&command[0]);
3288 cmd.args(&command[1..]);
3289 cmd.envs(env);
3290 cmd.env_remove("MCP_BEARER");
3295 if let Some(cwd) = cwd {
3296 cmd.current_dir(cwd);
3297 }
3298 cmd.stderr(std::process::Stdio::piped());
3299 let mut transport = StdioClientTransport::spawn_command(&mut cmd).await?;
3300 if let Some(stderr) = transport.take_stderr() {
3301 forward_child_stderr(stderr, async_output.clone());
3302 }
3303 builder
3304 .connect(TracingTransport::new(transport), make_handler())
3305 .await?
3306 }
3307 None => {
3308 exit_with_error(
3309 ExitStatus::Usage,
3310 "usage: mcp-repl <server command...> | --http <url> | \
3311 --server <name> | --demo",
3312 );
3313 }
3314 }
3315 };
3316
3317 let info = establish_connection(&client, args.protocol).await?;
3318 let server_name = info.server_info.name.clone();
3319 if let Ok(mut label) = server_label.write() {
3320 label.clone_from(&server_name);
3321 }
3322 if !quiet {
3323 print_banner(&info);
3324 }
3325 let session = Arc::new(Session::new(client, connector));
3326 let client = session.client();
3327
3328 let surface = Arc::new(RwLock::new(fetch_surface_initial(&client).await));
3329 if !quiet {
3330 let s = surface.read().unwrap();
3331 print_counts(&s);
3332 let instructions_list_tools = info
3336 .instructions
3337 .as_deref()
3338 .is_some_and(|instr| s.tools.first().is_some_and(|t| instr.contains(&t.name)));
3339 if !instructions_list_tools {
3340 print_tool_overview(&s);
3341 }
3342 if !one_shot {
3345 print_first_run_hint();
3346 }
3347 }
3348
3349 if one_shot {
3352 for cmd in &args.exec {
3353 match run_cancellable(
3354 &session,
3355 &surface,
3356 &aliases,
3357 &jobs,
3358 &schema_contracts,
3359 cmd.trim(),
3360 )
3361 .await
3362 {
3363 Ran::Completed(false) => {}
3364 Ran::Completed(true) | Ran::Cancelled => break,
3367 }
3368 }
3369 let status = exit_status::current().code();
3370 drop(client);
3371 match Arc::try_unwrap(session) {
3377 Ok(session) => session.shutdown().await?,
3378 Err(_) => eprintln!(
3379 "warning: a background task outlived its command; exiting without the orderly \
3380 shutdown"
3381 ),
3382 }
3383 std::process::exit(status);
3384 }
3385
3386 let _surface_subscription = (args.protocol == ProtocolMode::Final).then(|| {
3391 surface_subscription::SurfaceSubscription::start(session.clone(), async_output.clone())
3392 });
3393
3394 let history_capacity = profiles
3397 .repl
3398 .history_capacity
3399 .unwrap_or(editor::DEFAULT_HISTORY_CAPACITY);
3400
3401 let (line_tx, mut line_rx) = tokio::sync::mpsc::channel::<String>(1);
3403 let (ack_tx, ack_rx) = std::sync::mpsc::channel::<()>();
3404 editor::spawn_readline_thread(
3405 server_name,
3406 surface.clone(),
3407 session.clone(),
3408 aliases.clone(),
3409 tokio::runtime::Handle::current(),
3410 line_tx,
3411 ack_rx,
3412 at_prompt,
3413 async_output
3414 .external_printer()
3415 .expect("interactive sessions have an external printer"),
3416 !args.no_history && history_capacity > 0,
3417 history_capacity,
3418 );
3419
3420 loop {
3421 tokio::select! {
3422 Ok(()) = refresh_rx.changed() => {
3423 tokio::time::sleep(SURFACE_REFRESH_DEBOUNCE).await;
3426 refresh_rx.mark_unchanged();
3427 tracing::debug!("surface change signalled; re-fetching");
3428 let fresh = fetch_surface(&session.client()).await;
3429 async_output.line(format!("{} {}, {}, {}",
3430 tag(Style::new().fg(Color::Cyan), "surface changed"),
3431 plural(fresh.tools.len(), "tool"),
3432 plural(fresh.prompts.len(), "prompt"),
3433 plural(fresh.resources.len(), "resource")));
3434 *surface.write().unwrap() = fresh;
3435 }
3436 maybe_line = line_rx.recv() => {
3437 let Some(line) = maybe_line else { break };
3438 let ran = run_cancellable(
3439 &session,
3440 &surface,
3441 &aliases,
3442 &jobs,
3443 &schema_contracts,
3444 line.trim(),
3445 )
3446 .await;
3447 let _ = ack_tx.send(());
3451 if matches!(ran, Ran::Completed(true)) {
3452 break;
3453 }
3454 }
3455 }
3456 }
3457 Ok(())
3458}
3459
3460enum Ran {
3462 Completed(bool),
3464 Cancelled,
3466}
3467
3468fn backgroundable_tool(surface: &Arc<RwLock<Surface>>, line: &str) -> Option<String> {
3486 let line = line.trim();
3487 if line.ends_with('&') {
3488 return None;
3489 }
3490 let word = line.split_whitespace().next()?;
3491 if BUILTINS.iter().any(|(name, _)| *name == word) {
3492 return None;
3493 }
3494 let surface = surface.read().ok()?;
3495 let tool = surface.tools.iter().find(|tool| tool.name == word)?;
3496 tool_tags(tool)
3497 .contains(&"task-capable")
3498 .then(|| tool.name.clone())
3499}
3500
3501async fn run_cancellable(
3502 session: &Arc<Session>,
3503 surface: &Arc<RwLock<Surface>>,
3504 aliases: &Arc<RwLock<Aliases>>,
3505 jobs: &Arc<Jobs>,
3506 schema_contracts: &schema_contract::ContractSet,
3507 line: &str,
3508) -> Ran {
3509 tokio::select! {
3510 biased;
3511 quit = handle_line(session, surface, aliases, jobs, schema_contracts, line) => {
3512 Ran::Completed(quit)
3513 }
3514 _ = tokio::signal::ctrl_c() => {
3515 note_error(ExitStatus::Cancelled);
3516 if json_output() {
3517 print_json(&error_json(ExitStatus::Cancelled, "cancelled"));
3518 } else {
3519 let mut message = format!("{} cancelled", paint(Style::new().dimmed(), "^C"));
3522 if let Some(tool) = backgroundable_tool(surface, line) {
3527 message.push_str(&paint(
3528 Style::new().dimmed(),
3529 &format!(" `{tool} ... &` runs it as a task instead"),
3530 ));
3531 }
3532 eprintln!("{message}");
3533 }
3534 Ran::Cancelled
3535 }
3536 }
3537}
3538
3539async fn handle_line(
3540 session: &Arc<Session>,
3541 surface: &Arc<RwLock<Surface>>,
3542 aliases: &Arc<RwLock<Aliases>>,
3543 jobs: &Arc<Jobs>,
3544 schema_contracts: &schema_contract::ContractSet,
3545 line: &str,
3546) -> bool {
3547 if line.is_empty() {
3548 if json_output() {
3549 report_error(ExitStatus::Usage, "empty command");
3550 }
3551 return false;
3552 }
3553 let expanded;
3557 let line = match aliases.read().unwrap().expand(line) {
3558 Ok(None) => line,
3559 Ok(Some(text)) => {
3560 expanded = text;
3561 expanded.trim()
3562 }
3563 Err(e) => {
3564 report_error(ExitStatus::Usage, &e);
3565 return false;
3566 }
3567 };
3568 let (output, routed) = vars::route(line);
3573 let command = match vars::substitute(routed) {
3574 Ok(c) => c,
3575 Err(e) => {
3576 report_error(ExitStatus::Usage, &e);
3577 return false;
3578 }
3579 };
3580 let line = command.as_str();
3581 let client = session.client();
3582 let parsed = match command::parse(line) {
3583 Ok(parsed) => parsed,
3584 Err(e) => {
3585 report_error(ExitStatus::Usage, &e);
3586 return false;
3587 }
3588 };
3589 let background = parsed.background;
3590 let tokens: Vec<&str> = parsed.words.iter().map(String::as_str).collect();
3591 if tokens.is_empty() {
3592 if json_output() {
3593 report_error(ExitStatus::Usage, "empty command");
3594 }
3595 return false;
3596 }
3597 let cmd = tokens[0];
3598 let rest = &tokens[1..];
3599 COMMAND_RAN.store(true, Ordering::Relaxed);
3600
3601 let is_builtin = BUILTINS.iter().any(|(name, _)| *name == cmd);
3605 if !output.is_plain() && is_builtin && !ROUTABLE_BUILTINS.contains(&cmd) {
3606 let what = match (&output.capture, &output.filter) {
3607 (Some(_), _) => "capture",
3608 _ => "filter",
3609 };
3610 report_error(
3611 ExitStatus::Usage,
3612 &format!(
3613 "cannot {what} the result of `{cmd}`: it reports rather than returning a value. \
3614 Routable commands: {}",
3615 ROUTABLE_BUILTINS.join(", ")
3616 ),
3617 );
3618 return false;
3619 }
3620
3621 match cmd {
3622 "quit" | "exit" => {
3623 if json_output() {
3624 print_json(&serde_json::json!({ "exit": true }));
3625 }
3626 return true;
3627 }
3628 "help" => {
3629 if let Some(name) = rest.first()
3632 && let Some((usage, detail)) = builtin_help(name)
3633 {
3634 if json_output() {
3635 print_json(&serde_json::json!({
3636 "name": name,
3637 "usage": usage,
3638 "description": detail,
3639 }));
3640 } else {
3641 println!("{}", paint(Style::new().bold(), usage));
3642 println!(" {detail}");
3643 }
3644 return false;
3645 }
3646 if let Some(name) = rest.first() {
3647 report_error_with_hint(
3648 ExitStatus::NoMatch,
3649 &format!("no built-in named `{name}` (try `help` or `describe {name}`)"),
3650 find::did_you_mean(&surface.read().unwrap(), name).as_deref(),
3651 );
3652 return false;
3653 }
3654 if json_output() {
3655 let s = surface.read().unwrap();
3656 print_json(&serde_json::json!({
3657 "builtins": BUILTINS
3658 .iter()
3659 .map(|(name, description)| serde_json::json!({
3660 "name": name,
3661 "description": description,
3662 }))
3663 .collect::<Vec<_>>(),
3664 "tools": s.tools,
3665 }));
3666 return false;
3667 }
3668 println!("built-ins:");
3669 println!(" tools | prompts | resources | templates list the server surface");
3670 println!(" find [flags] <keyword> search the surface");
3671 println!(" describe <name> schemas and metadata");
3672 println!(" snapshot <name> [path] export a schema contract");
3673 println!(" validate <path> [mode] check a schema contract");
3674 println!(" read <uri> [--out <path>] read a resource");
3675 println!(" subscribe <uri> | unsubscribe <uri> watch a resource for updates");
3676 println!(" subscriptions list active subscriptions");
3677 println!(" prompt <name> [k=v...] get a prompt");
3678 println!(" call <tool> <json> call a tool with raw JSON");
3679 println!(" bench <tool> [k=v...] [--n N] [--concurrency C] time repeated calls");
3680 println!(" <tool> [k=v...] call a tool (schema-coerced)");
3681 println!(" <tool> [k=v...] & run task-augmented (SEP-2663)");
3682 println!(" jobs | task <id> | wait <id> | cancel <id> manage tasks");
3683 println!(" alias [<name>=<expansion>] | unalias <name> command aliases");
3684 println!(" wire [on|off] trace raw JSON-RPC frames");
3685 println!(" last reprint the previous exchange");
3686 println!(
3687 " vars | unset <name> list or clear captured variables"
3688 );
3689 println!(
3690 " name = <cmd> [| <path>] capture a result (filter with | path)"
3691 );
3692 println!(" $name.path in args reference a captured value");
3693 println!(" ping | refresh | info | quit");
3694 println!(" help <command> explain one built-in");
3695 let s = surface.read().unwrap();
3696 if !s.tools.is_empty() {
3697 println!("tools:");
3698 for t in &s.tools {
3699 println!(
3700 " {} {}",
3701 style::column(Style::new().fg(Color::Green), &sanitize(&t.name), 24),
3702 sanitize(t.description.as_deref().unwrap_or(""))
3703 );
3704 }
3705 }
3706 }
3707 "tools" | "prompts" | "resources" | "templates" => {
3708 let s = surface.read().unwrap();
3709 let what = if cmd == "templates" {
3713 "resource templates"
3714 } else {
3715 cmd
3716 };
3717 if s.is_unavailable(what) {
3718 report_error(
3719 ExitStatus::Transport,
3720 &format!(
3721 "the {what} listing is unavailable: it could not be read from this \
3722 server (try `refresh`)"
3723 ),
3724 );
3725 return false;
3726 }
3727 if !output.is_plain() || json_output() {
3728 let v = match cmd {
3729 "tools" => serde_json::to_value(&s.tools),
3730 "prompts" => serde_json::to_value(&s.prompts),
3731 "resources" => serde_json::to_value(&s.resources),
3732 _ => serde_json::to_value(&s.templates),
3733 }
3734 .unwrap_or_default();
3735 emit_value(v, &output, || unreachable!("plain output handled below"));
3736 return false;
3737 }
3738 let full = rest.contains(&"--full");
3741 let limit = if full { None } else { listing_limit() };
3742 match cmd {
3743 "tools" => {
3744 let total = s.tools.len();
3745 let shown = limit.unwrap_or(total).min(total);
3746 for t in s.tools.iter().take(shown) {
3747 println!(
3748 "{} {}{}",
3749 style::column(Style::new().fg(Color::Green), &sanitize(&t.name), 24),
3750 sanitize(t.description.as_deref().unwrap_or("")),
3751 tool_tag_suffix(t)
3752 );
3753 }
3754 note_truncation(shown, total, "tools --full");
3755 }
3756 "prompts" => {
3757 let total = s.prompts.len();
3758 let shown = limit.unwrap_or(total).min(total);
3759 for p in s.prompts.iter().take(shown) {
3760 let args: Vec<String> = p
3761 .arguments
3762 .iter()
3763 .map(|a| {
3764 if a.required {
3765 format!("<{}>", sanitize(&a.name))
3766 } else {
3767 format!("[{}]", sanitize(&a.name))
3768 }
3769 })
3770 .collect();
3771 println!(
3772 "{} {} {}",
3773 style::column(Style::new().fg(Color::Green), &sanitize(&p.name), 24),
3774 paint(Style::new().fg(Color::Cyan), &args.join(" ")),
3775 sanitize(p.description.as_deref().unwrap_or(""))
3776 );
3777 }
3778 note_truncation(shown, total, "prompts --full");
3779 }
3780 "resources" => {
3781 let total = s.resources.len();
3782 let shown = limit.unwrap_or(total).min(total);
3783 for r in s.resources.iter().take(shown) {
3784 println!(
3785 "{} {}",
3786 style::column(Style::new().fg(Color::Green), &sanitize(&r.uri), 40),
3787 sanitize(&r.name)
3788 );
3789 }
3790 note_truncation(shown, total, "resources --full");
3791 if !s.templates.is_empty() {
3794 println!(
3795 "{}",
3796 paint(
3797 Style::new().dimmed(),
3798 &format!(
3799 "(+ {} resource template(s) with variables, see `templates`)",
3800 s.templates.len()
3801 )
3802 )
3803 );
3804 }
3805 }
3806 _ => {
3807 let total = s.templates.len();
3808 let shown = limit.unwrap_or(total).min(total);
3809 for t in s.templates.iter().take(shown) {
3810 println!(
3811 "{} {}",
3812 style::column(
3813 Style::new().fg(Color::Green),
3814 &sanitize(&t.uri_template),
3815 40
3816 ),
3817 sanitize(&t.name)
3818 );
3819 }
3820 note_truncation(shown, total, "templates --full");
3821 if !s.resources.is_empty() {
3822 println!(
3823 "{}",
3824 paint(
3825 Style::new().dimmed(),
3826 &format!(
3827 "(+ {} concrete resource(s), see `resources`)",
3828 s.resources.len()
3829 )
3830 )
3831 );
3832 }
3833 }
3834 }
3835 }
3836 "find" => {
3837 match find::parse_query(rest) {
3840 Ok(query) => print_find(&surface.read().unwrap(), &query, &output),
3841 Err(message) => {
3842 command_error(&message);
3843 return false;
3844 }
3845 }
3846 }
3847 "describe" => {
3848 let Some(name) = rest.first() else {
3849 command_error("usage: describe <tool|prompt|resource|template>");
3850 return false;
3851 };
3852 let surface = surface.read().unwrap();
3853 if !output.is_plain() || json_output() {
3854 match describe_value(&surface, name) {
3855 Some(value) => {
3856 emit_value(value, &output, || unreachable!("plain handled below"))
3857 }
3858 None => report_error_with_hint(
3859 ExitStatus::NoMatch,
3860 &format!("nothing on the surface named `{name}`"),
3861 find::did_you_mean(&surface, name).as_deref(),
3862 ),
3863 }
3864 } else {
3865 describe(&surface, name);
3866 }
3867 }
3868 "snapshot" => {
3869 let Some(name) = rest.first() else {
3870 command_error("usage: snapshot <tool|prompt> [path]");
3871 return false;
3872 };
3873 if rest.len() > 2 {
3874 command_error("usage: snapshot <tool|prompt> [path]");
3875 return false;
3876 }
3877 let snapshot = {
3878 let surface = surface.read().unwrap();
3879 schema_contract::Snapshot::from_surface(&surface.tools, &surface.prompts, name)
3880 };
3881 let snapshot = match snapshot {
3882 Ok(snapshot) => snapshot,
3883 Err(error) => {
3884 report_error(ExitStatus::Usage, &error);
3885 return false;
3886 }
3887 };
3888 let Some(snapshot) = snapshot else {
3889 report_error(
3890 ExitStatus::NoMatch,
3891 &format!("no tool or prompt named `{name}`"),
3892 );
3893 return false;
3894 };
3895 if let Some(path) = rest.get(1) {
3896 let path = std::path::Path::new(path);
3897 match snapshot.write(path) {
3898 Ok(()) if json_output() => print_json(&serde_json::json!({
3899 "kind": snapshot.kind,
3900 "name": snapshot.name,
3901 "path": path,
3902 })),
3903 Ok(()) => println!(
3904 "saved {} {:?} schema snapshot to {}",
3905 snapshot.kind,
3906 snapshot.name,
3907 path.display()
3908 ),
3909 Err(error) => report_error(ExitStatus::Usage, &error),
3910 }
3911 } else if json_output() {
3912 print_json(&snapshot.canonical_value());
3913 } else {
3914 print!("{}", snapshot.to_pretty_json());
3915 }
3916 }
3917 "validate" => {
3918 let Some(path) = rest.first() else {
3919 command_error("usage: validate <snapshot-path> [strict|compatible|ignore]");
3920 return false;
3921 };
3922 if rest.len() > 2 {
3923 command_error("usage: validate <snapshot-path> [strict|compatible|ignore]");
3924 return false;
3925 }
3926 let mode = match rest.get(1) {
3927 Some(mode) => match schema_contract::ValidationMode::from_str(mode, true) {
3928 Ok(mode) => mode,
3929 Err(_) => {
3930 command_error(
3931 "validation mode must be `strict`, `compatible`, or `ignore`",
3932 );
3933 return false;
3934 }
3935 },
3936 None => schema_contracts.mode(),
3937 };
3938 let snapshot = match schema_contract::Snapshot::load(std::path::Path::new(path)) {
3939 Ok(snapshot) => snapshot,
3940 Err(error) => {
3941 report_error(ExitStatus::Usage, &error);
3942 return false;
3943 }
3944 };
3945 let current = {
3946 let surface = surface.read().unwrap();
3947 snapshot.matching_surface(&surface.tools, &surface.prompts)
3948 };
3949 let report = schema_contract::validate(&snapshot, current.as_ref(), mode);
3950 render_validation_report(&report, true);
3951 }
3952 "read" => {
3953 let (destination, force, rest) = match parse_read_flags(rest) {
3954 Ok(parsed) => parsed,
3955 Err(message) => {
3956 command_error(&message);
3957 return false;
3958 }
3959 };
3960 let Some(uri) = rest.first().copied() else {
3961 command_error("usage: read <uri> [--out <path>] [--force]");
3962 return false;
3963 };
3964 if let Some(path) = &destination
3965 && !force
3966 && std::path::Path::new(path).exists()
3967 {
3968 command_error(&format!(
3969 "{path} already exists; pass --force to overwrite it"
3970 ));
3971 return false;
3972 }
3973 let started = std::time::Instant::now();
3974 match with_reconnect(
3975 session,
3976 surface,
3977 |c| async move { c.read_resource(uri).await },
3978 )
3979 .await
3980 {
3981 Ok(result) if destination.is_some() => {
3984 let path = destination.clone().unwrap_or_default();
3985 match save_resource(&result, &path) {
3986 Ok(written) => {
3987 if json_output() {
3988 print_json(&serde_json::json!({
3989 "uri": uri,
3990 "path": path,
3991 "bytes": written,
3992 }));
3993 } else {
3994 println!(
3995 "wrote {} to {}",
3996 plural(written, "byte"),
3997 sanitize(&path)
3998 );
3999 }
4000 }
4001 Err(message) => report_error(ExitStatus::Usage, &message),
4002 }
4003 }
4004 Ok(result) if !output.is_plain() => {
4005 emit_result(serde_json::to_value(&result).unwrap_or_default(), &output)
4006 }
4007 Ok(result) if json_output() => {
4008 print_json(&serde_json::to_value(&result).unwrap_or_default())
4009 }
4010 Ok(result) => {
4011 for c in result.contents {
4012 if let Some(text) = c.text {
4013 let is_md = c
4014 .mime_type
4015 .as_deref()
4016 .is_some_and(|m| m.contains("markdown"))
4017 || style::looks_like_markdown(&text);
4018 if style::colors_enabled() && is_md {
4019 println!("{}", style::render_markdown(&text));
4020 } else {
4021 println!("{}", sanitize(&text));
4022 }
4023 } else if let Some(blob) = c.blob {
4024 println!(
4025 "{}",
4026 tag(Style::new(), &format!("binary {} base64 chars", blob.len()))
4027 );
4028 }
4029 }
4030 }
4031 Err(e) => report_mcp_error(&e),
4032 }
4033 if !json_output() {
4034 println!("{}", timing(started.elapsed()));
4035 }
4036 }
4037 "subscribe" | "unsubscribe" => {
4038 let Some(uri) = rest.first() else {
4039 command_error(&format!("usage: {cmd} <uri>"));
4040 return false;
4041 };
4042 handle_subscription(&client, cmd, uri).await;
4043 }
4044 "subscriptions" => {
4045 let active = subscribe::list();
4046 if json_output() {
4047 print_json(&serde_json::json!(active));
4048 return false;
4049 }
4050 if active.is_empty() {
4051 println!("no active subscriptions (try `subscribe <uri>`)");
4052 return false;
4053 }
4054 for uri in &active {
4055 println!("{}", paint(Style::new().fg(Color::Green), &sanitize(uri)));
4056 }
4057 }
4058 "prompt" => {
4059 let Some(name) = rest.first() else {
4060 command_error("usage: prompt <name> [k=v...]");
4061 return false;
4062 };
4063 if !enforce_prompt_contract(schema_contracts, surface, name) {
4064 return false;
4065 }
4066 let mut prompt_args = HashMap::new();
4067 for t in &rest[1..] {
4068 if let Some((k, v)) = t.split_once('=') {
4069 prompt_args.insert(k.to_string(), v.to_string());
4070 }
4071 }
4072 let started = std::time::Instant::now();
4073 match with_reconnect(session, surface, |c| {
4074 let prompt_args = prompt_args.clone();
4075 async move { c.get_prompt(name, Some(prompt_args)).await }
4076 })
4077 .await
4078 {
4079 Ok(result) if json_output() => {
4080 print_json(&serde_json::to_value(&result).unwrap_or_default())
4081 }
4082 Ok(result) => {
4083 for m in result.messages {
4084 let v = serde_json::to_value(&m).unwrap_or_default();
4085 let role = v.get("role").and_then(|r| r.as_str()).unwrap_or("?");
4086 let text = v
4087 .pointer("/content/text")
4088 .and_then(|t| t.as_str())
4089 .map(str::to_string)
4090 .unwrap_or_else(|| {
4091 v.get("content").map(|c| c.to_string()).unwrap_or_default()
4092 });
4093 println!(
4094 "{} {}",
4095 tag(Style::new().fg(Color::Cyan), &sanitize(role)),
4096 sanitize(&text)
4097 );
4098 }
4099 }
4100 Err(e) => report_mcp_error(&e),
4101 }
4102 if !json_output() {
4103 println!("{}", timing(started.elapsed()));
4104 }
4105 }
4106 "call" => {
4107 let Some(name) = rest.first() else {
4108 command_error("usage: call <tool> <json>");
4109 return false;
4110 };
4111 let json = rest[1..].join(" ");
4112 let arguments: serde_json::Value = match serde_json::from_str(&json) {
4113 Ok(v) => v,
4114 Err(e) => {
4115 report_error(ExitStatus::Usage, &format!("invalid JSON: {e}"));
4116 return false;
4117 }
4118 };
4119 run_tool(
4120 session,
4121 surface,
4122 jobs,
4123 schema_contracts,
4124 name,
4125 arguments,
4126 background,
4127 &output,
4128 )
4129 .await;
4130 }
4131 "bench" => {
4132 handle_bench(&client, surface, schema_contracts, rest, background).await;
4133 }
4134 "jobs" => {
4135 let started = std::time::Instant::now();
4138 if json_output() {
4139 let mut rendered = Vec::new();
4140 for job in jobs.list() {
4141 match client.task_get(&job.task_id).await {
4142 Ok(task) => {
4143 jobs.sync(&job.task_id, task.status, task.status_message.clone());
4144 rendered.push(serde_json::json!({
4145 "taskId": job.task_id,
4146 "tool": job.tool,
4147 "task": task,
4148 }));
4149 }
4150 Err(error) => {
4151 let status = ExitStatus::from_mcp_error(&error);
4152 note_error(status);
4153 rendered.push(serde_json::json!({
4154 "taskId": job.task_id,
4155 "tool": job.tool,
4156 "error": error.to_string(),
4157 "kind": status.label(),
4158 "exitStatus": status.code(),
4159 }));
4160 }
4161 }
4162 }
4163 print_json(&serde_json::Value::Array(rendered));
4164 return false;
4165 }
4166 if jobs.is_empty() {
4167 println!(
4168 "{}",
4169 paint(
4170 Style::new().dimmed(),
4171 "no background tasks (run a task-capable tool with a trailing `&`)"
4172 )
4173 );
4174 }
4175 for job in jobs.list() {
4176 match client.task_get(&job.task_id).await {
4177 Ok(task) => {
4178 jobs.sync(&job.task_id, task.status, task.status_message.clone());
4179 println!(
4180 "{} {} {}",
4181 sanitize(&job.label()),
4182 sanitize(&job.tool),
4183 paint(task_status_style(task.status), &task.status.to_string())
4184 );
4185 }
4186 Err(error) => {
4187 note_error(ExitStatus::from_mcp_error(&error));
4188 println!(
4189 "{} {} (gone)",
4190 sanitize(&job.label()),
4191 sanitize(&job.tool)
4192 );
4193 }
4194 }
4195 }
4196 if !json_output() {
4197 println!("{}", timing(started.elapsed()));
4198 }
4199 }
4200 "task" | "wait" | "cancel" => {
4204 let started = std::time::Instant::now();
4207 let (wait_limit, rest) = match parse_wait_timeout(cmd, rest) {
4212 Ok(parsed) => parsed,
4213 Err(message) => {
4214 command_error(&message);
4215 return false;
4216 }
4217 };
4218 if cmd == "wait" && rest.is_empty() {
4223 wait_for_all(&client, jobs, wait_limit, started).await;
4224 return false;
4225 }
4226 let Some(typed) = rest.first() else {
4227 command_error(&format!("usage: {cmd} <task>"));
4228 return false;
4229 };
4230 let Some(resolved) = jobs.resolve(typed) else {
4233 report_error(
4234 ExitStatus::NoMatch,
4235 &format!(
4236 "no task `{typed}` in this session (run `jobs`; a task id belongs to \
4237 the session that created it)"
4238 ),
4239 );
4240 return false;
4241 };
4242 let id = &resolved.as_str();
4243 if cmd == "task" && rest.get(1).is_some_and(|word| *word == "respond") {
4248 respond_to_task(&client, id, &jobs.label_for(id)).await;
4249 if !json_output() {
4250 println!("{}", timing(started.elapsed()));
4251 }
4252 return false;
4253 }
4254 let outcome = match cmd {
4255 "task" => client.task_get(id).await,
4256 "wait" => wait_for_one(&client, id, wait_limit).await,
4257 _ => match client.task_cancel(id, None).await {
4258 Ok(()) => {
4259 if !json_output() {
4260 println!("cancel acknowledged");
4261 }
4262 client.task_get(id).await
4263 }
4264 Err(e) => Err(e),
4265 },
4266 };
4267 match outcome {
4268 Ok(task) if json_output() => {
4269 jobs.sync(id, task.status, task.status_message.clone());
4270 if cmd == "wait" {
4271 note_settled_task(&task);
4272 }
4273 print_json(&serde_json::to_value(&task).unwrap_or_default());
4274 }
4275 Ok(task) => {
4276 jobs.sync(id, task.status, task.status_message.clone());
4277 if cmd == "wait" {
4278 note_settled_task(&task);
4279 }
4280 render_task(&task, &jobs.label_for(&task.task_id));
4281 }
4282 Err(e) => report_mcp_error(&e),
4283 }
4284 if !json_output() {
4285 println!("{}", timing(started.elapsed()));
4286 }
4287 }
4288 "alias" | "unalias" => {
4289 let raw = line.strip_prefix(cmd).unwrap_or("").trim();
4292 handle_alias(aliases, surface, cmd, raw);
4293 }
4294 "wire" => {
4295 match rest.first().copied() {
4296 Some("on") => wire().set_trace(true),
4297 Some("off") => wire().set_trace(false),
4298 None => {}
4299 Some(other) => {
4300 command_error(&format!("usage: wire [on|off] (got `{other}`)"));
4301 return false;
4302 }
4303 }
4304 let enabled = wire().trace_enabled();
4305 if json_output() {
4306 print_json(&serde_json::json!({ "wire": enabled }));
4307 } else if enabled {
4308 println!("wire tracing on (frames print to stderr)");
4309 } else {
4310 println!("wire tracing off");
4311 }
4312 }
4313 "last" => match wire().last_exchange() {
4316 None => {
4317 note_error(ExitStatus::NoMatch);
4318 if json_output() {
4319 print_json(&error_json(ExitStatus::NoMatch, "no exchange yet"));
4320 } else {
4321 println!("no request has been sent yet");
4322 }
4323 }
4324 Some((request, response)) => {
4325 if json_output() {
4326 print_json(&serde_json::json!({
4327 "request": request.json,
4328 "response": response.map(|r| r.json),
4329 }));
4330 } else {
4331 if !COMMAND_RAN.load(Ordering::Relaxed) {
4336 println!(
4337 "{}",
4338 paint(
4339 Style::new().dimmed(),
4340 "(no command has run yet; this is mcp-repl's own startup traffic)"
4341 )
4342 );
4343 }
4344 println!("{}", wire::render(wire::Direction::Sent, &request));
4345 match response {
4346 Some(response) => {
4347 println!("{}", wire::render(wire::Direction::Received, &response));
4348 }
4349 None => println!("(no response recorded for it)"),
4350 }
4351 }
4352 }
4353 },
4354 "ping" => {
4355 let started = std::time::Instant::now();
4356 match with_deadline(client.ping()).await {
4357 Ok(()) => {
4358 let elapsed = started.elapsed();
4359 if json_output() {
4360 print_json(&serde_json::json!({
4361 "ok": true,
4362 "elapsedMs": elapsed.as_millis(),
4363 }));
4364 } else {
4365 println!(
4366 "{} {}",
4367 paint(Style::new().fg(Color::Green), "ok"),
4368 timing(elapsed)
4369 );
4370 }
4371 }
4372 Err(e) => report_mcp_error(&e),
4373 }
4374 }
4375 "loglevel" => {
4376 let Some(typed) = rest.first() else {
4377 command_error(&format!("usage: loglevel <{}>", LOG_LEVELS.join("|")));
4378 return false;
4379 };
4380 let Some(level) = parse_log_level(typed) else {
4381 report_error(
4385 ExitStatus::Usage,
4386 &format!(
4387 "unknown log level `{}` (levels are {})",
4388 sanitize(typed),
4389 LOG_LEVELS.join(", ")
4390 ),
4391 );
4392 return false;
4393 };
4394 let declared = connection_info(&client)
4398 .await
4399 .is_some_and(|info| info.capabilities.logging.is_some());
4400 if !declared {
4401 report_error(
4402 ExitStatus::Server,
4403 "this server does not declare the `logging` capability, so it has no \
4404 level to set (any notifications it sends arrive regardless)",
4405 );
4406 return false;
4407 }
4408 let started = std::time::Instant::now();
4409 let params = serde_json::json!({ "level": level });
4410 match with_deadline(client.request::<_, serde_json::Value>("logging/setLevel", ¶ms))
4411 .await
4412 {
4413 Ok(_) => {
4414 if json_output() {
4415 print_json(&serde_json::json!({ "level": level }));
4416 } else {
4417 println!(
4418 "log level set to {} {}",
4419 paint(log_level_style(level), &level.to_string()),
4420 timing(started.elapsed())
4421 );
4422 }
4423 }
4424 Err(e) => report_mcp_error(&e),
4425 }
4426 }
4427 "refresh" => {
4428 let started = std::time::Instant::now();
4429 let fresh = refresh_surface(session).await;
4430 if json_output() {
4431 print_json(&serde_json::json!({
4432 "tools": fresh.tools.len(),
4433 "prompts": fresh.prompts.len(),
4434 "resources": fresh.resources.len(),
4435 "templates": fresh.templates.len(),
4436 }));
4437 } else {
4438 println!(
4439 "{}, {}, {}, {}",
4440 plural(fresh.tools.len(), "tool"),
4441 plural(fresh.prompts.len(), "prompt"),
4442 plural(fresh.resources.len(), "resource"),
4443 plural(fresh.templates.len(), "template")
4444 );
4445 }
4446 if !json_output() {
4447 println!("{}", timing(started.elapsed()));
4448 }
4449 *surface.write().unwrap() = fresh;
4450 }
4451 "info" => match connection_info(&client).await {
4452 Some(info) => {
4453 if !output.is_plain() || json_output() {
4454 emit_value(
4455 serde_json::json!({
4456 "protocolVersion": info.protocol_version,
4457 "serverInfo": info.server_info,
4458 "capabilities": info.capabilities,
4459 "instructions": info.instructions,
4460 "sampling": sampling::mode().as_str(),
4461 "elicitation": elicit::mode().as_str(),
4462 }),
4463 &output,
4464 || unreachable!("plain output handled below"),
4465 );
4466 return false;
4467 }
4468 print_banner(&info);
4470 print_counts(&surface.read().unwrap());
4471 let caps = serde_json::to_value(&info.capabilities).unwrap_or_default();
4472 println!("capabilities: {}", json_pretty(&caps));
4473 println!(
4475 "{}",
4476 paint(
4477 Style::new().dimmed(),
4478 &format!(
4479 "sampling: {}, elicitation: {}",
4480 sampling::mode().as_str(),
4481 elicit::mode().as_str()
4482 )
4483 )
4484 );
4485 }
4486 None => report_error(ExitStatus::Transport, "not initialized"),
4487 },
4488 "history" => {
4489 const DEFAULT_SHOWN: usize = 20;
4490 let limit = match rest.first() {
4491 None => DEFAULT_SHOWN,
4492 Some(raw) => match raw.parse::<usize>() {
4493 Ok(n) if n > 0 => n,
4494 _ => {
4495 command_error(&format!("usage: history [count] (got `{raw}`)"));
4496 return false;
4497 }
4498 },
4499 };
4500 let entries = editor::recent_history(limit);
4501 if json_output() {
4502 print_json(&serde_json::json!(entries));
4503 } else if entries.is_empty() {
4504 println!(
4505 "{}",
4506 paint(
4507 Style::new().dimmed(),
4508 "no history yet (it persists across sessions unless --no-history)"
4509 )
4510 );
4511 } else {
4512 for line in &entries {
4513 println!("{}", sanitize(line));
4514 }
4515 println!(
4516 "{}",
4517 paint(
4518 Style::new().dimmed(),
4519 "Ctrl-R searches history interactively"
4520 )
4521 );
4522 }
4523 }
4524 "vars" => {
4525 let all = vars::list();
4526 if json_output() {
4527 let map: serde_json::Map<String, serde_json::Value> = all.into_iter().collect();
4528 print_json(&serde_json::Value::Object(map));
4529 } else if all.is_empty() {
4530 println!(
4531 "{}",
4532 paint(
4533 Style::new().dimmed(),
4534 "no variables (capture one with `name = <command>`)"
4535 )
4536 );
4537 } else {
4538 for (name, value) in all {
4539 println!(
4540 "{} {}",
4541 paint(Style::new().fg(Color::Cyan), &format!("${name} =")),
4542 value_summary(&value)
4543 );
4544 }
4545 }
4546 }
4547 "unset" => match rest.first() {
4548 Some(name) => {
4549 if vars::unset(name) {
4550 if json_output() {
4551 print_json(&serde_json::json!({ "unset": name }));
4552 } else {
4553 println!("unset ${name}");
4554 }
4555 } else {
4556 command_error(&format!("no such variable `${name}`"));
4557 }
4558 }
4559 None => command_error("usage: unset <name>"),
4560 },
4561 tool_name => {
4562 let schema = {
4563 let s = surface.read().unwrap();
4564 s.tools
4565 .iter()
4566 .find(|t| t.name == tool_name)
4567 .map(|t| t.input_schema.clone())
4568 };
4569 let Some(schema) = schema else {
4570 let suggestion = find::did_you_mean(&surface.read().unwrap(), tool_name);
4576 let message = match suggestion {
4577 Some(_) => format!("unknown command: {tool_name}"),
4578 None => format!("unknown command: {tool_name} (try `help`)"),
4579 };
4580 report_error_with_hint(ExitStatus::Usage, &message, suggestion.as_deref());
4581 return false;
4582 };
4583 let arguments = parse_kv_args(&schema, rest);
4584 run_tool(
4585 session,
4586 surface,
4587 jobs,
4588 schema_contracts,
4589 tool_name,
4590 arguments,
4591 background,
4592 &output,
4593 )
4594 .await;
4595 }
4596 }
4597 false
4598}
4599
4600async fn handle_bench(
4605 client: &Arc<McpClient>,
4606 surface: &Arc<RwLock<Surface>>,
4607 schema_contracts: &schema_contract::ContractSet,
4608 rest: &[&str],
4609 background: bool,
4610) {
4611 if background {
4614 command_error("bench cannot run task-augmented; drop the trailing `&`");
4615 return;
4616 }
4617 let plan = match bench::parse(rest) {
4618 Ok(plan) => plan,
4619 Err(e) => {
4620 command_error(&e);
4621 return;
4622 }
4623 };
4624 let schema = {
4625 let s = surface.read().unwrap();
4626 s.tools
4627 .iter()
4628 .find(|t| t.name == plan.tool)
4629 .map(|t| t.input_schema.clone())
4630 };
4631 let Some(schema) = schema else {
4632 report_error_with_hint(
4635 ExitStatus::NoMatch,
4636 &format!("no tool named `{}` (try `tools`)", plan.tool),
4637 find::did_you_mean(&surface.read().unwrap(), &plan.tool).as_deref(),
4638 );
4639 return;
4640 };
4641 if !enforce_tool_contract(schema_contracts, surface, &plan.tool) {
4642 return;
4643 }
4644 let arg_tokens: Vec<&str> = plan.args.iter().map(String::as_str).collect();
4645 let arguments = parse_kv_args(&schema, &arg_tokens);
4646
4647 let outcome = bench::run(client, &plan.tool, arguments, plan.n, plan.concurrency).await;
4648 if outcome.errors > 0 {
4651 note_error(ExitStatus::Server);
4652 }
4653 if json_output() {
4654 print_json(&bench::render_json(&plan, &outcome));
4655 return;
4656 }
4657 println!("{}", bench::render(&plan, &outcome));
4658 if let Some(message) = &outcome.first_error {
4659 println!(
4660 "{} {}",
4661 tag(Style::new().fg(Color::Red), "first error"),
4662 sanitize(message)
4663 );
4664 }
4665 println!("{}", timing(outcome.total));
4666}
4667
4668async fn handle_subscription(client: &Arc<McpClient>, cmd: &str, uri: &str) {
4672 if cmd == "subscribe"
4675 && let Some(info) = connection_info(client).await
4676 && !subscribe::server_supports(
4677 &serde_json::to_value(&info.capabilities).unwrap_or_default(),
4678 )
4679 {
4680 eprintln!(
4681 "warning: {} does not advertise resources.subscribe; the request will \
4682 probably be rejected",
4683 info.server_info.name
4684 );
4685 }
4686 let started = std::time::Instant::now();
4687 let outcome = if cmd == "subscribe" {
4688 client.subscribe_resource(uri).await
4689 } else {
4690 client.unsubscribe_resource(uri).await
4691 };
4692 match outcome {
4693 Ok(()) => {
4694 let changed = if cmd == "subscribe" {
4695 subscribe::add(uri)
4696 } else {
4697 subscribe::remove(uri)
4698 };
4699 if json_output() {
4700 print_json(&serde_json::json!({
4701 cmd: uri,
4702 "alreadyInEffect": !changed,
4703 }));
4704 } else {
4705 let note = if changed {
4706 String::new()
4707 } else {
4708 format!(" {}", paint(Style::new().dimmed(), "(already in effect)"))
4709 };
4710 println!("{cmd}d {}{note}", paint(Style::new().fg(Color::Green), uri));
4711 }
4712 }
4713 Err(e) => report_mcp_error(&e),
4714 }
4715 if !json_output() {
4716 println!("{}", timing(started.elapsed()));
4717 }
4718}
4719
4720fn handle_alias(
4726 aliases: &Arc<RwLock<Aliases>>,
4727 surface: &Arc<RwLock<Surface>>,
4728 cmd: &str,
4729 raw: &str,
4730) {
4731 let (global, rest) = match raw.strip_prefix("--global") {
4734 Some(r) if r.is_empty() || r.starts_with(char::is_whitespace) => (true, r.trim_start()),
4735 _ => (false, raw),
4736 };
4737 let rest = rest.trim();
4738
4739 if cmd == "unalias" {
4740 if rest.is_empty() || rest.contains(char::is_whitespace) {
4741 command_error("usage: unalias [--global] <name>");
4742 return;
4743 }
4744 match aliases.write().unwrap().remove(rest, global) {
4745 Ok(applied) => {
4746 report_alias_warning(applied.warning.as_deref());
4747 if json_output() {
4748 print_json(&serde_json::json!({
4749 "removed": rest,
4750 "expansion": applied.previous,
4751 "scope": applied.scope.label(),
4752 }));
4753 } else {
4754 println!(
4755 "removed {} {}",
4756 paint(Style::new().fg(Color::Cyan), rest),
4757 paint(
4758 Style::new().dimmed(),
4759 &format!("({})", applied.scope.label())
4760 )
4761 );
4762 }
4763 }
4764 Err(e) => command_error(&e),
4765 }
4766 return;
4767 }
4768
4769 if rest.is_empty() {
4771 let aliases = aliases.read().unwrap();
4772 let entries = aliases.entries();
4773 if json_output() {
4774 let rendered: Vec<serde_json::Value> = entries
4775 .iter()
4776 .map(|e| {
4777 serde_json::json!({
4778 "name": e.name,
4779 "expansion": e.expansion,
4780 "scope": e.scope.label(),
4781 })
4782 })
4783 .collect();
4784 print_json(&serde_json::Value::Array(rendered));
4785 return;
4786 }
4787 if entries.is_empty() {
4788 println!("no aliases defined (try `alias t=tools`)");
4789 return;
4790 }
4791 let width = entries.iter().map(|e| e.name.len()).max().unwrap_or(0);
4792 for e in &entries {
4793 println!(
4794 "{} {} {}",
4795 style::column(Style::new().fg(Color::Cyan), &e.name, width),
4796 e.expansion,
4797 paint(Style::new().dimmed(), &format!("({})", e.scope.label()))
4798 );
4799 }
4800 return;
4801 }
4802
4803 let Some((name, expansion)) = rest.split_once('=') else {
4805 let aliases = aliases.read().unwrap();
4806 match aliases.lookup(rest) {
4807 Some((expansion, scope)) if json_output() => print_json(&serde_json::json!({
4808 "name": rest,
4809 "expansion": expansion,
4810 "scope": scope.label(),
4811 })),
4812 Some((expansion, scope)) => println!(
4813 "{} = {} {}",
4814 paint(Style::new().fg(Color::Cyan), rest),
4815 expansion,
4816 paint(Style::new().dimmed(), &format!("({})", scope.label()))
4817 ),
4818 None => command_error(&format!(
4819 "no alias named `{rest}` (define one with `alias {rest}=<expansion>`)"
4820 )),
4821 }
4822 return;
4823 };
4824 let name = name.trim();
4825 match aliases
4826 .write()
4827 .unwrap()
4828 .define(name, expansion.trim(), global)
4829 {
4830 Ok(applied) => {
4831 report_alias_warning(applied.warning.as_deref());
4832 if json_output() {
4833 print_json(&serde_json::json!({
4834 "name": name,
4835 "expansion": expansion.trim(),
4836 "scope": applied.scope.label(),
4837 "replaced": applied.previous,
4838 }));
4839 return;
4840 }
4841 println!(
4842 "{} = {} {}",
4843 paint(Style::new().fg(Color::Cyan), name),
4844 expansion.trim(),
4845 paint(
4846 Style::new().dimmed(),
4847 &format!("({})", applied.scope.label())
4848 )
4849 );
4850 if surface.read().unwrap().tools.iter().any(|t| t.name == name) {
4853 println!(
4854 "{}",
4855 paint(
4856 Style::new().dimmed(),
4857 &format!("note: this shadows the tool `{name}` on this server")
4858 )
4859 );
4860 }
4861 }
4862 Err(e) => command_error(&e),
4863 }
4864}
4865
4866fn report_alias_warning(warning: Option<&str>) {
4869 if let Some(w) = warning {
4870 eprintln!("warning: {w}");
4871 }
4872}
4873
4874fn command_error(message: &str) {
4875 report_error(ExitStatus::Usage, message);
4876}
4877
4878fn render_validation_report(
4881 report: &schema_contract::ValidationReport,
4882 render_success: bool,
4883) -> bool {
4884 if report.compatible && !render_success {
4885 return true;
4886 }
4887 if !report.compatible {
4888 note_error(ExitStatus::NoMatch);
4889 }
4890 if json_output() {
4891 print_json(&serde_json::to_value(report).unwrap_or_default());
4892 } else if report.compatible {
4893 println!(
4894 "{} {:?} is compatible under {} validation",
4895 report.kind, report.name, report.mode
4896 );
4897 } else {
4898 println!(
4899 "{} {:?} is incompatible under {} validation:",
4900 report.kind, report.name, report.mode
4901 );
4902 for issue in &report.issues {
4903 println!(" {} [{}] {}", issue.path, issue.code, issue.message);
4904 }
4905 }
4906 report.compatible
4907}
4908
4909fn enforce_tool_contract(
4910 contracts: &schema_contract::ContractSet,
4911 surface: &Arc<RwLock<Surface>>,
4912 name: &str,
4913) -> bool {
4914 let report = {
4915 let surface = surface.read().unwrap();
4916 surface
4917 .tools
4918 .iter()
4919 .find(|definition| definition.name == name)
4920 .and_then(|definition| contracts.check_tool(definition))
4921 };
4922 report
4923 .as_ref()
4924 .is_none_or(|report| render_validation_report(report, false))
4925}
4926
4927fn enforce_prompt_contract(
4928 contracts: &schema_contract::ContractSet,
4929 surface: &Arc<RwLock<Surface>>,
4930 name: &str,
4931) -> bool {
4932 let report = {
4933 let surface = surface.read().unwrap();
4934 surface
4935 .prompts
4936 .iter()
4937 .find(|definition| definition.name == name)
4938 .and_then(|definition| contracts.check_prompt(definition))
4939 };
4940 report
4941 .as_ref()
4942 .is_none_or(|report| render_validation_report(report, false))
4943}
4944
4945fn describe_value(surface: &Surface, name: &str) -> Option<serde_json::Value> {
4946 surface
4947 .tools
4948 .iter()
4949 .find(|definition| definition.name == name)
4950 .map(|definition| {
4951 serde_json::json!({
4952 "kind": "tool",
4953 "definition": definition,
4954 })
4955 })
4956 .or_else(|| {
4957 surface
4958 .prompts
4959 .iter()
4960 .find(|definition| definition.name == name)
4961 .map(|definition| {
4962 serde_json::json!({
4963 "kind": "prompt",
4964 "definition": definition,
4965 })
4966 })
4967 })
4968 .or_else(|| {
4969 surface
4970 .resources
4971 .iter()
4972 .find(|definition| definition.name == name || definition.uri == name)
4973 .map(|definition| {
4974 serde_json::json!({
4975 "kind": "resource",
4976 "definition": definition,
4977 })
4978 })
4979 })
4980 .or_else(|| {
4981 surface
4982 .templates
4983 .iter()
4984 .find(|definition| definition.name == name || definition.uri_template == name)
4985 .map(|definition| {
4986 serde_json::json!({
4987 "kind": "resourceTemplate",
4988 "definition": definition,
4989 })
4990 })
4991 })
4992}
4993
4994fn parse_read_flags<'a>(rest: &[&'a str]) -> Result<(Option<String>, bool, Vec<&'a str>), String> {
5000 let mut destination = None;
5001 let mut force = false;
5002 let mut remaining = Vec::new();
5003 let mut tokens = rest.iter().copied();
5004 while let Some(token) = tokens.next() {
5005 match token {
5006 "--force" => force = true,
5007 "--out" => {
5008 let path = tokens
5009 .next()
5010 .ok_or_else(|| "--out needs a path".to_string())?;
5011 destination = Some(path.to_string());
5012 }
5013 _ => match token.strip_prefix("--out=") {
5014 Some(path) if !path.is_empty() => destination = Some(path.to_string()),
5015 Some(_) => return Err("--out needs a path".to_string()),
5016 None if token.starts_with("--") => {
5017 return Err(format!(
5018 "unknown option `{token}` (read takes --out and --force)"
5019 ));
5020 }
5021 None => remaining.push(token),
5022 },
5023 }
5024 }
5025 Ok((destination, force, remaining))
5026}
5027
5028fn save_resource(
5034 result: &tower_mcp::protocol::ReadResourceResult,
5035 path: &str,
5036) -> Result<usize, String> {
5037 let mut contents = result.contents.iter();
5038 let (Some(content), None) = (contents.next(), contents.next()) else {
5039 return Err(format!(
5040 "the resource returned {} contents; --out writes a single one",
5041 result.contents.len()
5042 ));
5043 };
5044 let bytes: Vec<u8> = match (&content.text, &content.blob) {
5045 (Some(text), _) => text.as_bytes().to_vec(),
5046 (None, Some(blob)) => {
5047 use base64::Engine;
5048 base64::engine::general_purpose::STANDARD
5049 .decode(blob)
5050 .map_err(|e| format!("the server sent a blob that is not valid base64: {e}"))?
5051 }
5052 (None, None) => return Err("the resource returned no content".to_string()),
5053 };
5054 crate::secure_file::write_bytes(std::path::Path::new(path), &bytes)
5056 .map_err(|e| format!("could not write {path}: {e}"))?;
5057 Ok(bytes.len())
5058}
5059
5060fn parse_wait_timeout<'a>(
5061 cmd: &str,
5062 rest: &[&'a str],
5063) -> Result<(Option<Duration>, Vec<&'a str>), String> {
5064 let mut limit = None;
5065 let mut remaining = Vec::new();
5066 let mut tokens = rest.iter().copied();
5067 while let Some(token) = tokens.next() {
5068 let value = match token.strip_prefix("--timeout") {
5069 None => {
5070 remaining.push(token);
5071 continue;
5072 }
5073 Some("") => tokens
5074 .next()
5075 .ok_or_else(|| format!("usage: {cmd} <task-id> [--timeout <seconds>]"))?,
5076 Some(rest) => rest
5077 .strip_prefix('=')
5078 .ok_or_else(|| format!("unknown flag `{token}` for {cmd}"))?,
5079 };
5080 if cmd != "wait" {
5081 return Err(format!(
5082 "--timeout applies to `wait`, not `{cmd}` (it is a single request, bounded by the global --timeout)"
5083 ));
5084 }
5085 let secs: u64 = value
5086 .parse()
5087 .map_err(|_| format!("--timeout expects seconds, got `{value}`"))?;
5088 limit = (secs > 0).then(|| Duration::from_secs(secs));
5089 }
5090 Ok((limit, remaining))
5091}
5092
5093pub(crate) fn tool_tags(tool: &ToolDefinition) -> Vec<&'static str> {
5099 let mut tags = Vec::new();
5100 if let Some(a) = &tool.annotations {
5101 if a.read_only_hint {
5102 tags.push("read-only");
5103 }
5104 if a.destructive_hint && !a.read_only_hint {
5106 tags.push("destructive");
5107 }
5108 if a.idempotent_hint {
5109 tags.push("idempotent");
5110 }
5111 if a.open_world_hint {
5112 tags.push("open-world");
5113 }
5114 }
5115 if let Some(execution) = &tool.execution {
5116 let v = serde_json::to_value(execution).unwrap_or_default();
5117 match v.get("taskSupport").and_then(|m| m.as_str()) {
5118 Some("required") => tags.push("task-only"),
5119 Some("optional") => tags.push("task-capable"),
5120 _ => {}
5121 }
5122 }
5123 tags
5124}
5125
5126fn tool_tag_suffix(tool: &ToolDefinition) -> String {
5129 let tags = tool_tags(tool);
5130 if tags.is_empty() {
5131 return String::new();
5132 }
5133 format!(
5134 " {}",
5135 paint(Style::new().dimmed(), &format!("[{}]", tags.join(" ")))
5136 )
5137}
5138
5139fn example_invocation(name: &str, schema: &serde_json::Value) -> String {
5144 const SHOWN: usize = 4;
5145 let required: Vec<&str> = schema
5146 .get("required")
5147 .and_then(|r| r.as_array())
5148 .map(|r| r.iter().filter_map(|v| v.as_str()).collect())
5149 .unwrap_or_default();
5150 let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) else {
5151 return sanitize(name).into_owned();
5152 };
5153 let placeholder = |key: &str| -> String {
5154 let target = properties
5158 .get(key)
5159 .map(|property| editor::resolve_ref(schema, property));
5160 let ty = target
5161 .and_then(|t| t.get("type"))
5162 .and_then(|t| t.as_str())
5163 .unwrap_or("value");
5164 let sample = target
5166 .and_then(|t| t.get("enum"))
5167 .and_then(|e| e.as_array())
5168 .and_then(|values| values.first())
5169 .and_then(|v| {
5170 v.as_str()
5171 .map(str::to_string)
5172 .or_else(|| Some(v.to_string()))
5173 })
5174 .unwrap_or_else(|| format!("<{ty}>"));
5175 format!("{}={}", sanitize(key), sanitize(&sample))
5176 };
5177 let mut parts = vec![sanitize(name).into_owned()];
5178 for key in &required {
5179 parts.push(placeholder(key));
5180 }
5181 let optional: Vec<&String> = properties
5182 .keys()
5183 .filter(|key| !required.contains(&key.as_str()))
5184 .collect();
5185 for key in optional.iter().take(SHOWN.saturating_sub(required.len())) {
5186 parts.push(format!("[{}]", placeholder(key)));
5187 }
5188 if optional.len() > SHOWN.saturating_sub(required.len()) {
5189 parts.push("...".to_string());
5190 }
5191 parts.join(" ")
5192}
5193
5194fn describe(surface: &Surface, name: &str) {
5197 if let Some((usage, detail)) = builtin_help(name) {
5200 println!(
5201 "built-in {}",
5202 paint(Style::new().fg(Color::Cyan).bold(), name)
5203 );
5204 println!(" usage: {usage}");
5205 println!(" {detail}");
5206 return;
5207 }
5208 if let Some(t) = surface.tools.iter().find(|t| t.name == name) {
5209 println!(
5210 "tool {} {}",
5211 paint(Style::new().fg(Color::Green).bold(), &sanitize(&t.name)),
5212 sanitize(t.description.as_deref().unwrap_or(""))
5213 );
5214 if let Some(a) = &t.annotations {
5215 let mut hints = Vec::new();
5216 if a.read_only_hint {
5217 hints.push("read-only");
5218 }
5219 if a.idempotent_hint {
5220 hints.push("idempotent");
5221 }
5222 if a.destructive_hint && !a.read_only_hint {
5223 hints.push("destructive");
5224 }
5225 if a.open_world_hint {
5226 hints.push("open-world");
5227 }
5228 if !hints.is_empty() {
5229 println!(" hints: {}", hints.join(", "));
5230 }
5231 }
5232 if let Some(e) = &t.execution {
5233 let v = serde_json::to_value(e).unwrap_or_default();
5234 if let Some(mode) = v.get("taskSupport").and_then(|m| m.as_str()) {
5235 println!(" task support: {mode}");
5236 }
5237 }
5238 println!("input schema:");
5239 println!("{}", json_pretty(&t.input_schema));
5240 if let Some(out) = &t.output_schema {
5241 println!("output schema:");
5242 println!("{}", json_pretty(out));
5243 }
5244 println!(
5247 "example: {}",
5248 paint(
5249 Style::new().dimmed(),
5250 &example_invocation(&t.name, &t.input_schema)
5251 )
5252 );
5253 return;
5254 }
5255 if let Some(p) = surface.prompts.iter().find(|p| p.name == name) {
5256 println!(
5257 "prompt {} {}",
5258 paint(Style::new().fg(Color::Green).bold(), &sanitize(&p.name)),
5259 sanitize(p.description.as_deref().unwrap_or(""))
5260 );
5261 if p.arguments.is_empty() {
5262 println!(" (no arguments)");
5263 } else {
5264 println!("arguments:");
5265 for a in &p.arguments {
5266 println!(
5267 " {} {} {}",
5268 style::column(Style::new().fg(Color::Cyan), &sanitize(&a.name), 20),
5269 style::column(
5270 Style::new(),
5271 if a.required { "required" } else { "optional" },
5272 10
5273 ),
5274 sanitize(a.description.as_deref().unwrap_or(""))
5275 );
5276 }
5277 }
5278 return;
5279 }
5280 if let Some(r) = surface
5281 .resources
5282 .iter()
5283 .find(|r| r.uri == name || r.name == name)
5284 {
5285 println!(
5286 "resource {}",
5287 paint(Style::new().fg(Color::Green).bold(), &sanitize(&r.uri))
5288 );
5289 println!(" name: {}", sanitize(&r.name));
5290 if let Some(t) = &r.title {
5291 println!(" title: {}", sanitize(t));
5292 }
5293 if let Some(d) = &r.description {
5294 println!(" description: {}", sanitize(d));
5295 }
5296 if let Some(m) = &r.mime_type {
5297 println!(" mimeType: {}", sanitize(m));
5298 }
5299 if let Some(s) = r.size {
5300 println!(" size: {s} bytes");
5301 }
5302 return;
5303 }
5304 if let Some(t) = surface
5305 .templates
5306 .iter()
5307 .find(|t| t.uri_template == name || t.name == name)
5308 {
5309 println!(
5310 "template {}",
5311 paint(
5312 Style::new().fg(Color::Green).bold(),
5313 &sanitize(&t.uri_template)
5314 )
5315 );
5316 println!(" name: {}", sanitize(&t.name));
5317 if let Some(d) = &t.description {
5318 println!(" description: {}", sanitize(d));
5319 }
5320 if let Some(m) = &t.mime_type {
5321 println!(" mimeType: {}", sanitize(m));
5322 }
5323 if !t.arguments.is_empty() {
5324 println!("arguments:");
5325 for a in &t.arguments {
5326 println!(
5327 " {} {} {}",
5328 style::column(Style::new().fg(Color::Cyan), &sanitize(&a.name), 20),
5329 style::column(
5330 Style::new(),
5331 if a.required { "required" } else { "optional" },
5332 10
5333 ),
5334 sanitize(a.description.as_deref().unwrap_or(""))
5335 );
5336 }
5337 }
5338 return;
5339 }
5340 report_error_with_hint(
5344 ExitStatus::NoMatch,
5345 &format!("nothing on the surface named `{name}` (try `tools`, `prompts`, `resources`)"),
5346 find::did_you_mean(surface, name).as_deref(),
5347 );
5348}
5349
5350#[allow(clippy::too_many_arguments)]
5351async fn run_tool(
5352 session: &Arc<Session>,
5353 surface: &Arc<RwLock<Surface>>,
5354 jobs: &Arc<Jobs>,
5355 schema_contracts: &schema_contract::ContractSet,
5356 name: &str,
5357 arguments: serde_json::Value,
5358 background: bool,
5359 output: &vars::Output,
5360) {
5361 if !enforce_tool_contract(schema_contracts, surface, name) {
5362 return;
5363 }
5364 if background {
5365 match with_reconnect(session, surface, |c| {
5366 let arguments = arguments.clone();
5367 async move { c.call_tool_as_task(name, arguments, None).await }
5368 })
5369 .await
5370 {
5371 Ok(created) => {
5372 let created_value = serde_json::to_value(&created).unwrap_or_default();
5373 let task_id = created.task.task_id.clone();
5374 let poll_interval = created.task.poll_interval;
5375 jobs.register(
5378 created.task.task_id.clone(),
5379 name.to_string(),
5380 created.task.status,
5381 created.task.status_message.clone(),
5382 );
5383 if !output.is_plain() {
5384 emit_result(created_value, output);
5388 } else if json_output() {
5389 print_json(&created_value);
5390 } else {
5391 println!(
5392 "{} started",
5393 tag(
5394 Style::new().fg(Color::Yellow),
5395 &format!("task {}", sanitize(&jobs.label_for(&task_id)))
5396 )
5397 );
5398 }
5399 watch_task(session.clone(), jobs.clone(), task_id, poll_interval);
5400 }
5401 Err(e) => report_mcp_error(&e),
5402 }
5403 return;
5404 }
5405 let started = std::time::Instant::now();
5406 match with_reconnect(session, surface, |c| {
5407 let arguments = arguments.clone();
5408 async move { c.call_tool(name, arguments).await }
5409 })
5410 .await
5411 {
5412 Ok(result) => {
5413 if result.is_error {
5414 note_error(ExitStatus::Server);
5415 }
5416 if output.is_plain() {
5417 if json_output() {
5418 print_json(&serde_json::to_value(&result).unwrap_or_default());
5419 } else {
5420 if result.is_error {
5421 println!("{}", tag(Style::new().fg(Color::Red), "tool error"));
5422 }
5423 render_content(&result.content);
5424 }
5425 } else {
5426 emit_result(result_value(&result), output);
5427 }
5428 }
5429 Err(e) => report_mcp_error(&e),
5430 }
5431 if !json_output() {
5432 println!("{}", timing(started.elapsed()));
5433 }
5434}
5435
5436fn result_value(result: &tower_mcp::CallToolResult) -> serde_json::Value {
5439 if let Some(structured) = &result.structured_content {
5440 return structured.clone();
5441 }
5442 if let [Content::Text { text, .. }] = result.content.as_slice() {
5443 return serde_json::from_str(text)
5444 .unwrap_or_else(|_| serde_json::Value::String(text.clone()));
5445 }
5446 serde_json::to_value(&result.content).unwrap_or_default()
5447}
5448
5449const ROUTABLE_BUILTINS: &[&str] = &[
5454 "tools",
5455 "prompts",
5456 "resources",
5457 "templates",
5458 "describe",
5459 "read",
5460 "find",
5461 "info",
5462 "history",
5463];
5464
5465fn emit_value(value: serde_json::Value, output: &vars::Output, human: impl FnOnce()) {
5469 if !output.is_plain() {
5470 emit_result(value, output);
5471 } else if json_output() {
5472 print_json(&value);
5473 } else {
5474 human();
5475 }
5476}
5477
5478fn emit_result(mut value: serde_json::Value, output: &vars::Output) {
5481 if let Some(path) = &output.filter {
5482 match vars::get_path(&value, path) {
5483 Some(selected) => value = selected,
5484 None => {
5485 command_error(&format!("path `{path}` not found in result"));
5486 return;
5487 }
5488 }
5489 }
5490 if let Some(name) = &output.capture {
5491 vars::set(name, value.clone());
5492 if json_output() {
5493 print_json(&value);
5494 } else {
5495 println!(
5496 "{} {}",
5497 paint(Style::new().fg(Color::Cyan), &format!("${name} =")),
5498 value_summary(&value)
5499 );
5500 }
5501 } else if json_output() {
5502 print_json(&value);
5503 } else {
5504 render_value(&value);
5505 }
5506}
5507
5508fn value_summary(value: &serde_json::Value) -> String {
5509 match value {
5510 serde_json::Value::String(s) => format!("{s:?}"),
5511 serde_json::Value::Array(a) => format!("[{} items]", a.len()),
5512 serde_json::Value::Object(o) => format!("{{{} fields}}", o.len()),
5513 other => other.to_string(),
5514 }
5515}
5516
5517fn render_value(value: &serde_json::Value) {
5518 match value {
5519 serde_json::Value::String(s) => println!("{s}"),
5520 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
5521 println!("{}", json_pretty(value))
5522 }
5523 other => println!("{other}"),
5524 }
5525}
5526
5527#[cfg(test)]
5528mod tests {
5529 use super::*;
5530 use std::sync::Mutex;
5531
5532 use async_trait::async_trait;
5533 use tower_mcp::client::ClientTransport;
5534
5535 fn surface_with_a_task_capable_tool() -> Arc<RwLock<Surface>> {
5536 let tool = |name: &str, task: bool| -> ToolDefinition {
5537 let mut value = serde_json::json!({
5538 "name": name,
5539 "description": "",
5540 "inputSchema": { "type": "object" },
5541 });
5542 if task {
5543 value["execution"] = serde_json::json!({ "taskSupport": "optional" });
5544 }
5545 serde_json::from_value(value).expect("tool definition")
5546 };
5547 Arc::new(RwLock::new(Surface {
5548 tools: vec![tool("slow_add", true), tool("echo", false)],
5549 ..Default::default()
5550 }))
5551 }
5552
5553 #[test]
5556 fn only_a_task_capable_tool_is_worth_suggesting_backgrounding_for() {
5557 let surface = surface_with_a_task_capable_tool();
5558 assert_eq!(
5559 backgroundable_tool(&surface, "slow_add a=1 b=2").as_deref(),
5560 Some("slow_add")
5561 );
5562 assert_eq!(backgroundable_tool(&surface, "echo message=hi"), None);
5564 assert_eq!(backgroundable_tool(&surface, "slow_add a=1 b=2 &"), None);
5567 assert_eq!(backgroundable_tool(&surface, "wait 1"), None);
5569 assert_eq!(backgroundable_tool(&surface, "nope"), None);
5571 assert_eq!(backgroundable_tool(&surface, ""), None);
5572 }
5573
5574 #[test]
5575 fn a_saved_oauth_profile_reports_what_a_script_needs() {
5576 let metadata = config::OAuthProfile {
5577 url: "https://mcp.example.com/mcp".to_string(),
5578 scopes: vec!["openid".to_string(), "offline_access".to_string()],
5579 client_id_metadata_document: None,
5580 authorization_server: None,
5581 };
5582 let value = saved_profile_json("work", &metadata);
5583 assert_eq!(value["profile"], "work");
5584 assert_eq!(value["serverUrl"], "https://mcp.example.com/mcp");
5585 assert_eq!(value["scopes"][0], "openid");
5586 assert_eq!(value["scopes"][1], "offline_access");
5587 assert_eq!(
5591 value.as_object().map(|object| object.len()),
5592 Some(3),
5593 "{value}"
5594 );
5595 }
5596
5597 #[test]
5598 fn a_json_rpc_error_reads_as_a_sentence_and_a_code() {
5599 let error = tower_mcp::Error::JsonRpc(tower_mcp::error::JsonRpcError {
5600 code: -32601,
5601 message: "Method not found".to_string(),
5602 data: None,
5603 });
5604 assert_eq!(describe_mcp_error(&error), "Method not found (code -32601)");
5606 }
5607
5608 #[test]
5609 fn structured_error_data_is_shown_when_it_says_something() {
5610 let with_data = |data: serde_json::Value| {
5611 describe_mcp_error(&tower_mcp::Error::JsonRpc(tower_mcp::error::JsonRpcError {
5612 code: -32602,
5613 message: "Invalid params".to_string(),
5614 data: Some(data),
5615 }))
5616 };
5617 assert_eq!(
5618 with_data(serde_json::json!("field `name` is required")),
5619 "Invalid params (code -32602): field `name` is required"
5620 );
5621 assert_eq!(
5623 with_data(serde_json::Value::Null),
5624 "Invalid params (code -32602)"
5625 );
5626 }
5627
5628 #[test]
5629 fn an_error_relayed_as_json_shows_its_innermost_message() {
5630 assert_eq!(
5632 unwrap_nested(
5633 r#"Client error: {"code":-32007,"message":"sampling declined: --sampling decline"}"#
5634 ),
5635 "sampling declined: --sampling decline"
5636 );
5637 assert_eq!(
5639 unwrap_nested(r#"outer: {"message":"middle: {\"message\":\"inner\"}"}"#),
5640 "inner"
5641 );
5642 }
5643
5644 #[test]
5645 fn an_ordinary_message_is_left_alone_by_the_unwrapping() {
5646 for message in [
5647 "Method not found",
5648 "",
5649 "unexpected token {",
5651 r#"bad input: {"field":"name"}"#,
5653 r#"relayed: {"code":-1}"#,
5655 ] {
5656 assert_eq!(unwrap_nested(message), message, "{message:?}");
5657 }
5658 }
5659
5660 #[test]
5661 fn a_repeated_error_label_is_collapsed_to_one() {
5662 assert_eq!(
5665 collapse_repeated_label(
5666 "Transport error: Transport error: Transport error: HTTP request failed: refused"
5667 ),
5668 "Transport error: HTTP request failed: refused"
5669 );
5670 assert_eq!(
5672 collapse_repeated_label("Transport error: HTTP request failed: refused"),
5673 "Transport error: HTTP request failed: refused"
5674 );
5675 }
5676
5677 #[test]
5678 fn collapsing_leaves_ordinary_messages_alone() {
5679 for message in [
5682 "unknown command: nope",
5683 "Server error: tool `x` failed: bad input",
5684 "no colon here",
5685 "",
5686 ": leading colon",
5687 ] {
5688 assert_eq!(collapse_repeated_label(message), message, "{message:?}");
5689 }
5690 }
5691
5692 #[test]
5693 fn only_an_identical_label_collapses() {
5694 assert_eq!(
5696 collapse_repeated_label("Transport error: Server error: refused"),
5697 "Transport error: Server error: refused"
5698 );
5699 }
5700
5701 struct DiscoveryTransport {
5703 result: serde_json::Value,
5704 incoming_tx: tokio::sync::mpsc::Sender<String>,
5705 incoming_rx: tokio::sync::mpsc::Receiver<String>,
5706 outgoing: Arc<Mutex<Vec<serde_json::Value>>>,
5707 connected: bool,
5708 }
5709
5710 impl DiscoveryTransport {
5711 fn new(result: serde_json::Value) -> (Self, Arc<Mutex<Vec<serde_json::Value>>>) {
5712 let (incoming_tx, incoming_rx) = tokio::sync::mpsc::channel(4);
5713 let outgoing = Arc::new(Mutex::new(Vec::new()));
5714 (
5715 Self {
5716 result,
5717 incoming_tx,
5718 incoming_rx,
5719 outgoing: outgoing.clone(),
5720 connected: true,
5721 },
5722 outgoing,
5723 )
5724 }
5725 }
5726
5727 #[async_trait]
5728 impl ClientTransport for DiscoveryTransport {
5729 async fn send(&mut self, message: &str) -> tower_mcp::Result<()> {
5730 let request: serde_json::Value = serde_json::from_str(message)?;
5731 self.outgoing.lock().unwrap().push(request.clone());
5732 if let Some(id) = request.get("id") {
5733 self.incoming_tx
5734 .send(
5735 serde_json::json!({
5736 "jsonrpc": "2.0",
5737 "id": id,
5738 "result": self.result,
5739 })
5740 .to_string(),
5741 )
5742 .await
5743 .map_err(|error| tower_mcp::Error::Transport(error.to_string()))?;
5744 }
5745 Ok(())
5746 }
5747
5748 async fn recv(&mut self) -> tower_mcp::Result<Option<String>> {
5749 Ok(self.incoming_rx.recv().await)
5750 }
5751
5752 fn is_connected(&self) -> bool {
5753 self.connected
5754 }
5755
5756 async fn close(&mut self) -> tower_mcp::Result<()> {
5757 self.connected = false;
5758 Ok(())
5759 }
5760 }
5761
5762 fn jsonrpc(code: i32, message: &str) -> tower_mcp::Error {
5763 tower_mcp::Error::JsonRpc(tower_mcp::error::JsonRpcError {
5764 code,
5765 message: message.to_string(),
5766 data: None,
5767 })
5768 }
5769
5770 #[test]
5771 fn protocol_selection_is_stable_by_default_and_final_is_exact() {
5772 let stable = Args::try_parse_from(["mcp-repl", "--demo"]).unwrap();
5773 assert_eq!(stable.protocol, ProtocolMode::Stable);
5774 assert_eq!(
5775 stable.protocol.support().unwrap().versions(),
5776 tower_mcp::protocol::SUPPORTED_PROTOCOL_VERSIONS
5777 );
5778
5779 for value in ["2026-07-28", "final"] {
5780 let final_args =
5781 Args::try_parse_from(["mcp-repl", "--protocol", value, "--demo"]).unwrap();
5782 assert_eq!(final_args.protocol, ProtocolMode::Final);
5783 assert_eq!(
5784 final_args.protocol.support().unwrap().versions(),
5785 ["2026-07-28"]
5786 );
5787 }
5788 }
5789
5790 #[test]
5791 fn oauth_cli_parses_standalone_and_connection_workflows() {
5792 let login = Args::try_parse_from([
5793 "mcp-repl",
5794 "--login",
5795 "work",
5796 "--http",
5797 "https://mcp.example/mcp",
5798 "--oauth-scope",
5799 "openid",
5800 "--oauth-scope",
5801 "offline_access",
5802 "--no-browser",
5803 ])
5804 .unwrap();
5805 assert_eq!(login.login.as_deref(), Some("work"));
5806 assert_eq!(login.oauth_scopes, ["openid", "offline_access"]);
5807 assert!(login.no_browser);
5808
5809 let connection = Args::try_parse_from([
5810 "mcp-repl",
5811 "--oauth",
5812 "work",
5813 "--http",
5814 "https://mcp.example/mcp",
5815 "--exec",
5816 "tools",
5817 "--json",
5818 ])
5819 .unwrap();
5820 assert_eq!(connection.oauth.as_deref(), Some("work"));
5821 assert_eq!(connection.exec, ["tools"]);
5822
5823 assert!(Args::try_parse_from(["mcp-repl", "--login", "work", "--logout", "work"]).is_err());
5824 }
5825
5826 #[tokio::test]
5827 async fn stable_selection_uses_initialize() {
5828 let client = client_builder(ProtocolMode::Stable)
5829 .unwrap()
5830 .connect_simple(ChannelTransport::new(demo_router()))
5831 .await
5832 .unwrap();
5833 let info = establish_connection(&client, ProtocolMode::Stable)
5834 .await
5835 .unwrap();
5836
5837 assert_eq!(info.server_info.name, "mcp-repl-demo");
5838 assert_eq!(
5839 info.protocol_version,
5840 tower_mcp::protocol::LATEST_PROTOCOL_VERSION
5841 );
5842 assert!(client.server_info().await.is_some());
5843 assert!(client.discovery().await.is_none());
5844 }
5845
5846 #[tokio::test]
5847 async fn final_selection_uses_discover_with_required_metadata() {
5848 let (transport, outgoing) = DiscoveryTransport::new(serde_json::json!({
5849 "resultType": "complete",
5850 "supportedVersions": ["2026-07-28"],
5851 "capabilities": {"tools": {}},
5852 "ttlMs": 0,
5853 "cacheScope": "private",
5854 "_meta": {
5855 "io.modelcontextprotocol/serverInfo": {
5856 "name": "final-test-server",
5857 "version": "1.0.0"
5858 }
5859 }
5860 }));
5861 let client = client_builder(ProtocolMode::Final)
5862 .unwrap()
5863 .connect_simple(transport)
5864 .await
5865 .unwrap();
5866 let info = establish_connection(&client, ProtocolMode::Final)
5867 .await
5868 .unwrap();
5869
5870 assert_eq!(info.server_info.name, "final-test-server");
5871 assert_eq!(info.protocol_version, "2026-07-28");
5872 assert!(client.server_info().await.is_none());
5873 assert!(client.discovery().await.is_some());
5874
5875 let sent = outgoing.lock().unwrap();
5876 assert_eq!(sent.len(), 1);
5877 assert_eq!(sent[0]["method"], "server/discover");
5878 assert_eq!(
5879 sent[0]["params"]["_meta"]["io.modelcontextprotocol/protocolVersion"],
5880 "2026-07-28"
5881 );
5882 assert!(
5883 sent[0]["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"].is_object()
5884 );
5885 assert!(
5886 sent[0]["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"]["extensions"]
5887 [tower_mcp::protocol::TASKS_EXTENSION_ID]
5888 .is_object()
5889 );
5890 assert_eq!(
5891 sent[0]["params"]["_meta"]["io.modelcontextprotocol/clientInfo"]["name"],
5892 "mcp-repl"
5893 );
5894 }
5895
5896 #[test]
5897 fn build_http_config_sets_bearer_and_trims_headers() {
5898 let cfg = build_http_config(
5899 Some("tok".into()),
5900 &["X-Api-Key: abc".into(), "X-Trim : v ".into()],
5901 None,
5902 &[],
5903 )
5904 .unwrap();
5905 assert_eq!(
5906 cfg.headers.get("Authorization").map(String::as_str),
5907 Some("Bearer tok")
5908 );
5909 assert_eq!(
5910 cfg.headers.get("X-Api-Key").map(String::as_str),
5911 Some("abc")
5912 );
5913 assert_eq!(cfg.headers.get("X-Trim").map(String::as_str), Some("v"));
5914 }
5915
5916 #[test]
5917 fn profile_auth_applies_and_flags_override_it() {
5918 let profile_headers = [
5919 ("X-Api-Key".to_string(), "from-profile".to_string()),
5920 ("X-Kept".to_string(), "profile".to_string()),
5921 ];
5922 let cfg =
5924 build_http_config(None, &[], Some("profile-tok".into()), &profile_headers).unwrap();
5925 assert_eq!(
5926 cfg.headers.get("Authorization").map(String::as_str),
5927 Some("Bearer profile-tok")
5928 );
5929 assert_eq!(
5930 cfg.headers.get("X-Api-Key").map(String::as_str),
5931 Some("from-profile")
5932 );
5933
5934 let cfg = build_http_config(
5936 Some("flag-tok".into()),
5937 &["X-Api-Key: from-flag".into()],
5938 Some("profile-tok".into()),
5939 &profile_headers,
5940 )
5941 .unwrap();
5942 assert_eq!(
5943 cfg.headers.get("Authorization").map(String::as_str),
5944 Some("Bearer flag-tok")
5945 );
5946 assert_eq!(
5947 cfg.headers.get("X-Api-Key").map(String::as_str),
5948 Some("from-flag")
5949 );
5950 assert_eq!(
5951 cfg.headers.get("X-Kept").map(String::as_str),
5952 Some("profile")
5953 );
5954 }
5955
5956 #[test]
5957 fn oauth_precedence_is_explicit_static_then_cli_then_server_profile() {
5958 assert_eq!(
5959 selected_oauth_profile(Some("cli"), Some("server"), false, &[]),
5960 Some("cli".to_string())
5961 );
5962 assert_eq!(
5963 selected_oauth_profile(None, Some("server"), false, &[]),
5964 Some("server".to_string())
5965 );
5966 assert_eq!(
5967 selected_oauth_profile(Some("cli"), Some("server"), true, &[]),
5968 None
5969 );
5970 assert_eq!(
5971 selected_oauth_profile(
5972 Some("cli"),
5973 Some("server"),
5974 false,
5975 &["authorization: Basic explicit".to_string()],
5976 ),
5977 None
5978 );
5979 assert_eq!(
5980 selected_oauth_profile(
5981 Some("cli"),
5982 Some("server"),
5983 false,
5984 &["X-Tenant: acme".to_string()],
5985 ),
5986 Some("cli".to_string())
5987 );
5988 }
5989
5990 #[test]
5991 fn selected_authorization_header_beats_environment_bearer() {
5992 let selected_headers = [("authorization".to_string(), "Basic selected".to_string())];
5993 let cfg = build_http_config_with_env(
5994 None,
5995 &[],
5996 None,
5997 &selected_headers,
5998 Some("ambient-token".into()),
5999 )
6000 .unwrap();
6001 assert_eq!(
6002 cfg.headers.get("authorization").map(String::as_str),
6003 Some("Basic selected")
6004 );
6005
6006 let cfg = build_http_config_with_env(
6007 Some("explicit-token".into()),
6008 &[],
6009 None,
6010 &selected_headers,
6011 Some("ambient-token".into()),
6012 )
6013 .unwrap();
6014 assert_eq!(
6015 cfg.headers.get("Authorization").map(String::as_str),
6016 Some("Bearer explicit-token")
6017 );
6018 }
6019
6020 #[test]
6021 fn explicit_oauth_suppresses_profile_and_environment_bearers() {
6022 let selected = selected_oauth_profile(Some("work"), None, false, &[]);
6023 assert_eq!(selected.as_deref(), Some("work"));
6024
6025 let cfg = build_http_config_with_env(
6026 None,
6027 &[],
6028 selected.is_none().then(|| "profile-token".to_string()),
6029 &[],
6030 selected.is_none().then(|| "environment-token".to_string()),
6031 )
6032 .unwrap();
6033 assert!(!cfg.headers.contains_key("Authorization"));
6034 }
6035
6036 #[test]
6037 fn build_http_config_rejects_header_without_colon() {
6038 let err = build_http_config(Some("tok".into()), &["nope".into()], None, &[]).unwrap_err();
6039 assert!(
6040 err.contains("nope"),
6041 "error should name the bad header: {err}"
6042 );
6043 assert!(
6044 err.contains("Name: Value"),
6045 "error should show the format: {err}"
6046 );
6047 }
6048
6049 #[test]
6050 fn timing_formats_sub_second_and_seconds() {
6051 assert!(timing(Duration::from_millis(142)).contains("[142ms]"));
6052 assert!(timing(Duration::from_millis(2500)).contains("[2.50s]"));
6053 }
6054
6055 #[test]
6059 fn bench_is_a_listed_builtin() {
6060 assert!(BUILTINS.iter().any(|(name, _)| *name == "bench"));
6061 }
6062
6063 #[test]
6066 fn find_is_a_completable_builtin() {
6067 assert!(BUILTINS.iter().any(|(name, _)| *name == "find"));
6068 }
6069
6070 fn completion_script(shell: clap_complete::Shell) -> String {
6072 let mut command = <Args as clap::CommandFactory>::command();
6073 let mut out = Vec::new();
6074 clap_complete::generate(shell, &mut command, "mcp-repl", &mut out);
6075 String::from_utf8(out).expect("completion scripts are UTF-8")
6076 }
6077
6078 #[test]
6079 fn every_shell_gets_a_script_naming_the_binary() {
6080 for shell in [
6081 clap_complete::Shell::Bash,
6082 clap_complete::Shell::Zsh,
6083 clap_complete::Shell::Fish,
6084 clap_complete::Shell::PowerShell,
6085 clap_complete::Shell::Elvish,
6086 ] {
6087 let script = completion_script(shell);
6088 assert!(!script.is_empty(), "{shell} produced nothing");
6089 assert!(
6090 script.contains("mcp-repl"),
6091 "{shell} does not name the binary"
6092 );
6093 }
6094 }
6095
6096 #[test]
6097 fn completion_covers_flags_and_their_values() {
6098 let bash = completion_script(clap_complete::Shell::Bash);
6099 for flag in [
6102 "--protocol",
6103 "--http",
6104 "--elicitation",
6105 "--timeout",
6106 "--man",
6107 ] {
6108 assert!(bash.contains(flag), "bash completion is missing {flag}");
6109 }
6110 for value in ["stable", "2026-07-28", "decline", "compatible"] {
6113 assert!(
6114 bash.contains(value),
6115 "bash completion is missing value {value}"
6116 );
6117 }
6118 }
6119
6120 #[test]
6121 fn the_man_page_renders_with_the_real_sections() {
6122 let command = <Args as clap::CommandFactory>::command();
6123 let mut page = Vec::new();
6124 clap_mangen::Man::new(command)
6125 .render(&mut page)
6126 .expect("man page renders");
6127 let roff = String::from_utf8(page).expect("roff is UTF-8");
6128 assert!(roff.contains("mcp-repl"));
6129 for section in [".SH NAME", ".SH SYNOPSIS", ".SH DESCRIPTION", ".SH OPTIONS"] {
6130 assert!(roff.contains(section), "man page has no {section}");
6131 }
6132 assert!(roff.contains("surface is the command set"));
6135 }
6136
6137 #[test]
6138 fn every_builtin_can_explain_itself() {
6139 for (name, _) in BUILTINS {
6142 assert!(
6143 builtin_help(name).is_some(),
6144 "`{name}` has no usage line; add one to BUILTIN_HELP"
6145 );
6146 }
6147 for (name, _, _) in BUILTIN_HELP {
6149 assert!(
6150 BUILTINS.iter().any(|(builtin, _)| builtin == name),
6151 "BUILTIN_HELP documents `{name}`, which is not a built-in"
6152 );
6153 }
6154 }
6155
6156 #[test]
6157 fn an_example_invocation_shows_required_arguments_first() {
6158 let schema = serde_json::json!({
6159 "type": "object",
6160 "properties": {
6161 "b": {"type": "integer"},
6162 "a": {"type": "integer"},
6163 "note": {"type": "string"},
6164 },
6165 "required": ["a", "b"],
6166 });
6167 let example = example_invocation("add", &schema);
6168 assert!(
6169 example.starts_with("add a=<integer> b=<integer>"),
6170 "{example}"
6171 );
6172 assert!(example.contains("[note=<string>]"), "{example}");
6173 }
6174
6175 #[test]
6176 fn an_example_invocation_follows_a_ref_into_defs() {
6177 let schema = serde_json::json!({
6180 "type": "object",
6181 "properties": {
6182 "to": {"$ref": "#/$defs/Scale"},
6183 "value": {"type": "number"},
6184 },
6185 "required": ["value", "to"],
6186 "$defs": {
6187 "Scale": {"type": "string", "enum": ["celsius", "kelvin"]},
6188 },
6189 });
6190 let example = example_invocation("convert", &schema);
6191 assert!(example.contains("to=celsius"), "{example}");
6192 assert!(example.contains("value=<number>"), "{example}");
6193 }
6194
6195 #[test]
6196 fn an_example_invocation_prefers_enum_values_to_types() {
6197 let schema = serde_json::json!({
6198 "type": "object",
6199 "properties": {"mode": {"type": "string", "enum": ["fast", "slow"]}},
6200 "required": ["mode"],
6201 });
6202 assert_eq!(example_invocation("run", &schema), "run mode=fast");
6203 }
6204
6205 #[test]
6206 fn a_tool_without_properties_still_has_an_example() {
6207 let schema = serde_json::json!({"type": "object", "additionalProperties": true});
6208 assert_eq!(example_invocation("about", &schema), "about");
6209 }
6210
6211 #[test]
6218 fn quoted_arguments_reach_the_server_intact() {
6219 let schema = serde_json::json!({
6221 "type": "object",
6222 "properties": {
6223 "mission": {"type": "string"},
6224 "count": {"type": "integer"},
6225 "flag": {"type": "boolean"},
6226 "untyped": {},
6227 },
6228 });
6229 let arguments = |line: &str| -> serde_json::Value {
6230 let parsed = command::parse(line).expect("parses");
6231 let tokens: Vec<&str> = parsed.words[1..].iter().map(String::as_str).collect();
6232 parse_kv_args(&schema, &tokens)
6233 };
6234
6235 assert_eq!(
6236 arguments(r#"tool mission="two words" count=2"#),
6237 serde_json::json!({"mission": "two words", "count": 2})
6238 );
6239 assert_eq!(
6240 arguments("tool mission='two words'"),
6241 serde_json::json!({"mission": "two words"})
6242 );
6243 assert_eq!(
6244 arguments(r"tool mission=two\ words"),
6245 serde_json::json!({"mission": "two words"})
6246 );
6247 assert_eq!(
6248 arguments(r#"tool mission="say \"hi\"""#),
6249 serde_json::json!({"mission": "say \"hi\""})
6250 );
6251 assert_eq!(
6253 arguments(r#"tool mission="""#),
6254 serde_json::json!({"mission": ""})
6255 );
6256 assert_eq!(
6258 arguments(r#"tool mission="count=9""#),
6259 serde_json::json!({"mission": "count=9"})
6260 );
6261 assert_eq!(
6263 arguments(r#"tool count="7" flag="true""#),
6264 serde_json::json!({"count": 7, "flag": true})
6265 );
6266 assert_eq!(
6269 arguments(r#"tool untyped="two words""#),
6270 serde_json::json!({"untyped": "two words"})
6271 );
6272 }
6273
6274 #[test]
6275 fn read_flags_are_separated_from_the_uri() {
6276 let (out, force, rest) =
6277 parse_read_flags(&["note://status", "--out", "/tmp/x", "--force"]).unwrap();
6278 assert_eq!(out.as_deref(), Some("/tmp/x"));
6279 assert!(force);
6280 assert_eq!(rest, vec!["note://status"]);
6281
6282 let (out, force, rest) = parse_read_flags(&["--out=/tmp/y", "note://status"]).unwrap();
6284 assert_eq!(out.as_deref(), Some("/tmp/y"));
6285 assert!(!force);
6286 assert_eq!(rest, vec!["note://status"]);
6287
6288 let (out, _, rest) = parse_read_flags(&["note://status"]).unwrap();
6289 assert_eq!(out, None);
6290 assert_eq!(rest, vec!["note://status"]);
6291 }
6292
6293 #[test]
6294 fn read_flag_errors_say_what_is_wrong() {
6295 assert!(parse_read_flags(&["note://x", "--out"]).is_err());
6296 assert!(parse_read_flags(&["note://x", "--out="]).is_err());
6297 assert!(parse_read_flags(&["note://x", "--nope"]).is_err());
6298 }
6299
6300 #[test]
6301 fn saving_decodes_a_blob_and_writes_text_as_is() {
6302 use tower_mcp::protocol::{ReadResourceResult, ResourceContent};
6303 let dir = tempfile::tempdir().unwrap();
6304
6305 let content = |text: Option<&str>, blob: Option<&str>| ResourceContent {
6306 uri: "x://y".to_string(),
6307 mime_type: None,
6308 text: text.map(str::to_string),
6309 blob: blob.map(str::to_string),
6310 meta: None,
6311 };
6312
6313 let text_path = dir.path().join("note.txt");
6315 let result = ReadResourceResult {
6316 contents: vec![content(
6317 Some(
6318 "hello
6319world",
6320 ),
6321 None,
6322 )],
6323 ..Default::default()
6324 };
6325 let written = save_resource(&result, text_path.to_str().unwrap()).unwrap();
6326 assert_eq!(written, 11);
6327 assert_eq!(std::fs::read_to_string(&text_path).unwrap(), "hello\nworld");
6328
6329 let png_path = dir.path().join("pixel.png");
6331 let result = ReadResourceResult {
6332 contents: vec![content(None, Some(PIXEL_PNG_FOR_TEST))],
6333 ..Default::default()
6334 };
6335 let written = save_resource(&result, png_path.to_str().unwrap()).unwrap();
6336 let bytes = std::fs::read(&png_path).unwrap();
6337 assert_eq!(written, bytes.len());
6338 assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG header");
6339 }
6340
6341 const PIXEL_PNG_FOR_TEST: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
6343
6344 #[test]
6345 fn saving_refuses_what_it_cannot_write_faithfully() {
6346 use tower_mcp::protocol::{ReadResourceResult, ResourceContent};
6347 let dir = tempfile::tempdir().unwrap();
6348 let path = dir.path().join("out");
6349 let empty = ReadResourceResult::default();
6350 assert!(save_resource(&empty, path.to_str().unwrap()).is_err());
6351
6352 let two = ReadResourceResult {
6355 contents: vec![
6356 ResourceContent {
6357 uri: "a".into(),
6358 mime_type: None,
6359 text: Some("one".into()),
6360 blob: None,
6361 meta: None,
6362 },
6363 ResourceContent {
6364 uri: "b".into(),
6365 mime_type: None,
6366 text: Some("two".into()),
6367 blob: None,
6368 meta: None,
6369 },
6370 ],
6371 ..Default::default()
6372 };
6373 assert!(save_resource(&two, path.to_str().unwrap()).is_err());
6374 assert!(!path.exists());
6376 }
6377
6378 #[test]
6379 fn counted_nouns_agree_with_their_number() {
6380 assert_eq!(plural(0, "tool"), "0 tools");
6381 assert_eq!(plural(1, "tool"), "1 tool");
6382 assert_eq!(plural(2, "template"), "2 templates");
6383 }
6384
6385 #[test]
6386 fn only_commands_with_a_value_accept_capture_and_filter() {
6387 for routable in ["tools", "describe", "read", "find", "info"] {
6390 assert!(
6391 ROUTABLE_BUILTINS.contains(&routable),
6392 "{routable} returns a documented value"
6393 );
6394 }
6395 for reporting in ["help", "alias", "wire", "refresh", "quit", "unset"] {
6396 assert!(
6397 !ROUTABLE_BUILTINS.contains(&reporting),
6398 "{reporting} has no value to capture"
6399 );
6400 }
6401 for name in ROUTABLE_BUILTINS {
6404 assert!(
6405 BUILTINS.iter().any(|(builtin, _)| builtin == name),
6406 "{name} is not a built-in"
6407 );
6408 }
6409 }
6410
6411 #[test]
6412 fn error_json_is_a_valid_object() {
6413 let v = error_json(ExitStatus::Usage, "boom: it broke");
6414 assert_eq!(v["error"], "boom: it broke");
6415 assert_eq!(v["kind"], "usage");
6416 assert_eq!(v["exitStatus"], 2);
6417 }
6418
6419 #[tokio::test]
6422 async fn pagination_stops_at_the_page_cap() {
6423 let mut pages = 0usize;
6424 let items: Vec<u32> = collect_pages("tools", |cursor| {
6425 pages += 1;
6426 let next = cursor.map_or(0u32, |c| c.parse::<u32>().unwrap_or(0) + 1);
6427 async move { Ok((vec![next], Some((next + 1).to_string()))) }
6428 })
6429 .await
6430 .unwrap();
6431 assert_eq!(pages, MAX_SURFACE_PAGES);
6432 assert_eq!(items.len(), MAX_SURFACE_PAGES);
6433 }
6434
6435 #[tokio::test]
6436 async fn pagination_stops_at_the_item_cap() {
6437 let items: Vec<u32> = collect_pages("tools", |cursor| {
6440 let n = cursor.map_or(0u32, |c| c.parse::<u32>().unwrap_or(0) + 1);
6441 async move { Ok((vec![n; 500], Some((n + 1).to_string()))) }
6442 })
6443 .await
6444 .unwrap();
6445 assert_eq!(items.len(), MAX_SURFACE_ITEMS);
6446 }
6447
6448 #[tokio::test]
6449 async fn pagination_stops_when_a_cursor_repeats() {
6450 let mut pages = 0usize;
6451 let items: Vec<u32> = collect_pages("prompts", |_cursor| {
6452 pages += 1;
6453 async move { Ok((vec![1], Some("same".to_string()))) }
6454 })
6455 .await
6456 .unwrap();
6457 assert_eq!(pages, 2);
6459 assert_eq!(items.len(), 2);
6460 }
6461
6462 #[tokio::test]
6463 async fn pagination_follows_an_ordinary_multi_page_surface() {
6464 let items: Vec<u32> = collect_pages("tools", |cursor| async move {
6465 match cursor.as_deref() {
6466 None => Ok((vec![1, 2], Some("page2".to_string()))),
6467 Some("page2") => Ok((vec![3], None)),
6468 other => panic!("unexpected cursor {other:?}"),
6469 }
6470 })
6471 .await
6472 .unwrap();
6473 assert_eq!(items, vec![1, 2, 3]);
6474 }
6475
6476 #[test]
6477 fn wait_accepts_an_explicit_deadline() {
6478 let (limit, rest) = parse_wait_timeout("wait", &["task-1", "--timeout", "30"]).unwrap();
6479 assert_eq!(limit, Some(Duration::from_secs(30)));
6480 assert_eq!(rest, vec!["task-1"]);
6481
6482 let (limit, rest) = parse_wait_timeout("wait", &["--timeout=5", "task-1"]).unwrap();
6483 assert_eq!(limit, Some(Duration::from_secs(5)));
6484 assert_eq!(rest, vec!["task-1"]);
6485
6486 let (limit, _) = parse_wait_timeout("wait", &["task-1", "--timeout", "0"]).unwrap();
6488 assert_eq!(limit, None);
6489
6490 let (limit, rest) = parse_wait_timeout("wait", &["task-1"]).unwrap();
6491 assert_eq!(limit, None);
6492 assert_eq!(rest, vec!["task-1"]);
6493 }
6494
6495 #[test]
6496 fn wait_deadline_errors_are_explained() {
6497 assert!(parse_wait_timeout("wait", &["t", "--timeout"]).is_err());
6498 assert!(parse_wait_timeout("wait", &["t", "--timeout", "soon"]).is_err());
6499 assert!(parse_wait_timeout("task", &["t", "--timeout", "5"]).is_err());
6502 }
6503
6504 #[test]
6505 fn automatic_task_updates_are_interactive_text_only() {
6506 assert!(automatic_task_updates(false, false));
6507 assert!(!automatic_task_updates(true, false));
6508 assert!(!automatic_task_updates(true, true));
6509 assert!(!automatic_task_updates(false, true));
6510 }
6511
6512 #[test]
6513 fn quoted_task_arguments_reach_schema_coercion_intact() {
6514 let parsed = command::parse(
6515 r#"run.start instruction="Reply with exactly hello" mode=interactive &"#,
6516 )
6517 .unwrap();
6518 let tokens: Vec<&str> = parsed.words[1..].iter().map(String::as_str).collect();
6519 let schema = serde_json::json!({
6520 "type": "object",
6521 "properties": {
6522 "instruction": { "type": "string" },
6523 "mode": { "type": "string" }
6524 }
6525 });
6526
6527 assert!(parsed.background);
6528 assert_eq!(
6529 parse_kv_args(&schema, &tokens),
6530 serde_json::json!({
6531 "instruction": "Reply with exactly hello",
6532 "mode": "interactive"
6533 })
6534 );
6535 }
6536
6537 #[test]
6542 fn file_backed_history_writes_on_sync() {
6543 use reedline::{FileBackedHistory, History, HistoryItem};
6544 let path = std::env::temp_dir().join(format!("mcp-repl-hist-{}.txt", std::process::id()));
6545 let _ = std::fs::remove_file(&path);
6546 {
6547 let mut h = FileBackedHistory::with_file(10, path.clone()).unwrap();
6548 h.save(HistoryItem::from_command_line("echo persisted"))
6549 .unwrap();
6550 h.sync().unwrap();
6551 }
6552 let contents = std::fs::read_to_string(&path).unwrap();
6553 assert!(
6554 contents.contains("echo persisted"),
6555 "history was not written to disk: {contents:?}"
6556 );
6557 let _ = std::fs::remove_file(&path);
6558 }
6559
6560 async fn demo_client() -> McpClient {
6563 let client = McpClient::builder()
6564 .connect_simple(ChannelTransport::new(demo_router()))
6565 .await
6566 .unwrap();
6567 client.initialize("mcp-repl-test", "0").await.unwrap();
6568 client
6569 }
6570
6571 #[tokio::test(flavor = "multi_thread")]
6572 async fn bundled_slow_task_announces_completion_without_manual_polling() {
6573 let session = Arc::new(Session::new(demo_client().await, None));
6574 let surface = Arc::new(RwLock::new(Surface::default()));
6575 let output = AsyncOutput::new(Arc::new(AtomicBool::new(true)), true);
6576 let printer = output.external_printer().unwrap();
6577 let jobs = Arc::new(Jobs::new(output, true));
6578 let schema_contracts = schema_contract::ContractSet::default();
6579
6580 run_tool(
6581 &session,
6582 &surface,
6583 &jobs,
6584 &schema_contracts,
6585 "slow_add",
6586 serde_json::json!({ "a": 2, "b": 3 }),
6587 true,
6588 &vars::Output::default(),
6589 )
6590 .await;
6591
6592 let line = tokio::time::timeout(Duration::from_secs(6), async {
6593 loop {
6594 if let Some(line) = printer.get_line() {
6595 break line;
6596 }
6597 tokio::time::sleep(Duration::from_millis(25)).await;
6598 }
6599 })
6600 .await
6601 .expect("the task watcher should observe slow_add completion");
6602
6603 assert!(line.contains("completed"), "{line}");
6604 assert_eq!(
6605 jobs.list()[0].status,
6606 tower_mcp::protocol::TaskStatus::Completed
6607 );
6608 }
6609
6610 async fn demo_session() -> (Arc<Session>, Arc<std::sync::atomic::AtomicUsize>) {
6613 let connects = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6614 let counter = connects.clone();
6615 let connector: Connector = Box::new(move || {
6616 let counter = counter.clone();
6617 Box::pin(async move {
6618 counter.fetch_add(1, Ordering::SeqCst);
6619 Ok(demo_client().await)
6620 })
6621 });
6622 (
6623 Arc::new(Session::new(demo_client().await, Some(connector))),
6624 connects,
6625 )
6626 }
6627
6628 #[tokio::test(flavor = "multi_thread")]
6632 async fn dropped_session_is_rebuilt_and_the_command_retried() {
6633 let (session, connects) = demo_session().await;
6634 let surface = Arc::new(RwLock::new(Surface::default()));
6635 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6636 let dead = Arc::as_ptr(&session.client()) as usize;
6637 let seen: Arc<RwLock<Vec<usize>>> = Arc::new(RwLock::new(Vec::new()));
6638
6639 let (calls, saw) = (attempts.clone(), seen.clone());
6640 let result = with_reconnect(&session, &surface, |c| {
6641 let (calls, saw) = (calls.clone(), saw.clone());
6642 async move {
6643 saw.write().unwrap().push(Arc::as_ptr(&c) as usize);
6644 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
6646 return Err(jsonrpc(
6647 -32600,
6648 "Client must send notifications/initialized before making requests",
6649 ));
6650 }
6651 c.call_tool("echo", serde_json::json!({ "message": "alive" }))
6652 .await
6653 }
6654 })
6655 .await
6656 .expect("the retried call should succeed on the rebuilt session");
6657
6658 assert_eq!(attempts.load(Ordering::SeqCst), 2, "one retry, not a loop");
6659 let seen = seen.read().unwrap();
6661 assert_eq!(seen[0], dead);
6662 assert_ne!(seen[1], dead, "the retry reused the dead client");
6663 assert_eq!(
6664 connects.load(Ordering::SeqCst),
6665 1,
6666 "reconnected exactly once"
6667 );
6668 assert_eq!(session.generation(), 1);
6669 match result.content.first() {
6670 Some(Content::Text { text, .. }) => assert_eq!(text, "alive"),
6671 other => panic!("unexpected content: {other:?}"),
6672 }
6673 assert!(
6675 !surface.read().unwrap().tools.is_empty(),
6676 "surface should be refreshed after reconnect"
6677 );
6678 }
6679
6680 #[tokio::test(flavor = "multi_thread")]
6681 async fn a_still_dead_server_surfaces_the_error_after_one_retry() {
6682 let (session, connects) = demo_session().await;
6683 let surface = Arc::new(RwLock::new(Surface::default()));
6684 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6685
6686 let calls = attempts.clone();
6687 let err = with_reconnect(&session, &surface, |_c| {
6688 let calls = calls.clone();
6689 async move {
6690 calls.fetch_add(1, Ordering::SeqCst);
6691 Err::<(), _>(tower_mcp::Error::Transport(
6692 "HTTP 503 Service Unavailable from server: ".into(),
6693 ))
6694 }
6695 })
6696 .await
6697 .unwrap_err();
6698
6699 assert!(is_session_lost(&err));
6700 assert_eq!(attempts.load(Ordering::SeqCst), 2, "bounded to one retry");
6701 assert_eq!(connects.load(Ordering::SeqCst), 1);
6702 }
6703
6704 #[tokio::test(flavor = "multi_thread")]
6705 async fn ordinary_errors_do_not_reconnect() {
6706 let (session, connects) = demo_session().await;
6707 let surface = Arc::new(RwLock::new(Surface::default()));
6708 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6709
6710 let calls = attempts.clone();
6711 let err = with_reconnect(&session, &surface, |_c| {
6712 let calls = calls.clone();
6713 async move {
6714 calls.fetch_add(1, Ordering::SeqCst);
6715 Err::<(), _>(jsonrpc(-32602, "Invalid params"))
6716 }
6717 })
6718 .await
6719 .unwrap_err();
6720
6721 assert!(matches!(err, tower_mcp::Error::JsonRpc(j) if j.code == -32602));
6722 assert_eq!(attempts.load(Ordering::SeqCst), 1, "no retry");
6723 assert_eq!(connects.load(Ordering::SeqCst), 0, "no reconnect");
6724 }
6725
6726 #[tokio::test(flavor = "multi_thread")]
6729 async fn a_session_without_a_connector_never_retries() {
6730 let session = Arc::new(Session::new(demo_client().await, None));
6731 let surface = Arc::new(RwLock::new(Surface::default()));
6732 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6733
6734 assert!(!session.can_reconnect());
6735 let calls = attempts.clone();
6736 let err = with_reconnect(&session, &surface, |_c| {
6737 let calls = calls.clone();
6738 async move {
6739 calls.fetch_add(1, Ordering::SeqCst);
6740 Err::<(), _>(tower_mcp::Error::SessionExpired)
6741 }
6742 })
6743 .await
6744 .unwrap_err();
6745
6746 assert!(matches!(err, tower_mcp::Error::SessionExpired));
6747 assert_eq!(attempts.load(Ordering::SeqCst), 1);
6748 }
6749}