1mod alias;
44mod bearer_fd;
45mod bench;
46mod command;
47pub mod config;
48mod connection_auth;
49mod directories;
50mod editor;
51mod elicit;
52mod exit_status;
53mod find;
54pub mod import_config;
55mod import_trust;
56mod jobs;
57pub mod lifecycle;
58pub mod oauth_profile;
59mod output;
60#[cfg(test)]
61mod property;
62mod sampling;
63mod schema_contract;
64mod secure_file;
65mod session;
66mod style;
67mod subscribe;
68mod surface_subscription;
69mod tool_args;
70mod vars;
71mod wire;
72
73use std::future::Future;
74use std::sync::atomic::{AtomicBool, Ordering};
75use std::sync::{Arc, RwLock};
76use std::time::Duration;
77
78use clap::{Parser, ValueEnum};
79use connection_auth::{
80 raw_header_is_authorization, selected_oauth_profile, validate_bearer_fd_exclusive,
81 validate_profile_bearer_fd_exclusive,
82};
83use nu_ansi_term::{Color, Style};
84
85use tokio::io::{AsyncBufReadExt, BufReader};
86use tool_args::{parse_kv_args, parse_prompt_args};
87use tower_mcp::client::{
88 ChannelTransport, HttpClientConfig, HttpClientTransport, McpClient, McpClientBuilder,
89 NotificationHandler, OAuthAuthorizationFlow, OAuthAuthorizationStart, OAuthClientError,
90 OAuthScopeEscalationConfig, StdioClientTransport,
91};
92use tower_mcp::protocol::{
93 Content, DiscoverResult, Implementation, InitializeResult, LogLevel, PromptDefinition,
94 ResourceDefinition, ResourceTemplateDefinition, ServerCapabilities, SubscriptionFilter,
95 TaskObject, ToolDefinition,
96};
97use tower_mcp::{ProtocolSupport, ProtocolSupportError};
98
99use alias::Aliases;
100use elicit::ReplClientHandler;
101use exit_status::ExitStatus;
102use jobs::Jobs;
103use output::AsyncOutput;
104use session::{Connector, Session, is_not_initialized, is_session_lost};
105use style::{json_pretty, paint, sanitize, tag, task_status_style};
106use wire::{TracingTransport, wire};
107
108#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
114enum ProtocolMode {
115 #[default]
116 Stable,
117 #[value(name = "2026-07-28", alias = "final")]
118 Final,
119}
120
121impl ProtocolMode {
122 fn support(self) -> Result<ProtocolSupport, ProtocolSupportError> {
123 match self {
124 Self::Stable => Ok(ProtocolSupport::stable()),
125 Self::Final => ProtocolSupport::try_new(["2026-07-28"]),
126 }
127 }
128}
129
130#[derive(Parser)]
131#[command(
132 name = "mcp-repl",
133 version,
134 about = "Interactive MCP client REPL",
135 long_about = "\
136An interactive terminal REPL for any MCP server. The server's surface is the \
137command set: every tool becomes a top-level command with schema-coerced \
138key=value arguments, prompts and resources get built-ins, tab completion is \
139powered by the server itself where the protocol allows, and the command table \
140refreshes when the server's surface changes.
141
142Connects over stdio or streamable HTTP, reads the JSON config files other MCP \
143clients use, and keeps named profiles of its own.",
144 trailing_var_arg = true,
145 after_help = "\
148EXAMPLES:
149 mcp-repl --demo the bundled demo server
150 mcp-repl --http https://example/mcp a streamable HTTP server
151 mcp-repl -- ./my-server --stdio spawn a stdio server
152 mcp-repl .mcp.json:local an entry from a client config
153 mcp-repl --server prod a saved profile
154
155 mcp-repl --demo -e 'echo message=hi' run one command and exit
156 mcp-repl --demo --json -e tools | jq NDJSON for scripts
157
158Inside the REPL, `help` lists the built-ins and `help <command>` explains one."
159)]
160struct Args {
161 #[arg(long, value_enum, default_value = "stable")]
164 protocol: ProtocolMode,
165
166 #[arg(long)]
169 http: Option<String>,
170
171 #[arg(long, conflicts_with_all = ["http", "command", "server"])]
173 demo: bool,
174
175 #[arg(long, value_name = "NAME")]
179 server: Option<String>,
180
181 #[arg(long, value_name = "PATH")]
184 config: Option<String>,
185
186 #[arg(long)]
188 list_servers: bool,
189
190 #[arg(long)]
196 scan: bool,
197
198 #[arg(long, value_name = "SHELL")]
203 completions: Option<clap_complete::Shell>,
204
205 #[arg(long)]
209 man: bool,
210
211 #[arg(long, value_enum, default_value = "auto")]
213 color: style::ColorMode,
214
215 #[arg(long)]
220 bearer: Option<String>,
221
222 #[arg(long, value_name = "FD")]
226 bearer_fd: Option<i32>,
227
228 #[arg(long = "header", value_name = "NAME: VALUE")]
231 headers: Vec<String>,
232
233 #[arg(long, value_name = "NAME")]
235 oauth: Option<String>,
236
237 #[arg(long, value_name = "NAME", conflicts_with = "logout")]
240 login: Option<String>,
241
242 #[arg(long, value_name = "NAME", conflicts_with = "login")]
245 logout: Option<String>,
246
247 #[arg(long = "oauth-scope", value_name = "SCOPE")]
250 oauth_scopes: Vec<String>,
251
252 #[arg(long, value_name = "URL")]
255 oauth_client_id_metadata_document: Option<String>,
256
257 #[arg(long, value_name = "ISSUER")]
260 oauth_authorization_server: Option<String>,
261
262 #[arg(long)]
265 no_browser: bool,
266
267 #[arg(short = 'e', long = "exec", value_name = "COMMAND")]
272 exec: Vec<String>,
273
274 #[arg(long)]
277 json: bool,
278
279 #[arg(long)]
282 verbose: bool,
283
284 #[arg(long = "schema-contract", value_name = "PATH")]
287 schema_contracts: Vec<std::path::PathBuf>,
288
289 #[arg(long, value_enum, default_value = "compatible")]
291 schema_mode: schema_contract::ValidationMode,
292
293 #[arg(long, value_enum, value_name = "STRATEGY")]
298 sampling: Option<sampling::SamplingMode>,
299
300 #[arg(long, value_enum, value_name = "STRATEGY")]
305 elicitation: Option<elicit::ElicitationMode>,
306
307 #[arg(long)]
312 trust_import: bool,
313
314 #[arg(long)]
316 no_history: bool,
317
318 #[arg(long)]
322 no_reconnect: bool,
323
324 #[arg(long)]
327 trace: bool,
328
329 #[arg(long, value_name = "SECONDS")]
336 timeout: Option<u64>,
337
338 command: Vec<String>,
340}
341
342static JSON_OUTPUT: AtomicBool = AtomicBool::new(false);
344
345fn json_output() -> bool {
346 JSON_OUTPUT.load(Ordering::Relaxed)
347}
348
349static COMMAND_RAN: AtomicBool = AtomicBool::new(false);
352
353pub(crate) const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 120;
355
356static REQUEST_TIMEOUT_SECS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
358
359fn request_timeout() -> Option<Duration> {
360 match REQUEST_TIMEOUT_SECS.load(Ordering::Relaxed) {
361 0 => None,
362 secs => Some(Duration::from_secs(secs)),
363 }
364}
365
366async fn with_deadline<T, Fut>(fut: Fut) -> Result<T, tower_mcp::Error>
373where
374 Fut: Future<Output = Result<T, tower_mcp::Error>>,
375{
376 let Some(limit) = request_timeout() else {
377 return fut.await;
378 };
379 match tokio::time::timeout(limit, fut).await {
380 Ok(result) => result,
381 Err(_) => Err(tower_mcp::Error::Transport(format!(
382 "no response after {}s (--timeout); the request may still be running on the server",
383 limit.as_secs()
384 ))),
385 }
386}
387
388fn note_error(status: ExitStatus) {
389 exit_status::record(status);
390}
391
392fn automatic_task_updates(one_shot: bool, json: bool) -> bool {
393 !one_shot && !json
394}
395
396fn print_json(value: &serde_json::Value) {
400 println!("{value}");
401}
402
403fn error_json(status: ExitStatus, message: &str) -> serde_json::Value {
405 serde_json::json!({
406 "error": message,
407 "kind": status.label(),
408 "exitStatus": status.code(),
409 })
410}
411
412fn report_error(status: ExitStatus, message: &str) {
422 report_error_with_hint(status, message, None);
423}
424
425fn report_error_with_hint(status: ExitStatus, message: &str, hint: Option<&str>) {
427 note_error(status);
428 if json_output() {
429 let mut value = error_json(status, message);
430 if let Some(hint) = hint {
431 value["didYouMean"] = serde_json::json!(hint);
432 }
433 print_json(&value);
434 return;
435 }
436 let mut line = format!("{}: {}", style::error_prefix(), sanitize(message));
439 if let Some(hint) = hint {
440 line.push_str(&format!(
441 "; did you mean `{}`?",
442 paint(Style::new().fg(Color::Green), &sanitize(hint))
443 ));
444 }
445 eprintln!("{line}");
446}
447
448fn report_mcp_error(error: &tower_mcp::Error) {
449 report_error(
450 ExitStatus::from_mcp_error(error),
451 &describe_mcp_error(error),
452 );
453}
454
455fn unwrap_nested(message: &str) -> String {
466 let Some(start) = message.find('{') else {
467 return message.to_string();
468 };
469 let Ok(value) = serde_json::from_str::<serde_json::Value>(&message[start..]) else {
470 return message.to_string();
471 };
472 match value.get("message").and_then(serde_json::Value::as_str) {
473 Some(inner) if !inner.is_empty() => unwrap_nested(inner),
475 _ => message.to_string(),
476 }
477}
478
479fn describe_mcp_error(error: &tower_mcp::Error) -> String {
491 let tower_mcp::Error::JsonRpc(rpc) = error else {
492 return collapse_repeated_label(&error.to_string()).to_string();
493 };
494 let mut described = format!(
495 "{} (code {})",
496 sanitize(&unwrap_nested(&rpc.message)),
497 rpc.code
498 );
499 if let Some(data) = &rpc.data {
503 let detail = match data {
504 serde_json::Value::String(text) => text.clone(),
505 other => other.to_string(),
506 };
507 if !detail.is_empty() && detail != "null" {
508 described.push_str(&format!(": {}", sanitize(&detail)));
509 }
510 }
511 described
512}
513
514fn init_tracing(args: &Args) {
516 let ansi = match args.color {
520 style::ColorMode::Always => true,
521 style::ColorMode::Never => false,
522 style::ColorMode::Auto => {
523 std::env::var_os("NO_COLOR").is_none()
524 && std::io::IsTerminal::is_terminal(&std::io::stderr())
525 }
526 };
527 tracing_subscriber::fmt()
528 .with_writer(std::io::stderr)
529 .with_ansi(ansi)
530 .with_env_filter(
531 tracing_subscriber::EnvFilter::try_from_default_env()
538 .unwrap_or_else(|_| "warn,tower_mcp::client=off".into()),
539 )
540 .init();
541}
542
543fn collapse_repeated_label(message: &str) -> &str {
552 let Some((label, _)) = message.split_once(": ") else {
553 return message;
554 };
555 if label.is_empty() {
558 return message;
559 }
560 let prefix = format!("{label}: ");
561 let mut collapsed = message;
562 while let Some(rest) = collapsed.strip_prefix(&prefix) {
563 if !rest.starts_with(&prefix) {
564 break;
565 }
566 collapsed = rest;
567 }
568 collapsed
569}
570
571fn exit_with_error(status: ExitStatus, message: &str) -> ! {
572 if json_output() {
573 print_json(&error_json(status, message));
574 } else {
575 eprintln!("error: {}", sanitize(message));
576 }
577 std::process::exit(status.code());
578}
579
580#[derive(Default)]
583pub(crate) struct Surface {
584 pub tools: Vec<ToolDefinition>,
585 pub prompts: Vec<PromptDefinition>,
586 pub resources: Vec<ResourceDefinition>,
587 pub templates: Vec<ResourceTemplateDefinition>,
588 pub unavailable: Vec<&'static str>,
595}
596
597impl Surface {
598 pub fn is_unavailable(&self, what: &str) -> bool {
600 self.unavailable.contains(&what)
601 }
602}
603
604pub(crate) const BUILTINS: &[(&str, &str)] = &[
607 ("help", "list built-ins and the server's tools"),
608 ("connect", "connect to a server or switch servers"),
609 ("tool", "call a server tool explicitly"),
610 ("builtin", "run a REPL built-in explicitly"),
611 ("tools", "list tools"),
612 ("prompts", "list prompts"),
613 ("resources", "list resources"),
614 ("templates", "list resource templates"),
615 ("find", "search the surface by keyword"),
616 ("describe", "show schemas and metadata for a name"),
617 ("snapshot", "export a tool or prompt schema contract"),
618 ("validate", "compare the surface with a schema snapshot"),
619 ("read", "read a resource"),
620 ("subscribe", "watch a resource for updates"),
621 ("unsubscribe", "stop watching a resource"),
622 ("subscriptions", "list active resource subscriptions"),
623 ("prompt", "get a prompt"),
624 ("call", "call a tool with raw JSON"),
625 ("bench", "time repeated calls to a tool"),
626 ("jobs", "list background tasks"),
627 ("task", "show a background task"),
628 ("wait", "wait for background tasks"),
629 ("cancel", "cancel a background task"),
630 ("alias", "define, list, or show a command alias"),
631 ("unalias", "remove a command alias"),
632 ("ping", "check the server is answering"),
633 ("loglevel", "set the server's log verbosity"),
634 ("refresh", "re-fetch the server surface"),
635 ("info", "replay the connection banner plus capabilities"),
636 ("wire", "toggle raw JSON-RPC frame tracing (on|off)"),
637 ("last", "reprint the previous request and response"),
638 ("history", "list recent command history"),
639 ("vars", "list captured variables"),
640 ("unset", "clear a captured variable"),
641 ("quit", "exit"),
642 ("exit", "exit"),
643];
644
645const BUILTIN_HELP: &[(&str, &str, &str)] = &[
649 (
650 "help",
651 "help [command]",
652 "With no argument, list the built-ins and the server's tools. With one, explain that command.",
653 ),
654 (
655 "connect",
656 "connect <url|profile|path.json:entry|command...|demo>",
657 "Connect from a disconnected prompt or switch the live REPL to another server.",
658 ),
659 (
660 "tool",
661 "tool <name> [k=v...]",
662 "Call a server tool explicitly. Use this when its name also belongs to a built-in.",
663 ),
664 (
665 "builtin",
666 "builtin <name> [args...]",
667 "Run a REPL built-in explicitly. Use this when a server tool has the same name.",
668 ),
669 (
670 "tools",
671 "tools [--full]",
672 "List the server's tools. Every tool is also a command: `<tool> [k=v...]`. \
673 A long list is trimmed to the window; `--full` prints all of it.",
674 ),
675 ("prompts", "prompts [--full]", "List the server's prompts."),
676 (
677 "resources",
678 "resources [--full]",
679 "List concrete resources. Parameterized ones are under `templates`.",
680 ),
681 (
682 "templates",
683 "templates [--full]",
684 "List resource templates: URIs with `{variable}` parts, completed by the server.",
685 ),
686 (
687 "find",
688 "find [-E] [-m N] [--case-sensitive] [--tools|--prompts|--resources|--templates|--builtins] <keyword>",
689 "Search names and descriptions across the server surface and REPL built-ins.",
690 ),
691 (
692 "describe",
693 "describe <name>",
694 "Show a tool's schemas, a prompt's arguments, or a resource's metadata, plus an example invocation.",
695 ),
696 (
697 "snapshot",
698 "snapshot <name> [path]",
699 "Export a tool or prompt's schema as a versioned contract. Without a path, print it.",
700 ),
701 (
702 "validate",
703 "validate <path> [strict|compatible|ignore]",
704 "Compare a saved snapshot with the live surface. No request is sent.",
705 ),
706 (
707 "read",
708 "read <uri> [--out <path>] [--force]",
709 "Read a resource, or write one returned content item to a file.",
710 ),
711 (
712 "subscribe",
713 "subscribe <uri>",
714 "Ask the server to report updates to a resource. Updates print inline.",
715 ),
716 (
717 "unsubscribe",
718 "unsubscribe <uri>",
719 "Stop receiving updates for a resource.",
720 ),
721 (
722 "subscriptions",
723 "subscriptions",
724 "List the resources the server is currently reporting updates for.",
725 ),
726 (
727 "prompt",
728 "prompt <name> [k=v...]",
729 "Retrieve a prompt. Argument values tab-complete through the server.",
730 ),
731 (
732 "call",
733 "call <tool> <json>",
734 "Call a tool with a raw JSON argument object, for when `k=v` coercion is not enough.",
735 ),
736 (
737 "bench",
738 "bench <tool> [k=v...] [--n N] [--concurrency C]",
739 "Time repeated tool calls and report their latency distribution.",
740 ),
741 (
742 "jobs",
743 "jobs",
744 "List the background tasks this session started, with their current status.",
745 ),
746 (
747 "task",
748 "task <task> [respond]",
749 "Show one background task, or answer a task waiting for operator input.",
750 ),
751 (
752 "wait",
753 "wait [<task>] [--timeout <seconds>]",
754 "Block until one background task, or all tasks this session started, settle.",
755 ),
756 (
757 "cancel",
758 "cancel <task>",
759 "Ask the server to cancel a task. `last` names the most recent.",
760 ),
761 (
762 "alias",
763 "alias [--global] [<name>=<expansion>]",
764 "Define, list, or show command aliases stored with the server profiles.",
765 ),
766 (
767 "unalias",
768 "unalias [--global] <name>",
769 "Remove the alias that is in effect for a name.",
770 ),
771 (
772 "loglevel",
773 "loglevel <debug|info|notice|warning|error|critical|alert|emergency>",
774 "Ask the server to change how much it logs, via `logging/setLevel`. Levels are the \
775 syslog severities the MCP spec uses, least severe first: a level means that one and \
776 everything more severe. Needs the server to declare the `logging` capability.",
777 ),
778 (
779 "ping",
780 "ping",
781 "Send an empty request and report the round trip. Exits non-zero if the server does not answer.",
782 ),
783 (
784 "refresh",
785 "refresh",
786 "Re-fetch the surface. Usually unnecessary: list_changed notifications refresh it live.",
787 ),
788 (
789 "info",
790 "info",
791 "Replay the connection banner and show the server's capabilities.",
792 ),
793 (
794 "wire",
795 "wire [on|off]",
796 "Toggle redacted JSON-RPC frame tracing, or report its current state.",
797 ),
798 (
799 "last",
800 "last",
801 "Reprint the previous request and response. Frames are recorded whether or not tracing is on.",
802 ),
803 (
804 "history",
805 "history [count]",
806 "List recent commands from previous sessions. Ctrl-R searches them interactively.",
807 ),
808 ("vars", "vars", "List values captured from command results."),
809 ("unset", "unset <name>", "Clear one captured variable."),
810 ("quit", "quit", "Close the session and exit."),
811 ("exit", "exit", "Close the session and exit."),
812];
813
814struct BuiltinGuide {
818 name: &'static str,
819 details: &'static [&'static str],
820 examples: &'static [&'static str],
821}
822
823const BUILTIN_GUIDES: &[BuiltinGuide] = &[
824 BuiltinGuide {
825 name: "connect",
826 details: &[
827 "The target may be an HTTP URL, a saved profile, a path.json:entry import, a stdio command, or demo. Bare connect lists saved and discovered candidates.",
828 "A candidate is initialized and its surface fetched before it replaces the current server. A failed switch leaves the old session usable. History and aliases survive; captured variables, task ids, and resource subscriptions are cleared after a successful switch.",
829 ],
830 examples: &[
831 "connect demo",
832 "connect https://example.com/mcp",
833 "connect -- ./my-server --stdio",
834 ],
835 },
836 BuiltinGuide {
837 name: "find",
838 details: &[
839 "Kind flags can be combined. -m/--max caps the best-ranked results, -E/--regex treats the query as a regular expression, and --case-sensitive disables case folding.",
840 "The cached surface is searched without sending a request. No matches set the no-match exit status, which makes find useful in scripts as well as at the prompt.",
841 ],
842 examples: &["find --tools -m 3 download", "find -E '^get_.*downloads$'"],
843 },
844 BuiltinGuide {
845 name: "read",
846 details: &[
847 "Tab completes concrete resource URIs and asks the server to complete variables in resource templates.",
848 "--out writes one returned content item to an owner-only file and decodes blobs. Existing files require --force; multiple contents are refused rather than concatenated.",
849 ],
850 examples: &["read note://ideas", "read img://pixel --out pixel.png"],
851 },
852 BuiltinGuide {
853 name: "prompt",
854 details: &[
855 "Argument names come from the prompt definition. Values stay strings, and completion/complete is used when the server supports prompt argument completion.",
856 ],
857 examples: &["prompt greet name=Ada"],
858 },
859 BuiltinGuide {
860 name: "bench",
861 details: &[
862 "Arguments are coerced exactly like a direct tool call. --n defaults to 20; --concurrency defaults to 1 and never exceeds the call count.",
863 "The distribution uses successful calls. Failures are counted separately, the first error is shown, and any failure sets a non-zero exit status.",
864 ],
865 examples: &[
866 "bench get_downloads crate=serde --n 50",
867 "bench get_downloads crate=serde --n 50 --concurrency 8",
868 ],
869 },
870 BuiltinGuide {
871 name: "task",
872 details: &[
873 "A task can be named by its short jobs number, last, full server id, or an unambiguous id prefix.",
874 "respond is available on the 2026-07-28 protocol when a task is input_required. It collects the requested elicitation answers and resumes the task handler.",
875 ],
876 examples: &["task 1", "task last respond"],
877 },
878 BuiltinGuide {
879 name: "wait",
880 details: &[
881 "With no task, wait reports every task in start order. --timeout is a per-task deadline; the global request timeout does not apply to task waiting.",
882 "A failed or cancelled task sets a non-zero exit status. Ctrl-C interrupts the wait without inventing a result for unfinished work.",
883 ],
884 examples: &["wait last", "wait --timeout 30"],
885 },
886 BuiltinGuide {
887 name: "alias",
888 details: &[
889 "An alias replaces the first command word and may expand through another alias; cycles are rejected. It can include arguments, an explicit tool/builtin qualifier, or a trailing &.",
890 "Definitions made through a profile are profile-scoped; otherwise they are global. --global forces the shared table. Changes preserve comments and formatting in the config file.",
891 ],
892 examples: &[
893 "alias dl=get_downloads",
894 "alias w=tool wait",
895 "alias --global t=tools",
896 ],
897 },
898 BuiltinGuide {
899 name: "wire",
900 details: &[
901 "Frames are written to stderr with direction, timestamp, and request latency. Recognized credential fields and authorization schemes are redacted before storage or display.",
902 "Tracing can be enabled at startup with --trace. The last exchange is recorded even while tracing is off and can be replayed with last.",
903 ],
904 examples: &["wire on", "wire off"],
905 },
906 BuiltinGuide {
907 name: "vars",
908 details: &[
909 "Capture with name = command, filter with command | path, and reference a value later as $name or $name.path[index]. Captures are cleared when connect switches servers.",
910 ],
911 examples: &[
912 "result = search query=serde",
913 "describe $result.items[0].name",
914 ],
915 },
916];
917
918#[derive(Clone, Copy)]
919struct BuiltinHelp {
920 name: &'static str,
921 usage: &'static str,
922 description: &'static str,
923 details: &'static [&'static str],
924 examples: &'static [&'static str],
925}
926
927fn builtin_help(name: &str) -> Option<BuiltinHelp> {
930 let &(name, usage, description) = BUILTIN_HELP
931 .iter()
932 .find(|(builtin, _, _)| *builtin == name)?;
933 let guide = BUILTIN_GUIDES.iter().find(|guide| guide.name == name);
934 Some(BuiltinHelp {
935 name,
936 usage,
937 description,
938 details: guide.map(|guide| guide.details).unwrap_or_default(),
939 examples: guide.map(|guide| guide.examples).unwrap_or_default(),
940 })
941}
942
943fn print_builtin_help(help: BuiltinHelp) {
944 println!("{}", paint(Style::new().bold(), help.usage));
945 println!(" {}", help.description);
946 for paragraph in help.details {
947 println!();
948 println!(" {paragraph}");
949 }
950 if !help.examples.is_empty() {
951 println!();
952 println!("examples:");
953 for example in help.examples {
954 println!(" {example}");
955 }
956 }
957}
958
959pub(crate) fn is_builtin(name: &str) -> bool {
960 BUILTINS.iter().any(|(builtin, _)| *builtin == name)
961}
962
963pub(crate) fn is_tool(surface: &Surface, name: &str) -> bool {
964 surface.tools.iter().any(|tool| tool.name == name)
965}
966
967pub(crate) fn is_ambiguous_command(surface: &Surface, name: &str) -> bool {
968 is_builtin(name) && is_tool(surface, name)
969}
970
971fn render_content(content: &[Content]) {
972 for c in content {
973 match c {
974 Content::Text { text, .. } => {
975 if style::colors_enabled() && style::looks_like_markdown(text) {
976 println!("{}", style::render_markdown(text));
977 } else {
978 println!("{}", sanitize(text));
979 }
980 }
981 other => {
982 let v = serde_json::to_value(other).unwrap_or_default();
983 let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("content");
984 match ty {
985 "image" | "audio" => {
986 let mime = v.get("mimeType").and_then(|m| m.as_str()).unwrap_or("?");
987 let len = v.get("data").and_then(|d| d.as_str()).map_or(0, str::len);
988 println!(
989 "{}",
990 tag(
991 Style::new(),
992 &format!("{ty} {}, {len} base64 chars", sanitize(mime))
993 )
994 );
995 }
996 _ => println!("{}", json_pretty(&v)),
997 }
998 }
999 }
1000 }
1001}
1002
1003fn render_task(task: &TaskObject, label: &str) {
1004 println!(
1005 "task {} status={} {}",
1006 paint(Style::new().bold(), &sanitize(label)),
1007 paint(task_status_style(task.status), &task.status.to_string()),
1008 sanitize(task.status_message.as_deref().unwrap_or(""))
1009 );
1010 if let Some(result) = &task.result {
1011 if result.is_error {
1016 println!("{}", tag(Style::new().fg(Color::Red), "tool error"));
1017 }
1018 render_content(&result.content);
1019 }
1020 if let Some(err) = &task.error {
1021 println!(
1022 "{} {}: {}",
1023 style::error_prefix(),
1024 err.code,
1025 sanitize(&err.message)
1026 );
1027 }
1028}
1029
1030async fn wait_for_one(
1032 client: &McpClient,
1033 id: &str,
1034 limit: Option<Duration>,
1035) -> tower_mcp::Result<TaskObject> {
1036 match limit {
1037 None => client.task_wait(id).await,
1038 Some(limit) => match tokio::time::timeout(limit, client.task_wait(id)).await {
1039 Ok(result) => result,
1040 Err(_) => Err(tower_mcp::Error::Transport(format!(
1041 "task {id} was still running after {}s (--timeout)",
1042 limit.as_secs()
1043 ))),
1044 },
1045 }
1046}
1047
1048fn note_settled_task(task: &TaskObject) {
1055 use tower_mcp::protocol::TaskStatus;
1056 if task.error.is_some() || task.result.as_ref().is_some_and(|r| r.is_error) {
1061 note_error(ExitStatus::Server);
1062 return;
1063 }
1064 match task.status {
1065 TaskStatus::Failed => note_error(ExitStatus::Server),
1066 TaskStatus::Cancelled => note_error(ExitStatus::Cancelled),
1070 _ => {}
1071 }
1072}
1073
1074async fn wait_for_all(
1078 client: &McpClient,
1079 jobs: &Arc<Jobs>,
1080 limit: Option<Duration>,
1081 started: std::time::Instant,
1082) {
1083 let ids = jobs.all_ids();
1084 if ids.is_empty() {
1085 report_error(
1086 ExitStatus::NoMatch,
1087 "no tasks in this session to wait for (start one with a trailing `&`)",
1088 );
1089 return;
1090 }
1091 let mut settled = Vec::new();
1092 for id in &ids {
1093 match wait_for_one(client, id, limit).await {
1094 Ok(task) => {
1095 jobs.sync(id, task.status, task.status_message.clone());
1096 note_settled_task(&task);
1097 if !json_output() {
1098 render_task(&task, &jobs.label_for(&task.task_id));
1099 }
1100 settled.push(task);
1101 }
1102 Err(e) => report_mcp_error(&e),
1105 }
1106 }
1107 if json_output() {
1108 print_json(&serde_json::to_value(&settled).unwrap_or_default());
1111 } else {
1112 println!("{}", timing(started.elapsed()));
1113 }
1114}
1115
1116async fn respond_to_task(client: &McpClient, id: &str, label: &str) {
1124 use tower_mcp::protocol::{InputRequest, InputResponse, InputResponses};
1125
1126 if client.selected_protocol_version().await.as_deref()
1130 != Some(tower_mcp::protocol::PROTOCOL_VERSION_2026_07_28)
1131 {
1132 report_error(
1133 ExitStatus::Usage,
1134 "`respond` needs --protocol 2026-07-28: only that lifecycle reports what a task is \
1135 waiting for. On the stable lifecycle a server asks by sending `elicitation/create` \
1136 itself, which is declined while the editor holds the terminal, so run the tool in \
1137 the foreground instead of as a task",
1138 );
1139 return;
1140 }
1141 let detailed = match client.task_get_detailed(id).await {
1142 Ok(detailed) => detailed,
1143 Err(e) => {
1144 report_mcp_error(&e);
1145 return;
1146 }
1147 };
1148 let Some(outstanding) = detailed.task.input_requests().filter(|r| !r.is_empty()) else {
1149 report_error(
1150 ExitStatus::NoMatch,
1151 &format!(
1152 "task {label} is not waiting for input (status: {})",
1153 detailed.task.status()
1154 ),
1155 );
1156 return;
1157 };
1158
1159 let server = connection_info(client)
1160 .await
1161 .map(|info| info.server_info.name)
1162 .unwrap_or_default();
1163 let mut responses = InputResponses::new();
1164 for (key, request) in outstanding.clone() {
1165 match request {
1166 InputRequest::Elicit(params) => {
1167 let answer = elicit::answer_in_foreground(&server, params).await;
1168 responses.insert(key, InputResponse::Elicit(answer));
1169 }
1170 InputRequest::CreateMessage(params) => {
1171 match tokio::task::spawn_blocking(move || sampling::prompt(¶ms)).await {
1172 Ok(Ok(result)) => {
1173 responses.insert(key, InputResponse::CreateMessage(result));
1174 }
1175 Ok(Err(e)) => command_error(&format!(
1179 "could not answer `{}`: {}",
1180 sanitize(&key),
1181 sanitize(&e.message)
1182 )),
1183 Err(e) => command_error(&format!("could not answer `{}`: {e}", sanitize(&key))),
1184 }
1185 }
1186 InputRequest::ListRoots(_) => {
1189 println!(
1190 "{} answered `{}` with no roots (mcp-repl declares none)",
1191 tag(Style::new().fg(Color::Purple), "elicit"),
1192 sanitize(&key)
1193 );
1194 responses.insert(
1195 key,
1196 InputResponse::ListRoots(tower_mcp::protocol::ListRootsResult {
1197 roots: Vec::new(),
1198 meta: None,
1199 }),
1200 );
1201 }
1202 other => command_error(&format!(
1203 "cannot answer `{}`: unsupported request {}",
1204 sanitize(&key),
1205 sanitize(other.method_name())
1206 )),
1207 }
1208 }
1209
1210 if responses.is_empty() {
1211 report_error(
1212 ExitStatus::Usage,
1213 &format!("nothing was answered, so task {label} is still waiting"),
1214 );
1215 return;
1216 }
1217 if let Err(e) = client.task_update(id, responses).await {
1218 report_mcp_error(&e);
1219 return;
1220 }
1221 match client.task_get(id).await {
1224 Ok(task) if json_output() => print_json(&serde_json::to_value(&task).unwrap_or_default()),
1225 Ok(task) => render_task(&task, label),
1226 Err(e) => report_mcp_error(&e),
1227 }
1228}
1229
1230#[derive(Clone, Debug)]
1232struct ConnectionInfo {
1233 protocol_version: String,
1234 capabilities: ServerCapabilities,
1235 server_info: Implementation,
1236 instructions: Option<String>,
1237}
1238
1239impl From<InitializeResult> for ConnectionInfo {
1240 fn from(info: InitializeResult) -> Self {
1241 Self {
1242 protocol_version: info.protocol_version,
1243 capabilities: info.capabilities,
1244 server_info: info.server_info,
1245 instructions: info.instructions,
1246 }
1247 }
1248}
1249
1250impl ConnectionInfo {
1251 fn from_discovery(discovery: DiscoverResult, protocol_version: String) -> Self {
1252 let server_info = discovery
1253 .meta
1254 .as_ref()
1255 .and_then(|meta| meta.server_info.clone())
1256 .unwrap_or_else(|| Implementation {
1257 name: "MCP server".to_string(),
1258 version: "unknown".to_string(),
1259 ..Default::default()
1260 });
1261 Self {
1262 protocol_version,
1263 capabilities: discovery.capabilities,
1264 server_info,
1265 instructions: discovery.instructions,
1266 }
1267 }
1268}
1269
1270async fn connection_info(client: &McpClient) -> Option<ConnectionInfo> {
1271 if let Some(info) = client.server_info().await {
1272 return Some(info.into());
1273 }
1274 let discovery = client.discovery().await?;
1275 let protocol_version = client.selected_protocol_version().await?;
1276 Some(ConnectionInfo::from_discovery(discovery, protocol_version))
1277}
1278
1279async fn establish_connection(
1280 client: &McpClient,
1281 protocol: ProtocolMode,
1282) -> tower_mcp::Result<ConnectionInfo> {
1283 match protocol {
1284 ProtocolMode::Stable => client
1285 .initialize("mcp-repl", env!("CARGO_PKG_VERSION"))
1286 .await
1287 .map(Into::into),
1288 ProtocolMode::Final => {
1289 let discovery: DiscoverResult = client
1290 .discover("mcp-repl", env!("CARGO_PKG_VERSION"))
1291 .await?;
1292 let protocol_version = client
1293 .selected_protocol_version()
1294 .await
1295 .unwrap_or_else(|| "2026-07-28".to_string());
1296 Ok(ConnectionInfo::from_discovery(discovery, protocol_version))
1297 }
1298 }
1299}
1300
1301fn client_builder(protocol: ProtocolMode) -> Result<McpClientBuilder, ProtocolSupportError> {
1302 let builder = McpClient::builder()
1303 .protocol_support(protocol.support()?)
1304 .with_elicitation()
1305 .with_sampling()
1306 .request_progress();
1310 Ok(match protocol {
1311 ProtocolMode::Stable => builder,
1312 ProtocolMode::Final => builder.with_tasks(),
1313 })
1314}
1315
1316fn print_banner(info: &ConnectionInfo) {
1320 println!(
1321 "connected: {} v{} {}",
1322 paint(Style::new().bold(), &sanitize(&info.server_info.name)),
1323 sanitize(&info.server_info.version),
1324 paint(
1325 Style::new().dimmed(),
1326 &format!("(protocol {})", sanitize(&info.protocol_version))
1327 )
1328 );
1329 if let Some(instructions) = &info.instructions {
1330 if style::colors_enabled() && style::looks_like_markdown(instructions) {
1331 println!("{}", style::render_markdown(instructions));
1332 } else {
1333 println!("{}", sanitize(instructions));
1334 }
1335 }
1336}
1337
1338pub(crate) fn timing(elapsed: Duration) -> String {
1342 let body = if elapsed.as_millis() < 1000 {
1343 format!("[{}ms]", elapsed.as_millis())
1344 } else {
1345 format!("[{:.2}s]", elapsed.as_secs_f64())
1346 };
1347 paint(Style::new().dimmed(), &body)
1348}
1349
1350fn listing_limit() -> Option<usize> {
1359 if json_output() || !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
1360 return None;
1361 }
1362 const RESERVED: usize = 4;
1364 const FALLBACK_ROWS: usize = 24;
1365 let rows = crossterm::terminal::size()
1366 .map(|(_, rows)| rows as usize)
1367 .unwrap_or(FALLBACK_ROWS);
1368 Some(rows.saturating_sub(RESERVED).max(5))
1370}
1371
1372fn note_truncation(shown: usize, total: usize, full: &str) {
1377 if shown >= total {
1378 return;
1379 }
1380 println!(
1381 "{}",
1382 paint(
1383 Style::new().dimmed(),
1384 &format!(
1385 "... {} more of {total}; `{full}` shows everything",
1386 total - shown
1387 )
1388 )
1389 );
1390}
1391
1392fn print_tool_overview(surface: &Surface) {
1396 if surface.tools.is_empty() {
1397 return;
1398 }
1399 let cap = listing_limit().map_or(surface.tools.len(), |rows| (rows / 2).max(5));
1402 for t in surface.tools.iter().take(cap) {
1403 println!(
1404 "{} {}{}",
1405 style::column(Style::new().fg(Color::Green), &sanitize(&t.name), 24),
1406 sanitize(t.description.as_deref().unwrap_or("")),
1407 tool_tag_suffix(t)
1408 );
1409 }
1410 if surface.tools.len() > cap {
1411 println!(
1412 "{}",
1413 paint(
1414 Style::new().dimmed(),
1415 &format!("... +{} more, type `tools`", surface.tools.len() - cap)
1416 )
1417 );
1418 }
1419}
1420
1421fn print_find(surface: &Surface, query: &find::Query, output: &vars::Output) {
1425 let hits = find::search_query(surface, query);
1426 if !output.is_plain() || json_output() {
1427 let v: Vec<serde_json::Value> = hits
1428 .iter()
1429 .map(|h| {
1430 serde_json::json!({
1431 "kind": h.kind.heading(),
1432 "name": h.name,
1433 "description": h.description,
1434 "score": h.score,
1435 })
1436 })
1437 .collect();
1438 if v.is_empty() {
1441 note_error(ExitStatus::NoMatch);
1442 }
1443 emit_value(serde_json::Value::Array(v), output, || {
1444 unreachable!("plain output handled below")
1445 });
1446 return;
1447 }
1448 if hits.is_empty() {
1449 report_error(ExitStatus::NoMatch, &format!("no match for {}", query.text));
1452 return;
1453 }
1454 let total = hits.len();
1455 for (kind, group) in find::grouped(hits) {
1456 println!("{}:", paint(Style::new().bold(), kind.heading()));
1457 for hit in group {
1458 println!(
1459 " {} {}",
1460 style::column(Style::new().fg(Color::Green), &sanitize(&hit.name), 24),
1461 sanitize(&hit.description)
1462 );
1463 }
1464 }
1465 println!(
1466 "{}",
1467 paint(
1468 Style::new().dimmed(),
1469 &format!("{total} match{}", if total == 1 { "" } else { "es" })
1470 )
1471 );
1472}
1473
1474fn print_counts(surface: &Surface) {
1476 println!(
1477 "{}, {}, {}, {}. Type `help`.",
1478 plural(surface.tools.len(), "tool"),
1479 plural(surface.prompts.len(), "prompt"),
1480 plural(surface.resources.len(), "resource"),
1481 plural(surface.templates.len(), "template")
1482 );
1483}
1484
1485fn print_first_run_hint() {
1489 println!(
1490 "{}",
1491 paint(
1492 Style::new().dimmed(),
1493 "Tab completes · `find <word>` searches · `describe <name>` shows \
1494 schemas · `&` runs a tool as a task"
1495 )
1496 );
1497}
1498
1499fn plural(count: usize, noun: &str) -> String {
1501 if count == 1 {
1502 format!("{count} {noun}")
1503 } else {
1504 format!("{count} {noun}s")
1505 }
1506}
1507
1508async fn with_reconnect<T, F, Fut>(
1520 session: &Session,
1521 surface: &Arc<RwLock<Surface>>,
1522 op: F,
1523) -> Result<T, tower_mcp::Error>
1524where
1525 F: Fn(Arc<McpClient>) -> Fut,
1526 Fut: Future<Output = Result<T, tower_mcp::Error>>,
1527{
1528 let seen = session.generation();
1529 let err = match with_deadline(op(session.client())).await {
1532 Ok(value) => return Ok(value),
1533 Err(e) => e,
1534 };
1535 if !session.can_reconnect() || !is_session_lost(&err) {
1536 return Err(err);
1537 }
1538 if let Err(reconnect_err) = session.reconnect(seen).await {
1539 eprintln!("reconnect failed: {reconnect_err}");
1540 return Err(err);
1541 }
1542 *surface.write().unwrap() = fetch_surface(&session.client()).await;
1547 eprintln!("{}", paint(Style::new().dimmed(), "[reconnected]"));
1550
1551 let retried = with_deadline(op(session.client())).await;
1552 if let Err(e) = &retried
1553 && is_session_lost(e)
1554 {
1555 eprintln!(
1556 "still no session after reconnecting. The server is likely down or \
1557 restart-looping; check its logs, or pass --no-reconnect to see the \
1558 raw errors."
1559 );
1560 }
1561 retried
1562}
1563
1564const MAX_SURFACE_PAGES: usize = 100;
1571const MAX_SURFACE_ITEMS: usize = 10_000;
1572
1573async fn collect_pages<T, F, Fut>(what: &str, mut page: F) -> Result<Vec<T>, tower_mcp::Error>
1579where
1580 F: FnMut(Option<String>) -> Fut,
1581 Fut: Future<Output = Result<(Vec<T>, Option<String>), tower_mcp::Error>>,
1582{
1583 let mut all: Vec<T> = Vec::new();
1584 let mut cursor: Option<String> = None;
1585 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1586 for _ in 0..MAX_SURFACE_PAGES {
1587 let (items, next) = page(cursor).await?;
1588 all.extend(items);
1589 if all.len() >= MAX_SURFACE_ITEMS {
1590 all.truncate(MAX_SURFACE_ITEMS);
1591 eprintln!(
1592 "warning: {what} stopped at {MAX_SURFACE_ITEMS} entries; the server offered more"
1593 );
1594 return Ok(all);
1595 }
1596 match next {
1597 None => return Ok(all),
1598 Some(next) if !seen.insert(next.clone()) => {
1599 eprintln!("warning: {what} paging stopped: the server repeated a cursor");
1600 return Ok(all);
1601 }
1602 Some(next) => cursor = Some(next),
1603 }
1604 }
1605 eprintln!("warning: {what} stopped after {MAX_SURFACE_PAGES} pages; the server offered more");
1606 Ok(all)
1607}
1608
1609async fn fetch_surface_once(client: &McpClient) -> (Surface, bool) {
1612 struct Outcome {
1613 not_initialized: bool,
1614 unavailable: Vec<&'static str>,
1615 }
1616
1617 fn take<T>(
1618 what: &'static str,
1619 r: Option<Result<Vec<T>, tower_mcp::Error>>,
1620 at: &mut Outcome,
1621 ) -> Vec<T> {
1622 match r {
1623 None => Vec::new(),
1626 Some(Ok(v)) => v,
1627 Some(Err(e)) => {
1628 if is_not_initialized(&e) {
1629 at.not_initialized = true;
1630 } else {
1631 eprintln!(
1632 "warning: fetching {what} failed: {}",
1633 describe_mcp_error(&e)
1634 );
1635 note_error(ExitStatus::Transport);
1640 at.unavailable.push(what);
1641 }
1642 Vec::new()
1643 }
1644 }
1645 }
1646
1647 let declared = connection_info(client).await.map(|info| info.capabilities);
1653 let has = |pick: fn(&ServerCapabilities) -> bool| declared.as_ref().is_none_or(pick);
1654 let (want_tools, want_prompts, want_resources) = (
1655 has(|c| c.tools.is_some()),
1656 has(|c| c.prompts.is_some()),
1657 has(|c| c.resources.is_some()),
1658 );
1659 let (tools, prompts, resources, templates) = tokio::join!(
1672 maybe(want_tools, async {
1673 with_deadline(collect_pages("tools", |cursor| async move {
1674 let page = client.list_tools_with_cursor(cursor).await?;
1675 Ok((page.tools, page.next_cursor))
1676 }))
1677 .await
1678 }),
1679 maybe(want_prompts, async {
1680 with_deadline(collect_pages("prompts", |cursor| async move {
1681 let page = client.list_prompts_with_cursor(cursor).await?;
1682 Ok((page.prompts, page.next_cursor))
1683 }))
1684 .await
1685 }),
1686 maybe(want_resources, async {
1687 with_deadline(collect_pages("resources", |cursor| async move {
1688 let page = client.list_resources_with_cursor(cursor).await?;
1689 Ok((page.resources, page.next_cursor))
1690 }))
1691 .await
1692 }),
1693 maybe(want_resources, async {
1696 with_deadline(collect_pages("resource templates", |cursor| async move {
1697 let page = client.list_resource_templates_with_cursor(cursor).await?;
1698 Ok((page.resource_templates, page.next_cursor))
1699 }))
1700 .await
1701 }),
1702 );
1703 let mut at = Outcome {
1704 not_initialized: false,
1705 unavailable: Vec::new(),
1706 };
1707 let surface = Surface {
1708 tools: take("tools", tools, &mut at),
1709 prompts: take("prompts", prompts, &mut at),
1710 resources: take("resources", resources, &mut at),
1711 templates: take("resource templates", templates, &mut at),
1712 unavailable: std::mem::take(&mut at.unavailable),
1713 };
1714 (surface, at.not_initialized)
1715}
1716
1717async fn maybe<T, F: Future<Output = T>>(wanted: bool, work: F) -> Option<T> {
1719 if wanted { Some(work.await) } else { None }
1720}
1721
1722async fn fetch_surface(client: &McpClient) -> Surface {
1723 fetch_surface_once(client).await.0
1724}
1725
1726async fn refresh_surface(session: &Session) -> Surface {
1731 let (fresh, not_initialized) = fetch_surface_once(&session.client()).await;
1732 if !not_initialized || !session.can_reconnect() {
1733 return fresh;
1734 }
1735 let seen = session.generation();
1736 match session.reconnect(seen).await {
1737 Ok(()) => {
1738 eprintln!("{}", paint(Style::new().dimmed(), "[reconnected]"));
1739 fetch_surface(&session.client()).await
1740 }
1741 Err(e) => {
1742 eprintln!("reconnect failed: {e}");
1743 fresh
1744 }
1745 }
1746}
1747
1748async fn fetch_surface_initial(client: &McpClient) -> Surface {
1751 const ATTEMPTS: usize = 4;
1752 for attempt in 1..=ATTEMPTS {
1753 let (surface, not_initialized) = fetch_surface_once(client).await;
1754 if !not_initialized {
1755 return surface;
1756 }
1757 if attempt == ATTEMPTS {
1758 eprintln!(
1759 "warning: the server kept rejecting surface requests as not-initialized \
1760 after {ATTEMPTS} attempts. The session the handshake established is not \
1761 being recognized on follow-up requests. Two common causes: the server runs \
1762 multiple instances without a shared session store, so requests scatter \
1763 across instances; or a single instance restarted (crash, OOM, or redeploy) \
1764 between requests and lost its in-memory sessions. Try `refresh`. A \
1765 persistent session store or the stateless protocol avoids both; if it is a \
1766 single instance, check its logs and resources (an OOM-looping machine \
1767 flaps like this)."
1768 );
1769 return surface;
1770 }
1771 tokio::time::sleep(Duration::from_millis(200 * attempt as u64)).await;
1772 }
1773 unreachable!()
1774}
1775
1776fn build_http_config(
1783 bearer: Option<String>,
1784 headers: &[String],
1785 profile_bearer: Option<String>,
1786 profile_headers: &[(String, String)],
1787) -> Result<HttpClientConfig, String> {
1788 build_http_config_with_env(
1789 bearer,
1790 headers,
1791 profile_bearer,
1792 profile_headers,
1793 std::env::var("MCP_BEARER").ok(),
1794 )
1795}
1796
1797fn build_http_config_with_env(
1798 bearer: Option<String>,
1799 headers: &[String],
1800 profile_bearer: Option<String>,
1801 profile_headers: &[(String, String)],
1802 env_bearer: Option<String>,
1803) -> Result<HttpClientConfig, String> {
1804 connection_auth::build_http_config(
1805 bearer,
1806 headers,
1807 profile_bearer,
1808 profile_headers,
1809 env_bearer,
1810 request_timeout(),
1811 )
1812}
1813
1814fn demo_router() -> tower_mcp::McpRouter {
1815 use tower_mcp::context::RequestContext;
1816 use tower_mcp::extract::{Context, Json, RawArgs};
1817 use tower_mcp::protocol::ToolAnnotations;
1818 use tower_mcp::protocol::{
1819 CompleteResult, CompletionReference, ElicitRequestParams, InputRequest, InputRequests,
1820 InputRequiredResult, InputResponse, ReadResourceResult, RequestOutcome,
1821 };
1822 use tower_mcp::resource::ResourceTemplateBuilder;
1823 use tower_mcp::{CallToolResult, PromptBuilder, TaskSupportMode, ToolBuilder};
1824
1825 fn local_read_only() -> ToolAnnotations {
1830 ToolAnnotations {
1831 read_only_hint: true,
1832 idempotent_hint: true,
1833 destructive_hint: false,
1834 open_world_hint: false,
1835 ..Default::default()
1836 }
1837 }
1838
1839 const PIXEL_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
1841
1842 const NOTES: &[(&str, &str)] = &[
1843 ("groceries", "- eggs\n- coffee"),
1844 ("ideas", "# Ideas\n\n- a REPL for MCP servers"),
1845 ("todo", "1. ship it"),
1846 ];
1847
1848 tower_mcp::McpRouter::new()
1849 .server_info("mcp-repl-demo", env!("CARGO_PKG_VERSION"))
1850 .with_tasks()
1851 .prompt(
1852 PromptBuilder::new("greet")
1853 .description("Generate a greeting (name tab-completes via the server)")
1854 .required_arg("name", "The person to greet")
1855 .handler(|args| async move {
1856 let name = args.get("name").map(|s| s.as_str()).unwrap_or("World");
1857 Ok(tower_mcp::GetPromptResult::user_message(format!(
1858 "Please greet {name} warmly."
1859 )))
1860 })
1861 .build(),
1862 )
1863 .resource(
1868 tower_mcp::resource::ResourceBuilder::new("note://status")
1869 .name("Status")
1870 .description("A one-line status note (subscribe to it)")
1871 .mime_type("text/plain")
1872 .handler(|| async {
1873 Ok(ReadResourceResult::text(
1874 "note://status",
1875 "all quiet on the demo server",
1876 ))
1877 })
1878 .build(),
1879 )
1880 .resource(
1884 tower_mcp::resource::ResourceBuilder::new("img://pixel")
1885 .name("Pixel")
1886 .description("A 1x1 transparent PNG (try `read img://pixel --out pixel.png`)")
1887 .mime_type("image/png")
1888 .handler(|| async {
1889 Ok(ReadResourceResult {
1890 contents: vec![tower_mcp::protocol::ResourceContent {
1891 uri: "img://pixel".to_string(),
1892 mime_type: Some("image/png".to_string()),
1893 text: None,
1894 blob: Some(PIXEL_PNG.to_string()),
1895 meta: None,
1896 }],
1897 ..Default::default()
1898 })
1899 })
1900 .build(),
1901 )
1902 .resource_template(
1903 ResourceTemplateBuilder::new("note://{name}")
1904 .name("Notes")
1905 .description("Tiny in-memory notes (name tab-completes via the server)")
1906 .mime_type("text/markdown")
1907 .handler(
1908 |uri: String, vars: std::collections::HashMap<String, String>| async move {
1909 let name = vars.get("name").cloned().unwrap_or_default();
1910 let text = NOTES
1911 .iter()
1912 .find(|(n, _)| *n == name)
1913 .map(|(_, t)| (*t).to_string())
1914 .unwrap_or_else(|| format!("no note named `{name}`"));
1915 Ok(ReadResourceResult::text(uri, text))
1916 },
1917 ),
1918 )
1919 .completion_handler(|params| async move {
1920 let partial = params.argument.value;
1921 let candidates: Vec<String> = match ¶ms.reference {
1922 CompletionReference::Prompt { name } if name == "greet" => {
1923 ["Ada", "Alan", "Grace", "Linus"]
1924 .iter()
1925 .map(|s| s.to_string())
1926 .collect()
1927 }
1928 CompletionReference::Resource { uri } if uri == "note://{name}" => {
1929 NOTES.iter().map(|(n, _)| n.to_string()).collect()
1930 }
1931 _ => Vec::new(),
1932 };
1933 Ok(CompleteResult::new(
1934 candidates
1935 .into_iter()
1936 .filter(|c| c.starts_with(&partial))
1937 .collect::<Vec<_>>(),
1938 ))
1939 })
1940 .tool(
1941 ToolBuilder::new("echo")
1942 .description("Echo a message back")
1943 .annotations(local_read_only())
1944 .handler(|input: EchoInput| async move {
1945 let text = match input.repeat {
1946 1 => input.message,
1947 n => std::iter::repeat_n(input.message.as_str(), n as usize)
1948 .collect::<Vec<_>>()
1949 .join(" "),
1950 };
1951 Ok(CallToolResult::text(text))
1952 })
1953 .build(),
1954 )
1955 .tool(
1956 ToolBuilder::new("about")
1957 .description("Notes about this demo server, in markdown")
1958 .annotations(local_read_only())
1959 .extractor_handler((), |RawArgs(_): RawArgs| async move {
1960 Ok(CallToolResult::text(
1961 "# mcp-repl demo\n\n\
1962 A tiny in-process router for exploring the REPL.\n\n\
1963 - `echo message=hi` echoes back, and `echo <Tab>` completes its arguments\n\
1964 - `convert value=100 to=<Tab>` completes the enum values\n\
1965 - `slow_add a=2 b=3 &` runs **task-augmented**\n\
1966 - `scan steps=5` reports **progress** while it runs\n\
1967 - `sign_in` asks *you* for the answers (elicitation)\n\
1968 - `describe slow_add` shows the tool's schemas\n",
1969 ))
1970 })
1971 .build(),
1972 )
1973 .tool(
1974 ToolBuilder::new("convert")
1975 .description("Convert a temperature between scales")
1976 .annotations(local_read_only())
1977 .handler(|input: ConvertInput| async move {
1978 let celsius = match input.from {
1979 Scale::Celsius => input.value,
1980 Scale::Fahrenheit => (input.value - 32.0) * 5.0 / 9.0,
1981 Scale::Kelvin => input.value - 273.15,
1982 };
1983 let out = match input.to {
1984 Scale::Celsius => celsius,
1985 Scale::Fahrenheit => celsius * 9.0 / 5.0 + 32.0,
1986 Scale::Kelvin => celsius + 273.15,
1987 };
1988 Ok(CallToolResult::text(format!("{out:.2}")))
1989 })
1990 .build(),
1991 )
1992 .tool(
1993 ToolBuilder::new("slow_add")
1994 .description("Add two numbers, slowly")
1995 .task_support(TaskSupportMode::Optional)
1996 .annotations(local_read_only())
1997 .handler(|input: AddInput| async move {
1998 tokio::time::sleep(Duration::from_secs(3)).await;
1999 Ok(CallToolResult::text((input.a + input.b).to_string()))
2000 })
2001 .build(),
2002 )
2003 .tool(
2006 ToolBuilder::new("scan")
2007 .description("Scan slowly, reporting progress")
2008 .annotations(local_read_only())
2009 .extractor_handler(
2010 (),
2011 |ctx: Context, Json(input): Json<ScanInput>| async move {
2012 let steps = input.steps.clamp(1, 20);
2013 for step in 1..=steps {
2014 ctx.report_progress(
2015 f64::from(step),
2016 Some(f64::from(steps)),
2017 Some(&format!("scanned {step} of {steps}")),
2018 )
2019 .await;
2020 tokio::time::sleep(Duration::from_millis(400)).await;
2021 }
2022 Ok(CallToolResult::text(format!("scanned {steps} items")))
2023 },
2024 )
2025 .build(),
2026 )
2027 .tool(
2035 ToolBuilder::new("fail")
2036 .description("Always fails (try `fail &` then `wait`)")
2037 .annotations(local_read_only())
2038 .task_support(TaskSupportMode::Optional)
2039 .extractor_handler((), |_ctx: Context, RawArgs(_): RawArgs| async move {
2040 Ok(CallToolResult::error("the demo `fail` tool always fails"))
2044 })
2045 .build(),
2046 )
2047 .tool(
2050 ToolBuilder::new("sign_in")
2051 .description("Ask you for credentials (elicitation demo)")
2052 .task_support(TaskSupportMode::Optional)
2057 .mrtr_handler(|ctx: RequestContext, _input: SignInInput| async move {
2061 if let Some(responses) = ctx.input_responses() {
2064 let answer = responses.values().find_map(|response| match response {
2065 InputResponse::Elicit(result) => Some(result.clone()),
2066 _ => None,
2067 });
2068 return Ok(RequestOutcome::Complete(CallToolResult::text(
2069 describe_sign_in(answer.as_ref()),
2070 )));
2071 }
2072 if !ctx.can_elicit() {
2073 let mut requests = InputRequests::new();
2080 requests.insert(
2081 "credentials".to_string(),
2082 InputRequest::Elicit(ElicitRequestParams::Form(sign_in_form())),
2083 );
2084 return Ok(RequestOutcome::input_required(
2085 InputRequiredResult::with_requests(requests),
2086 ));
2087 }
2088 let answer = ctx.elicit_form(sign_in_form()).await?;
2090 Ok(RequestOutcome::Complete(CallToolResult::text(
2091 describe_sign_in(Some(&answer)),
2092 )))
2093 })
2094 .build(),
2095 )
2096 .tool(
2100 ToolBuilder::new("summarize")
2101 .description("Ask your client for a one-line summary (sampling demo)")
2102 .annotations(local_read_only())
2103 .mrtr_handler(|ctx: RequestContext, input: SummarizeInput| async move {
2107 if let Some(responses) = ctx.input_responses() {
2108 let answer = responses.values().find_map(|response| match response {
2109 InputResponse::CreateMessage(result) => Some(result.clone()),
2110 _ => None,
2111 });
2112 return Ok(RequestOutcome::Complete(CallToolResult::text(
2113 describe_summary(answer.as_ref()),
2114 )));
2115 }
2116 let params = summarize_request(&input.text);
2117 if !ctx.can_sample() {
2118 let mut requests = InputRequests::new();
2119 requests.insert(
2120 "summary".to_string(),
2121 InputRequest::CreateMessage(params),
2122 );
2123 return Ok(RequestOutcome::input_required(
2124 InputRequiredResult::with_requests(requests),
2125 ));
2126 }
2127 let answer = ctx.sample(params).await?;
2128 Ok(RequestOutcome::Complete(CallToolResult::text(
2129 describe_summary(Some(&answer)),
2130 )))
2131 })
2132 .build(),
2133 )
2134}
2135
2136#[derive(serde::Deserialize, schemars::JsonSchema)]
2138struct SummarizeInput {
2139 text: String,
2141}
2142
2143fn summarize_request(text: &str) -> tower_mcp::protocol::CreateMessageParams {
2145 use tower_mcp::protocol::{
2146 ContentRole, CreateMessageParams, SamplingContent, SamplingContentOrArray, SamplingMessage,
2147 };
2148 CreateMessageParams {
2149 messages: vec![SamplingMessage {
2150 role: ContentRole::User,
2151 content: SamplingContentOrArray::Single(SamplingContent::Text {
2152 text: format!("Summarize this in one line:\n\n{text}"),
2153 annotations: None,
2154 meta: None,
2155 }),
2156 meta: None,
2157 }],
2158 max_tokens: 64,
2159 system_prompt: Some("You write single-line summaries.".to_string()),
2160 temperature: None,
2161 stop_sequences: Vec::new(),
2162 model_preferences: None,
2163 include_context: None,
2164 metadata: None,
2165 tools: None,
2166 tool_choice: None,
2167 task: None,
2168 meta: None,
2169 }
2170}
2171
2172fn describe_summary(answer: Option<&tower_mcp::protocol::CreateMessageResult>) -> String {
2174 use tower_mcp::protocol::SamplingContent;
2175 let Some(answer) = answer else {
2176 return "no summary: the client declined the sampling request".to_string();
2177 };
2178 let text: String = answer
2179 .content
2180 .items()
2181 .iter()
2182 .filter_map(|item| match item {
2183 SamplingContent::Text { text, .. } => Some(text.as_str()),
2184 _ => None,
2185 })
2186 .collect::<Vec<_>>()
2187 .join(" ");
2188 format!("summary ({}): {text}", answer.model)
2189}
2190
2191#[derive(serde::Deserialize, schemars::JsonSchema)]
2193struct SignInInput {}
2194
2195fn sign_in_form() -> tower_mcp::protocol::ElicitFormParams {
2197 tower_mcp::protocol::ElicitFormParams {
2198 mode: None,
2199 message: "The demo server would like to know who you are.".to_string(),
2200 requested_schema: tower_mcp::protocol::ElicitFormSchema::new()
2201 .string_field("username", Some("Any name will do"), true)
2202 .enum_field(
2203 "environment",
2204 Some("Which environment to sign in to"),
2205 vec!["staging".to_string(), "production".to_string()],
2206 false,
2207 )
2208 .boolean_field("remember_me", Some("Stay signed in"), false),
2209 meta: None,
2210 }
2211}
2212
2213fn describe_sign_in(answer: Option<&tower_mcp::protocol::ElicitResult>) -> String {
2215 use tower_mcp::protocol::ElicitAction;
2216 let Some(answer) = answer else {
2217 return "no answer".to_string();
2218 };
2219 match answer.action {
2220 ElicitAction::Accept => {
2221 let content = answer.content.clone().unwrap_or_default();
2222 let username = content
2223 .get("username")
2224 .and_then(|v| serde_json::to_value(v).ok())
2225 .and_then(|v| v.as_str().map(str::to_string))
2226 .unwrap_or_else(|| "(nobody)".to_string());
2227 format!("signed in as {username}")
2228 }
2229 ElicitAction::Decline => "declined".to_string(),
2230 _ => "cancelled".to_string(),
2231 }
2232}
2233
2234#[derive(serde::Deserialize, schemars::JsonSchema)]
2239struct EchoInput {
2240 message: String,
2242 #[serde(default = "one")]
2244 repeat: u8,
2245}
2246
2247fn one() -> u8 {
2248 1
2249}
2250
2251#[derive(serde::Deserialize, schemars::JsonSchema)]
2253struct AddInput {
2254 a: i64,
2256 b: i64,
2258}
2259
2260#[derive(serde::Deserialize, schemars::JsonSchema)]
2262struct ScanInput {
2263 #[serde(default = "five")]
2265 steps: u32,
2266}
2267
2268fn five() -> u32 {
2269 5
2270}
2271
2272#[derive(serde::Deserialize, schemars::JsonSchema)]
2277#[serde(rename_all = "lowercase")]
2278enum Scale {
2279 Celsius,
2280 Fahrenheit,
2281 Kelvin,
2282}
2283
2284#[derive(serde::Deserialize, schemars::JsonSchema)]
2286struct ConvertInput {
2287 value: f64,
2289 from: Scale,
2291 to: Scale,
2293}
2294
2295const SURFACE_REFRESH_DEBOUNCE: Duration = Duration::from_millis(250);
2301
2302type RefreshSignal = Arc<tokio::sync::watch::Sender<u64>>;
2307
2308fn note_surface_change(signal: &RefreshSignal) {
2309 signal.send_modify(|seen| *seen = seen.wrapping_add(1));
2310}
2311
2312fn notification_handler(
2316 refresh: RefreshSignal,
2317 output: AsyncOutput,
2318 jobs: Arc<Jobs>,
2319) -> NotificationHandler {
2320 let t = refresh.clone();
2321 let r = refresh.clone();
2322 let p = refresh;
2323 NotificationHandler::new()
2324 .on_tools_changed(move || note_surface_change(&t))
2325 .on_resources_changed(move || note_surface_change(&r))
2326 .on_prompts_changed(move || note_surface_change(&p))
2327 .on_task_status_changed({
2328 let jobs = jobs.clone();
2329 move |params| jobs.observe_legacy(params)
2330 })
2331 .on_final_task_status_changed(move |params| jobs.observe_final(params))
2332 .on_progress({
2333 let output = output.clone();
2334 move |p| {
2335 let pct = match (p.progress, p.total) {
2336 (done, Some(total)) if total > 0.0 => {
2337 format!(" {:.0}%", 100.0 * done / total)
2338 }
2339 _ => String::new(),
2340 };
2341 output.line(format!(
2342 "{} {}",
2343 tag(Style::new().fg(Color::Cyan), &format!("progress{pct}")),
2344 sanitize(p.message.as_deref().unwrap_or(""))
2345 ));
2346 }
2347 })
2348 .on_resource_updated({
2352 let output = output.clone();
2353 move |uri| {
2354 let known = if subscribe::contains(&uri) {
2355 String::new()
2356 } else {
2357 format!(" {}", paint(Style::new().dimmed(), "(not subscribed here)"))
2358 };
2359 output.line(format!(
2360 "{} {}{known}",
2361 tag(Style::new().fg(Color::Cyan), "resource updated"),
2362 sanitize(&uri)
2363 ));
2364 }
2365 })
2366 .on_log_message(move |m| {
2367 output.line(format!(
2368 "{} {}",
2369 tag(log_level_style(m.level), &format!("log {}", m.level)),
2370 sanitize(&m.data.to_string())
2371 ));
2372 })
2373}
2374
2375fn forward_child_stderr(stderr: tokio::process::ChildStderr, output: AsyncOutput) {
2377 tokio::spawn(async move {
2378 let mut lines = BufReader::new(stderr).lines();
2379 loop {
2380 match lines.next_line().await {
2381 Ok(Some(line)) => output.line(sanitize(&line).into_owned()),
2384 Ok(None) => break,
2385 Err(error) => {
2386 output.line(format!("warning: reading server stderr failed: {error}"));
2387 break;
2388 }
2389 }
2390 }
2391 });
2392}
2393
2394fn watch_task(session: Arc<Session>, jobs: Arc<Jobs>, task_id: String, poll_interval: Option<u64>) {
2399 if !jobs.automatic_updates_enabled() || jobs.is_terminal(&task_id) {
2400 return;
2401 }
2402 tokio::spawn(async move {
2403 let generation = session.generation();
2404 let client = session.client();
2405 let _subscription =
2406 if client.selected_protocol_version().await.as_deref() == Some("2026-07-28") {
2407 match client
2408 .listen_subscriptions(SubscriptionFilter {
2409 task_ids: Some(vec![task_id.clone()]),
2410 ..Default::default()
2411 })
2412 .await
2413 {
2414 Ok(mut handle) => match handle.acknowledged().await {
2415 Ok(accepted)
2416 if accepted
2417 .task_ids
2418 .as_ref()
2419 .is_some_and(|ids| ids.iter().any(|id| id == &task_id)) =>
2420 {
2421 Some(handle)
2422 }
2423 _ => None,
2424 },
2425 Err(_) => None,
2426 }
2427 } else {
2428 None
2429 };
2430 let mut interval_ms = poll_interval.unwrap_or(1000).clamp(50, 30_000);
2431 let mut consecutive_errors = 0;
2432 loop {
2433 tokio::time::sleep(Duration::from_millis(interval_ms)).await;
2434 if session.generation() != generation {
2435 break;
2436 }
2437 if jobs.is_terminal(&task_id) {
2438 break;
2439 }
2440 match client.task_get(&task_id).await {
2441 Ok(task) => {
2442 consecutive_errors = 0;
2443 interval_ms = task.poll_interval.unwrap_or(1000).clamp(50, 30_000);
2444 let terminal = task.status.is_terminal();
2445 jobs.observe_task(&task);
2446 if terminal {
2447 break;
2448 }
2449 }
2450 Err(_) => {
2451 consecutive_errors += 1;
2452 if consecutive_errors >= 3 {
2453 break;
2454 }
2455 }
2456 }
2457 }
2458 });
2459}
2460
2461#[derive(Clone)]
2469struct OAuthRuntime {
2470 flow: OAuthAuthorizationFlow,
2471 scopes: Vec<String>,
2472}
2473
2474fn http_transport(
2475 url: String,
2476 config: HttpClientConfig,
2477 oauth: Option<OAuthRuntime>,
2478) -> HttpClientTransport {
2479 let transport = HttpClientTransport::with_config(url, config);
2480 match oauth {
2481 Some(oauth) => transport.with_scope_aware_token_provider(
2482 oauth.flow,
2483 OAuthScopeEscalationConfig::new(oauth.scopes).max_attempts(2),
2484 ),
2485 None => transport,
2486 }
2487}
2488
2489fn http_connector(
2490 url: String,
2491 config: HttpClientConfig,
2492 oauth: Option<OAuthRuntime>,
2493 make_handler: Arc<dyn Fn() -> ReplClientHandler + Send + Sync>,
2494 protocol: ProtocolMode,
2495) -> Connector {
2496 Arc::new(move || {
2497 let (url, config, oauth, handler) =
2498 (url.clone(), config.clone(), oauth.clone(), make_handler());
2499 Box::pin(async move {
2500 let client = client_builder(protocol)
2501 .map_err(|error| tower_mcp::Error::Transport(error.to_string()))?
2502 .connect(
2503 TracingTransport::new(http_transport(url, config, oauth)),
2504 handler,
2505 )
2506 .await?;
2507 establish_connection(&client, protocol).await?;
2508 restore_resource_subscriptions(&client).await?;
2509 Ok(client)
2510 })
2511 })
2512}
2513
2514struct ConnectRuntime {
2518 profiles: Arc<config::Config>,
2519 config_file: Option<std::path::PathBuf>,
2520 protocol: ProtocolMode,
2521 make_handler: Arc<dyn Fn() -> ReplClientHandler + Send + Sync>,
2522 async_output: AsyncOutput,
2523 server_label: elicit::ServerLabel,
2524 bearer: Option<String>,
2525 bearer_from_fd: Option<String>,
2526 headers: Vec<String>,
2527 oauth: Option<String>,
2528 trust_import: bool,
2529 no_browser: bool,
2530 no_reconnect: bool,
2531}
2532
2533struct ConnectedTarget {
2534 client: McpClient,
2535 connector: Option<Connector>,
2536 info: ConnectionInfo,
2537 surface: Surface,
2538 profile_name: Option<String>,
2539 profile_aliases: std::collections::BTreeMap<String, String>,
2540 source_label: Option<String>,
2541}
2542
2543#[derive(Debug)]
2544struct ConnectFailure {
2545 status: ExitStatus,
2546 message: String,
2547}
2548
2549impl ConnectFailure {
2550 fn usage(message: impl Into<String>) -> Self {
2551 Self {
2552 status: ExitStatus::Usage,
2553 message: message.into(),
2554 }
2555 }
2556
2557 fn mcp(error: tower_mcp::Error) -> Self {
2558 Self {
2559 status: ExitStatus::from_mcp_error(&error),
2560 message: collapse_repeated_label(&error.to_string()).to_string(),
2561 }
2562 }
2563}
2564
2565impl ConnectRuntime {
2566 async fn connect(&self, words: &[&str]) -> Result<ConnectedTarget, ConnectFailure> {
2570 if words.is_empty() {
2571 return Err(ConnectFailure::usage(self.candidates()));
2572 }
2573
2574 let mut profile_name = None;
2575 let mut source_label = None;
2576 let mut import_selector = None;
2577 let mut import_http_trust = None;
2578 let demo = words == ["demo"] || words == ["--demo"];
2579 if demo {
2580 if self.bearer_from_fd.is_some() {
2581 return Err(ConnectFailure::usage(
2582 "--bearer-fd applies only to HTTP servers and cannot be ignored safely",
2583 ));
2584 }
2585 if self.bearer.is_some() || !self.headers.is_empty() {
2586 eprintln!(
2587 "warning: --bearer/--header apply only to HTTP servers; ignoring them here"
2588 );
2589 }
2590 if self.oauth.is_some() {
2591 return Err(ConnectFailure::usage(
2592 "--oauth applies only to HTTP servers",
2593 ));
2594 }
2595 }
2596 let connection = if demo {
2597 None
2598 } else if let ["--http", url] = words {
2599 Some(config::Connection::Http {
2600 url: (*url).to_string(),
2601 bearer: None,
2602 headers: Vec::new(),
2603 oauth: None,
2604 })
2605 } else if let ["--server", name] = words {
2606 if let Some(parsed) = import_config::parse_selector(name) {
2607 let selector = parsed.map_err(ConnectFailure::usage)?;
2608 let imported =
2609 import_config::load_with(selector, |variable| std::env::var(variable).ok())
2610 .map_err(ConnectFailure::usage)?;
2611 source_label = Some(format!("import {}", imported.label()));
2612 import_selector = Some(imported.selector);
2613 import_http_trust = imported.http_trust;
2614 Some(imported.connection)
2615 } else {
2616 let connection = self.resolve_profile(name)?;
2617 profile_name = Some((*name).to_string());
2618 source_label = Some(format!("profile {name}"));
2619 Some(connection)
2620 }
2621 } else if words.len() == 1 && is_http_url(words[0]) {
2622 Some(config::Connection::Http {
2623 url: words[0].to_string(),
2624 bearer: None,
2625 headers: Vec::new(),
2626 oauth: None,
2627 })
2628 } else if words.len() == 1 {
2629 if let Some(parsed) = import_config::parse_selector(words[0]) {
2630 let selector = parsed.map_err(ConnectFailure::usage)?;
2631 let imported = import_config::load_with(selector, |name| std::env::var(name).ok())
2632 .map_err(ConnectFailure::usage)?;
2633 source_label = Some(format!("import {}", imported.label()));
2634 import_selector = Some(imported.selector);
2635 import_http_trust = imported.http_trust;
2636 Some(imported.connection)
2637 } else if self.profiles.servers.contains_key(words[0]) {
2638 let name = words[0];
2639 let connection = self.resolve_profile(name)?;
2640 profile_name = Some(name.to_string());
2641 source_label = Some(format!("profile {name}"));
2642 Some(connection)
2643 } else {
2644 Some(config::Connection::Stdio {
2645 command: vec![words[0].to_string()],
2646 env: std::collections::BTreeMap::new(),
2647 cwd: None,
2648 })
2649 }
2650 } else {
2651 let command = words.strip_prefix(&["--"]).unwrap_or(words);
2652 if command.is_empty() {
2653 return Err(ConnectFailure::usage(
2654 "usage: connect <url|profile|path.json:entry|command...|demo>",
2655 ));
2656 }
2657 Some(config::Connection::Stdio {
2658 command: command.iter().map(|word| (*word).to_string()).collect(),
2659 env: std::collections::BTreeMap::new(),
2660 cwd: None,
2661 })
2662 };
2663
2664 let mut connector = None;
2665 let builder = client_builder(self.protocol)
2666 .map_err(|error| ConnectFailure::usage(error.to_string()))?;
2667 let client = if demo {
2668 builder
2669 .connect(
2670 TracingTransport::new(ChannelTransport::new(demo_router())),
2671 (self.make_handler)(),
2672 )
2673 .await
2674 .map_err(ConnectFailure::mcp)?
2675 } else {
2676 match connection.expect("non-demo targets resolve a connection") {
2677 config::Connection::Http {
2678 url,
2679 bearer,
2680 headers,
2681 oauth: profile_oauth,
2682 } => {
2683 self.authorize_import_http(
2684 import_selector.as_ref(),
2685 import_http_trust.as_ref(),
2686 &url,
2687 )?;
2688 validate_bearer_fd_exclusive(
2689 self.bearer_from_fd.is_some(),
2690 false,
2691 false,
2692 &[],
2693 bearer.is_some(),
2694 &headers,
2695 false,
2696 profile_oauth.is_some(),
2697 )
2698 .map_err(ConnectFailure::usage)?;
2699 let explicit_bearer =
2700 self.bearer_from_fd.clone().or_else(|| self.bearer.clone());
2701 let oauth_name = selected_oauth_profile(
2702 self.oauth.as_deref(),
2703 profile_oauth.as_deref(),
2704 explicit_bearer.is_some(),
2705 &self.headers,
2706 );
2707 let profile_headers = if oauth_name.is_some() {
2708 headers
2709 .into_iter()
2710 .filter(|(name, _)| !name.eq_ignore_ascii_case("authorization"))
2711 .collect::<Vec<_>>()
2712 } else {
2713 headers
2714 };
2715 let http_config = if oauth_name.is_some() {
2716 build_http_config_with_env(
2717 explicit_bearer,
2718 &self.headers,
2719 None,
2720 &profile_headers,
2721 None,
2722 )
2723 } else {
2724 build_http_config(explicit_bearer, &self.headers, bearer, &profile_headers)
2725 }
2726 .map_err(ConnectFailure::usage)?;
2727 let oauth = self.oauth_runtime(oauth_name.as_deref(), &url).await?;
2728 if !self.no_reconnect {
2729 connector = Some(http_connector(
2730 url.clone(),
2731 http_config.clone(),
2732 oauth.clone(),
2733 self.make_handler.clone(),
2734 self.protocol,
2735 ));
2736 }
2737 builder
2738 .connect(
2739 TracingTransport::new(http_transport(url, http_config, oauth)),
2740 (self.make_handler)(),
2741 )
2742 .await
2743 .map_err(ConnectFailure::mcp)?
2744 }
2745 config::Connection::Stdio { command, env, cwd } => {
2746 if self.bearer_from_fd.is_some() {
2747 return Err(ConnectFailure::usage(
2748 "--bearer-fd applies only to HTTP servers and cannot be ignored safely",
2749 ));
2750 }
2751 if self.bearer.is_some() || !self.headers.is_empty() {
2752 eprintln!(
2753 "warning: --bearer/--header apply only to HTTP servers; ignoring them here"
2754 );
2755 }
2756 if self.oauth.is_some() {
2757 return Err(ConnectFailure::usage(
2758 "--oauth applies only to HTTP servers",
2759 ));
2760 }
2761 if let Some(selector) = import_selector.as_ref() {
2762 let plan = import_trust::ImportPlan::stdio(
2763 &selector.path,
2764 &selector.entry,
2765 &command,
2766 cwd.as_deref(),
2767 &env,
2768 );
2769 self.authorize_import(&plan)?;
2770 }
2771 let Some(program) = command.first() else {
2772 return Err(ConnectFailure::usage("stdio command is empty"));
2773 };
2774 let mut child = tokio::process::Command::new(program);
2775 child.args(&command[1..]);
2776 child.envs(env);
2777 child.env_remove("MCP_BEARER");
2778 if let Some(cwd) = cwd {
2779 child.current_dir(cwd);
2780 }
2781 child.stderr(std::process::Stdio::piped());
2782 let mut transport = StdioClientTransport::spawn_command(&mut child)
2783 .await
2784 .map_err(|error| {
2785 ConnectFailure::mcp(tower_mcp::Error::Transport(format!(
2786 "could not start stdio server {program:?}: {error}"
2787 )))
2788 })?;
2789 if let Some(stderr) = transport.take_stderr() {
2790 forward_child_stderr(stderr, self.async_output.clone());
2791 }
2792 builder
2793 .connect(TracingTransport::new(transport), (self.make_handler)())
2794 .await
2795 .map_err(ConnectFailure::mcp)?
2796 }
2797 }
2798 };
2799
2800 let info = establish_connection(&client, self.protocol)
2801 .await
2802 .map_err(ConnectFailure::mcp)?;
2803 let surface = fetch_surface_initial(&client).await;
2804 let profile_aliases = profile_name
2805 .as_ref()
2806 .and_then(|name| self.profiles.servers.get(name))
2807 .map(|profile| profile.aliases.clone())
2808 .unwrap_or_default();
2809 Ok(ConnectedTarget {
2810 client,
2811 connector,
2812 info,
2813 surface,
2814 profile_name,
2815 profile_aliases,
2816 source_label,
2817 })
2818 }
2819
2820 fn resolve_profile(&self, name: &str) -> Result<config::Connection, ConnectFailure> {
2821 let profile = self.profiles.profile(name).map_err(ConnectFailure::usage)?;
2822 validate_profile_bearer_fd_exclusive(self.bearer_from_fd.is_some(), profile)
2823 .map_err(ConnectFailure::usage)?;
2824 if profile.bearer.is_some() {
2825 eprintln!(
2826 "warning: profile {name:?} stores a literal `bearer` token; prefer \
2827 `bearer_env = \"VAR\"` so the token is not kept in the config file"
2828 );
2829 }
2830 self.profiles
2831 .resolve_profile_with(name, |variable| std::env::var(variable).ok())
2832 .map_err(|error| ConnectFailure::usage(format!("server profile {name:?}: {error}")))
2833 }
2834
2835 fn authorize_import_http(
2836 &self,
2837 selector: Option<&import_config::Selector>,
2838 trust: Option<&import_config::ImportedHttpTrust>,
2839 url: &str,
2840 ) -> Result<(), ConnectFailure> {
2841 let (Some(selector), Some(trust)) = (selector, trust) else {
2842 return Ok(());
2843 };
2844 let plan = import_trust::ImportPlan::http(
2845 &selector.path,
2846 &selector.entry,
2847 url,
2848 &trust.header_names,
2849 &trust
2850 .header_env_keys
2851 .iter()
2852 .chain(trust.url_env_keys.iter())
2853 .cloned()
2854 .collect::<Vec<_>>(),
2855 )
2856 .map_err(ConnectFailure::usage)?;
2857 self.authorize_import(&plan)
2858 }
2859
2860 fn authorize_import(&self, plan: &import_trust::ImportPlan) -> Result<(), ConnectFailure> {
2861 let interactive = std::io::IsTerminal::is_terminal(&std::io::stdin());
2862 match import_trust::authorize(
2863 plan,
2864 self.config_file.as_deref(),
2865 self.trust_import,
2866 interactive,
2867 ) {
2868 import_trust::Decision::Approved => Ok(()),
2869 import_trust::Decision::Refused(reason) => Err(ConnectFailure::usage(reason)),
2870 }
2871 }
2872
2873 async fn oauth_runtime(
2874 &self,
2875 name: Option<&str>,
2876 url: &str,
2877 ) -> Result<Option<OAuthRuntime>, ConnectFailure> {
2878 let Some(name) = name else {
2879 return Ok(None);
2880 };
2881 let metadata = self.profiles.oauth.get(name).ok_or_else(|| {
2882 ConnectFailure::usage(format!(
2883 "no OAuth profile named {name:?}; create it with \
2884 `mcp-repl --login {name} --http {url}`"
2885 ))
2886 })?;
2887 let interactive = std::io::IsTerminal::is_terminal(&std::io::stdin());
2888 let (flow, store) = oauth_profile::build_flow(
2889 name,
2890 url,
2891 metadata,
2892 interactive,
2893 interactive && !self.no_browser,
2894 )
2895 .map_err(|error| ConnectFailure {
2896 status: ExitStatus::Auth,
2897 message: error,
2898 })?;
2899 if interactive {
2900 flow.authorize(metadata.scopes.clone())
2901 .await
2902 .map_err(|error| ConnectFailure {
2903 status: ExitStatus::Auth,
2904 message: format!("OAuth authorization failed for profile {name:?}: {error}"),
2905 })?;
2906 } else {
2907 let has_tokens = store.has_tokens().await.map_err(|error| ConnectFailure {
2908 status: ExitStatus::Auth,
2909 message: format!("OAuth credential restore failed for profile {name:?}: {error}"),
2910 })?;
2911 if !has_tokens {
2912 return Err(ConnectFailure {
2913 status: ExitStatus::Auth,
2914 message: format!(
2915 "OAuth login required for profile {name:?}; run \
2916 `mcp-repl --login {name} --http {url}` first"
2917 ),
2918 });
2919 }
2920 match flow
2921 .begin(metadata.scopes.clone())
2922 .await
2923 .map_err(|error| ConnectFailure {
2924 status: ExitStatus::Auth,
2925 message: format!(
2926 "OAuth credential restore failed for profile {name:?}: {error}"
2927 ),
2928 })? {
2929 OAuthAuthorizationStart::Authorized { .. } => {}
2930 _ => {
2931 return Err(ConnectFailure {
2932 status: ExitStatus::Auth,
2933 message: format!(
2934 "OAuth login required for profile {name:?}; run \
2935 `mcp-repl --login {name} --http {url}` first"
2936 ),
2937 });
2938 }
2939 }
2940 }
2941 Ok(Some(OAuthRuntime {
2942 flow,
2943 scopes: metadata.scopes.clone(),
2944 }))
2945 }
2946
2947 fn candidates(&self) -> String {
2948 let profiles = self.profiles.names();
2949 let configured = if profiles.is_empty() {
2950 "no saved profiles".to_string()
2951 } else {
2952 format!("saved profiles: {}", profiles.join(", "))
2953 };
2954 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2955 let directories = directories::Directories::current();
2956 let imported =
2957 import_config::scan(&import_config::candidate_paths_with(&cwd, &directories))
2958 .into_iter()
2959 .filter_map(|file| {
2960 let entries = file.result.ok()?;
2961 let path = typeable_path(&file.path, &cwd, directories.home());
2962 Some(
2963 entries
2964 .into_iter()
2965 .map(|entry| format!("{path}:{}", entry.name))
2966 .collect::<Vec<_>>(),
2967 )
2968 })
2969 .flatten()
2970 .collect::<Vec<_>>();
2971 let imported = if imported.is_empty() {
2972 String::new()
2973 } else {
2974 format!("\nimported targets: {}", imported.join(", "))
2975 };
2976 format!(
2977 "usage: connect <url|profile|path.json:entry|command...|demo>\n{configured}{imported}\n\
2978 examples: connect demo · connect https://example/mcp · connect -- ./server --stdio"
2979 )
2980 }
2981}
2982
2983fn is_http_url(value: &str) -> bool {
2984 value.starts_with("http://") || value.starts_with("https://")
2985}
2986
2987async fn restore_resource_subscriptions(client: &McpClient) -> Result<(), tower_mcp::Error> {
2992 let report = subscribe::replay(
2993 subscribe::list(),
2994 |uri| async move { client.subscribe_resource(&uri).await },
2995 is_session_lost,
2996 )
2997 .await?;
2998 if report.restored > 0 {
2999 tracing::debug!(
3000 count = report.restored,
3001 "restored resource subscriptions after reconnect"
3002 );
3003 }
3004 for (uri, error) in report.failed {
3005 subscribe::remove(&uri);
3006 eprintln!(
3007 "warning: resource subscription {} was not restored after reconnect: {}",
3008 sanitize(&uri),
3009 sanitize(&error)
3010 );
3011 }
3012 Ok(())
3013}
3014
3015fn load_config(explicit: Option<&str>) -> config::Config {
3018 let Some((path, explicit)) = config::config_path(explicit) else {
3019 return config::Config::default();
3020 };
3021 match config::Config::load(&path, explicit) {
3022 Ok(c) => c,
3023 Err(e) => {
3024 exit_with_error(ExitStatus::Usage, &e);
3025 }
3026 }
3027}
3028
3029async fn handle_oauth_profile_action(
3030 args: &Args,
3031 profiles: &config::Config,
3032 config_file: Option<&std::path::Path>,
3033) -> bool {
3034 let Some(name) = args.login.as_deref().or(args.logout.as_deref()) else {
3035 if !args.oauth_scopes.is_empty()
3036 || args.oauth_client_id_metadata_document.is_some()
3037 || args.oauth_authorization_server.is_some()
3038 {
3039 exit_with_error(
3040 ExitStatus::Usage,
3041 "--oauth-scope, --oauth-client-id-metadata-document, and \
3042 --oauth-authorization-server apply only to --login",
3043 );
3044 }
3045 return false;
3046 };
3047 oauth_profile::validate_name(name)
3048 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
3049 if args.demo
3050 || !args.command.is_empty()
3051 || !args.exec.is_empty()
3052 || args.list_servers
3053 || args.bearer.is_some()
3054 || args.bearer_fd.is_some()
3055 || !args.headers.is_empty()
3056 || args.oauth.is_some()
3057 {
3058 exit_with_error(
3059 ExitStatus::Usage,
3060 "--login/--logout are standalone credential operations; do not combine them with \
3061 a command, --demo, --exec, --list-servers, --bearer, --bearer-fd, --header, or --oauth \
3062 (--json is allowed, and reports what was created)",
3063 );
3064 }
3065 let path = config_file.unwrap_or_else(|| {
3066 exit_with_error(
3067 ExitStatus::Usage,
3068 "no platform config directory is available; pass --config",
3069 )
3070 });
3071
3072 if args.logout.is_some() {
3073 let store = oauth_profile::CredentialStore::keyring(name)
3074 .unwrap_or_else(|error| exit_with_error(ExitStatus::Auth, &error));
3075 store
3076 .clear()
3077 .await
3078 .unwrap_or_else(|error| exit_with_error(ExitStatus::Auth, &error));
3079 oauth_profile::remove_metadata(path, name)
3080 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
3081 if json_output() {
3082 print_json(&serde_json::json!({
3083 "profile": name,
3084 "removed": true,
3085 }));
3086 } else {
3087 println!("removed OAuth profile {name:?} and its stored credentials");
3088 }
3089 return true;
3090 }
3091
3092 let existing = profiles.oauth.get(name).cloned().unwrap_or_default();
3093 let server_url = args.server.as_deref().map(|server_name| {
3094 let profile = profiles
3095 .profile(server_name)
3096 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
3097 match profile.transport() {
3098 Ok(config::Transport::Http) => profile
3099 .url
3100 .clone()
3101 .or_else(|| {
3102 profile
3103 .oauth
3104 .as_deref()
3105 .and_then(|oauth| profiles.oauth.get(oauth))
3106 .map(|metadata| metadata.url.clone())
3107 })
3108 .unwrap_or_else(|| {
3109 exit_with_error(
3110 ExitStatus::Usage,
3111 &format!("server profile {server_name:?} has no HTTP URL"),
3112 )
3113 }),
3114 Ok(config::Transport::Stdio) => exit_with_error(
3115 ExitStatus::Usage,
3116 &format!("server profile {server_name:?} is stdio; OAuth requires HTTP"),
3117 ),
3118 Err(error) => exit_with_error(ExitStatus::Usage, &error),
3119 }
3120 });
3121 let url = args
3122 .http
3123 .clone()
3124 .or(server_url)
3125 .or_else(|| (!existing.url.is_empty()).then(|| existing.url.clone()))
3126 .unwrap_or_else(|| {
3127 exit_with_error(
3128 ExitStatus::Usage,
3129 "a new OAuth profile needs --http URL (or --server with an HTTP profile)",
3130 )
3131 });
3132 let scopes = if args.oauth_scopes.is_empty() {
3133 existing.scopes
3134 } else {
3135 args.oauth_scopes
3136 .iter()
3137 .flat_map(|scope| scope.split_ascii_whitespace())
3138 .map(str::to_string)
3139 .fold(Vec::new(), |mut scopes, scope| {
3140 if !scope.is_empty() && !scopes.contains(&scope) {
3141 scopes.push(scope);
3142 }
3143 scopes
3144 })
3145 };
3146 let metadata = config::OAuthProfile {
3147 url: url.clone(),
3148 scopes,
3149 client_id_metadata_document: args
3150 .oauth_client_id_metadata_document
3151 .clone()
3152 .or(existing.client_id_metadata_document),
3153 authorization_server: args
3154 .oauth_authorization_server
3155 .clone()
3156 .or(existing.authorization_server),
3157 };
3158 let (flow, store) = oauth_profile::build_flow(name, &url, &metadata, true, !args.no_browser)
3159 .unwrap_or_else(|error| exit_with_error(ExitStatus::Auth, &error));
3160 if let Err(error) = flow.authorize(metadata.scopes.clone()).await {
3161 if matches!(error, OAuthClientError::TokenRequest(_)) {
3162 store
3163 .clear_tokens()
3164 .await
3165 .unwrap_or_else(|store_error| exit_with_error(ExitStatus::Auth, &store_error));
3166 let (retry, _) =
3167 oauth_profile::build_flow(name, &url, &metadata, true, !args.no_browser)
3168 .unwrap_or_else(|build_error| exit_with_error(ExitStatus::Auth, &build_error));
3169 retry
3170 .authorize(metadata.scopes.clone())
3171 .await
3172 .unwrap_or_else(|retry_error| {
3173 exit_with_error(ExitStatus::Auth, &retry_error.to_string())
3174 });
3175 } else {
3176 exit_with_error(ExitStatus::Auth, &error.to_string());
3177 }
3178 }
3179 if let Err(error) = oauth_profile::save_metadata(path, name, &metadata) {
3180 let _ = store.clear().await;
3181 exit_with_error(ExitStatus::Usage, &error);
3182 }
3183 if json_output() {
3184 print_json(&saved_profile_json(name, &metadata));
3185 } else {
3186 println!(
3187 "saved OAuth profile {name:?}; credentials are in the operating-system credential store"
3188 );
3189 }
3190 true
3191}
3192
3193fn saved_profile_json(name: &str, metadata: &config::OAuthProfile) -> serde_json::Value {
3200 serde_json::json!({
3201 "profile": name,
3202 "serverUrl": metadata.url,
3203 "scopes": metadata.scopes,
3204 })
3205}
3206
3207fn program_name() -> String {
3211 <Args as clap::CommandFactory>::command()
3212 .get_name()
3213 .to_string()
3214}
3215
3216fn print_completions(shell: clap_complete::Shell) {
3218 let mut command = <Args as clap::CommandFactory>::command();
3219 let name = program_name();
3220 clap_complete::generate(shell, &mut command, name, &mut std::io::stdout());
3221}
3222
3223fn roff_escape(text: &str) -> String {
3224 text.replace('\\', "\\e").replace('-', "\\-")
3225}
3226
3227fn render_man_page() -> Result<Vec<u8>, String> {
3231 let command = <Args as clap::CommandFactory>::command();
3232 let mut page = Vec::new();
3233 clap_mangen::Man::new(command)
3234 .render(&mut page)
3235 .map_err(|error| format!("could not render the man page: {error}"))?;
3236
3237 use std::io::Write;
3238 writeln!(page, ".SH \"REPL BUILT-INS\"").map_err(|error| error.to_string())?;
3239 writeln!(
3240 page,
3241 "The server's tools are top-level commands. These built-ins are supplied by mcp-repl. The same reference is available interactively through \\fBhelp <command>\\fR."
3242 )
3243 .map_err(|error| error.to_string())?;
3244 for &(name, _, _) in BUILTIN_HELP {
3245 let help = builtin_help(name).expect("BUILTIN_HELP entry resolves itself");
3246 writeln!(page, ".TP\n\\fB{}\\fR", roff_escape(help.usage))
3247 .map_err(|error| error.to_string())?;
3248 writeln!(page, "{}", roff_escape(help.description)).map_err(|error| error.to_string())?;
3249 for paragraph in help.details {
3250 writeln!(page, ".PP\n{}", roff_escape(paragraph)).map_err(|error| error.to_string())?;
3251 }
3252 if !help.examples.is_empty() {
3253 writeln!(page, ".RS 4\nExamples:\n.nf").map_err(|error| error.to_string())?;
3254 for example in help.examples {
3255 writeln!(page, "{}", roff_escape(example)).map_err(|error| error.to_string())?;
3256 }
3257 writeln!(page, ".fi\n.RE").map_err(|error| error.to_string())?;
3258 }
3259 }
3260 Ok(page)
3261}
3262
3263fn print_man() {
3265 let page = render_man_page().unwrap_or_else(|error| {
3266 exit_with_error(ExitStatus::Usage, &error);
3267 });
3268 use std::io::Write;
3269 if let Err(error) = std::io::stdout().write_all(&page) {
3270 exit_with_error(
3271 ExitStatus::Usage,
3272 &format!("could not write the man page: {error}"),
3273 );
3274 }
3275}
3276
3277fn typeable_path(
3289 path: &std::path::Path,
3290 cwd: &std::path::Path,
3291 home: Option<&std::path::Path>,
3292) -> String {
3293 if let Ok(relative) = path.strip_prefix(cwd) {
3294 return relative.display().to_string();
3295 }
3296 if let Some(relative) = home.and_then(|home| path.strip_prefix(home).ok()) {
3297 return format!("~/{}", relative.display());
3298 }
3299 path.display().to_string()
3300}
3301
3302fn no_target_message() -> String {
3310 const USAGE: &str =
3311 "usage: mcp-repl <server command...> | --http <url> | --server <name> | --demo";
3312
3313 if json_output() || !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
3317 return USAGE.to_string();
3318 }
3319
3320 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
3321 let directories = directories::Directories::current();
3322 let home = directories.home();
3323 let found: Vec<String> =
3324 import_config::scan(&import_config::candidate_paths_with(&cwd, &directories))
3325 .into_iter()
3326 .filter_map(|file| {
3327 let entries = file.result.ok()?;
3328 let path = typeable_path(&file.path, &cwd, home);
3329 Some(
3330 entries
3331 .into_iter()
3332 .map(|entry| format!("{path}:{}", entry.name))
3333 .collect::<Vec<_>>(),
3334 )
3335 })
3336 .flatten()
3337 .collect();
3338
3339 if found.is_empty() {
3340 return format!("{USAGE}\n\ntry `mcp-repl --demo`, which needs no server at all");
3341 }
3342
3343 const SHOWN: usize = 5;
3346 let mut message =
3347 String::from("mcp-repl needs a server. These are configured on this machine:");
3348 for selector in found.iter().take(SHOWN) {
3349 message.push_str(&format!("\n {}", sanitize(selector)));
3350 }
3351 if found.len() > SHOWN {
3352 message.push_str(&format!(
3353 "\n ... and {} more; `mcp-repl --scan` lists them all",
3354 found.len() - SHOWN
3355 ));
3356 }
3357 message.push_str(&format!(
3358 "\n\ntry `mcp-repl {}`, or `mcp-repl --demo` for the built-in one",
3359 found[0]
3360 ));
3361 message
3362}
3363
3364fn print_scan() -> ExitStatus {
3365 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
3366 let directories = directories::Directories::current();
3367 let paths = import_config::candidate_paths_with(&cwd, &directories);
3368 let scanned = import_config::scan(&paths);
3369
3370 if json_output() {
3371 let files: Vec<serde_json::Value> = scanned
3372 .iter()
3373 .map(|file| match &file.result {
3374 Ok(entries) => serde_json::json!({
3375 "path": file.path.display().to_string(),
3376 "entries": entries.iter().map(|entry| serde_json::json!({
3377 "entry": entry.name,
3378 "selector": format!("{}:{}", file.path.display(), entry.name),
3379 "transport": entry.transport,
3380 "summary": entry.summary,
3381 })).collect::<Vec<_>>(),
3382 }),
3383 Err(error) => serde_json::json!({
3384 "path": file.path.display().to_string(),
3385 "error": error,
3386 }),
3387 })
3388 .collect();
3389 let found = scanned
3390 .iter()
3391 .filter_map(|file| file.result.as_ref().ok())
3392 .map(Vec::len)
3393 .sum::<usize>();
3394 print_json(&serde_json::Value::Array(files));
3395 return no_match_when_empty(found);
3396 }
3397
3398 if scanned.is_empty() {
3399 report_error(
3401 ExitStatus::NoMatch,
3402 "no MCP client configs found (looked for .mcp.json, .vscode/mcp.json, \
3403 .cursor/mcp.json, and the Claude configs in your platform user directories)",
3404 );
3405 return ExitStatus::NoMatch;
3406 }
3407
3408 let mut total = 0usize;
3409 for file in &scanned {
3410 println!(
3411 "{}",
3412 paint(Style::new().bold(), &file.path.display().to_string())
3413 );
3414 match &file.result {
3415 Err(error) => println!(" {} {}", style::error_prefix(), sanitize(error)),
3418 Ok(entries) if entries.is_empty() => {
3419 println!(" {}", paint(Style::new().dimmed(), "(no servers)"));
3420 }
3421 Ok(entries) => {
3422 let width = entries.iter().map(|e| e.name.len()).max().unwrap_or(0);
3423 for entry in entries {
3424 total += 1;
3425 println!(
3426 " {} {} {}",
3427 style::column(Style::new().fg(Color::Green), &sanitize(&entry.name), width),
3428 paint(Style::new().dimmed(), &format!("{:>5}", entry.transport)),
3429 sanitize(&entry.summary)
3430 );
3431 }
3432 }
3433 }
3434 }
3435 if total > 0 {
3436 println!(
3437 "{}",
3438 paint(
3439 Style::new().dimmed(),
3440 &format!(
3441 "{} in {}. Connect with `mcp-repl <path>:<entry>`.",
3442 plural(total, "server"),
3443 plural(scanned.len(), "file")
3444 )
3445 )
3446 );
3447 }
3448 no_match_when_empty(total)
3449}
3450
3451fn no_match_when_empty(found: usize) -> ExitStatus {
3454 if found == 0 {
3455 ExitStatus::NoMatch
3456 } else {
3457 ExitStatus::Success
3458 }
3459}
3460
3461fn print_servers(config: &config::Config) {
3463 if config.servers.is_empty() {
3464 println!("no server profiles configured");
3465 return;
3466 }
3467 let width = config.names().iter().map(|n| n.len()).max().unwrap_or(0);
3468 for (name, profile) in &config.servers {
3469 println!(
3470 "{} {}",
3471 style::column(Style::new().fg(Color::Cyan), name, width),
3472 paint(Style::new().dimmed(), &profile.summary()),
3473 );
3474 }
3475}
3476
3477fn resolve_profile(
3482 args: &Args,
3483 config: &config::Config,
3484 bearer_fd: bool,
3485) -> Option<(String, config::Connection)> {
3486 let name = args
3487 .server
3488 .clone()
3489 .or_else(|| match args.command.as_slice() {
3490 [only] if config.servers.contains_key(only) => Some(only.clone()),
3491 _ => None,
3492 })?;
3493 let profile = match config.profile(&name) {
3494 Ok(p) => p,
3495 Err(e) => {
3496 exit_with_error(ExitStatus::Usage, &e);
3497 }
3498 };
3499 validate_profile_bearer_fd_exclusive(bearer_fd, profile)
3500 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
3501 if profile.bearer.is_some() {
3502 eprintln!(
3503 "warning: profile {name:?} stores a literal `bearer` token; prefer \
3504 `bearer_env = \"VAR\"` so the token is not kept in the config file"
3505 );
3506 }
3507 match config.resolve_profile_with(&name, |var| std::env::var(var).ok()) {
3508 Ok(connection) => Some((name, connection)),
3509 Err(e) => {
3510 exit_with_error(ExitStatus::Usage, &format!("server profile {name:?}: {e}"));
3511 }
3512 }
3513}
3514
3515fn resolve_import(args: &Args) -> Option<import_config::ImportedConnection> {
3519 let candidate = match args.server.as_deref() {
3520 Some(server) => server,
3521 None => match args.command.as_slice() {
3522 [only] => only,
3523 _ => return None,
3524 },
3525 };
3526 let selector = match import_config::parse_selector(candidate)? {
3527 Ok(selector) => selector,
3528 Err(error) => exit_with_error(ExitStatus::Usage, &error),
3529 };
3530 Some(
3531 import_config::load_with(selector, |variable| std::env::var(variable).ok())
3532 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error)),
3533 )
3534}
3535
3536pub(crate) const LOG_LEVELS: &[&str] = &[
3539 "debug",
3540 "info",
3541 "notice",
3542 "warning",
3543 "error",
3544 "critical",
3545 "alert",
3546 "emergency",
3547];
3548
3549fn parse_log_level(word: &str) -> Option<LogLevel> {
3550 match word.to_ascii_lowercase().as_str() {
3551 "debug" => Some(LogLevel::Debug),
3552 "info" => Some(LogLevel::Info),
3553 "notice" => Some(LogLevel::Notice),
3554 "warning" => Some(LogLevel::Warning),
3555 "error" => Some(LogLevel::Error),
3556 "critical" => Some(LogLevel::Critical),
3557 "alert" => Some(LogLevel::Alert),
3558 "emergency" => Some(LogLevel::Emergency),
3559 _ => None,
3560 }
3561}
3562
3563fn log_level_style(level: LogLevel) -> Style {
3564 match level {
3565 LogLevel::Emergency | LogLevel::Alert | LogLevel::Critical | LogLevel::Error => {
3566 Style::new().fg(Color::Red)
3567 }
3568 LogLevel::Warning => Style::new().fg(Color::Yellow),
3569 LogLevel::Notice | LogLevel::Info => Style::new().fg(Color::Green),
3570 _ => Style::new().dimmed(),
3571 }
3572}
3573
3574pub fn run_cli() {
3580 let args = Args::parse();
3584 init_tracing(&args);
3585
3586 if let Some(shell) = args.completions {
3590 print_completions(shell);
3591 return;
3592 }
3593 if args.man {
3594 print_man();
3595 return;
3596 }
3597
3598 style::init(args.color);
3599 wire::init(args.trace);
3600 JSON_OUTPUT.store(args.json, Ordering::Relaxed);
3601
3602 validate_bearer_fd_exclusive(
3606 args.bearer_fd.is_some(),
3607 args.bearer.is_some(),
3608 std::env::var_os("MCP_BEARER").is_some(),
3609 &args.headers,
3610 false,
3611 &[],
3612 args.oauth.is_some(),
3613 false,
3614 )
3615 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
3616
3617 let bearer_from_fd = args
3621 .bearer_fd
3622 .map(bearer_fd::read)
3623 .transpose()
3624 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
3625
3626 let runtime = tokio::runtime::Builder::new_multi_thread()
3627 .enable_all()
3628 .build()
3629 .expect("build Tokio runtime");
3630
3631 if let Err(error) = runtime.block_on(run(args, bearer_from_fd)) {
3632 exit_with_error(
3633 ExitStatus::from_mcp_error(&error),
3634 collapse_repeated_label(&error.to_string()),
3635 );
3636 }
3637}
3638
3639async fn run(args: Args, bearer_from_fd: Option<String>) -> tower_mcp::Result<()> {
3640 let config_file = config::config_path(args.config.as_deref()).map(|(path, _)| path);
3643 let profiles = Arc::new(if args.login.is_some() || args.logout.is_some() {
3644 config_file
3645 .as_deref()
3646 .map(|path| {
3647 config::Config::load(path, false)
3648 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error))
3649 })
3650 .unwrap_or_default()
3651 } else {
3652 load_config(args.config.as_deref())
3653 });
3654 REQUEST_TIMEOUT_SECS.store(
3659 args.timeout
3660 .or(profiles.repl.request_timeout)
3661 .unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS),
3662 Ordering::Relaxed,
3663 );
3664 editor::set_completion_timeout(
3665 profiles
3666 .repl
3667 .completion_timeout_ms
3668 .map(Duration::from_millis)
3669 .unwrap_or(editor::DEFAULT_COMPLETION_TIMEOUT),
3670 );
3671
3672 if handle_oauth_profile_action(&args, &profiles, config_file.as_deref()).await {
3673 return Ok(());
3674 }
3675 if bearer_from_fd.is_some() && (args.list_servers || args.scan) {
3676 exit_with_error(
3677 ExitStatus::Usage,
3678 "--bearer-fd requires an HTTP connection and cannot be used while only listing servers",
3679 );
3680 }
3681 if args.list_servers {
3682 print_servers(&profiles);
3683 return Ok(());
3684 }
3685 if args.scan {
3686 std::process::exit(print_scan().code());
3687 }
3688 let schema_contracts =
3689 schema_contract::ContractSet::load(&args.schema_contracts, args.schema_mode)
3690 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
3691 let imported = resolve_import(&args);
3692 let profile = if imported.is_none() {
3693 resolve_profile(&args, &profiles, bearer_from_fd.is_some())
3694 } else {
3695 None
3696 };
3697 let one_shot = !args.exec.is_empty();
3700 let quiet = one_shot && (!args.verbose || args.json);
3703
3704 let at_prompt = Arc::new(AtomicBool::new(false));
3708 let async_output = AsyncOutput::new(at_prompt.clone(), !one_shot);
3709 let jobs = Arc::new(Jobs::new(
3713 async_output.clone(),
3714 automatic_task_updates(one_shot, args.json),
3715 ));
3716
3717 let (refresh_tx, mut refresh_rx) = tokio::sync::watch::channel(0u64);
3722 let refresh_tx: RefreshSignal = Arc::new(refresh_tx);
3723
3724 let server_label: elicit::ServerLabel = Arc::new(RwLock::new(String::new()));
3728
3729 let make_handler: Arc<dyn Fn() -> ReplClientHandler + Send + Sync> = {
3732 let refresh_tx = refresh_tx.clone();
3733 let at_prompt = at_prompt.clone();
3734 let async_output = async_output.clone();
3735 let jobs = jobs.clone();
3736 let server_label = server_label.clone();
3737 Arc::new(move || {
3738 ReplClientHandler::new(
3739 notification_handler(refresh_tx.clone(), async_output.clone(), jobs.clone()),
3740 at_prompt.clone(),
3741 server_label.clone(),
3742 async_output.clone(),
3743 )
3744 })
3745 };
3746 let connect_runtime = Arc::new(ConnectRuntime {
3747 profiles: profiles.clone(),
3748 config_file: config_file.clone(),
3749 protocol: args.protocol,
3750 make_handler: make_handler.clone(),
3751 async_output: async_output.clone(),
3752 server_label: server_label.clone(),
3753 bearer: args.bearer.clone(),
3754 bearer_from_fd: bearer_from_fd.clone(),
3755 headers: args.headers.clone(),
3756 oauth: args.oauth.clone(),
3757 trust_import: args.trust_import,
3758 no_browser: args.no_browser,
3759 no_reconnect: args.no_reconnect,
3760 });
3761 sampling::init(sampling::resolve(args.sampling, one_shot));
3765 elicit::init(elicit::resolve(args.elicitation, one_shot));
3768
3769 let (profile_name, import_label, import_selector, import_http_trust, connection) =
3773 match (imported, profile) {
3774 (Some(imported), _) => (
3775 None,
3776 Some(imported.label()),
3777 Some(imported.selector),
3778 imported.http_trust,
3779 Some(imported.connection),
3780 ),
3781 (None, Some((name, connection))) => (Some(name), None, None, None, Some(connection)),
3782 (None, None) => (None, None, None, None, None),
3783 };
3784 let trust_store_config = config_file.clone();
3787
3788 let aliases = Arc::new(RwLock::new(Aliases::new(
3791 profiles.aliases.clone(),
3792 profile_name
3793 .as_ref()
3794 .and_then(|name| profiles.servers.get(name))
3795 .map(|p| p.aliases.clone())
3796 .unwrap_or_default(),
3797 profile_name.clone(),
3798 config_file,
3799 )));
3800
3801 let connection = match (args.http.clone(), connection) {
3802 (
3803 Some(url),
3804 Some(config::Connection::Http {
3805 bearer,
3806 headers,
3807 oauth,
3808 ..
3809 }),
3810 ) => Some(config::Connection::Http {
3811 url,
3812 bearer,
3813 headers,
3814 oauth,
3815 }),
3816 (Some(url), _) => Some(config::Connection::Http {
3817 url,
3818 bearer: None,
3819 headers: Vec::new(),
3820 oauth: None,
3821 }),
3822 (None, Some(c)) => Some(c),
3823 (None, None) if args.command.is_empty() && args.oauth.is_some() => {
3824 let name = args.oauth.as_deref().expect("guarded above");
3825 let metadata = profiles.oauth.get(name).unwrap_or_else(|| {
3826 exit_with_error(
3827 ExitStatus::Usage,
3828 &format!("no OAuth profile named {name:?}; create it with --login"),
3829 )
3830 });
3831 Some(config::Connection::Http {
3832 url: metadata.url.clone(),
3833 bearer: None,
3834 headers: Vec::new(),
3835 oauth: Some(name.to_string()),
3836 })
3837 }
3838 (None, None) if !args.command.is_empty() => Some(config::Connection::Stdio {
3839 command: args.command.clone(),
3840 env: std::collections::BTreeMap::new(),
3841 cwd: None,
3842 }),
3843 (None, None) => None,
3844 };
3845
3846 let over_http = matches!(connection, Some(config::Connection::Http { .. }));
3847 let starts_disconnected = connection.is_none() && !args.demo && !one_shot && !args.json;
3848 if !over_http && bearer_from_fd.is_some() && !starts_disconnected {
3849 exit_with_error(
3850 ExitStatus::Usage,
3851 "--bearer-fd applies only to HTTP servers and cannot be ignored safely",
3852 );
3853 }
3854 if !over_http && !starts_disconnected && (args.bearer.is_some() || !args.headers.is_empty()) {
3855 eprintln!("warning: --bearer/--header apply only to HTTP servers; ignoring them here");
3856 }
3857 if !over_http && args.oauth.is_some() && !starts_disconnected {
3858 exit_with_error(ExitStatus::Usage, "--oauth applies only to HTTP servers");
3859 }
3860 if let Some(name) = &profile_name
3861 && !quiet
3862 {
3863 println!(
3864 "{}",
3865 tag(Style::new().fg(Color::Cyan), &format!("profile {name}"))
3866 );
3867 } else if let Some(label) = &import_label
3868 && !quiet
3869 {
3870 println!(
3871 "{}",
3872 tag(Style::new().fg(Color::Cyan), &format!("import {label}"))
3873 );
3874 }
3875
3876 let builder = client_builder(args.protocol)
3884 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error.to_string()));
3885 let mut connector: Option<Connector> = None;
3889 let client = if args.demo {
3890 tracing::debug!("connecting to the in-process demo server");
3891 Some(
3892 builder
3893 .connect(
3894 TracingTransport::new(ChannelTransport::new(demo_router())),
3895 make_handler(),
3896 )
3897 .await?,
3898 )
3899 } else {
3900 match connection {
3901 Some(config::Connection::Http {
3902 url,
3903 bearer,
3904 headers,
3905 oauth: profile_oauth,
3906 }) => {
3907 if let (Some(selector), Some(trust)) = (&import_selector, &import_http_trust) {
3911 let mut env_keys = trust.header_env_keys.clone();
3912 if args.http.is_none() {
3913 env_keys.extend(trust.url_env_keys.iter().cloned());
3914 }
3915 let plan = import_trust::ImportPlan::http(
3916 &selector.path,
3917 &selector.entry,
3918 &url,
3919 &trust.header_names,
3920 &env_keys,
3921 )
3922 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
3923 let interactive =
3924 !one_shot && std::io::IsTerminal::is_terminal(&std::io::stdin());
3925 match import_trust::authorize(
3926 &plan,
3927 trust_store_config.as_deref(),
3928 args.trust_import,
3929 interactive,
3930 ) {
3931 import_trust::Decision::Approved => {}
3932 import_trust::Decision::Refused(reason) => {
3933 exit_with_error(ExitStatus::Usage, &reason);
3934 }
3935 }
3936 }
3937 validate_bearer_fd_exclusive(
3938 bearer_from_fd.is_some(),
3939 false,
3940 false,
3941 &[],
3942 bearer.is_some(),
3943 &headers,
3944 false,
3945 profile_oauth.is_some(),
3946 )
3947 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
3948 let explicit_bearer = bearer_from_fd.or_else(|| args.bearer.clone());
3949 let oauth_name = selected_oauth_profile(
3950 args.oauth.as_deref(),
3951 profile_oauth.as_deref(),
3952 explicit_bearer.is_some(),
3953 &args.headers,
3954 );
3955 let cli_authorization = oauth_name.is_none()
3956 && (explicit_bearer.is_some()
3957 || args
3958 .headers
3959 .iter()
3960 .any(|header| raw_header_is_authorization(header)));
3961 if cli_authorization && (args.oauth.is_some() || profile_oauth.is_some()) && !quiet
3962 {
3963 eprintln!(
3964 "warning: explicit --bearer/--header Authorization takes precedence over OAuth"
3965 );
3966 }
3967 let profile_headers = if oauth_name.is_some() {
3968 headers
3969 .into_iter()
3970 .filter(|(name, _)| !name.eq_ignore_ascii_case("authorization"))
3971 .collect::<Vec<_>>()
3972 } else {
3973 headers
3974 };
3975 let config = if oauth_name.is_some() {
3976 build_http_config_with_env(
3977 explicit_bearer,
3978 &args.headers,
3979 None,
3980 &profile_headers,
3981 None,
3982 )
3983 } else {
3984 build_http_config(explicit_bearer, &args.headers, bearer, &profile_headers)
3985 }
3986 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
3987 let oauth = if let Some(name) = oauth_name {
3988 let metadata = profiles.oauth.get(&name).unwrap_or_else(|| {
3989 exit_with_error(
3990 ExitStatus::Usage,
3991 &format!(
3992 "no OAuth profile named {name:?}; create it with \
3993 `mcp-repl --login {name} --http {url}`"
3994 ),
3995 )
3996 });
3997 let interactive = !one_shot && !args.json;
3998 let (flow, store) = oauth_profile::build_flow(
3999 &name,
4000 &url,
4001 metadata,
4002 interactive,
4003 interactive && !args.no_browser,
4004 )
4005 .unwrap_or_else(|error| exit_with_error(ExitStatus::Auth, &error));
4006 if interactive {
4007 tracing::debug!(profile = %name, "OAuth: interactive authorization");
4008 flow.authorize(metadata.scopes.clone())
4009 .await
4010 .map_err(|error| {
4011 tower_mcp::Error::Transport(format!(
4012 "OAuth authorization failed for profile {name:?}: {error}. \
4013 Run `mcp-repl --login {name} --http {url}` to reauthorize"
4014 ))
4015 })?;
4016 } else {
4017 if !store.has_tokens().await.map_err(|error| {
4018 tower_mcp::Error::Transport(format!(
4019 "OAuth credential restore failed for profile {name:?}: {error}"
4020 ))
4021 })? {
4022 return Err(tower_mcp::Error::Transport(format!(
4023 "OAuth login required for profile {name:?}; run \
4024 `mcp-repl --login {name} --http {url}` before using --exec/--json"
4025 )));
4026 }
4027 match flow.begin(metadata.scopes.clone()).await.map_err(|error| {
4028 tower_mcp::Error::Transport(format!(
4029 "OAuth credential restore failed for profile {name:?}: {error}. \
4030 Run `mcp-repl --login {name} --http {url}` to reauthorize"
4031 ))
4032 })? {
4033 OAuthAuthorizationStart::Authorized { .. } => {
4034 tracing::debug!(
4035 profile = %name,
4036 "OAuth: restored a stored credential"
4037 );
4038 }
4039 OAuthAuthorizationStart::Pending(_) => {
4040 return Err(tower_mcp::Error::Transport(format!(
4041 "OAuth login required for profile {name:?}; run \
4042 `mcp-repl --login {name} --http {url}` before using --exec/--json"
4043 )));
4044 }
4045 _ => {
4046 return Err(tower_mcp::Error::Transport(format!(
4047 "OAuth login required for profile {name:?}; run \
4048 `mcp-repl --login {name} --http {url}` before using --exec/--json"
4049 )));
4050 }
4051 }
4052 }
4053 Some(OAuthRuntime {
4054 flow,
4055 scopes: metadata.scopes.clone(),
4056 })
4057 } else {
4058 None
4059 };
4060 if !args.no_reconnect {
4061 connector = Some(http_connector(
4062 url.clone(),
4063 config.clone(),
4064 oauth.clone(),
4065 make_handler.clone(),
4066 args.protocol,
4067 ));
4068 }
4069 Some(
4070 builder
4071 .connect(
4072 TracingTransport::new(http_transport(url, config, oauth)),
4073 make_handler(),
4074 )
4075 .await?,
4076 )
4077 }
4078 Some(config::Connection::Stdio { command, env, cwd }) => {
4079 if let Some(selector) = &import_selector {
4083 let plan = import_trust::ImportPlan::stdio(
4084 &selector.path,
4085 &selector.entry,
4086 &command,
4087 cwd.as_deref(),
4088 &env,
4089 );
4090 let interactive =
4091 !one_shot && std::io::IsTerminal::is_terminal(&std::io::stdin());
4092 match import_trust::authorize(
4093 &plan,
4094 trust_store_config.as_deref(),
4095 args.trust_import,
4096 interactive,
4097 ) {
4098 import_trust::Decision::Approved => {}
4099 import_trust::Decision::Refused(reason) => {
4100 exit_with_error(ExitStatus::Usage, &reason);
4101 }
4102 }
4103 }
4104 let mut cmd = tokio::process::Command::new(&command[0]);
4105 cmd.args(&command[1..]);
4106 cmd.envs(env);
4107 cmd.env_remove("MCP_BEARER");
4112 if let Some(cwd) = cwd {
4113 cmd.current_dir(cwd);
4114 }
4115 cmd.stderr(std::process::Stdio::piped());
4116 let mut transport = StdioClientTransport::spawn_command(&mut cmd).await?;
4117 if let Some(stderr) = transport.take_stderr() {
4118 forward_child_stderr(stderr, async_output.clone());
4119 }
4120 Some(
4121 builder
4122 .connect(TracingTransport::new(transport), make_handler())
4123 .await?,
4124 )
4125 }
4126 None => {
4127 if one_shot || args.json {
4128 exit_with_error(ExitStatus::Usage, &no_target_message());
4129 }
4130 None
4131 }
4132 }
4133 };
4134 let (session, surface) = if let Some(client) = client {
4135 let info = establish_connection(&client, args.protocol).await?;
4136 if let Ok(mut label) = server_label.write() {
4137 label.clone_from(&info.server_info.name);
4138 }
4139 if !quiet {
4140 print_banner(&info);
4141 }
4142 let session = Arc::new(Session::new(client, connector));
4143 let surface = Arc::new(RwLock::new(fetch_surface_initial(&session.client()).await));
4144 if !quiet {
4145 let s = surface.read().unwrap();
4146 print_counts(&s);
4147 let instructions_list_tools = info
4150 .instructions
4151 .as_deref()
4152 .is_some_and(|instr| s.tools.first().is_some_and(|t| instr.contains(&t.name)));
4153 if !instructions_list_tools {
4154 print_tool_overview(&s);
4155 }
4156 if !one_shot {
4157 print_first_run_hint();
4158 }
4159 }
4160 (session, surface)
4161 } else {
4162 if let Ok(mut label) = server_label.write() {
4163 *label = "mcp-repl".to_string();
4164 }
4165 println!("not connected — run `connect` to see targets, or try `connect demo`");
4166 (
4167 Arc::new(Session::disconnected()),
4168 Arc::new(RwLock::new(Surface::default())),
4169 )
4170 };
4171
4172 if one_shot {
4175 let client = session.client();
4176 for cmd in &args.exec {
4177 match run_cancellable(
4178 &session,
4179 &surface,
4180 &aliases,
4181 &jobs,
4182 &schema_contracts,
4183 &connect_runtime,
4184 cmd.trim(),
4185 )
4186 .await
4187 {
4188 Ran::Completed(false) => {}
4189 Ran::Completed(true) | Ran::Cancelled => break,
4192 }
4193 }
4194 let status = exit_status::current().code();
4195 drop(client);
4196 match Arc::try_unwrap(session) {
4202 Ok(session) => session.shutdown().await?,
4203 Err(_) => eprintln!(
4204 "warning: a background task outlived its command; exiting without the orderly \
4205 shutdown"
4206 ),
4207 }
4208 std::process::exit(status);
4209 }
4210
4211 let _surface_subscription = (args.protocol == ProtocolMode::Final).then(|| {
4216 surface_subscription::SurfaceSubscription::start(session.clone(), async_output.clone())
4217 });
4218
4219 let history_capacity = profiles
4222 .repl
4223 .history_capacity
4224 .unwrap_or(editor::DEFAULT_HISTORY_CAPACITY);
4225
4226 let (line_tx, mut line_rx) = tokio::sync::mpsc::channel::<String>(1);
4228 let (ack_tx, ack_rx) = std::sync::mpsc::channel::<()>();
4229 editor::spawn_readline_thread(
4230 server_label.clone(),
4231 surface.clone(),
4232 session.clone(),
4233 aliases.clone(),
4234 tokio::runtime::Handle::current(),
4235 line_tx,
4236 ack_rx,
4237 at_prompt,
4238 async_output
4239 .external_printer()
4240 .expect("interactive sessions have an external printer"),
4241 !args.no_history && history_capacity > 0,
4242 history_capacity,
4243 );
4244
4245 loop {
4246 tokio::select! {
4247 Ok(()) = refresh_rx.changed() => {
4248 tokio::time::sleep(SURFACE_REFRESH_DEBOUNCE).await;
4251 refresh_rx.mark_unchanged();
4252 tracing::debug!("surface change signalled; re-fetching");
4253 let fresh = fetch_surface(&session.client()).await;
4254 async_output.line(format!("{} {}, {}, {}",
4255 tag(Style::new().fg(Color::Cyan), "surface changed"),
4256 plural(fresh.tools.len(), "tool"),
4257 plural(fresh.prompts.len(), "prompt"),
4258 plural(fresh.resources.len(), "resource")));
4259 *surface.write().unwrap() = fresh;
4260 }
4261 maybe_line = line_rx.recv() => {
4262 let Some(line) = maybe_line else { break };
4263 let ran = run_cancellable(
4264 &session,
4265 &surface,
4266 &aliases,
4267 &jobs,
4268 &schema_contracts,
4269 &connect_runtime,
4270 line.trim(),
4271 )
4272 .await;
4273 let _ = ack_tx.send(());
4277 if matches!(ran, Ran::Completed(true)) {
4278 break;
4279 }
4280 }
4281 }
4282 }
4283 Ok(())
4284}
4285
4286enum Ran {
4288 Completed(bool),
4290 Cancelled,
4292}
4293
4294fn backgroundable_tool(surface: &Arc<RwLock<Surface>>, line: &str) -> Option<String> {
4312 let line = line.trim();
4313 if line.ends_with('&') {
4314 return None;
4315 }
4316 let mut words = line.split_whitespace();
4317 let first = words.next()?;
4318 let (word, forced_tool) = match first {
4319 "tool" => (words.next()?, true),
4320 "builtin" => return None,
4321 word => (word, false),
4322 };
4323 if !forced_tool && is_builtin(word) {
4324 return None;
4325 }
4326 let surface = surface.read().ok()?;
4327 let tool = surface.tools.iter().find(|tool| tool.name == word)?;
4328 tool_tags(tool)
4329 .contains(&"task-capable")
4330 .then(|| tool.name.clone())
4331}
4332
4333async fn run_cancellable(
4334 session: &Arc<Session>,
4335 surface: &Arc<RwLock<Surface>>,
4336 aliases: &Arc<RwLock<Aliases>>,
4337 jobs: &Arc<Jobs>,
4338 schema_contracts: &schema_contract::ContractSet,
4339 connect_runtime: &ConnectRuntime,
4340 line: &str,
4341) -> Ran {
4342 tokio::select! {
4343 biased;
4344 quit = handle_line(
4345 session,
4346 surface,
4347 aliases,
4348 jobs,
4349 schema_contracts,
4350 connect_runtime,
4351 line,
4352 ) => {
4353 Ran::Completed(quit)
4354 }
4355 _ = tokio::signal::ctrl_c() => {
4356 note_error(ExitStatus::Cancelled);
4357 if json_output() {
4358 print_json(&error_json(ExitStatus::Cancelled, "cancelled"));
4359 } else {
4360 let mut message = format!("{} cancelled", paint(Style::new().dimmed(), "^C"));
4363 if let Some(tool) = backgroundable_tool(surface, line) {
4368 message.push_str(&paint(
4369 Style::new().dimmed(),
4370 &format!(" `{tool} ... &` runs it as a task instead"),
4371 ));
4372 }
4373 eprintln!("{message}");
4374 }
4375 Ran::Cancelled
4376 }
4377 }
4378}
4379
4380#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4381enum CommandNamespace {
4382 Automatic,
4383 Tool,
4384 Builtin,
4385}
4386
4387async fn handle_line(
4388 session: &Arc<Session>,
4389 surface: &Arc<RwLock<Surface>>,
4390 aliases: &Arc<RwLock<Aliases>>,
4391 jobs: &Arc<Jobs>,
4392 schema_contracts: &schema_contract::ContractSet,
4393 connect_runtime: &ConnectRuntime,
4394 line: &str,
4395) -> bool {
4396 if line.is_empty() {
4397 if json_output() {
4398 report_error(ExitStatus::Usage, "empty command");
4399 }
4400 return false;
4401 }
4402 let expanded;
4406 let line = match aliases.read().unwrap().expand(line) {
4407 Ok(None) => line,
4408 Ok(Some(text)) => {
4409 expanded = text;
4410 expanded.trim()
4411 }
4412 Err(e) => {
4413 report_error(ExitStatus::Usage, &e);
4414 return false;
4415 }
4416 };
4417 let (output, routed) = vars::route(line);
4422 if let Some(path) = &output.filter
4423 && let Err(error) = vars::validate_path(path)
4424 {
4425 report_error(ExitStatus::Usage, &error);
4426 return false;
4427 }
4428 let command = match vars::substitute(routed) {
4429 Ok(c) => c,
4430 Err(e) => {
4431 report_error(ExitStatus::Usage, &e);
4432 return false;
4433 }
4434 };
4435 let line = command.as_str();
4436 let parsed = match command::parse(line) {
4437 Ok(parsed) => parsed,
4438 Err(e) => {
4439 report_error(ExitStatus::Usage, &e);
4440 return false;
4441 }
4442 };
4443 let background = parsed.background;
4444 let tokens: Vec<&str> = parsed.words.iter().map(String::as_str).collect();
4445 if tokens.is_empty() {
4446 if json_output() {
4447 report_error(ExitStatus::Usage, "empty command");
4448 }
4449 return false;
4450 }
4451 let mut cmd = tokens[0];
4452 let mut rest = &tokens[1..];
4453 let namespace = match cmd {
4454 "tool" => CommandNamespace::Tool,
4455 "builtin" => CommandNamespace::Builtin,
4456 _ => CommandNamespace::Automatic,
4457 };
4458 if namespace != CommandNamespace::Automatic {
4459 let Some((name, arguments)) = rest.split_first() else {
4460 command_error(match namespace {
4461 CommandNamespace::Tool => "usage: tool <name> [k=v...]",
4462 CommandNamespace::Builtin => "usage: builtin <name> [args...]",
4463 CommandNamespace::Automatic => unreachable!(),
4464 });
4465 return false;
4466 };
4467 cmd = name;
4468 rest = arguments;
4469 }
4470 COMMAND_RAN.store(true, Ordering::Relaxed);
4471
4472 let (is_builtin_command, is_tool_command) = {
4473 let surface = surface.read().unwrap();
4474 (is_builtin(cmd), is_tool(&surface, cmd))
4475 };
4476 if !session.is_connected() && namespace == CommandNamespace::Tool {
4477 report_error(
4478 ExitStatus::Usage,
4479 "not connected; run `connect` to see targets, or try `connect demo`",
4480 );
4481 return false;
4482 }
4483 match namespace {
4484 CommandNamespace::Automatic if is_builtin_command && is_tool_command => {
4485 report_error(
4486 ExitStatus::Usage,
4487 &format!(
4488 "ambiguous command `{cmd}`: both a server tool and a built-in use that name; \
4489 use `tool {cmd} ...` for the server tool or `builtin {cmd} ...` for the \
4490 built-in"
4491 ),
4492 );
4493 return false;
4494 }
4495 CommandNamespace::Tool if !is_tool_command => {
4496 report_error(
4497 ExitStatus::NoMatch,
4498 &format!("no server tool named `{cmd}` (try `tools`)"),
4499 );
4500 return false;
4501 }
4502 CommandNamespace::Builtin if !is_builtin_command => {
4503 report_error(
4504 ExitStatus::NoMatch,
4505 &format!("no built-in named `{cmd}` (try `help`)"),
4506 );
4507 return false;
4508 }
4509 _ => {}
4510 }
4511
4512 let dispatches_builtin = namespace != CommandNamespace::Tool && is_builtin_command;
4516 if !output.is_plain() && dispatches_builtin && !ROUTABLE_BUILTINS.contains(&cmd) {
4517 let what = match (&output.capture, &output.filter) {
4518 (Some(_), _) => "capture",
4519 _ => "filter",
4520 };
4521 report_error(
4522 ExitStatus::Usage,
4523 &format!(
4524 "cannot {what} the result of `{cmd}`: it reports rather than returning a value. \
4525 Routable commands: {}",
4526 ROUTABLE_BUILTINS.join(", ")
4527 ),
4528 );
4529 return false;
4530 }
4531
4532 if cmd == "connect" && namespace != CommandNamespace::Tool {
4533 match connect_runtime.connect(rest).await {
4534 Ok(connected) => {
4535 let previous = session.replace(connected.client, connected.connector).await;
4536 if let Some(previous) = previous
4537 && let Ok(previous) = Arc::try_unwrap(previous)
4538 && let Err(error) = previous.shutdown().await
4539 {
4540 eprintln!("warning: closing the previous server failed: {error}");
4541 }
4542 let cleared_vars = vars::clear();
4546 let cleared_jobs = jobs.clear();
4547 let cleared_subscriptions = subscribe::clear();
4548 aliases
4549 .write()
4550 .unwrap()
4551 .select_profile(connected.profile_name, connected.profile_aliases);
4552 *surface.write().unwrap() = connected.surface;
4553 if let Ok(mut label) = connect_runtime.server_label.write() {
4554 label.clone_from(&connected.info.server_info.name);
4555 }
4556 if let Some(label) = connected.source_label {
4557 println!("{}", tag(Style::new().fg(Color::Cyan), &label));
4558 }
4559 print_banner(&connected.info);
4560 let current = surface.read().unwrap();
4561 print_counts(¤t);
4562 print_tool_overview(¤t);
4563 drop(current);
4564 let cleared = [
4565 (cleared_vars, "captured variable"),
4566 (cleared_jobs, "background task"),
4567 (cleared_subscriptions, "resource subscription"),
4568 ]
4569 .into_iter()
4570 .filter(|(count, _)| *count > 0)
4571 .map(|(count, noun)| plural(count, noun))
4572 .collect::<Vec<_>>();
4573 if !cleared.is_empty() {
4574 println!(
4575 "{}",
4576 paint(
4577 Style::new().dimmed(),
4578 &format!("server-scoped state cleared: {}", cleared.join(", ")),
4579 )
4580 );
4581 }
4582 }
4583 Err(error) => report_error(error.status, &error.message),
4584 }
4585 return false;
4586 }
4587
4588 let usable_disconnected = matches!(
4589 cmd,
4590 "help"
4591 | "alias"
4592 | "unalias"
4593 | "wire"
4594 | "last"
4595 | "history"
4596 | "vars"
4597 | "unset"
4598 | "quit"
4599 | "exit"
4600 );
4601 if !session.is_connected() && !usable_disconnected {
4602 report_error(
4603 ExitStatus::Usage,
4604 "not connected; run `connect` to see targets, or try `connect demo`",
4605 );
4606 return false;
4607 }
4608 let client = session.try_client();
4609
4610 if namespace == CommandNamespace::Tool {
4611 dispatch_direct_tool(
4612 session,
4613 surface,
4614 jobs,
4615 schema_contracts,
4616 cmd,
4617 rest,
4618 background,
4619 &output,
4620 )
4621 .await;
4622 return false;
4623 }
4624
4625 match cmd {
4626 "tool" | "builtin" => {
4627 command_error(if cmd == "tool" {
4628 "usage: tool <name> [k=v...]"
4629 } else {
4630 "usage: builtin <name> [args...]"
4631 });
4632 }
4633 "quit" | "exit" => {
4634 if json_output() {
4635 print_json(&serde_json::json!({ "exit": true }));
4636 }
4637 return true;
4638 }
4639 "help" => {
4640 if let Some(name) = rest.first()
4643 && let Some(help) = builtin_help(name)
4644 {
4645 if json_output() {
4646 print_json(&serde_json::json!({
4647 "name": help.name,
4648 "usage": help.usage,
4649 "description": help.description,
4650 "details": help.details,
4651 "examples": help.examples,
4652 }));
4653 } else {
4654 print_builtin_help(help);
4655 }
4656 return false;
4657 }
4658 if let Some(name) = rest.first() {
4659 report_error_with_hint(
4660 ExitStatus::NoMatch,
4661 &format!("no built-in named `{name}` (try `help` or `describe {name}`)"),
4662 find::did_you_mean(&surface.read().unwrap(), name).as_deref(),
4663 );
4664 return false;
4665 }
4666 if json_output() {
4667 let s = surface.read().unwrap();
4668 print_json(&serde_json::json!({
4669 "builtins": BUILTINS
4670 .iter()
4671 .map(|(name, description)| serde_json::json!({
4672 "name": name,
4673 "description": description,
4674 }))
4675 .collect::<Vec<_>>(),
4676 "tools": s.tools,
4677 }));
4678 return false;
4679 }
4680 println!("built-ins:");
4681 println!(" connect <target> connect or switch servers");
4682 println!(" tools | prompts | resources | templates list the server surface");
4683 println!(" find [flags] <keyword> search the surface");
4684 println!(" describe <name> schemas and metadata");
4685 println!(" snapshot <name> [path] export a schema contract");
4686 println!(" validate <path> [mode] check a schema contract");
4687 println!(" read <uri> [--out <path>] read a resource");
4688 println!(" subscribe <uri> | unsubscribe <uri> watch a resource for updates");
4689 println!(" subscriptions list active subscriptions");
4690 println!(" prompt <name> [k=v...] get a prompt");
4691 println!(" call <tool> <json> call a tool with raw JSON");
4692 println!(" bench <tool> [k=v...] [--n N] [--concurrency C] time repeated calls");
4693 println!(" <tool> [k=v...] call a tool (schema-coerced)");
4694 println!(" tool <name> [k=v...] force a server tool");
4695 println!(" builtin <name> [args...] force a REPL built-in");
4696 println!(" <tool> [k=v...] & run task-augmented (SEP-2663)");
4697 println!(" jobs | task <id> | wait <id> | cancel <id> manage tasks");
4698 println!(" alias [<name>=<expansion>] | unalias <name> command aliases");
4699 println!(" wire [on|off] trace raw JSON-RPC frames");
4700 println!(" last reprint the previous exchange");
4701 println!(
4702 " vars | unset <name> list or clear captured variables"
4703 );
4704 println!(
4705 " name = <cmd> [| <path>] capture a result (filter with | path)"
4706 );
4707 println!(" $name.path in args reference a captured value");
4708 println!(" ping | refresh | info | quit");
4709 println!(" help <command> explain one built-in");
4710 let s = surface.read().unwrap();
4711 if !s.tools.is_empty() {
4712 println!("tools:");
4713 for t in &s.tools {
4714 println!(
4715 " {} {}",
4716 style::column(Style::new().fg(Color::Green), &sanitize(&t.name), 24),
4717 sanitize(t.description.as_deref().unwrap_or(""))
4718 );
4719 }
4720 }
4721 }
4722 "tools" | "prompts" | "resources" | "templates" => {
4723 let s = surface.read().unwrap();
4724 let what = if cmd == "templates" {
4728 "resource templates"
4729 } else {
4730 cmd
4731 };
4732 if s.is_unavailable(what) {
4733 report_error(
4734 ExitStatus::Transport,
4735 &format!(
4736 "the {what} listing is unavailable: it could not be read from this \
4737 server (try `refresh`)"
4738 ),
4739 );
4740 return false;
4741 }
4742 if !output.is_plain() || json_output() {
4743 let v = match cmd {
4744 "tools" => serde_json::to_value(&s.tools),
4745 "prompts" => serde_json::to_value(&s.prompts),
4746 "resources" => serde_json::to_value(&s.resources),
4747 _ => serde_json::to_value(&s.templates),
4748 }
4749 .unwrap_or_default();
4750 emit_value(v, &output, || unreachable!("plain output handled below"));
4751 return false;
4752 }
4753 let full = rest.contains(&"--full");
4756 let limit = if full { None } else { listing_limit() };
4757 match cmd {
4758 "tools" => {
4759 let total = s.tools.len();
4760 let shown = limit.unwrap_or(total).min(total);
4761 for t in s.tools.iter().take(shown) {
4762 println!(
4763 "{} {}{}",
4764 style::column(Style::new().fg(Color::Green), &sanitize(&t.name), 24),
4765 sanitize(t.description.as_deref().unwrap_or("")),
4766 tool_tag_suffix(t)
4767 );
4768 }
4769 note_truncation(shown, total, "tools --full");
4770 }
4771 "prompts" => {
4772 let total = s.prompts.len();
4773 let shown = limit.unwrap_or(total).min(total);
4774 for p in s.prompts.iter().take(shown) {
4775 let args: Vec<String> = p
4776 .arguments
4777 .iter()
4778 .map(|a| {
4779 if a.required {
4780 format!("<{}>", sanitize(&a.name))
4781 } else {
4782 format!("[{}]", sanitize(&a.name))
4783 }
4784 })
4785 .collect();
4786 println!(
4787 "{} {} {}",
4788 style::column(Style::new().fg(Color::Green), &sanitize(&p.name), 24),
4789 paint(Style::new().fg(Color::Cyan), &args.join(" ")),
4790 sanitize(p.description.as_deref().unwrap_or(""))
4791 );
4792 }
4793 note_truncation(shown, total, "prompts --full");
4794 }
4795 "resources" => {
4796 let total = s.resources.len();
4797 let shown = limit.unwrap_or(total).min(total);
4798 for r in s.resources.iter().take(shown) {
4799 println!(
4800 "{} {}",
4801 style::column(Style::new().fg(Color::Green), &sanitize(&r.uri), 40),
4802 sanitize(&r.name)
4803 );
4804 }
4805 note_truncation(shown, total, "resources --full");
4806 if !s.templates.is_empty() {
4809 println!(
4810 "{}",
4811 paint(
4812 Style::new().dimmed(),
4813 &format!(
4814 "(+ {} resource template(s) with variables, see `templates`)",
4815 s.templates.len()
4816 )
4817 )
4818 );
4819 }
4820 }
4821 _ => {
4822 let total = s.templates.len();
4823 let shown = limit.unwrap_or(total).min(total);
4824 for t in s.templates.iter().take(shown) {
4825 println!(
4826 "{} {}",
4827 style::column(
4828 Style::new().fg(Color::Green),
4829 &sanitize(&t.uri_template),
4830 40
4831 ),
4832 sanitize(&t.name)
4833 );
4834 }
4835 note_truncation(shown, total, "templates --full");
4836 if !s.resources.is_empty() {
4837 println!(
4838 "{}",
4839 paint(
4840 Style::new().dimmed(),
4841 &format!(
4842 "(+ {} concrete resource(s), see `resources`)",
4843 s.resources.len()
4844 )
4845 )
4846 );
4847 }
4848 }
4849 }
4850 }
4851 "find" => {
4852 match find::parse_query(rest) {
4855 Ok(query) => print_find(&surface.read().unwrap(), &query, &output),
4856 Err(message) => {
4857 command_error(&message);
4858 return false;
4859 }
4860 }
4861 }
4862 "describe" => {
4863 let Some(name) = rest.first() else {
4864 command_error("usage: describe <tool|prompt|resource|template>");
4865 return false;
4866 };
4867 let surface = surface.read().unwrap();
4868 if !output.is_plain() || json_output() {
4869 match describe_value(&surface, name) {
4870 Some(value) => {
4871 emit_value(value, &output, || unreachable!("plain handled below"))
4872 }
4873 None => report_error_with_hint(
4874 ExitStatus::NoMatch,
4875 &format!("nothing on the surface named `{name}`"),
4876 find::did_you_mean(&surface, name).as_deref(),
4877 ),
4878 }
4879 } else {
4880 describe(&surface, name);
4881 }
4882 }
4883 "snapshot" => {
4884 let Some(name) = rest.first() else {
4885 command_error("usage: snapshot <tool|prompt> [path]");
4886 return false;
4887 };
4888 if rest.len() > 2 {
4889 command_error("usage: snapshot <tool|prompt> [path]");
4890 return false;
4891 }
4892 let snapshot = {
4893 let surface = surface.read().unwrap();
4894 schema_contract::Snapshot::from_surface(&surface.tools, &surface.prompts, name)
4895 };
4896 let snapshot = match snapshot {
4897 Ok(snapshot) => snapshot,
4898 Err(error) => {
4899 report_error(ExitStatus::Usage, &error);
4900 return false;
4901 }
4902 };
4903 let Some(snapshot) = snapshot else {
4904 report_error(
4905 ExitStatus::NoMatch,
4906 &format!("no tool or prompt named `{name}`"),
4907 );
4908 return false;
4909 };
4910 if let Some(path) = rest.get(1) {
4911 let path = std::path::Path::new(path);
4912 match snapshot.write(path) {
4913 Ok(()) if json_output() => print_json(&serde_json::json!({
4914 "kind": snapshot.kind,
4915 "name": snapshot.name,
4916 "path": path,
4917 })),
4918 Ok(()) => println!(
4919 "saved {} {:?} schema snapshot to {}",
4920 snapshot.kind,
4921 snapshot.name,
4922 path.display()
4923 ),
4924 Err(error) => report_error(ExitStatus::Usage, &error),
4925 }
4926 } else if json_output() {
4927 print_json(&snapshot.canonical_value());
4928 } else {
4929 print!("{}", snapshot.to_pretty_json());
4930 }
4931 }
4932 "validate" => {
4933 let Some(path) = rest.first() else {
4934 command_error("usage: validate <snapshot-path> [strict|compatible|ignore]");
4935 return false;
4936 };
4937 if rest.len() > 2 {
4938 command_error("usage: validate <snapshot-path> [strict|compatible|ignore]");
4939 return false;
4940 }
4941 let mode = match rest.get(1) {
4942 Some(mode) => match schema_contract::ValidationMode::from_str(mode, true) {
4943 Ok(mode) => mode,
4944 Err(_) => {
4945 command_error(
4946 "validation mode must be `strict`, `compatible`, or `ignore`",
4947 );
4948 return false;
4949 }
4950 },
4951 None => schema_contracts.mode(),
4952 };
4953 let snapshot = match schema_contract::Snapshot::load(std::path::Path::new(path)) {
4954 Ok(snapshot) => snapshot,
4955 Err(error) => {
4956 report_error(ExitStatus::Usage, &error);
4957 return false;
4958 }
4959 };
4960 let current = {
4961 let surface = surface.read().unwrap();
4962 snapshot.matching_surface(&surface.tools, &surface.prompts)
4963 };
4964 let report = schema_contract::validate(&snapshot, current.as_ref(), mode);
4965 render_validation_report(&report, true);
4966 }
4967 "read" => {
4968 let (destination, force, rest) = match parse_read_flags(rest) {
4969 Ok(parsed) => parsed,
4970 Err(message) => {
4971 command_error(&message);
4972 return false;
4973 }
4974 };
4975 let Some(uri) = rest.first().copied() else {
4976 command_error("usage: read <uri> [--out <path>] [--force]");
4977 return false;
4978 };
4979 if let Some(path) = &destination
4980 && !force
4981 && std::path::Path::new(path).exists()
4982 {
4983 command_error(&format!(
4984 "{path} already exists; pass --force to overwrite it"
4985 ));
4986 return false;
4987 }
4988 let started = std::time::Instant::now();
4989 match with_reconnect(
4990 session,
4991 surface,
4992 |c| async move { c.read_resource(uri).await },
4993 )
4994 .await
4995 {
4996 Ok(result) if destination.is_some() => {
4999 let path = destination.clone().unwrap_or_default();
5000 match save_resource(&result, &path) {
5001 Ok(written) => {
5002 if json_output() {
5003 print_json(&serde_json::json!({
5004 "uri": uri,
5005 "path": path,
5006 "bytes": written,
5007 }));
5008 } else {
5009 println!(
5010 "wrote {} to {}",
5011 plural(written, "byte"),
5012 sanitize(&path)
5013 );
5014 }
5015 }
5016 Err(message) => report_error(ExitStatus::Usage, &message),
5017 }
5018 }
5019 Ok(result) if !output.is_plain() => {
5020 emit_result(serde_json::to_value(&result).unwrap_or_default(), &output)
5021 }
5022 Ok(result) if json_output() => {
5023 print_json(&serde_json::to_value(&result).unwrap_or_default())
5024 }
5025 Ok(result) => {
5026 for c in result.contents {
5027 if let Some(text) = c.text {
5028 let is_md = c
5029 .mime_type
5030 .as_deref()
5031 .is_some_and(|m| m.contains("markdown"))
5032 || style::looks_like_markdown(&text);
5033 if style::colors_enabled() && is_md {
5034 println!("{}", style::render_markdown(&text));
5035 } else {
5036 println!("{}", sanitize(&text));
5037 }
5038 } else if let Some(blob) = c.blob {
5039 println!(
5040 "{}",
5041 tag(Style::new(), &format!("binary {} base64 chars", blob.len()))
5042 );
5043 }
5044 }
5045 }
5046 Err(e) => report_mcp_error(&e),
5047 }
5048 if !json_output() {
5049 println!("{}", timing(started.elapsed()));
5050 }
5051 }
5052 "subscribe" | "unsubscribe" => {
5053 let Some(uri) = rest.first() else {
5054 command_error(&format!("usage: {cmd} <uri>"));
5055 return false;
5056 };
5057 handle_subscription(client.as_ref().expect("connected above"), cmd, uri).await;
5058 }
5059 "subscriptions" => {
5060 let active = subscribe::list();
5061 if json_output() {
5062 print_json(&serde_json::json!(active));
5063 return false;
5064 }
5065 if active.is_empty() {
5066 println!("no active subscriptions (try `subscribe <uri>`)");
5067 return false;
5068 }
5069 for uri in &active {
5070 println!("{}", paint(Style::new().fg(Color::Green), &sanitize(uri)));
5071 }
5072 }
5073 "prompt" => {
5074 let Some(name) = rest.first() else {
5075 command_error("usage: prompt <name> [k=v...]");
5076 return false;
5077 };
5078 if !enforce_prompt_contract(schema_contracts, surface, name) {
5079 return false;
5080 }
5081 let prompt_args = match parse_prompt_args(&rest[1..]) {
5082 Ok(arguments) => arguments,
5083 Err(error) => {
5084 report_error(
5085 ExitStatus::Usage,
5086 &format!("invalid arguments for prompt {name:?}: {error}"),
5087 );
5088 return false;
5089 }
5090 };
5091 let started = std::time::Instant::now();
5092 match with_reconnect(session, surface, |c| {
5093 let prompt_args = prompt_args.clone();
5094 async move { c.get_prompt(name, Some(prompt_args)).await }
5095 })
5096 .await
5097 {
5098 Ok(result) if json_output() => {
5099 print_json(&serde_json::to_value(&result).unwrap_or_default())
5100 }
5101 Ok(result) => {
5102 for m in result.messages {
5103 let v = serde_json::to_value(&m).unwrap_or_default();
5104 let role = v.get("role").and_then(|r| r.as_str()).unwrap_or("?");
5105 let text = v
5106 .pointer("/content/text")
5107 .and_then(|t| t.as_str())
5108 .map(str::to_string)
5109 .unwrap_or_else(|| {
5110 v.get("content").map(|c| c.to_string()).unwrap_or_default()
5111 });
5112 println!(
5113 "{} {}",
5114 tag(Style::new().fg(Color::Cyan), &sanitize(role)),
5115 sanitize(&text)
5116 );
5117 }
5118 }
5119 Err(e) => report_mcp_error(&e),
5120 }
5121 if !json_output() {
5122 println!("{}", timing(started.elapsed()));
5123 }
5124 }
5125 "call" => {
5126 let Some(name) = rest.first() else {
5127 command_error("usage: call <tool> <json>");
5128 return false;
5129 };
5130 let json = rest[1..].join(" ");
5131 let arguments: serde_json::Value = match serde_json::from_str(&json) {
5132 Ok(v) => v,
5133 Err(e) => {
5134 report_error(ExitStatus::Usage, &format!("invalid JSON: {e}"));
5135 return false;
5136 }
5137 };
5138 run_tool(
5139 session,
5140 surface,
5141 jobs,
5142 schema_contracts,
5143 name,
5144 arguments,
5145 background,
5146 &output,
5147 )
5148 .await;
5149 }
5150 "bench" => {
5151 handle_bench(
5152 client.as_ref().expect("connected above"),
5153 surface,
5154 schema_contracts,
5155 rest,
5156 background,
5157 )
5158 .await;
5159 }
5160 "jobs" => {
5161 let started = std::time::Instant::now();
5164 if json_output() {
5165 let mut rendered = Vec::new();
5166 for job in jobs.list() {
5167 match client
5168 .as_deref()
5169 .expect("connected above")
5170 .task_get(&job.task_id)
5171 .await
5172 {
5173 Ok(task) => {
5174 jobs.sync(&job.task_id, task.status, task.status_message.clone());
5175 rendered.push(serde_json::json!({
5176 "taskId": job.task_id,
5177 "tool": job.tool,
5178 "task": task,
5179 }));
5180 }
5181 Err(error) => {
5182 let status = ExitStatus::from_mcp_error(&error);
5183 note_error(status);
5184 rendered.push(serde_json::json!({
5185 "taskId": job.task_id,
5186 "tool": job.tool,
5187 "error": error.to_string(),
5188 "kind": status.label(),
5189 "exitStatus": status.code(),
5190 }));
5191 }
5192 }
5193 }
5194 print_json(&serde_json::Value::Array(rendered));
5195 return false;
5196 }
5197 if jobs.is_empty() {
5198 println!(
5199 "{}",
5200 paint(
5201 Style::new().dimmed(),
5202 "no background tasks (run a task-capable tool with a trailing `&`)"
5203 )
5204 );
5205 }
5206 for job in jobs.list() {
5207 match client
5208 .as_deref()
5209 .expect("connected above")
5210 .task_get(&job.task_id)
5211 .await
5212 {
5213 Ok(task) => {
5214 jobs.sync(&job.task_id, task.status, task.status_message.clone());
5215 println!(
5216 "{} {} {}",
5217 sanitize(&job.label()),
5218 sanitize(&job.tool),
5219 paint(task_status_style(task.status), &task.status.to_string())
5220 );
5221 }
5222 Err(error) => {
5223 note_error(ExitStatus::from_mcp_error(&error));
5224 println!(
5225 "{} {} (gone)",
5226 sanitize(&job.label()),
5227 sanitize(&job.tool)
5228 );
5229 }
5230 }
5231 }
5232 if !json_output() {
5233 println!("{}", timing(started.elapsed()));
5234 }
5235 }
5236 "task" | "wait" | "cancel" => {
5240 let started = std::time::Instant::now();
5243 let (wait_limit, rest) = match parse_wait_timeout(cmd, rest) {
5248 Ok(parsed) => parsed,
5249 Err(message) => {
5250 command_error(&message);
5251 return false;
5252 }
5253 };
5254 if cmd == "wait" && rest.is_empty() {
5259 wait_for_all(
5260 client.as_deref().expect("connected above"),
5261 jobs,
5262 wait_limit,
5263 started,
5264 )
5265 .await;
5266 return false;
5267 }
5268 let Some(typed) = rest.first() else {
5269 command_error(&format!("usage: {cmd} <task>"));
5270 return false;
5271 };
5272 let Some(resolved) = jobs.resolve(typed) else {
5275 report_error(
5276 ExitStatus::NoMatch,
5277 &format!(
5278 "no task `{typed}` in this session (run `jobs`; a task id belongs to \
5279 the session that created it)"
5280 ),
5281 );
5282 return false;
5283 };
5284 let id = &resolved.as_str();
5285 if cmd == "task" && rest.get(1).is_some_and(|word| *word == "respond") {
5290 respond_to_task(
5291 client.as_deref().expect("connected above"),
5292 id,
5293 &jobs.label_for(id),
5294 )
5295 .await;
5296 if !json_output() {
5297 println!("{}", timing(started.elapsed()));
5298 }
5299 return false;
5300 }
5301 let outcome = match cmd {
5302 "task" => {
5303 client
5304 .as_deref()
5305 .expect("connected above")
5306 .task_get(id)
5307 .await
5308 }
5309 "wait" => {
5310 wait_for_one(client.as_deref().expect("connected above"), id, wait_limit).await
5311 }
5312 _ => match client
5313 .as_deref()
5314 .expect("connected above")
5315 .task_cancel(id, None)
5316 .await
5317 {
5318 Ok(()) => {
5319 if !json_output() {
5320 println!("cancel acknowledged");
5321 }
5322 client
5323 .as_deref()
5324 .expect("connected above")
5325 .task_get(id)
5326 .await
5327 }
5328 Err(e) => Err(e),
5329 },
5330 };
5331 match outcome {
5332 Ok(task) if json_output() => {
5333 jobs.sync(id, task.status, task.status_message.clone());
5334 if cmd == "wait" {
5335 note_settled_task(&task);
5336 }
5337 print_json(&serde_json::to_value(&task).unwrap_or_default());
5338 }
5339 Ok(task) => {
5340 jobs.sync(id, task.status, task.status_message.clone());
5341 if cmd == "wait" {
5342 note_settled_task(&task);
5343 }
5344 render_task(&task, &jobs.label_for(&task.task_id));
5345 }
5346 Err(e) => report_mcp_error(&e),
5347 }
5348 if !json_output() {
5349 println!("{}", timing(started.elapsed()));
5350 }
5351 }
5352 "alias" | "unalias" => {
5353 let command_line = if namespace == CommandNamespace::Builtin {
5356 line.strip_prefix("builtin").unwrap_or(line).trim_start()
5357 } else {
5358 line
5359 };
5360 let raw = command_line.strip_prefix(cmd).unwrap_or("").trim();
5361 handle_alias(aliases, surface, cmd, raw);
5362 }
5363 "wire" => {
5364 match rest.first().copied() {
5365 Some("on") => wire().set_trace(true),
5366 Some("off") => wire().set_trace(false),
5367 None => {}
5368 Some(other) => {
5369 command_error(&format!("usage: wire [on|off] (got `{other}`)"));
5370 return false;
5371 }
5372 }
5373 let enabled = wire().trace_enabled();
5374 if json_output() {
5375 print_json(&serde_json::json!({ "wire": enabled }));
5376 } else if enabled {
5377 println!("wire tracing on (frames print to stderr)");
5378 } else {
5379 println!("wire tracing off");
5380 }
5381 }
5382 "last" => match wire().last_exchange() {
5385 None => {
5386 note_error(ExitStatus::NoMatch);
5387 if json_output() {
5388 print_json(&error_json(ExitStatus::NoMatch, "no exchange yet"));
5389 } else {
5390 println!("no request has been sent yet");
5391 }
5392 }
5393 Some((request, response)) => {
5394 if json_output() {
5395 print_json(&serde_json::json!({
5396 "request": request.json,
5397 "response": response.map(|r| r.json),
5398 }));
5399 } else {
5400 if !COMMAND_RAN.load(Ordering::Relaxed) {
5405 println!(
5406 "{}",
5407 paint(
5408 Style::new().dimmed(),
5409 "(no command has run yet; this is mcp-repl's own startup traffic)"
5410 )
5411 );
5412 }
5413 println!("{}", wire::render(wire::Direction::Sent, &request));
5414 match response {
5415 Some(response) => {
5416 println!("{}", wire::render(wire::Direction::Received, &response));
5417 }
5418 None => println!("(no response recorded for it)"),
5419 }
5420 }
5421 }
5422 },
5423 "ping" => {
5424 let started = std::time::Instant::now();
5425 match with_deadline(client.as_deref().expect("connected above").ping()).await {
5426 Ok(()) => {
5427 let elapsed = started.elapsed();
5428 if json_output() {
5429 print_json(&serde_json::json!({
5430 "ok": true,
5431 "elapsedMs": elapsed.as_millis(),
5432 }));
5433 } else {
5434 println!(
5435 "{} {}",
5436 paint(Style::new().fg(Color::Green), "ok"),
5437 timing(elapsed)
5438 );
5439 }
5440 }
5441 Err(e) => report_mcp_error(&e),
5442 }
5443 }
5444 "loglevel" => {
5445 let Some(typed) = rest.first() else {
5446 command_error(&format!("usage: loglevel <{}>", LOG_LEVELS.join("|")));
5447 return false;
5448 };
5449 let Some(level) = parse_log_level(typed) else {
5450 report_error(
5454 ExitStatus::Usage,
5455 &format!(
5456 "unknown log level `{}` (levels are {})",
5457 sanitize(typed),
5458 LOG_LEVELS.join(", ")
5459 ),
5460 );
5461 return false;
5462 };
5463 let declared = connection_info(client.as_deref().expect("connected above"))
5467 .await
5468 .is_some_and(|info| info.capabilities.logging.is_some());
5469 if !declared {
5470 report_error(
5471 ExitStatus::Server,
5472 "this server does not declare the `logging` capability, so it has no \
5473 level to set (any notifications it sends arrive regardless)",
5474 );
5475 return false;
5476 }
5477 let started = std::time::Instant::now();
5478 let params = serde_json::json!({ "level": level });
5479 match with_deadline(
5480 client
5481 .as_deref()
5482 .expect("connected above")
5483 .request::<_, serde_json::Value>("logging/setLevel", ¶ms),
5484 )
5485 .await
5486 {
5487 Ok(_) => {
5488 if json_output() {
5489 print_json(&serde_json::json!({ "level": level }));
5490 } else {
5491 println!(
5492 "log level set to {} {}",
5493 paint(log_level_style(level), &level.to_string()),
5494 timing(started.elapsed())
5495 );
5496 }
5497 }
5498 Err(e) => report_mcp_error(&e),
5499 }
5500 }
5501 "refresh" => {
5502 let started = std::time::Instant::now();
5503 let fresh = refresh_surface(session).await;
5504 if json_output() {
5505 print_json(&serde_json::json!({
5506 "tools": fresh.tools.len(),
5507 "prompts": fresh.prompts.len(),
5508 "resources": fresh.resources.len(),
5509 "templates": fresh.templates.len(),
5510 }));
5511 } else {
5512 println!(
5513 "{}, {}, {}, {}",
5514 plural(fresh.tools.len(), "tool"),
5515 plural(fresh.prompts.len(), "prompt"),
5516 plural(fresh.resources.len(), "resource"),
5517 plural(fresh.templates.len(), "template")
5518 );
5519 }
5520 if !json_output() {
5521 println!("{}", timing(started.elapsed()));
5522 }
5523 *surface.write().unwrap() = fresh;
5524 }
5525 "info" => match connection_info(client.as_deref().expect("connected above")).await {
5526 Some(info) => {
5527 if !output.is_plain() || json_output() {
5528 emit_value(
5529 serde_json::json!({
5530 "protocolVersion": info.protocol_version,
5531 "serverInfo": info.server_info,
5532 "capabilities": info.capabilities,
5533 "instructions": info.instructions,
5534 "sampling": sampling::mode().as_str(),
5535 "elicitation": elicit::mode().as_str(),
5536 }),
5537 &output,
5538 || unreachable!("plain output handled below"),
5539 );
5540 return false;
5541 }
5542 print_banner(&info);
5544 print_counts(&surface.read().unwrap());
5545 let caps = serde_json::to_value(&info.capabilities).unwrap_or_default();
5546 println!("capabilities: {}", json_pretty(&caps));
5547 println!(
5549 "{}",
5550 paint(
5551 Style::new().dimmed(),
5552 &format!(
5553 "sampling: {}, elicitation: {}",
5554 sampling::mode().as_str(),
5555 elicit::mode().as_str()
5556 )
5557 )
5558 );
5559 }
5560 None => report_error(ExitStatus::Transport, "not initialized"),
5561 },
5562 "history" => {
5563 const DEFAULT_SHOWN: usize = 20;
5564 let limit = match rest.first() {
5565 None => DEFAULT_SHOWN,
5566 Some(raw) => match raw.parse::<usize>() {
5567 Ok(n) if n > 0 => n,
5568 _ => {
5569 command_error(&format!("usage: history [count] (got `{raw}`)"));
5570 return false;
5571 }
5572 },
5573 };
5574 let entries = editor::recent_history(limit);
5575 if json_output() {
5576 print_json(&serde_json::json!(entries));
5577 } else if entries.is_empty() {
5578 println!(
5579 "{}",
5580 paint(
5581 Style::new().dimmed(),
5582 "no history yet (it persists across sessions unless --no-history)"
5583 )
5584 );
5585 } else {
5586 for line in &entries {
5587 println!("{}", sanitize(line));
5588 }
5589 println!(
5590 "{}",
5591 paint(
5592 Style::new().dimmed(),
5593 "Ctrl-R searches history interactively"
5594 )
5595 );
5596 }
5597 }
5598 "vars" => {
5599 let all = vars::list();
5600 if json_output() {
5601 let map: serde_json::Map<String, serde_json::Value> = all.into_iter().collect();
5602 print_json(&serde_json::Value::Object(map));
5603 } else if all.is_empty() {
5604 println!(
5605 "{}",
5606 paint(
5607 Style::new().dimmed(),
5608 "no variables (capture one with `name = <command>`)"
5609 )
5610 );
5611 } else {
5612 for (name, value) in all {
5613 println!(
5614 "{} {}",
5615 paint(Style::new().fg(Color::Cyan), &format!("${name} =")),
5616 value_summary(&value)
5617 );
5618 }
5619 }
5620 }
5621 "unset" => match rest.first() {
5622 Some(name) => {
5623 if vars::unset(name) {
5624 if json_output() {
5625 print_json(&serde_json::json!({ "unset": name }));
5626 } else {
5627 println!("unset ${name}");
5628 }
5629 } else {
5630 command_error(&format!("no such variable `${name}`"));
5631 }
5632 }
5633 None => command_error("usage: unset <name>"),
5634 },
5635 tool_name => {
5636 dispatch_direct_tool(
5637 session,
5638 surface,
5639 jobs,
5640 schema_contracts,
5641 tool_name,
5642 rest,
5643 background,
5644 &output,
5645 )
5646 .await;
5647 }
5648 }
5649 false
5650}
5651
5652#[allow(clippy::too_many_arguments)]
5653async fn dispatch_direct_tool(
5654 session: &Arc<Session>,
5655 surface: &Arc<RwLock<Surface>>,
5656 jobs: &Arc<Jobs>,
5657 schema_contracts: &schema_contract::ContractSet,
5658 tool_name: &str,
5659 rest: &[&str],
5660 background: bool,
5661 output: &vars::Output,
5662) {
5663 let schema = {
5664 let surface = surface.read().unwrap();
5665 surface
5666 .tools
5667 .iter()
5668 .find(|tool| tool.name == tool_name)
5669 .map(|tool| tool.input_schema.clone())
5670 };
5671 let Some(schema) = schema else {
5672 let suggestion = find::did_you_mean(&surface.read().unwrap(), tool_name);
5675 let message = match suggestion {
5676 Some(_) => format!("unknown command: {tool_name}"),
5677 None => format!("unknown command: {tool_name} (try `help`)"),
5678 };
5679 report_error_with_hint(ExitStatus::Usage, &message, suggestion.as_deref());
5680 return;
5681 };
5682 let arguments = match parse_kv_args(&schema, rest) {
5683 Ok(arguments) => arguments,
5684 Err(error) => {
5685 report_error(
5686 ExitStatus::Usage,
5687 &format!("invalid arguments for tool {tool_name:?}: {error}"),
5688 );
5689 return;
5690 }
5691 };
5692 run_tool(
5693 session,
5694 surface,
5695 jobs,
5696 schema_contracts,
5697 tool_name,
5698 arguments,
5699 background,
5700 output,
5701 )
5702 .await;
5703}
5704
5705async fn handle_bench(
5710 client: &Arc<McpClient>,
5711 surface: &Arc<RwLock<Surface>>,
5712 schema_contracts: &schema_contract::ContractSet,
5713 rest: &[&str],
5714 background: bool,
5715) {
5716 if background {
5719 command_error("bench cannot run task-augmented; drop the trailing `&`");
5720 return;
5721 }
5722 let plan = match bench::parse(rest) {
5723 Ok(plan) => plan,
5724 Err(e) => {
5725 command_error(&e);
5726 return;
5727 }
5728 };
5729 let schema = {
5730 let s = surface.read().unwrap();
5731 s.tools
5732 .iter()
5733 .find(|t| t.name == plan.tool)
5734 .map(|t| t.input_schema.clone())
5735 };
5736 let Some(schema) = schema else {
5737 report_error_with_hint(
5740 ExitStatus::NoMatch,
5741 &format!("no tool named `{}` (try `tools`)", plan.tool),
5742 find::did_you_mean(&surface.read().unwrap(), &plan.tool).as_deref(),
5743 );
5744 return;
5745 };
5746 if !enforce_tool_contract(schema_contracts, surface, &plan.tool) {
5747 return;
5748 }
5749 let arg_tokens: Vec<&str> = plan.args.iter().map(String::as_str).collect();
5750 let arguments = match parse_kv_args(&schema, &arg_tokens) {
5751 Ok(arguments) => arguments,
5752 Err(error) => {
5753 report_error(
5754 ExitStatus::Usage,
5755 &format!("invalid arguments for tool {:?}: {error}", plan.tool),
5756 );
5757 return;
5758 }
5759 };
5760
5761 let outcome = bench::run(client, &plan.tool, arguments, plan.n, plan.concurrency).await;
5762 if outcome.errors > 0 {
5765 note_error(ExitStatus::Server);
5766 }
5767 if json_output() {
5768 print_json(&bench::render_json(&plan, &outcome));
5769 return;
5770 }
5771 println!("{}", bench::render(&plan, &outcome));
5772 if let Some(message) = &outcome.first_error {
5773 println!(
5774 "{} {}",
5775 tag(Style::new().fg(Color::Red), "first error"),
5776 sanitize(message)
5777 );
5778 }
5779 println!("{}", timing(outcome.total));
5780}
5781
5782async fn handle_subscription(client: &Arc<McpClient>, cmd: &str, uri: &str) {
5786 if cmd == "subscribe"
5789 && let Some(info) = connection_info(client).await
5790 && !subscribe::server_supports(
5791 &serde_json::to_value(&info.capabilities).unwrap_or_default(),
5792 )
5793 {
5794 eprintln!(
5795 "warning: {} does not advertise resources.subscribe; the request will \
5796 probably be rejected",
5797 info.server_info.name
5798 );
5799 }
5800 let started = std::time::Instant::now();
5801 let outcome = if cmd == "subscribe" {
5802 client.subscribe_resource(uri).await
5803 } else {
5804 client.unsubscribe_resource(uri).await
5805 };
5806 match outcome {
5807 Ok(()) => {
5808 let changed = if cmd == "subscribe" {
5809 subscribe::add(uri)
5810 } else {
5811 subscribe::remove(uri)
5812 };
5813 if json_output() {
5814 print_json(&serde_json::json!({
5815 cmd: uri,
5816 "alreadyInEffect": !changed,
5817 }));
5818 } else {
5819 let note = if changed {
5820 String::new()
5821 } else {
5822 format!(" {}", paint(Style::new().dimmed(), "(already in effect)"))
5823 };
5824 println!("{cmd}d {}{note}", paint(Style::new().fg(Color::Green), uri));
5825 }
5826 }
5827 Err(e) => report_mcp_error(&e),
5828 }
5829 if !json_output() {
5830 println!("{}", timing(started.elapsed()));
5831 }
5832}
5833
5834fn handle_alias(
5840 aliases: &Arc<RwLock<Aliases>>,
5841 surface: &Arc<RwLock<Surface>>,
5842 cmd: &str,
5843 raw: &str,
5844) {
5845 let (global, rest) = match raw.strip_prefix("--global") {
5848 Some(r) if r.is_empty() || r.starts_with(char::is_whitespace) => (true, r.trim_start()),
5849 _ => (false, raw),
5850 };
5851 let rest = rest.trim();
5852
5853 if cmd == "unalias" {
5854 if rest.is_empty() || rest.contains(char::is_whitespace) {
5855 command_error("usage: unalias [--global] <name>");
5856 return;
5857 }
5858 match aliases.write().unwrap().remove(rest, global) {
5859 Ok(applied) => {
5860 report_alias_warning(applied.warning.as_deref());
5861 if json_output() {
5862 print_json(&serde_json::json!({
5863 "removed": rest,
5864 "expansion": applied.previous,
5865 "scope": applied.scope.label(),
5866 }));
5867 } else {
5868 println!(
5869 "removed {} {}",
5870 paint(Style::new().fg(Color::Cyan), rest),
5871 paint(
5872 Style::new().dimmed(),
5873 &format!("({})", applied.scope.label())
5874 )
5875 );
5876 }
5877 }
5878 Err(e) => command_error(&e),
5879 }
5880 return;
5881 }
5882
5883 if rest.is_empty() {
5885 let aliases = aliases.read().unwrap();
5886 let entries = aliases.entries();
5887 if json_output() {
5888 let rendered: Vec<serde_json::Value> = entries
5889 .iter()
5890 .map(|e| {
5891 serde_json::json!({
5892 "name": e.name,
5893 "expansion": e.expansion,
5894 "scope": e.scope.label(),
5895 })
5896 })
5897 .collect();
5898 print_json(&serde_json::Value::Array(rendered));
5899 return;
5900 }
5901 if entries.is_empty() {
5902 println!("no aliases defined (try `alias t=tools`)");
5903 return;
5904 }
5905 let width = entries.iter().map(|e| e.name.len()).max().unwrap_or(0);
5906 for e in &entries {
5907 println!(
5908 "{} {} {}",
5909 style::column(Style::new().fg(Color::Cyan), &e.name, width),
5910 e.expansion,
5911 paint(Style::new().dimmed(), &format!("({})", e.scope.label()))
5912 );
5913 }
5914 return;
5915 }
5916
5917 let Some((name, expansion)) = rest.split_once('=') else {
5919 let aliases = aliases.read().unwrap();
5920 match aliases.lookup(rest) {
5921 Some((expansion, scope)) if json_output() => print_json(&serde_json::json!({
5922 "name": rest,
5923 "expansion": expansion,
5924 "scope": scope.label(),
5925 })),
5926 Some((expansion, scope)) => println!(
5927 "{} = {} {}",
5928 paint(Style::new().fg(Color::Cyan), rest),
5929 expansion,
5930 paint(Style::new().dimmed(), &format!("({})", scope.label()))
5931 ),
5932 None => command_error(&format!(
5933 "no alias named `{rest}` (define one with `alias {rest}=<expansion>`)"
5934 )),
5935 }
5936 return;
5937 };
5938 let name = name.trim();
5939 match aliases
5940 .write()
5941 .unwrap()
5942 .define(name, expansion.trim(), global)
5943 {
5944 Ok(applied) => {
5945 report_alias_warning(applied.warning.as_deref());
5946 if json_output() {
5947 print_json(&serde_json::json!({
5948 "name": name,
5949 "expansion": expansion.trim(),
5950 "scope": applied.scope.label(),
5951 "replaced": applied.previous,
5952 }));
5953 return;
5954 }
5955 println!(
5956 "{} = {} {}",
5957 paint(Style::new().fg(Color::Cyan), name),
5958 expansion.trim(),
5959 paint(
5960 Style::new().dimmed(),
5961 &format!("({})", applied.scope.label())
5962 )
5963 );
5964 if surface.read().unwrap().tools.iter().any(|t| t.name == name) {
5967 println!(
5968 "{}",
5969 paint(
5970 Style::new().dimmed(),
5971 &format!("note: this shadows the tool `{name}` on this server")
5972 )
5973 );
5974 }
5975 }
5976 Err(e) => command_error(&e),
5977 }
5978}
5979
5980fn report_alias_warning(warning: Option<&str>) {
5983 if let Some(w) = warning {
5984 eprintln!("warning: {w}");
5985 }
5986}
5987
5988fn command_error(message: &str) {
5989 report_error(ExitStatus::Usage, message);
5990}
5991
5992fn render_validation_report(
5995 report: &schema_contract::ValidationReport,
5996 render_success: bool,
5997) -> bool {
5998 if report.compatible && !render_success {
5999 return true;
6000 }
6001 if !report.compatible {
6002 note_error(ExitStatus::NoMatch);
6003 }
6004 if json_output() {
6005 print_json(&serde_json::to_value(report).unwrap_or_default());
6006 } else if report.compatible {
6007 println!(
6008 "{} {:?} is compatible under {} validation",
6009 report.kind, report.name, report.mode
6010 );
6011 } else {
6012 println!(
6013 "{} {:?} is incompatible under {} validation:",
6014 report.kind, report.name, report.mode
6015 );
6016 for issue in &report.issues {
6017 println!(" {} [{}] {}", issue.path, issue.code, issue.message);
6018 }
6019 }
6020 report.compatible
6021}
6022
6023fn enforce_tool_contract(
6024 contracts: &schema_contract::ContractSet,
6025 surface: &Arc<RwLock<Surface>>,
6026 name: &str,
6027) -> bool {
6028 let report = {
6029 let surface = surface.read().unwrap();
6030 surface
6031 .tools
6032 .iter()
6033 .find(|definition| definition.name == name)
6034 .and_then(|definition| contracts.check_tool(definition))
6035 };
6036 report
6037 .as_ref()
6038 .is_none_or(|report| render_validation_report(report, false))
6039}
6040
6041fn enforce_prompt_contract(
6042 contracts: &schema_contract::ContractSet,
6043 surface: &Arc<RwLock<Surface>>,
6044 name: &str,
6045) -> bool {
6046 let report = {
6047 let surface = surface.read().unwrap();
6048 surface
6049 .prompts
6050 .iter()
6051 .find(|definition| definition.name == name)
6052 .and_then(|definition| contracts.check_prompt(definition))
6053 };
6054 report
6055 .as_ref()
6056 .is_none_or(|report| render_validation_report(report, false))
6057}
6058
6059fn describe_value(surface: &Surface, name: &str) -> Option<serde_json::Value> {
6060 surface
6061 .tools
6062 .iter()
6063 .find(|definition| definition.name == name)
6064 .map(|definition| {
6065 serde_json::json!({
6066 "kind": "tool",
6067 "definition": definition,
6068 })
6069 })
6070 .or_else(|| {
6071 surface
6072 .prompts
6073 .iter()
6074 .find(|definition| definition.name == name)
6075 .map(|definition| {
6076 serde_json::json!({
6077 "kind": "prompt",
6078 "definition": definition,
6079 })
6080 })
6081 })
6082 .or_else(|| {
6083 surface
6084 .resources
6085 .iter()
6086 .find(|definition| definition.name == name || definition.uri == name)
6087 .map(|definition| {
6088 serde_json::json!({
6089 "kind": "resource",
6090 "definition": definition,
6091 })
6092 })
6093 })
6094 .or_else(|| {
6095 surface
6096 .templates
6097 .iter()
6098 .find(|definition| definition.name == name || definition.uri_template == name)
6099 .map(|definition| {
6100 serde_json::json!({
6101 "kind": "resourceTemplate",
6102 "definition": definition,
6103 })
6104 })
6105 })
6106 .or_else(|| {
6107 builtin_help(name).map(|help| {
6108 serde_json::json!({
6109 "kind": "builtin",
6110 "name": help.name,
6111 "usage": help.usage,
6112 "description": help.description,
6113 "details": help.details,
6114 "examples": help.examples,
6115 })
6116 })
6117 })
6118}
6119
6120fn parse_read_flags<'a>(rest: &[&'a str]) -> Result<(Option<String>, bool, Vec<&'a str>), String> {
6126 let mut destination = None;
6127 let mut force = false;
6128 let mut remaining = Vec::new();
6129 let mut tokens = rest.iter().copied();
6130 while let Some(token) = tokens.next() {
6131 match token {
6132 "--force" => force = true,
6133 "--out" => {
6134 let path = tokens
6135 .next()
6136 .ok_or_else(|| "--out needs a path".to_string())?;
6137 destination = Some(path.to_string());
6138 }
6139 _ => match token.strip_prefix("--out=") {
6140 Some(path) if !path.is_empty() => destination = Some(path.to_string()),
6141 Some(_) => return Err("--out needs a path".to_string()),
6142 None if token.starts_with("--") => {
6143 return Err(format!(
6144 "unknown option `{token}` (read takes --out and --force)"
6145 ));
6146 }
6147 None => remaining.push(token),
6148 },
6149 }
6150 }
6151 Ok((destination, force, remaining))
6152}
6153
6154fn save_resource(
6160 result: &tower_mcp::protocol::ReadResourceResult,
6161 path: &str,
6162) -> Result<usize, String> {
6163 let mut contents = result.contents.iter();
6164 let (Some(content), None) = (contents.next(), contents.next()) else {
6165 return Err(format!(
6166 "the resource returned {} contents; --out writes a single one",
6167 result.contents.len()
6168 ));
6169 };
6170 let bytes: Vec<u8> = match (&content.text, &content.blob) {
6171 (Some(text), _) => text.as_bytes().to_vec(),
6172 (None, Some(blob)) => {
6173 use base64::Engine;
6174 base64::engine::general_purpose::STANDARD
6175 .decode(blob)
6176 .map_err(|e| format!("the server sent a blob that is not valid base64: {e}"))?
6177 }
6178 (None, None) => return Err("the resource returned no content".to_string()),
6179 };
6180 crate::secure_file::write_bytes(std::path::Path::new(path), &bytes)
6182 .map_err(|e| format!("could not write {path}: {e}"))?;
6183 Ok(bytes.len())
6184}
6185
6186fn parse_wait_timeout<'a>(
6187 cmd: &str,
6188 rest: &[&'a str],
6189) -> Result<(Option<Duration>, Vec<&'a str>), String> {
6190 let mut limit = None;
6191 let mut remaining = Vec::new();
6192 let mut tokens = rest.iter().copied();
6193 while let Some(token) = tokens.next() {
6194 let value = match token.strip_prefix("--timeout") {
6195 None => {
6196 remaining.push(token);
6197 continue;
6198 }
6199 Some("") => tokens
6200 .next()
6201 .ok_or_else(|| format!("usage: {cmd} <task-id> [--timeout <seconds>]"))?,
6202 Some(rest) => rest
6203 .strip_prefix('=')
6204 .ok_or_else(|| format!("unknown flag `{token}` for {cmd}"))?,
6205 };
6206 if cmd != "wait" {
6207 return Err(format!(
6208 "--timeout applies to `wait`, not `{cmd}` (it is a single request, bounded by the global --timeout)"
6209 ));
6210 }
6211 let secs: u64 = value
6212 .parse()
6213 .map_err(|_| format!("--timeout expects seconds, got `{value}`"))?;
6214 limit = (secs > 0).then(|| Duration::from_secs(secs));
6215 }
6216 Ok((limit, remaining))
6217}
6218
6219pub(crate) fn tool_tags(tool: &ToolDefinition) -> Vec<&'static str> {
6225 let mut tags = Vec::new();
6226 if let Some(a) = &tool.annotations {
6227 if a.read_only_hint {
6228 tags.push("read-only");
6229 }
6230 if a.destructive_hint && !a.read_only_hint {
6232 tags.push("destructive");
6233 }
6234 if a.idempotent_hint {
6235 tags.push("idempotent");
6236 }
6237 if a.open_world_hint {
6238 tags.push("open-world");
6239 }
6240 }
6241 if let Some(execution) = &tool.execution {
6242 let v = serde_json::to_value(execution).unwrap_or_default();
6243 match v.get("taskSupport").and_then(|m| m.as_str()) {
6244 Some("required") => tags.push("task-only"),
6245 Some("optional") => tags.push("task-capable"),
6246 _ => {}
6247 }
6248 }
6249 tags
6250}
6251
6252fn tool_tag_suffix(tool: &ToolDefinition) -> String {
6255 let tags = tool_tags(tool);
6256 if tags.is_empty() {
6257 return String::new();
6258 }
6259 format!(
6260 " {}",
6261 paint(Style::new().dimmed(), &format!("[{}]", tags.join(" ")))
6262 )
6263}
6264
6265fn example_invocation(name: &str, schema: &serde_json::Value) -> String {
6270 const SHOWN: usize = 4;
6271 let required: Vec<&str> = schema
6272 .get("required")
6273 .and_then(|r| r.as_array())
6274 .map(|r| r.iter().filter_map(|v| v.as_str()).collect())
6275 .unwrap_or_default();
6276 let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) else {
6277 return sanitize(name).into_owned();
6278 };
6279 let placeholder = |key: &str| -> String {
6280 let target = properties
6284 .get(key)
6285 .map(|property| editor::resolve_ref(schema, property));
6286 let ty = target
6287 .and_then(|t| t.get("type"))
6288 .and_then(|t| t.as_str())
6289 .unwrap_or("value");
6290 let sample = target
6292 .and_then(|t| t.get("enum"))
6293 .and_then(|e| e.as_array())
6294 .and_then(|values| values.first())
6295 .and_then(|v| {
6296 v.as_str()
6297 .map(str::to_string)
6298 .or_else(|| Some(v.to_string()))
6299 })
6300 .unwrap_or_else(|| format!("<{ty}>"));
6301 format!("{}={}", sanitize(key), sanitize(&sample))
6302 };
6303 let mut parts = vec![sanitize(name).into_owned()];
6304 for key in &required {
6305 parts.push(placeholder(key));
6306 }
6307 let optional: Vec<&String> = properties
6308 .keys()
6309 .filter(|key| !required.contains(&key.as_str()))
6310 .collect();
6311 for key in optional.iter().take(SHOWN.saturating_sub(required.len())) {
6312 parts.push(format!("[{}]", placeholder(key)));
6313 }
6314 if optional.len() > SHOWN.saturating_sub(required.len()) {
6315 parts.push("...".to_string());
6316 }
6317 parts.join(" ")
6318}
6319
6320fn describe(surface: &Surface, name: &str) {
6323 let surface_has_name = surface.tools.iter().any(|tool| tool.name == name)
6327 || surface.prompts.iter().any(|prompt| prompt.name == name)
6328 || surface
6329 .resources
6330 .iter()
6331 .any(|resource| resource.name == name || resource.uri == name)
6332 || surface
6333 .templates
6334 .iter()
6335 .any(|template| template.name == name || template.uri_template == name);
6336 if !surface_has_name && let Some(help) = builtin_help(name) {
6337 println!(
6338 "built-in {}",
6339 paint(Style::new().fg(Color::Cyan).bold(), name)
6340 );
6341 println!(" usage: {}", help.usage);
6342 println!(" {}", help.description);
6343 for paragraph in help.details {
6344 println!(" {paragraph}");
6345 }
6346 if !help.examples.is_empty() {
6347 println!(" examples:");
6348 for example in help.examples {
6349 println!(" {example}");
6350 }
6351 }
6352 return;
6353 }
6354 if let Some(t) = surface.tools.iter().find(|t| t.name == name) {
6355 println!(
6356 "tool {} {}",
6357 paint(Style::new().fg(Color::Green).bold(), &sanitize(&t.name)),
6358 sanitize(t.description.as_deref().unwrap_or(""))
6359 );
6360 if let Some(a) = &t.annotations {
6361 let mut hints = Vec::new();
6362 if a.read_only_hint {
6363 hints.push("read-only");
6364 }
6365 if a.idempotent_hint {
6366 hints.push("idempotent");
6367 }
6368 if a.destructive_hint && !a.read_only_hint {
6369 hints.push("destructive");
6370 }
6371 if a.open_world_hint {
6372 hints.push("open-world");
6373 }
6374 if !hints.is_empty() {
6375 println!(" hints: {}", hints.join(", "));
6376 }
6377 }
6378 if let Some(e) = &t.execution {
6379 let v = serde_json::to_value(e).unwrap_or_default();
6380 if let Some(mode) = v.get("taskSupport").and_then(|m| m.as_str()) {
6381 println!(" task support: {mode}");
6382 }
6383 }
6384 println!("input schema:");
6385 println!("{}", json_pretty(&t.input_schema));
6386 if let Some(out) = &t.output_schema {
6387 println!("output schema:");
6388 println!("{}", json_pretty(out));
6389 }
6390 println!(
6393 "example: {}",
6394 paint(
6395 Style::new().dimmed(),
6396 &example_invocation(&t.name, &t.input_schema)
6397 )
6398 );
6399 return;
6400 }
6401 if let Some(p) = surface.prompts.iter().find(|p| p.name == name) {
6402 println!(
6403 "prompt {} {}",
6404 paint(Style::new().fg(Color::Green).bold(), &sanitize(&p.name)),
6405 sanitize(p.description.as_deref().unwrap_or(""))
6406 );
6407 if p.arguments.is_empty() {
6408 println!(" (no arguments)");
6409 } else {
6410 println!("arguments:");
6411 for a in &p.arguments {
6412 println!(
6413 " {} {} {}",
6414 style::column(Style::new().fg(Color::Cyan), &sanitize(&a.name), 20),
6415 style::column(
6416 Style::new(),
6417 if a.required { "required" } else { "optional" },
6418 10
6419 ),
6420 sanitize(a.description.as_deref().unwrap_or(""))
6421 );
6422 }
6423 }
6424 return;
6425 }
6426 if let Some(r) = surface
6427 .resources
6428 .iter()
6429 .find(|r| r.uri == name || r.name == name)
6430 {
6431 println!(
6432 "resource {}",
6433 paint(Style::new().fg(Color::Green).bold(), &sanitize(&r.uri))
6434 );
6435 println!(" name: {}", sanitize(&r.name));
6436 if let Some(t) = &r.title {
6437 println!(" title: {}", sanitize(t));
6438 }
6439 if let Some(d) = &r.description {
6440 println!(" description: {}", sanitize(d));
6441 }
6442 if let Some(m) = &r.mime_type {
6443 println!(" mimeType: {}", sanitize(m));
6444 }
6445 if let Some(s) = r.size {
6446 println!(" size: {s} bytes");
6447 }
6448 return;
6449 }
6450 if let Some(t) = surface
6451 .templates
6452 .iter()
6453 .find(|t| t.uri_template == name || t.name == name)
6454 {
6455 println!(
6456 "template {}",
6457 paint(
6458 Style::new().fg(Color::Green).bold(),
6459 &sanitize(&t.uri_template)
6460 )
6461 );
6462 println!(" name: {}", sanitize(&t.name));
6463 if let Some(d) = &t.description {
6464 println!(" description: {}", sanitize(d));
6465 }
6466 if let Some(m) = &t.mime_type {
6467 println!(" mimeType: {}", sanitize(m));
6468 }
6469 if !t.arguments.is_empty() {
6470 println!("arguments:");
6471 for a in &t.arguments {
6472 println!(
6473 " {} {} {}",
6474 style::column(Style::new().fg(Color::Cyan), &sanitize(&a.name), 20),
6475 style::column(
6476 Style::new(),
6477 if a.required { "required" } else { "optional" },
6478 10
6479 ),
6480 sanitize(a.description.as_deref().unwrap_or(""))
6481 );
6482 }
6483 }
6484 return;
6485 }
6486 report_error_with_hint(
6490 ExitStatus::NoMatch,
6491 &format!("nothing on the surface named `{name}` (try `tools`, `prompts`, `resources`)"),
6492 find::did_you_mean(surface, name).as_deref(),
6493 );
6494}
6495
6496#[allow(clippy::too_many_arguments)]
6497async fn run_tool(
6498 session: &Arc<Session>,
6499 surface: &Arc<RwLock<Surface>>,
6500 jobs: &Arc<Jobs>,
6501 schema_contracts: &schema_contract::ContractSet,
6502 name: &str,
6503 arguments: serde_json::Value,
6504 background: bool,
6505 output: &vars::Output,
6506) {
6507 if !enforce_tool_contract(schema_contracts, surface, name) {
6508 return;
6509 }
6510 if background {
6511 match with_reconnect(session, surface, |c| {
6512 let arguments = arguments.clone();
6513 async move { c.call_tool_as_task(name, arguments, None).await }
6514 })
6515 .await
6516 {
6517 Ok(created) => {
6518 let created_value = serde_json::to_value(&created).unwrap_or_default();
6519 let task_id = created.task.task_id.clone();
6520 let poll_interval = created.task.poll_interval;
6521 jobs.register(
6524 created.task.task_id.clone(),
6525 name.to_string(),
6526 created.task.status,
6527 created.task.status_message.clone(),
6528 );
6529 if !output.is_plain() {
6530 emit_result(created_value, output);
6534 } else if json_output() {
6535 print_json(&created_value);
6536 } else {
6537 println!(
6538 "{} started",
6539 tag(
6540 Style::new().fg(Color::Yellow),
6541 &format!("task {}", sanitize(&jobs.label_for(&task_id)))
6542 )
6543 );
6544 }
6545 watch_task(session.clone(), jobs.clone(), task_id, poll_interval);
6546 }
6547 Err(e) => report_mcp_error(&e),
6548 }
6549 return;
6550 }
6551 let started = std::time::Instant::now();
6552 match with_reconnect(session, surface, |c| {
6553 let arguments = arguments.clone();
6554 async move { c.call_tool(name, arguments).await }
6555 })
6556 .await
6557 {
6558 Ok(result) => {
6559 if result.is_error {
6560 note_error(ExitStatus::Server);
6561 }
6562 if output.is_plain() {
6563 if json_output() {
6564 print_json(&serde_json::to_value(&result).unwrap_or_default());
6565 } else {
6566 if result.is_error {
6567 println!("{}", tag(Style::new().fg(Color::Red), "tool error"));
6568 }
6569 render_content(&result.content);
6570 }
6571 } else {
6572 emit_result(result_value(&result), output);
6573 }
6574 }
6575 Err(e) => report_mcp_error(&e),
6576 }
6577 if !json_output() {
6578 println!("{}", timing(started.elapsed()));
6579 }
6580}
6581
6582fn result_value(result: &tower_mcp::CallToolResult) -> serde_json::Value {
6585 if let Some(structured) = &result.structured_content {
6586 return structured.clone();
6587 }
6588 if let [Content::Text { text, .. }] = result.content.as_slice() {
6589 return serde_json::from_str(text)
6590 .unwrap_or_else(|_| serde_json::Value::String(text.clone()));
6591 }
6592 serde_json::to_value(&result.content).unwrap_or_default()
6593}
6594
6595const ROUTABLE_BUILTINS: &[&str] = &[
6600 "tools",
6601 "prompts",
6602 "resources",
6603 "templates",
6604 "describe",
6605 "read",
6606 "find",
6607 "info",
6608 "history",
6609];
6610
6611fn emit_value(value: serde_json::Value, output: &vars::Output, human: impl FnOnce()) {
6615 if !output.is_plain() {
6616 emit_result(value, output);
6617 } else if json_output() {
6618 print_json(&value);
6619 } else {
6620 human();
6621 }
6622}
6623
6624fn emit_result(mut value: serde_json::Value, output: &vars::Output) {
6627 if let Some(path) = &output.filter {
6628 match vars::get_path(&value, path) {
6629 Ok(Some(selected)) => value = selected,
6630 Ok(None) => {
6631 command_error(&format!("path `{path}` not found in result"));
6632 return;
6633 }
6634 Err(error) => {
6635 report_error(ExitStatus::Usage, &error);
6636 return;
6637 }
6638 }
6639 }
6640 if let Some(name) = &output.capture {
6641 vars::set(name, value.clone());
6642 if json_output() {
6643 print_json(&value);
6644 } else {
6645 println!(
6646 "{} {}",
6647 paint(Style::new().fg(Color::Cyan), &format!("${name} =")),
6648 value_summary(&value)
6649 );
6650 }
6651 } else if json_output() {
6652 print_json(&value);
6653 } else {
6654 render_value(&value);
6655 }
6656}
6657
6658fn value_summary(value: &serde_json::Value) -> String {
6659 match value {
6660 serde_json::Value::String(s) => format!("{s:?}"),
6661 serde_json::Value::Array(a) => format!("[{} items]", a.len()),
6662 serde_json::Value::Object(o) => format!("{{{} fields}}", o.len()),
6663 other => other.to_string(),
6664 }
6665}
6666
6667fn render_value(value: &serde_json::Value) {
6668 match value {
6669 serde_json::Value::String(s) => println!("{s}"),
6670 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
6671 println!("{}", json_pretty(value))
6672 }
6673 other => println!("{other}"),
6674 }
6675}
6676
6677#[cfg(test)]
6678mod tests {
6679 use super::*;
6680 use std::collections::HashMap;
6681 use std::sync::Mutex;
6682
6683 use async_trait::async_trait;
6684 use tower_mcp::client::ClientTransport;
6685
6686 fn surface_with_a_task_capable_tool() -> Arc<RwLock<Surface>> {
6687 let tool = |name: &str, task: bool| -> ToolDefinition {
6688 let mut value = serde_json::json!({
6689 "name": name,
6690 "description": "",
6691 "inputSchema": { "type": "object" },
6692 });
6693 if task {
6694 value["execution"] = serde_json::json!({ "taskSupport": "optional" });
6695 }
6696 serde_json::from_value(value).expect("tool definition")
6697 };
6698 Arc::new(RwLock::new(Surface {
6699 tools: vec![
6700 tool("slow_add", true),
6701 tool("echo", false),
6702 tool("wait", true),
6703 ],
6704 ..Default::default()
6705 }))
6706 }
6707
6708 #[test]
6711 fn only_a_task_capable_tool_is_worth_suggesting_backgrounding_for() {
6712 let surface = surface_with_a_task_capable_tool();
6713 assert_eq!(
6714 backgroundable_tool(&surface, "slow_add a=1 b=2").as_deref(),
6715 Some("slow_add")
6716 );
6717 assert_eq!(backgroundable_tool(&surface, "echo message=hi"), None);
6719 assert_eq!(backgroundable_tool(&surface, "slow_add a=1 b=2 &"), None);
6722 assert_eq!(backgroundable_tool(&surface, "wait 1"), None);
6724 assert_eq!(
6726 backgroundable_tool(&surface, "tool wait id=1").as_deref(),
6727 Some("wait")
6728 );
6729 assert_eq!(backgroundable_tool(&surface, "builtin wait 1"), None);
6730 assert_eq!(backgroundable_tool(&surface, "nope"), None);
6732 assert_eq!(backgroundable_tool(&surface, ""), None);
6733 }
6734
6735 #[test]
6736 fn command_collisions_are_detected_from_the_live_surface() {
6737 let surface = surface_with_a_task_capable_tool();
6738 let surface = surface.read().unwrap();
6739 assert!(is_ambiguous_command(&surface, "wait"));
6740 assert!(!is_ambiguous_command(&surface, "slow_add"));
6741 assert!(!is_ambiguous_command(&surface, "jobs"));
6742 }
6743
6744 #[test]
6745 fn describe_is_surface_first_and_can_still_render_builtins_as_json() {
6746 let surface = surface_with_a_task_capable_tool();
6747 let surface = surface.read().unwrap();
6748 assert_eq!(describe_value(&surface, "wait").unwrap()["kind"], "tool");
6749 assert_eq!(describe_value(&surface, "jobs").unwrap()["kind"], "builtin");
6750 }
6751
6752 #[test]
6753 fn a_saved_oauth_profile_reports_what_a_script_needs() {
6754 let metadata = config::OAuthProfile {
6755 url: "https://mcp.example.com/mcp".to_string(),
6756 scopes: vec!["openid".to_string(), "offline_access".to_string()],
6757 client_id_metadata_document: None,
6758 authorization_server: None,
6759 };
6760 let value = saved_profile_json("work", &metadata);
6761 assert_eq!(value["profile"], "work");
6762 assert_eq!(value["serverUrl"], "https://mcp.example.com/mcp");
6763 assert_eq!(value["scopes"][0], "openid");
6764 assert_eq!(value["scopes"][1], "offline_access");
6765 assert_eq!(
6769 value.as_object().map(|object| object.len()),
6770 Some(3),
6771 "{value}"
6772 );
6773 }
6774
6775 #[test]
6776 fn a_config_path_is_shown_the_way_it_would_be_typed() {
6777 let cwd = std::path::Path::new("/work/project");
6778 let home = std::path::Path::new("/home/ada");
6779 assert_eq!(
6782 typeable_path(
6783 std::path::Path::new("/work/project/.mcp.json"),
6784 cwd,
6785 Some(home)
6786 ),
6787 ".mcp.json"
6788 );
6789 assert_eq!(
6790 typeable_path(
6791 std::path::Path::new("/work/project/.vscode/mcp.json"),
6792 cwd,
6793 Some(home)
6794 ),
6795 ".vscode/mcp.json"
6796 );
6797 assert_eq!(
6799 typeable_path(
6800 std::path::Path::new("/home/ada/.claude.json"),
6801 cwd,
6802 Some(home)
6803 ),
6804 "~/.claude.json"
6805 );
6806 assert_eq!(
6808 typeable_path(std::path::Path::new("/etc/mcp.json"), cwd, Some(home)),
6809 "/etc/mcp.json"
6810 );
6811 assert_eq!(
6813 typeable_path(std::path::Path::new("/home/ada/.claude.json"), cwd, None),
6814 "/home/ada/.claude.json"
6815 );
6816 }
6817
6818 #[test]
6819 fn a_json_rpc_error_reads_as_a_sentence_and_a_code() {
6820 let error = tower_mcp::Error::JsonRpc(tower_mcp::error::JsonRpcError {
6821 code: -32601,
6822 message: "Method not found".to_string(),
6823 data: None,
6824 });
6825 assert_eq!(describe_mcp_error(&error), "Method not found (code -32601)");
6827 }
6828
6829 #[test]
6830 fn structured_error_data_is_shown_when_it_says_something() {
6831 let with_data = |data: serde_json::Value| {
6832 describe_mcp_error(&tower_mcp::Error::JsonRpc(tower_mcp::error::JsonRpcError {
6833 code: -32602,
6834 message: "Invalid params".to_string(),
6835 data: Some(data),
6836 }))
6837 };
6838 assert_eq!(
6839 with_data(serde_json::json!("field `name` is required")),
6840 "Invalid params (code -32602): field `name` is required"
6841 );
6842 assert_eq!(
6844 with_data(serde_json::Value::Null),
6845 "Invalid params (code -32602)"
6846 );
6847 }
6848
6849 #[test]
6850 fn an_error_relayed_as_json_shows_its_innermost_message() {
6851 assert_eq!(
6853 unwrap_nested(
6854 r#"Client error: {"code":-32007,"message":"sampling declined: --sampling decline"}"#
6855 ),
6856 "sampling declined: --sampling decline"
6857 );
6858 assert_eq!(
6860 unwrap_nested(r#"outer: {"message":"middle: {\"message\":\"inner\"}"}"#),
6861 "inner"
6862 );
6863 }
6864
6865 #[test]
6866 fn an_ordinary_message_is_left_alone_by_the_unwrapping() {
6867 for message in [
6868 "Method not found",
6869 "",
6870 "unexpected token {",
6872 r#"bad input: {"field":"name"}"#,
6874 r#"relayed: {"code":-1}"#,
6876 ] {
6877 assert_eq!(unwrap_nested(message), message, "{message:?}");
6878 }
6879 }
6880
6881 #[test]
6882 fn a_repeated_error_label_is_collapsed_to_one() {
6883 assert_eq!(
6886 collapse_repeated_label(
6887 "Transport error: Transport error: Transport error: HTTP request failed: refused"
6888 ),
6889 "Transport error: HTTP request failed: refused"
6890 );
6891 assert_eq!(
6893 collapse_repeated_label("Transport error: HTTP request failed: refused"),
6894 "Transport error: HTTP request failed: refused"
6895 );
6896 }
6897
6898 #[test]
6899 fn collapsing_leaves_ordinary_messages_alone() {
6900 for message in [
6903 "unknown command: nope",
6904 "Server error: tool `x` failed: bad input",
6905 "no colon here",
6906 "",
6907 ": leading colon",
6908 ] {
6909 assert_eq!(collapse_repeated_label(message), message, "{message:?}");
6910 }
6911 }
6912
6913 #[test]
6914 fn only_an_identical_label_collapses() {
6915 assert_eq!(
6917 collapse_repeated_label("Transport error: Server error: refused"),
6918 "Transport error: Server error: refused"
6919 );
6920 }
6921
6922 struct DiscoveryTransport {
6924 result: serde_json::Value,
6925 incoming_tx: tokio::sync::mpsc::Sender<String>,
6926 incoming_rx: tokio::sync::mpsc::Receiver<String>,
6927 outgoing: Arc<Mutex<Vec<serde_json::Value>>>,
6928 connected: bool,
6929 }
6930
6931 impl DiscoveryTransport {
6932 fn new(result: serde_json::Value) -> (Self, Arc<Mutex<Vec<serde_json::Value>>>) {
6933 let (incoming_tx, incoming_rx) = tokio::sync::mpsc::channel(4);
6934 let outgoing = Arc::new(Mutex::new(Vec::new()));
6935 (
6936 Self {
6937 result,
6938 incoming_tx,
6939 incoming_rx,
6940 outgoing: outgoing.clone(),
6941 connected: true,
6942 },
6943 outgoing,
6944 )
6945 }
6946 }
6947
6948 #[async_trait]
6949 impl ClientTransport for DiscoveryTransport {
6950 async fn send(&mut self, message: &str) -> tower_mcp::Result<()> {
6951 let request: serde_json::Value = serde_json::from_str(message)?;
6952 self.outgoing.lock().unwrap().push(request.clone());
6953 if let Some(id) = request.get("id") {
6954 self.incoming_tx
6955 .send(
6956 serde_json::json!({
6957 "jsonrpc": "2.0",
6958 "id": id,
6959 "result": self.result,
6960 })
6961 .to_string(),
6962 )
6963 .await
6964 .map_err(|error| tower_mcp::Error::Transport(error.to_string()))?;
6965 }
6966 Ok(())
6967 }
6968
6969 async fn recv(&mut self) -> tower_mcp::Result<Option<String>> {
6970 Ok(self.incoming_rx.recv().await)
6971 }
6972
6973 fn is_connected(&self) -> bool {
6974 self.connected
6975 }
6976
6977 async fn close(&mut self) -> tower_mcp::Result<()> {
6978 self.connected = false;
6979 Ok(())
6980 }
6981 }
6982
6983 fn jsonrpc(code: i32, message: &str) -> tower_mcp::Error {
6984 tower_mcp::Error::JsonRpc(tower_mcp::error::JsonRpcError {
6985 code,
6986 message: message.to_string(),
6987 data: None,
6988 })
6989 }
6990
6991 #[test]
6992 fn protocol_selection_is_stable_by_default_and_final_is_exact() {
6993 let stable = Args::try_parse_from(["mcp-repl", "--demo"]).unwrap();
6994 assert_eq!(stable.protocol, ProtocolMode::Stable);
6995 assert_eq!(
6996 stable.protocol.support().unwrap().versions(),
6997 tower_mcp::protocol::SUPPORTED_PROTOCOL_VERSIONS
6998 );
6999
7000 for value in ["2026-07-28", "final"] {
7001 let final_args =
7002 Args::try_parse_from(["mcp-repl", "--protocol", value, "--demo"]).unwrap();
7003 assert_eq!(final_args.protocol, ProtocolMode::Final);
7004 assert_eq!(
7005 final_args.protocol.support().unwrap().versions(),
7006 ["2026-07-28"]
7007 );
7008 }
7009 }
7010
7011 #[test]
7012 fn oauth_cli_parses_standalone_and_connection_workflows() {
7013 let login = Args::try_parse_from([
7014 "mcp-repl",
7015 "--login",
7016 "work",
7017 "--http",
7018 "https://mcp.example/mcp",
7019 "--oauth-scope",
7020 "openid",
7021 "--oauth-scope",
7022 "offline_access",
7023 "--no-browser",
7024 ])
7025 .unwrap();
7026 assert_eq!(login.login.as_deref(), Some("work"));
7027 assert_eq!(login.oauth_scopes, ["openid", "offline_access"]);
7028 assert!(login.no_browser);
7029
7030 let connection = Args::try_parse_from([
7031 "mcp-repl",
7032 "--oauth",
7033 "work",
7034 "--http",
7035 "https://mcp.example/mcp",
7036 "--exec",
7037 "tools",
7038 "--json",
7039 ])
7040 .unwrap();
7041 assert_eq!(connection.oauth.as_deref(), Some("work"));
7042 assert_eq!(connection.exec, ["tools"]);
7043
7044 assert!(Args::try_parse_from(["mcp-repl", "--login", "work", "--logout", "work"]).is_err());
7045 }
7046
7047 #[tokio::test]
7048 async fn stable_selection_uses_initialize() {
7049 let client = client_builder(ProtocolMode::Stable)
7050 .unwrap()
7051 .connect_simple(ChannelTransport::new(demo_router()))
7052 .await
7053 .unwrap();
7054 let info = establish_connection(&client, ProtocolMode::Stable)
7055 .await
7056 .unwrap();
7057
7058 assert_eq!(info.server_info.name, "mcp-repl-demo");
7059 assert_eq!(
7060 info.protocol_version,
7061 tower_mcp::protocol::LATEST_PROTOCOL_VERSION
7062 );
7063 assert!(client.server_info().await.is_some());
7064 assert!(client.discovery().await.is_none());
7065 }
7066
7067 #[tokio::test]
7068 async fn final_selection_uses_discover_with_required_metadata() {
7069 let (transport, outgoing) = DiscoveryTransport::new(serde_json::json!({
7070 "resultType": "complete",
7071 "supportedVersions": ["2026-07-28"],
7072 "capabilities": {"tools": {}},
7073 "ttlMs": 0,
7074 "cacheScope": "private",
7075 "_meta": {
7076 "io.modelcontextprotocol/serverInfo": {
7077 "name": "final-test-server",
7078 "version": "1.0.0"
7079 }
7080 }
7081 }));
7082 let client = client_builder(ProtocolMode::Final)
7083 .unwrap()
7084 .connect_simple(transport)
7085 .await
7086 .unwrap();
7087 let info = establish_connection(&client, ProtocolMode::Final)
7088 .await
7089 .unwrap();
7090
7091 assert_eq!(info.server_info.name, "final-test-server");
7092 assert_eq!(info.protocol_version, "2026-07-28");
7093 assert!(client.server_info().await.is_none());
7094 assert!(client.discovery().await.is_some());
7095
7096 let sent = outgoing.lock().unwrap();
7097 assert_eq!(sent.len(), 1);
7098 assert_eq!(sent[0]["method"], "server/discover");
7099 assert_eq!(
7100 sent[0]["params"]["_meta"]["io.modelcontextprotocol/protocolVersion"],
7101 "2026-07-28"
7102 );
7103 assert!(
7104 sent[0]["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"].is_object()
7105 );
7106 assert!(
7107 sent[0]["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"]["extensions"]
7108 [tower_mcp::protocol::TASKS_EXTENSION_ID]
7109 .is_object()
7110 );
7111 assert_eq!(
7112 sent[0]["params"]["_meta"]["io.modelcontextprotocol/clientInfo"]["name"],
7113 "mcp-repl"
7114 );
7115 }
7116
7117 #[test]
7118 fn build_http_config_sets_bearer_and_trims_headers() {
7119 let cfg = build_http_config(
7120 Some("tok".into()),
7121 &["X-Api-Key: abc".into(), "X-Trim : v ".into()],
7122 None,
7123 &[],
7124 )
7125 .unwrap();
7126 assert_eq!(
7127 cfg.headers.get("Authorization").map(String::as_str),
7128 Some("Bearer tok")
7129 );
7130 assert_eq!(
7131 cfg.headers.get("X-Api-Key").map(String::as_str),
7132 Some("abc")
7133 );
7134 assert_eq!(cfg.headers.get("X-Trim").map(String::as_str), Some("v"));
7135 }
7136
7137 #[test]
7138 fn profile_auth_applies_and_flags_override_it() {
7139 let profile_headers = [
7140 ("X-Api-Key".to_string(), "from-profile".to_string()),
7141 ("X-Kept".to_string(), "profile".to_string()),
7142 ];
7143 let cfg =
7145 build_http_config(None, &[], Some("profile-tok".into()), &profile_headers).unwrap();
7146 assert_eq!(
7147 cfg.headers.get("Authorization").map(String::as_str),
7148 Some("Bearer profile-tok")
7149 );
7150 assert_eq!(
7151 cfg.headers.get("X-Api-Key").map(String::as_str),
7152 Some("from-profile")
7153 );
7154
7155 let cfg = build_http_config(
7157 Some("flag-tok".into()),
7158 &["X-Api-Key: from-flag".into()],
7159 Some("profile-tok".into()),
7160 &profile_headers,
7161 )
7162 .unwrap();
7163 assert_eq!(
7164 cfg.headers.get("Authorization").map(String::as_str),
7165 Some("Bearer flag-tok")
7166 );
7167 assert_eq!(
7168 cfg.headers.get("X-Api-Key").map(String::as_str),
7169 Some("from-flag")
7170 );
7171 assert_eq!(
7172 cfg.headers.get("X-Kept").map(String::as_str),
7173 Some("profile")
7174 );
7175 }
7176
7177 #[test]
7178 fn oauth_precedence_is_explicit_static_then_cli_then_server_profile() {
7179 assert_eq!(
7180 selected_oauth_profile(Some("cli"), Some("server"), false, &[]),
7181 Some("cli".to_string())
7182 );
7183 assert_eq!(
7184 selected_oauth_profile(None, Some("server"), false, &[]),
7185 Some("server".to_string())
7186 );
7187 assert_eq!(
7188 selected_oauth_profile(Some("cli"), Some("server"), true, &[]),
7189 None
7190 );
7191 assert_eq!(
7192 selected_oauth_profile(
7193 Some("cli"),
7194 Some("server"),
7195 false,
7196 &["authorization: Basic explicit".to_string()],
7197 ),
7198 None
7199 );
7200 assert_eq!(
7201 selected_oauth_profile(
7202 Some("cli"),
7203 Some("server"),
7204 false,
7205 &["X-Tenant: acme".to_string()],
7206 ),
7207 Some("cli".to_string())
7208 );
7209 }
7210
7211 #[test]
7212 fn selected_authorization_header_beats_environment_bearer() {
7213 let selected_headers = [("authorization".to_string(), "Basic selected".to_string())];
7214 let cfg = build_http_config_with_env(
7215 None,
7216 &[],
7217 None,
7218 &selected_headers,
7219 Some("ambient-token".into()),
7220 )
7221 .unwrap();
7222 assert_eq!(
7223 cfg.headers.get("authorization").map(String::as_str),
7224 Some("Basic selected")
7225 );
7226
7227 let cfg = build_http_config_with_env(
7228 Some("explicit-token".into()),
7229 &[],
7230 None,
7231 &selected_headers,
7232 Some("ambient-token".into()),
7233 )
7234 .unwrap();
7235 assert_eq!(
7236 cfg.headers.get("Authorization").map(String::as_str),
7237 Some("Bearer explicit-token")
7238 );
7239 }
7240
7241 #[test]
7242 fn explicit_oauth_suppresses_profile_and_environment_bearers() {
7243 let selected = selected_oauth_profile(Some("work"), None, false, &[]);
7244 assert_eq!(selected.as_deref(), Some("work"));
7245
7246 let cfg = build_http_config_with_env(
7247 None,
7248 &[],
7249 selected.is_none().then(|| "profile-token".to_string()),
7250 &[],
7251 selected.is_none().then(|| "environment-token".to_string()),
7252 )
7253 .unwrap();
7254 assert!(!cfg.headers.contains_key("Authorization"));
7255 }
7256
7257 #[test]
7258 fn bearer_fd_rejects_every_competing_authorization_source() {
7259 let cli_headers = vec!["Authorization: Basic cli".to_string()];
7260 let selected_headers = vec![("authorization".to_string(), "Basic profile".to_string())];
7261 let error = validate_bearer_fd_exclusive(
7262 true,
7263 true,
7264 true,
7265 &cli_headers,
7266 true,
7267 &selected_headers,
7268 true,
7269 true,
7270 )
7271 .unwrap_err();
7272 for source in [
7273 "--bearer",
7274 "MCP_BEARER",
7275 "profile `bearer`/`bearer_env`",
7276 "--header Authorization",
7277 "profile/import Authorization header",
7278 "--oauth",
7279 "profile OAuth",
7280 ] {
7281 assert!(error.contains(source), "missing {source:?} from {error:?}");
7282 }
7283 assert!(
7284 validate_bearer_fd_exclusive(true, false, false, &[], false, &[], false, false).is_ok()
7285 );
7286 assert!(
7288 validate_bearer_fd_exclusive(
7289 false,
7290 true,
7291 true,
7292 &cli_headers,
7293 true,
7294 &selected_headers,
7295 true,
7296 true,
7297 )
7298 .is_ok()
7299 );
7300 }
7301
7302 #[test]
7303 fn build_http_config_rejects_header_without_colon() {
7304 let err = build_http_config(Some("tok".into()), &["nope".into()], None, &[]).unwrap_err();
7305 assert!(
7306 err.contains("nope"),
7307 "error should name the bad header: {err}"
7308 );
7309 assert!(
7310 err.contains("Name: Value"),
7311 "error should show the format: {err}"
7312 );
7313 }
7314
7315 #[test]
7316 fn timing_formats_sub_second_and_seconds() {
7317 assert!(timing(Duration::from_millis(142)).contains("[142ms]"));
7318 assert!(timing(Duration::from_millis(2500)).contains("[2.50s]"));
7319 }
7320
7321 #[test]
7325 fn bench_is_a_listed_builtin() {
7326 assert!(BUILTINS.iter().any(|(name, _)| *name == "bench"));
7327 }
7328
7329 #[test]
7332 fn find_is_a_completable_builtin() {
7333 assert!(BUILTINS.iter().any(|(name, _)| *name == "find"));
7334 }
7335
7336 fn completion_script(shell: clap_complete::Shell) -> String {
7338 let mut command = <Args as clap::CommandFactory>::command();
7339 let mut out = Vec::new();
7340 clap_complete::generate(shell, &mut command, "mcp-repl", &mut out);
7341 String::from_utf8(out).expect("completion scripts are UTF-8")
7342 }
7343
7344 #[test]
7345 fn every_shell_gets_a_script_naming_the_binary() {
7346 for shell in [
7347 clap_complete::Shell::Bash,
7348 clap_complete::Shell::Zsh,
7349 clap_complete::Shell::Fish,
7350 clap_complete::Shell::PowerShell,
7351 clap_complete::Shell::Elvish,
7352 ] {
7353 let script = completion_script(shell);
7354 assert!(!script.is_empty(), "{shell} produced nothing");
7355 assert!(
7356 script.contains("mcp-repl"),
7357 "{shell} does not name the binary"
7358 );
7359 }
7360 }
7361
7362 #[test]
7363 fn completion_covers_flags_and_their_values() {
7364 let bash = completion_script(clap_complete::Shell::Bash);
7365 for flag in [
7368 "--protocol",
7369 "--http",
7370 "--bearer-fd",
7371 "--elicitation",
7372 "--timeout",
7373 "--man",
7374 ] {
7375 assert!(bash.contains(flag), "bash completion is missing {flag}");
7376 }
7377 for value in ["stable", "2026-07-28", "decline", "compatible"] {
7380 assert!(
7381 bash.contains(value),
7382 "bash completion is missing value {value}"
7383 );
7384 }
7385 }
7386
7387 #[test]
7388 fn the_man_page_renders_with_the_real_sections() {
7389 let page = render_man_page().expect("man page renders");
7390 let roff = String::from_utf8(page).expect("roff is UTF-8");
7391 assert!(roff.contains("mcp-repl"));
7392 for section in [
7393 ".SH NAME",
7394 ".SH SYNOPSIS",
7395 ".SH DESCRIPTION",
7396 ".SH OPTIONS",
7397 ".SH \"REPL BUILT-INS\"",
7398 ] {
7399 assert!(roff.contains(section), "man page has no {section}");
7400 }
7401 assert!(roff.contains("surface is the command set"));
7404 assert!(roff.contains("connect demo"));
7405 assert!(roff.contains("wait \\-\\-timeout 30"));
7406 }
7407
7408 #[test]
7409 fn every_builtin_can_explain_itself() {
7410 for (name, _) in BUILTINS {
7413 assert!(
7414 builtin_help(name).is_some(),
7415 "`{name}` has no usage line; add one to BUILTIN_HELP"
7416 );
7417 }
7418 for (name, _, _) in BUILTIN_HELP {
7420 assert!(
7421 BUILTINS.iter().any(|(builtin, _)| builtin == name),
7422 "BUILTIN_HELP documents `{name}`, which is not a built-in"
7423 );
7424 }
7425 for guide in BUILTIN_GUIDES {
7426 assert!(
7427 BUILTINS.iter().any(|(name, _)| *name == guide.name),
7428 "BUILTIN_GUIDES documents unknown `{}`",
7429 guide.name
7430 );
7431 assert!(
7432 !guide.details.is_empty() || !guide.examples.is_empty(),
7433 "guide `{}` adds no detail",
7434 guide.name
7435 );
7436 }
7437 for (index, guide) in BUILTIN_GUIDES.iter().enumerate() {
7438 assert!(
7439 !BUILTIN_GUIDES[index + 1..]
7440 .iter()
7441 .any(|other| other.name == guide.name),
7442 "BUILTIN_GUIDES documents `{}` more than once",
7443 guide.name
7444 );
7445 }
7446 for name in ["connect", "find", "read", "bench", "wait", "alias", "wire"] {
7447 assert!(
7448 !builtin_help(name).unwrap().examples.is_empty(),
7449 "high-value help for `{name}` needs a runnable example"
7450 );
7451 }
7452 }
7453
7454 #[test]
7455 fn an_example_invocation_shows_required_arguments_first() {
7456 let schema = serde_json::json!({
7457 "type": "object",
7458 "properties": {
7459 "b": {"type": "integer"},
7460 "a": {"type": "integer"},
7461 "note": {"type": "string"},
7462 },
7463 "required": ["a", "b"],
7464 });
7465 let example = example_invocation("add", &schema);
7466 assert!(
7467 example.starts_with("add a=<integer> b=<integer>"),
7468 "{example}"
7469 );
7470 assert!(example.contains("[note=<string>]"), "{example}");
7471 }
7472
7473 #[test]
7474 fn an_example_invocation_follows_a_ref_into_defs() {
7475 let schema = serde_json::json!({
7478 "type": "object",
7479 "properties": {
7480 "to": {"$ref": "#/$defs/Scale"},
7481 "value": {"type": "number"},
7482 },
7483 "required": ["value", "to"],
7484 "$defs": {
7485 "Scale": {"type": "string", "enum": ["celsius", "kelvin"]},
7486 },
7487 });
7488 let example = example_invocation("convert", &schema);
7489 assert!(example.contains("to=celsius"), "{example}");
7490 assert!(example.contains("value=<number>"), "{example}");
7491 }
7492
7493 #[test]
7494 fn an_example_invocation_prefers_enum_values_to_types() {
7495 let schema = serde_json::json!({
7496 "type": "object",
7497 "properties": {"mode": {"type": "string", "enum": ["fast", "slow"]}},
7498 "required": ["mode"],
7499 });
7500 assert_eq!(example_invocation("run", &schema), "run mode=fast");
7501 }
7502
7503 #[test]
7504 fn a_tool_without_properties_still_has_an_example() {
7505 let schema = serde_json::json!({"type": "object", "additionalProperties": true});
7506 assert_eq!(example_invocation("about", &schema), "about");
7507 }
7508
7509 #[test]
7516 fn quoted_arguments_reach_the_server_intact() {
7517 let schema = serde_json::json!({
7519 "type": "object",
7520 "properties": {
7521 "mission": {"type": "string"},
7522 "count": {"type": "integer"},
7523 "flag": {"type": "boolean"},
7524 "untyped": {},
7525 },
7526 });
7527 let arguments = |line: &str| -> serde_json::Value {
7528 let parsed = command::parse(line).expect("parses");
7529 let tokens: Vec<&str> = parsed.words[1..].iter().map(String::as_str).collect();
7530 parse_kv_args(&schema, &tokens).unwrap()
7531 };
7532
7533 assert_eq!(
7534 arguments(r#"tool mission="two words" count=2"#),
7535 serde_json::json!({"mission": "two words", "count": 2})
7536 );
7537 assert_eq!(
7538 arguments("tool mission='two words'"),
7539 serde_json::json!({"mission": "two words"})
7540 );
7541 assert_eq!(
7542 arguments(r"tool mission=two\ words"),
7543 serde_json::json!({"mission": "two words"})
7544 );
7545 assert_eq!(
7546 arguments(r#"tool mission="say \"hi\"""#),
7547 serde_json::json!({"mission": "say \"hi\""})
7548 );
7549 assert_eq!(
7551 arguments(r#"tool mission="""#),
7552 serde_json::json!({"mission": ""})
7553 );
7554 assert_eq!(
7556 arguments(r#"tool mission="count=9""#),
7557 serde_json::json!({"mission": "count=9"})
7558 );
7559 assert_eq!(
7561 arguments(r#"tool count="7" flag="true""#),
7562 serde_json::json!({"count": 7, "flag": true})
7563 );
7564 assert_eq!(
7567 arguments(r#"tool untyped="two words""#),
7568 serde_json::json!({"untyped": "two words"})
7569 );
7570 }
7571
7572 #[test]
7573 fn read_flags_are_separated_from_the_uri() {
7574 let (out, force, rest) =
7575 parse_read_flags(&["note://status", "--out", "/tmp/x", "--force"]).unwrap();
7576 assert_eq!(out.as_deref(), Some("/tmp/x"));
7577 assert!(force);
7578 assert_eq!(rest, vec!["note://status"]);
7579
7580 let (out, force, rest) = parse_read_flags(&["--out=/tmp/y", "note://status"]).unwrap();
7582 assert_eq!(out.as_deref(), Some("/tmp/y"));
7583 assert!(!force);
7584 assert_eq!(rest, vec!["note://status"]);
7585
7586 let (out, _, rest) = parse_read_flags(&["note://status"]).unwrap();
7587 assert_eq!(out, None);
7588 assert_eq!(rest, vec!["note://status"]);
7589 }
7590
7591 #[test]
7592 fn read_flag_errors_say_what_is_wrong() {
7593 assert!(parse_read_flags(&["note://x", "--out"]).is_err());
7594 assert!(parse_read_flags(&["note://x", "--out="]).is_err());
7595 assert!(parse_read_flags(&["note://x", "--nope"]).is_err());
7596 }
7597
7598 #[test]
7599 fn saving_decodes_a_blob_and_writes_text_as_is() {
7600 use tower_mcp::protocol::{ReadResourceResult, ResourceContent};
7601 let dir = tempfile::tempdir().unwrap();
7602
7603 let content = |text: Option<&str>, blob: Option<&str>| ResourceContent {
7604 uri: "x://y".to_string(),
7605 mime_type: None,
7606 text: text.map(str::to_string),
7607 blob: blob.map(str::to_string),
7608 meta: None,
7609 };
7610
7611 let text_path = dir.path().join("note.txt");
7613 let result = ReadResourceResult {
7614 contents: vec![content(
7615 Some(
7616 "hello
7617world",
7618 ),
7619 None,
7620 )],
7621 ..Default::default()
7622 };
7623 let written = save_resource(&result, text_path.to_str().unwrap()).unwrap();
7624 assert_eq!(written, 11);
7625 assert_eq!(std::fs::read_to_string(&text_path).unwrap(), "hello\nworld");
7626
7627 let png_path = dir.path().join("pixel.png");
7629 let result = ReadResourceResult {
7630 contents: vec![content(None, Some(PIXEL_PNG_FOR_TEST))],
7631 ..Default::default()
7632 };
7633 let written = save_resource(&result, png_path.to_str().unwrap()).unwrap();
7634 let bytes = std::fs::read(&png_path).unwrap();
7635 assert_eq!(written, bytes.len());
7636 assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG header");
7637 }
7638
7639 const PIXEL_PNG_FOR_TEST: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
7641
7642 #[test]
7643 fn saving_refuses_what_it_cannot_write_faithfully() {
7644 use tower_mcp::protocol::{ReadResourceResult, ResourceContent};
7645 let dir = tempfile::tempdir().unwrap();
7646 let path = dir.path().join("out");
7647 let empty = ReadResourceResult::default();
7648 assert!(save_resource(&empty, path.to_str().unwrap()).is_err());
7649
7650 let two = ReadResourceResult {
7653 contents: vec![
7654 ResourceContent {
7655 uri: "a".into(),
7656 mime_type: None,
7657 text: Some("one".into()),
7658 blob: None,
7659 meta: None,
7660 },
7661 ResourceContent {
7662 uri: "b".into(),
7663 mime_type: None,
7664 text: Some("two".into()),
7665 blob: None,
7666 meta: None,
7667 },
7668 ],
7669 ..Default::default()
7670 };
7671 assert!(save_resource(&two, path.to_str().unwrap()).is_err());
7672 assert!(!path.exists());
7674 }
7675
7676 #[test]
7677 fn counted_nouns_agree_with_their_number() {
7678 assert_eq!(plural(0, "tool"), "0 tools");
7679 assert_eq!(plural(1, "tool"), "1 tool");
7680 assert_eq!(plural(2, "template"), "2 templates");
7681 }
7682
7683 #[test]
7684 fn only_commands_with_a_value_accept_capture_and_filter() {
7685 for routable in ["tools", "describe", "read", "find", "info"] {
7688 assert!(
7689 ROUTABLE_BUILTINS.contains(&routable),
7690 "{routable} returns a documented value"
7691 );
7692 }
7693 for reporting in ["help", "alias", "wire", "refresh", "quit", "unset"] {
7694 assert!(
7695 !ROUTABLE_BUILTINS.contains(&reporting),
7696 "{reporting} has no value to capture"
7697 );
7698 }
7699 for name in ROUTABLE_BUILTINS {
7702 assert!(
7703 BUILTINS.iter().any(|(builtin, _)| builtin == name),
7704 "{name} is not a built-in"
7705 );
7706 }
7707 }
7708
7709 #[test]
7710 fn error_json_is_a_valid_object() {
7711 let v = error_json(ExitStatus::Usage, "boom: it broke");
7712 assert_eq!(v["error"], "boom: it broke");
7713 assert_eq!(v["kind"], "usage");
7714 assert_eq!(v["exitStatus"], 2);
7715 }
7716
7717 #[tokio::test]
7720 async fn pagination_stops_at_the_page_cap() {
7721 let mut pages = 0usize;
7722 let items: Vec<u32> = collect_pages("tools", |cursor| {
7723 pages += 1;
7724 let next = cursor.map_or(0u32, |c| c.parse::<u32>().unwrap_or(0) + 1);
7725 async move { Ok((vec![next], Some((next + 1).to_string()))) }
7726 })
7727 .await
7728 .unwrap();
7729 assert_eq!(pages, MAX_SURFACE_PAGES);
7730 assert_eq!(items.len(), MAX_SURFACE_PAGES);
7731 }
7732
7733 #[tokio::test]
7734 async fn pagination_stops_at_the_item_cap() {
7735 let items: Vec<u32> = collect_pages("tools", |cursor| {
7738 let n = cursor.map_or(0u32, |c| c.parse::<u32>().unwrap_or(0) + 1);
7739 async move { Ok((vec![n; 500], Some((n + 1).to_string()))) }
7740 })
7741 .await
7742 .unwrap();
7743 assert_eq!(items.len(), MAX_SURFACE_ITEMS);
7744 }
7745
7746 #[tokio::test]
7747 async fn pagination_stops_when_a_cursor_repeats() {
7748 let mut pages = 0usize;
7749 let items: Vec<u32> = collect_pages("prompts", |_cursor| {
7750 pages += 1;
7751 async move { Ok((vec![1], Some("same".to_string()))) }
7752 })
7753 .await
7754 .unwrap();
7755 assert_eq!(pages, 2);
7757 assert_eq!(items.len(), 2);
7758 }
7759
7760 #[tokio::test]
7761 async fn pagination_follows_an_ordinary_multi_page_surface() {
7762 let items: Vec<u32> = collect_pages("tools", |cursor| async move {
7763 match cursor.as_deref() {
7764 None => Ok((vec![1, 2], Some("page2".to_string()))),
7765 Some("page2") => Ok((vec![3], None)),
7766 other => panic!("unexpected cursor {other:?}"),
7767 }
7768 })
7769 .await
7770 .unwrap();
7771 assert_eq!(items, vec![1, 2, 3]);
7772 }
7773
7774 #[test]
7775 fn wait_accepts_an_explicit_deadline() {
7776 let (limit, rest) = parse_wait_timeout("wait", &["task-1", "--timeout", "30"]).unwrap();
7777 assert_eq!(limit, Some(Duration::from_secs(30)));
7778 assert_eq!(rest, vec!["task-1"]);
7779
7780 let (limit, rest) = parse_wait_timeout("wait", &["--timeout=5", "task-1"]).unwrap();
7781 assert_eq!(limit, Some(Duration::from_secs(5)));
7782 assert_eq!(rest, vec!["task-1"]);
7783
7784 let (limit, _) = parse_wait_timeout("wait", &["task-1", "--timeout", "0"]).unwrap();
7786 assert_eq!(limit, None);
7787
7788 let (limit, rest) = parse_wait_timeout("wait", &["task-1"]).unwrap();
7789 assert_eq!(limit, None);
7790 assert_eq!(rest, vec!["task-1"]);
7791 }
7792
7793 #[test]
7794 fn wait_deadline_errors_are_explained() {
7795 assert!(parse_wait_timeout("wait", &["t", "--timeout"]).is_err());
7796 assert!(parse_wait_timeout("wait", &["t", "--timeout", "soon"]).is_err());
7797 assert!(parse_wait_timeout("task", &["t", "--timeout", "5"]).is_err());
7800 }
7801
7802 #[test]
7803 fn automatic_task_updates_are_interactive_text_only() {
7804 assert!(automatic_task_updates(false, false));
7805 assert!(!automatic_task_updates(true, false));
7806 assert!(!automatic_task_updates(true, true));
7807 assert!(!automatic_task_updates(false, true));
7808 }
7809
7810 #[test]
7811 fn quoted_task_arguments_reach_schema_coercion_intact() {
7812 let parsed = command::parse(
7813 r#"run.start instruction="Reply with exactly hello" mode=interactive &"#,
7814 )
7815 .unwrap();
7816 let tokens: Vec<&str> = parsed.words[1..].iter().map(String::as_str).collect();
7817 let schema = serde_json::json!({
7818 "type": "object",
7819 "properties": {
7820 "instruction": { "type": "string" },
7821 "mode": { "type": "string" }
7822 }
7823 });
7824
7825 assert!(parsed.background);
7826 assert_eq!(
7827 parse_kv_args(&schema, &tokens).unwrap(),
7828 serde_json::json!({
7829 "instruction": "Reply with exactly hello",
7830 "mode": "interactive"
7831 })
7832 );
7833 }
7834
7835 #[test]
7836 fn malformed_schema_coerced_arguments_are_errors() {
7837 let schema = serde_json::json!({"type": "object"});
7838 let positional = parse_kv_args(&schema, &["forgot-the-equals"]).unwrap_err();
7839 assert!(positional.contains("key=value"), "{positional}");
7840
7841 let empty = parse_kv_args(&schema, &["=value"]).unwrap_err();
7842 assert!(empty.contains("empty name"), "{empty}");
7843
7844 let malformed_json = parse_kv_args(&schema, &[r#"{"a":}"#]).unwrap_err();
7845 assert!(
7846 malformed_json.contains("invalid JSON object"),
7847 "{malformed_json}"
7848 );
7849
7850 assert_eq!(
7851 parse_kv_args(&schema, &[r#"{"a":1}"#]).unwrap(),
7852 serde_json::json!({"a": 1})
7853 );
7854 assert_eq!(
7855 parse_kv_args(&schema, &["empty="]).unwrap(),
7856 serde_json::json!({"empty": ""})
7857 );
7858 }
7859
7860 #[test]
7861 fn malformed_prompt_arguments_are_errors() {
7862 assert!(parse_prompt_args(&["missing"]).is_err());
7863 assert!(parse_prompt_args(&["=value"]).is_err());
7864 assert_eq!(
7865 parse_prompt_args(&["name=Ada"]).unwrap(),
7866 HashMap::from([("name".to_string(), "Ada".to_string())])
7867 );
7868 }
7869
7870 #[test]
7875 fn file_backed_history_writes_on_sync() {
7876 use reedline::{FileBackedHistory, History, HistoryItem};
7877 let path = std::env::temp_dir().join(format!("mcp-repl-hist-{}.txt", std::process::id()));
7878 let _ = std::fs::remove_file(&path);
7879 {
7880 let mut h = FileBackedHistory::with_file(10, path.clone()).unwrap();
7881 h.save(HistoryItem::from_command_line("echo persisted"))
7882 .unwrap();
7883 h.sync().unwrap();
7884 }
7885 let contents = std::fs::read_to_string(&path).unwrap();
7886 assert!(
7887 contents.contains("echo persisted"),
7888 "history was not written to disk: {contents:?}"
7889 );
7890 let _ = std::fs::remove_file(&path);
7891 }
7892
7893 async fn demo_client() -> McpClient {
7896 let client = McpClient::builder()
7897 .connect_simple(ChannelTransport::new(demo_router()))
7898 .await
7899 .unwrap();
7900 client.initialize("mcp-repl-test", "0").await.unwrap();
7901 client
7902 }
7903
7904 #[tokio::test(flavor = "multi_thread")]
7905 async fn bundled_slow_task_announces_completion_without_manual_polling() {
7906 let session = Arc::new(Session::new(demo_client().await, None));
7907 let surface = Arc::new(RwLock::new(Surface::default()));
7908 let output = AsyncOutput::new(Arc::new(AtomicBool::new(true)), true);
7909 let printer = output.external_printer().unwrap();
7910 let jobs = Arc::new(Jobs::new(output, true));
7911 let schema_contracts = schema_contract::ContractSet::default();
7912
7913 run_tool(
7914 &session,
7915 &surface,
7916 &jobs,
7917 &schema_contracts,
7918 "slow_add",
7919 serde_json::json!({ "a": 2, "b": 3 }),
7920 true,
7921 &vars::Output::default(),
7922 )
7923 .await;
7924
7925 let line = tokio::time::timeout(Duration::from_secs(6), async {
7926 loop {
7927 if let Some(line) = printer.get_line() {
7928 break line;
7929 }
7930 tokio::time::sleep(Duration::from_millis(25)).await;
7931 }
7932 })
7933 .await
7934 .expect("the task watcher should observe slow_add completion");
7935
7936 assert!(line.contains("completed"), "{line}");
7937 assert_eq!(
7938 jobs.list()[0].status,
7939 tower_mcp::protocol::TaskStatus::Completed
7940 );
7941 }
7942
7943 async fn demo_session() -> (Arc<Session>, Arc<std::sync::atomic::AtomicUsize>) {
7946 let connects = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7947 let counter = connects.clone();
7948 let connector: Connector = Arc::new(move || {
7949 let counter = counter.clone();
7950 Box::pin(async move {
7951 counter.fetch_add(1, Ordering::SeqCst);
7952 Ok(demo_client().await)
7953 })
7954 });
7955 (
7956 Arc::new(Session::new(demo_client().await, Some(connector))),
7957 connects,
7958 )
7959 }
7960
7961 #[tokio::test(flavor = "multi_thread")]
7965 async fn dropped_session_is_rebuilt_and_the_command_retried() {
7966 let (session, connects) = demo_session().await;
7967 let surface = Arc::new(RwLock::new(Surface::default()));
7968 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7969 let dead = Arc::as_ptr(&session.client()) as usize;
7970 let seen: Arc<RwLock<Vec<usize>>> = Arc::new(RwLock::new(Vec::new()));
7971
7972 let (calls, saw) = (attempts.clone(), seen.clone());
7973 let result = with_reconnect(&session, &surface, |c| {
7974 let (calls, saw) = (calls.clone(), saw.clone());
7975 async move {
7976 saw.write().unwrap().push(Arc::as_ptr(&c) as usize);
7977 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
7979 return Err(jsonrpc(
7980 -32600,
7981 "Client must send notifications/initialized before making requests",
7982 ));
7983 }
7984 c.call_tool("echo", serde_json::json!({ "message": "alive" }))
7985 .await
7986 }
7987 })
7988 .await
7989 .expect("the retried call should succeed on the rebuilt session");
7990
7991 assert_eq!(attempts.load(Ordering::SeqCst), 2, "one retry, not a loop");
7992 let seen = seen.read().unwrap();
7994 assert_eq!(seen[0], dead);
7995 assert_ne!(seen[1], dead, "the retry reused the dead client");
7996 assert_eq!(
7997 connects.load(Ordering::SeqCst),
7998 1,
7999 "reconnected exactly once"
8000 );
8001 assert_eq!(session.generation(), 1);
8002 match result.content.first() {
8003 Some(Content::Text { text, .. }) => assert_eq!(text, "alive"),
8004 other => panic!("unexpected content: {other:?}"),
8005 }
8006 assert!(
8008 !surface.read().unwrap().tools.is_empty(),
8009 "surface should be refreshed after reconnect"
8010 );
8011 }
8012
8013 #[tokio::test(flavor = "multi_thread")]
8014 async fn a_still_dead_server_surfaces_the_error_after_one_retry() {
8015 let (session, connects) = demo_session().await;
8016 let surface = Arc::new(RwLock::new(Surface::default()));
8017 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8018
8019 let calls = attempts.clone();
8020 let err = with_reconnect(&session, &surface, |_c| {
8021 let calls = calls.clone();
8022 async move {
8023 calls.fetch_add(1, Ordering::SeqCst);
8024 Err::<(), _>(tower_mcp::Error::Transport(
8025 "HTTP 503 Service Unavailable from server: ".into(),
8026 ))
8027 }
8028 })
8029 .await
8030 .unwrap_err();
8031
8032 assert!(is_session_lost(&err));
8033 assert_eq!(attempts.load(Ordering::SeqCst), 2, "bounded to one retry");
8034 assert_eq!(connects.load(Ordering::SeqCst), 1);
8035 }
8036
8037 #[tokio::test(flavor = "multi_thread")]
8038 async fn ordinary_errors_do_not_reconnect() {
8039 let (session, connects) = demo_session().await;
8040 let surface = Arc::new(RwLock::new(Surface::default()));
8041 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8042
8043 let calls = attempts.clone();
8044 let err = with_reconnect(&session, &surface, |_c| {
8045 let calls = calls.clone();
8046 async move {
8047 calls.fetch_add(1, Ordering::SeqCst);
8048 Err::<(), _>(jsonrpc(-32602, "Invalid params"))
8049 }
8050 })
8051 .await
8052 .unwrap_err();
8053
8054 assert!(matches!(err, tower_mcp::Error::JsonRpc(j) if j.code == -32602));
8055 assert_eq!(attempts.load(Ordering::SeqCst), 1, "no retry");
8056 assert_eq!(connects.load(Ordering::SeqCst), 0, "no reconnect");
8057 }
8058
8059 #[tokio::test(flavor = "multi_thread")]
8062 async fn a_session_without_a_connector_never_retries() {
8063 let session = Arc::new(Session::new(demo_client().await, None));
8064 let surface = Arc::new(RwLock::new(Surface::default()));
8065 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8066
8067 assert!(!session.can_reconnect());
8068 let calls = attempts.clone();
8069 let err = with_reconnect(&session, &surface, |_c| {
8070 let calls = calls.clone();
8071 async move {
8072 calls.fetch_add(1, Ordering::SeqCst);
8073 Err::<(), _>(tower_mcp::Error::SessionExpired)
8074 }
8075 })
8076 .await
8077 .unwrap_err();
8078
8079 assert!(matches!(err, tower_mcp::Error::SessionExpired));
8080 assert_eq!(attempts.load(Ordering::SeqCst), 1);
8081 }
8082}