1mod alias;
44mod bench;
45mod command;
46pub mod config;
47mod editor;
48mod elicit;
49mod exit_status;
50mod find;
51pub mod import_config;
52mod jobs;
53pub mod lifecycle;
54pub mod oauth_profile;
55mod output;
56mod sampling;
57mod schema_contract;
58mod session;
59mod style;
60mod subscribe;
61mod surface_subscription;
62mod vars;
63mod wire;
64
65use std::collections::HashMap;
66use std::future::Future;
67use std::sync::atomic::{AtomicBool, Ordering};
68use std::sync::{Arc, RwLock};
69use std::time::Duration;
70
71use clap::{Parser, ValueEnum};
72use nu_ansi_term::{Color, Style};
73
74use tokio::io::{AsyncBufReadExt, BufReader};
75use tower_mcp::client::{
76 ChannelTransport, HttpClientConfig, HttpClientTransport, McpClient, McpClientBuilder,
77 NotificationHandler, OAuthAuthorizationFlow, OAuthAuthorizationStart, OAuthClientError,
78 OAuthScopeEscalationConfig, StdioClientTransport,
79};
80use tower_mcp::protocol::{
81 Content, DiscoverResult, Implementation, InitializeResult, LogLevel, PromptDefinition,
82 ResourceDefinition, ResourceTemplateDefinition, ServerCapabilities, SubscriptionFilter,
83 TaskObject, ToolDefinition,
84};
85use tower_mcp::{ProtocolSupport, ProtocolSupportError};
86
87use alias::Aliases;
88use elicit::ReplClientHandler;
89use exit_status::ExitStatus;
90use jobs::Jobs;
91use output::AsyncOutput;
92use session::{Connector, Session, is_not_initialized, is_session_lost};
93use style::{json_pretty, paint, tag, task_status_style};
94use wire::{TracingTransport, wire};
95
96#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
102enum ProtocolMode {
103 #[default]
104 Stable,
105 #[value(name = "2026-07-28", alias = "final")]
106 Final,
107}
108
109impl ProtocolMode {
110 fn support(self) -> Result<ProtocolSupport, ProtocolSupportError> {
111 match self {
112 Self::Stable => Ok(ProtocolSupport::stable()),
113 Self::Final => ProtocolSupport::try_new(["2026-07-28"]),
114 }
115 }
116}
117
118#[derive(Parser)]
119#[command(
120 name = "mcp-repl",
121 about = "Interactive MCP client REPL",
122 trailing_var_arg = true
123)]
124struct Args {
125 #[arg(long, value_enum, default_value = "stable")]
128 protocol: ProtocolMode,
129
130 #[arg(long)]
133 http: Option<String>,
134
135 #[arg(long, conflicts_with_all = ["http", "command", "server"])]
137 demo: bool,
138
139 #[arg(long, value_name = "NAME")]
143 server: Option<String>,
144
145 #[arg(long, value_name = "PATH")]
148 config: Option<String>,
149
150 #[arg(long)]
152 list_servers: bool,
153
154 #[arg(long, value_enum, default_value = "auto")]
156 color: style::ColorMode,
157
158 #[arg(long)]
163 bearer: Option<String>,
164
165 #[arg(long = "header", value_name = "NAME: VALUE")]
168 headers: Vec<String>,
169
170 #[arg(long, value_name = "NAME")]
172 oauth: Option<String>,
173
174 #[arg(long, value_name = "NAME", conflicts_with = "logout")]
177 login: Option<String>,
178
179 #[arg(long, value_name = "NAME", conflicts_with = "login")]
182 logout: Option<String>,
183
184 #[arg(long = "oauth-scope", value_name = "SCOPE")]
187 oauth_scopes: Vec<String>,
188
189 #[arg(long, value_name = "URL")]
192 oauth_client_id_metadata_document: Option<String>,
193
194 #[arg(long, value_name = "ISSUER")]
197 oauth_authorization_server: Option<String>,
198
199 #[arg(long)]
202 no_browser: bool,
203
204 #[arg(short = 'e', long = "exec", value_name = "COMMAND")]
209 exec: Vec<String>,
210
211 #[arg(long)]
214 json: bool,
215
216 #[arg(long)]
219 verbose: bool,
220
221 #[arg(long = "schema-contract", value_name = "PATH")]
224 schema_contracts: Vec<std::path::PathBuf>,
225
226 #[arg(long, value_enum, default_value = "compatible")]
228 schema_mode: schema_contract::ValidationMode,
229
230 #[arg(long, value_enum, value_name = "STRATEGY")]
235 sampling: Option<sampling::SamplingMode>,
236
237 #[arg(long)]
239 no_history: bool,
240
241 #[arg(long)]
245 no_reconnect: bool,
246
247 #[arg(long)]
250 trace: bool,
251
252 command: Vec<String>,
254}
255
256static JSON_OUTPUT: AtomicBool = AtomicBool::new(false);
258
259fn json_output() -> bool {
260 JSON_OUTPUT.load(Ordering::Relaxed)
261}
262
263fn note_error(status: ExitStatus) {
264 exit_status::record(status);
265}
266
267fn automatic_task_updates(one_shot: bool, json: bool) -> bool {
268 !one_shot && !json
269}
270
271fn print_json(value: &serde_json::Value) {
275 println!("{value}");
276}
277
278fn error_json(status: ExitStatus, message: &str) -> serde_json::Value {
280 serde_json::json!({
281 "error": message,
282 "kind": status.label(),
283 "exitStatus": status.code(),
284 })
285}
286
287fn report_error(status: ExitStatus, message: &str) {
288 note_error(status);
289 if json_output() {
290 print_json(&error_json(status, message));
291 } else {
292 println!("{}: {message}", style::error_prefix());
293 }
294}
295
296fn report_mcp_error(error: &tower_mcp::Error) {
297 report_error(ExitStatus::from_mcp_error(error), &error.to_string());
298}
299
300fn exit_with_error(status: ExitStatus, message: &str) -> ! {
301 if json_output() {
302 print_json(&error_json(status, message));
303 } else {
304 eprintln!("error: {message}");
305 }
306 std::process::exit(status.code());
307}
308
309#[derive(Default)]
312pub(crate) struct Surface {
313 pub tools: Vec<ToolDefinition>,
314 pub prompts: Vec<PromptDefinition>,
315 pub resources: Vec<ResourceDefinition>,
316 pub templates: Vec<ResourceTemplateDefinition>,
317}
318
319pub(crate) const BUILTINS: &[(&str, &str)] = &[
322 ("help", "list built-ins and the server's tools"),
323 ("tools", "list tools"),
324 ("prompts", "list prompts"),
325 ("resources", "list resources"),
326 ("templates", "list resource templates"),
327 ("find", "search the surface by keyword"),
328 ("describe", "show schemas and metadata for a name"),
329 ("snapshot", "export a tool or prompt schema contract"),
330 ("validate", "compare the surface with a schema snapshot"),
331 ("read", "read a resource"),
332 ("subscribe", "watch a resource for updates"),
333 ("unsubscribe", "stop watching a resource"),
334 ("subscriptions", "list active resource subscriptions"),
335 ("prompt", "get a prompt"),
336 ("call", "call a tool with raw JSON"),
337 ("bench", "time repeated calls to a tool"),
338 ("jobs", "list background tasks"),
339 ("task", "show a background task"),
340 ("wait", "wait for a background task"),
341 ("cancel", "cancel a background task"),
342 ("alias", "define, list, or show a command alias"),
343 ("unalias", "remove a command alias"),
344 ("refresh", "re-fetch the server surface"),
345 ("info", "replay the connection banner plus capabilities"),
346 ("wire", "toggle raw JSON-RPC frame tracing (on|off)"),
347 ("last", "reprint the previous request and response"),
348 ("vars", "list captured variables"),
349 ("unset", "clear a captured variable"),
350 ("quit", "exit"),
351 ("exit", "exit"),
352];
353
354fn coerce_arg(schema: &serde_json::Value, key: &str, raw: &str) -> serde_json::Value {
356 let ty = schema
357 .get("properties")
358 .and_then(|p| p.get(key))
359 .and_then(|s| s.get("type"))
360 .and_then(|t| t.as_str());
361 match ty {
362 Some("integer") => raw
363 .parse::<i64>()
364 .map(Into::into)
365 .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
366 Some("number") => raw
367 .parse::<f64>()
368 .ok()
369 .and_then(|n| serde_json::Number::from_f64(n).map(serde_json::Value::Number))
370 .unwrap_or_else(|| serde_json::Value::String(raw.to_string())),
371 Some("boolean") => raw
372 .parse::<bool>()
373 .map(serde_json::Value::Bool)
374 .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
375 Some("array") | Some("object") => {
376 serde_json::from_str(raw).unwrap_or_else(|_| serde_json::Value::String(raw.to_string()))
377 }
378 _ => {
379 serde_json::from_str(raw).unwrap_or_else(|_| serde_json::Value::String(raw.to_string()))
381 }
382 }
383}
384
385fn parse_kv_args(schema: &serde_json::Value, tokens: &[&str]) -> serde_json::Value {
386 if tokens.len() == 1
388 && tokens[0].starts_with('{')
389 && let Ok(v) = serde_json::from_str::<serde_json::Value>(tokens[0])
390 {
391 return v;
392 }
393 let mut map = serde_json::Map::new();
394 for t in tokens {
395 if let Some((k, v)) = t.split_once('=') {
396 map.insert(k.to_string(), coerce_arg(schema, k, v));
397 }
398 }
399 serde_json::Value::Object(map)
400}
401
402fn render_content(content: &[Content]) {
403 for c in content {
404 match c {
405 Content::Text { text, .. } => {
406 if style::colors_enabled() && style::looks_like_markdown(text) {
407 println!("{}", style::render_markdown(text));
408 } else {
409 println!("{text}");
410 }
411 }
412 other => {
413 let v = serde_json::to_value(other).unwrap_or_default();
414 let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("content");
415 match ty {
416 "image" | "audio" => {
417 let mime = v.get("mimeType").and_then(|m| m.as_str()).unwrap_or("?");
418 let len = v.get("data").and_then(|d| d.as_str()).map_or(0, str::len);
419 println!(
420 "{}",
421 tag(Style::new(), &format!("{ty} {mime}, {len} base64 chars"))
422 );
423 }
424 _ => println!("{}", json_pretty(&v)),
425 }
426 }
427 }
428 }
429}
430
431fn render_task(task: &TaskObject) {
432 println!(
433 "task {} status={} {}",
434 paint(Style::new().bold(), &task.task_id),
435 paint(task_status_style(task.status), &task.status.to_string()),
436 task.status_message.as_deref().unwrap_or("")
437 );
438 if let Some(result) = &task.result {
439 render_content(&result.content);
440 }
441 if let Some(err) = &task.error {
442 println!("{} {}: {}", style::error_prefix(), err.code, err.message);
443 }
444}
445
446#[derive(Clone, Debug)]
448struct ConnectionInfo {
449 protocol_version: String,
450 capabilities: ServerCapabilities,
451 server_info: Implementation,
452 instructions: Option<String>,
453}
454
455impl From<InitializeResult> for ConnectionInfo {
456 fn from(info: InitializeResult) -> Self {
457 Self {
458 protocol_version: info.protocol_version,
459 capabilities: info.capabilities,
460 server_info: info.server_info,
461 instructions: info.instructions,
462 }
463 }
464}
465
466impl ConnectionInfo {
467 fn from_discovery(discovery: DiscoverResult, protocol_version: String) -> Self {
468 let server_info = discovery
469 .meta
470 .as_ref()
471 .and_then(|meta| meta.server_info.clone())
472 .unwrap_or_else(|| Implementation {
473 name: "MCP server".to_string(),
474 version: "unknown".to_string(),
475 ..Default::default()
476 });
477 Self {
478 protocol_version,
479 capabilities: discovery.capabilities,
480 server_info,
481 instructions: discovery.instructions,
482 }
483 }
484}
485
486async fn connection_info(client: &McpClient) -> Option<ConnectionInfo> {
487 if let Some(info) = client.server_info().await {
488 return Some(info.into());
489 }
490 let discovery = client.discovery().await?;
491 let protocol_version = client.selected_protocol_version().await?;
492 Some(ConnectionInfo::from_discovery(discovery, protocol_version))
493}
494
495async fn establish_connection(
496 client: &McpClient,
497 protocol: ProtocolMode,
498) -> tower_mcp::Result<ConnectionInfo> {
499 match protocol {
500 ProtocolMode::Stable => client
501 .initialize("mcp-repl", env!("CARGO_PKG_VERSION"))
502 .await
503 .map(Into::into),
504 ProtocolMode::Final => {
505 let discovery: DiscoverResult = client
506 .discover("mcp-repl", env!("CARGO_PKG_VERSION"))
507 .await?;
508 let protocol_version = client
509 .selected_protocol_version()
510 .await
511 .unwrap_or_else(|| "2026-07-28".to_string());
512 Ok(ConnectionInfo::from_discovery(discovery, protocol_version))
513 }
514 }
515}
516
517fn client_builder(protocol: ProtocolMode) -> Result<McpClientBuilder, ProtocolSupportError> {
518 let builder = McpClient::builder()
519 .protocol_support(protocol.support()?)
520 .with_elicitation()
521 .with_sampling();
522 Ok(match protocol {
523 ProtocolMode::Stable => builder,
524 ProtocolMode::Final => builder.with_tasks(),
525 })
526}
527
528fn print_banner(info: &ConnectionInfo) {
532 println!(
533 "connected: {} v{} {}",
534 paint(Style::new().bold(), &info.server_info.name),
535 info.server_info.version,
536 paint(
537 Style::new().dimmed(),
538 &format!("(protocol {})", info.protocol_version)
539 )
540 );
541 if let Some(instructions) = &info.instructions {
542 if style::colors_enabled() && style::looks_like_markdown(instructions) {
543 println!("{}", style::render_markdown(instructions));
544 } else {
545 println!("{instructions}");
546 }
547 }
548}
549
550pub(crate) fn timing(elapsed: Duration) -> String {
554 let body = if elapsed.as_millis() < 1000 {
555 format!("[{}ms]", elapsed.as_millis())
556 } else {
557 format!("[{:.2}s]", elapsed.as_secs_f64())
558 };
559 paint(Style::new().dimmed(), &body)
560}
561
562fn print_tool_overview(surface: &Surface) {
566 const CAP: usize = 30;
567 if surface.tools.is_empty() {
568 return;
569 }
570 for t in surface.tools.iter().take(CAP) {
571 println!(
572 "{:24} {}",
573 paint(Style::new().fg(Color::Green), &t.name),
574 t.description.as_deref().unwrap_or("")
575 );
576 }
577 if surface.tools.len() > CAP {
578 println!(
579 "{}",
580 paint(
581 Style::new().dimmed(),
582 &format!("... +{} more, type `tools`", surface.tools.len() - CAP)
583 )
584 );
585 }
586}
587
588fn print_find(surface: &Surface, query: &str) {
592 let hits = find::search(surface, query);
593 if json_output() {
594 let v: Vec<serde_json::Value> = hits
595 .iter()
596 .map(|h| {
597 serde_json::json!({
598 "kind": h.kind.heading(),
599 "name": h.name,
600 "description": h.description,
601 "score": h.score,
602 })
603 })
604 .collect();
605 if v.is_empty() {
606 note_error(ExitStatus::NoMatch);
607 }
608 print_json(&serde_json::Value::Array(v));
609 return;
610 }
611 if hits.is_empty() {
612 note_error(ExitStatus::NoMatch);
615 println!("no match for {}", paint(Style::new().fg(Color::Red), query));
616 return;
617 }
618 let total = hits.len();
619 for (kind, group) in find::grouped(hits) {
620 println!("{}:", paint(Style::new().bold(), kind.heading()));
621 for hit in group {
622 println!(
623 " {:24} {}",
624 paint(Style::new().fg(Color::Green), &hit.name),
625 hit.description
626 );
627 }
628 }
629 println!(
630 "{}",
631 paint(
632 Style::new().dimmed(),
633 &format!("{total} match{}", if total == 1 { "" } else { "es" })
634 )
635 );
636}
637
638fn print_counts(surface: &Surface) {
640 println!(
641 "{} tools, {} prompts, {} resources, {} templates. Type `help`.",
642 surface.tools.len(),
643 surface.prompts.len(),
644 surface.resources.len(),
645 surface.templates.len()
646 );
647}
648
649async fn with_reconnect<T, F, Fut>(
661 session: &Session,
662 surface: &Arc<RwLock<Surface>>,
663 op: F,
664) -> Result<T, tower_mcp::Error>
665where
666 F: Fn(Arc<McpClient>) -> Fut,
667 Fut: Future<Output = Result<T, tower_mcp::Error>>,
668{
669 let seen = session.generation();
670 let err = match op(session.client()).await {
671 Ok(value) => return Ok(value),
672 Err(e) => e,
673 };
674 if !session.can_reconnect() || !is_session_lost(&err) {
675 return Err(err);
676 }
677 if let Err(reconnect_err) = session.reconnect(seen).await {
678 eprintln!("reconnect failed: {reconnect_err}");
679 return Err(err);
680 }
681 *surface.write().unwrap() = fetch_surface(&session.client()).await;
686 eprintln!("{}", paint(Style::new().dimmed(), "[reconnected]"));
689
690 let retried = op(session.client()).await;
691 if let Err(e) = &retried
692 && is_session_lost(e)
693 {
694 eprintln!(
695 "still no session after reconnecting. The server is likely down or \
696 restart-looping; check its logs, or pass --no-reconnect to see the \
697 raw errors."
698 );
699 }
700 retried
701}
702
703async fn fetch_surface_once(client: &McpClient) -> (Surface, bool) {
706 fn take<T>(
707 what: &str,
708 r: Result<Vec<T>, tower_mcp::Error>,
709 not_initialized: &mut bool,
710 ) -> Vec<T> {
711 match r {
712 Ok(v) => v,
713 Err(e) => {
714 if is_not_initialized(&e) {
715 *not_initialized = true;
716 } else {
717 eprintln!("warning: fetching {what} failed: {e}");
718 }
719 Vec::new()
720 }
721 }
722 }
723 let (tools, prompts, resources, templates) = tokio::join!(
730 client.list_all_tools(),
731 client.list_all_prompts(),
732 client.list_all_resources(),
733 client.list_all_resource_templates(),
734 );
735 let mut ni = false;
736 let surface = Surface {
737 tools: take("tools", tools, &mut ni),
738 prompts: take("prompts", prompts, &mut ni),
739 resources: take("resources", resources, &mut ni),
740 templates: take("resource templates", templates, &mut ni),
741 };
742 (surface, ni)
743}
744
745async fn fetch_surface(client: &McpClient) -> Surface {
746 fetch_surface_once(client).await.0
747}
748
749async fn refresh_surface(session: &Session) -> Surface {
754 let (fresh, not_initialized) = fetch_surface_once(&session.client()).await;
755 if !not_initialized || !session.can_reconnect() {
756 return fresh;
757 }
758 let seen = session.generation();
759 match session.reconnect(seen).await {
760 Ok(()) => {
761 eprintln!("{}", paint(Style::new().dimmed(), "[reconnected]"));
762 fetch_surface(&session.client()).await
763 }
764 Err(e) => {
765 eprintln!("reconnect failed: {e}");
766 fresh
767 }
768 }
769}
770
771async fn fetch_surface_initial(client: &McpClient) -> Surface {
774 const ATTEMPTS: usize = 4;
775 for attempt in 1..=ATTEMPTS {
776 let (surface, not_initialized) = fetch_surface_once(client).await;
777 if !not_initialized {
778 return surface;
779 }
780 if attempt == ATTEMPTS {
781 eprintln!(
782 "warning: the server kept rejecting surface requests as not-initialized \
783 after {ATTEMPTS} attempts. The session the handshake established is not \
784 being recognized on follow-up requests. Two common causes: the server runs \
785 multiple instances without a shared session store, so requests scatter \
786 across instances; or a single instance restarted (crash, OOM, or redeploy) \
787 between requests and lost its in-memory sessions. Try `refresh`. A \
788 persistent session store or the stateless protocol avoids both; if it is a \
789 single instance, check its logs and resources (an OOM-looping machine \
790 flaps like this)."
791 );
792 return surface;
793 }
794 tokio::time::sleep(Duration::from_millis(200 * attempt as u64)).await;
795 }
796 unreachable!()
797}
798
799fn build_http_config(
806 bearer: Option<String>,
807 headers: &[String],
808 profile_bearer: Option<String>,
809 profile_headers: &[(String, String)],
810) -> Result<HttpClientConfig, String> {
811 build_http_config_with_env(
812 bearer,
813 headers,
814 profile_bearer,
815 profile_headers,
816 std::env::var("MCP_BEARER").ok(),
817 )
818}
819
820fn build_http_config_with_env(
821 bearer: Option<String>,
822 headers: &[String],
823 profile_bearer: Option<String>,
824 profile_headers: &[(String, String)],
825 env_bearer: Option<String>,
826) -> Result<HttpClientConfig, String> {
827 let mut config = HttpClientConfig::default();
828 for (name, value) in profile_headers {
829 config = config.header(name.as_str(), value.as_str());
830 }
831 let selected_has_authorization = profile_headers
832 .iter()
833 .any(|(name, _)| name.eq_ignore_ascii_case("authorization"));
834 if let Some(token) = bearer.or(profile_bearer).or_else(|| {
835 (!selected_has_authorization)
836 .then_some(env_bearer)
837 .flatten()
838 }) {
839 config = config.bearer_token(token);
840 }
841 for raw in headers {
842 let (name, value) = raw
843 .split_once(':')
844 .ok_or_else(|| format!("invalid --header {raw:?}: expected `Name: Value`"))?;
845 config = config.header(name.trim(), value.trim());
846 }
847 Ok(config)
848}
849
850fn selected_oauth_profile(
851 cli_oauth: Option<&str>,
852 profile_oauth: Option<&str>,
853 cli_bearer: bool,
854 cli_headers: &[String],
855) -> Option<String> {
856 let explicit_authorization = cli_bearer
857 || cli_headers.iter().any(|header| {
858 header
859 .split_once(':')
860 .is_some_and(|(name, _)| name.trim().eq_ignore_ascii_case("authorization"))
861 });
862 (!explicit_authorization)
863 .then(|| cli_oauth.or(profile_oauth).map(str::to_string))
864 .flatten()
865}
866
867fn demo_router() -> tower_mcp::McpRouter {
868 use tower_mcp::extract::RawArgs;
869 use tower_mcp::protocol::{CompleteResult, CompletionReference, ReadResourceResult};
870 use tower_mcp::resource::ResourceTemplateBuilder;
871 use tower_mcp::{CallToolResult, PromptBuilder, TaskSupportMode, ToolBuilder};
872
873 const NOTES: &[(&str, &str)] = &[
874 ("groceries", "- eggs\n- coffee"),
875 ("ideas", "# Ideas\n\n- a REPL for MCP servers"),
876 ("todo", "1. ship it"),
877 ];
878
879 tower_mcp::McpRouter::new()
880 .server_info("mcp-repl-demo", env!("CARGO_PKG_VERSION"))
881 .with_tasks()
882 .prompt(
883 PromptBuilder::new("greet")
884 .description("Generate a greeting (name tab-completes via the server)")
885 .required_arg("name", "The person to greet")
886 .handler(|args| async move {
887 let name = args.get("name").map(|s| s.as_str()).unwrap_or("World");
888 Ok(tower_mcp::GetPromptResult::user_message(format!(
889 "Please greet {name} warmly."
890 )))
891 })
892 .build(),
893 )
894 .resource(
899 tower_mcp::resource::ResourceBuilder::new("note://status")
900 .name("Status")
901 .description("A one-line status note (subscribe to it)")
902 .mime_type("text/plain")
903 .handler(|| async {
904 Ok(ReadResourceResult::text(
905 "note://status",
906 "all quiet on the demo server",
907 ))
908 })
909 .build(),
910 )
911 .resource_template(
912 ResourceTemplateBuilder::new("note://{name}")
913 .name("Notes")
914 .description("Tiny in-memory notes (name tab-completes via the server)")
915 .mime_type("text/markdown")
916 .handler(
917 |uri: String, vars: std::collections::HashMap<String, String>| async move {
918 let name = vars.get("name").cloned().unwrap_or_default();
919 let text = NOTES
920 .iter()
921 .find(|(n, _)| *n == name)
922 .map(|(_, t)| (*t).to_string())
923 .unwrap_or_else(|| format!("no note named `{name}`"));
924 Ok(ReadResourceResult::text(uri, text))
925 },
926 ),
927 )
928 .completion_handler(|params| async move {
929 let partial = params.argument.value;
930 let candidates: Vec<String> = match ¶ms.reference {
931 CompletionReference::Prompt { name } if name == "greet" => {
932 ["Ada", "Alan", "Grace", "Linus"]
933 .iter()
934 .map(|s| s.to_string())
935 .collect()
936 }
937 CompletionReference::Resource { uri } if uri == "note://{name}" => {
938 NOTES.iter().map(|(n, _)| n.to_string()).collect()
939 }
940 _ => Vec::new(),
941 };
942 Ok(CompleteResult::new(
943 candidates
944 .into_iter()
945 .filter(|c| c.starts_with(&partial))
946 .collect::<Vec<_>>(),
947 ))
948 })
949 .tool(
950 ToolBuilder::new("echo")
951 .description("Echo a message back")
952 .extractor_handler((), |RawArgs(args): RawArgs| async move {
953 let msg = args.get("message").and_then(|v| v.as_str()).unwrap_or("");
954 Ok(CallToolResult::text(msg.to_string()))
955 })
956 .build(),
957 )
958 .tool(
959 ToolBuilder::new("about")
960 .description("Markdown-formatted notes about this demo server")
961 .extractor_handler((), |RawArgs(_): RawArgs| async move {
962 Ok(CallToolResult::text(
963 "# mcp-repl demo\n\n\
964 A tiny in-process router for exploring the REPL.\n\n\
965 - `echo message=hi` echoes back\n\
966 - `slow_add a=2 b=3 &` runs **task-augmented**\n\
967 - `describe slow_add` shows the tool's schemas\n",
968 ))
969 })
970 .build(),
971 )
972 .tool(
973 ToolBuilder::new("slow_add")
974 .description("Add two numbers, slowly (try running with a trailing &)")
975 .task_support(TaskSupportMode::Optional)
976 .extractor_handler((), |RawArgs(args): RawArgs| async move {
977 let a = args.get("a").and_then(|v| v.as_i64()).unwrap_or(0);
978 let b = args.get("b").and_then(|v| v.as_i64()).unwrap_or(0);
979 tokio::time::sleep(Duration::from_secs(3)).await;
980 Ok(CallToolResult::text(format!("{}", a + b)))
981 })
982 .build(),
983 )
984}
985
986fn notification_handler(
990 refresh_tx: tokio::sync::mpsc::UnboundedSender<()>,
991 output: AsyncOutput,
992 jobs: Arc<Jobs>,
993) -> NotificationHandler {
994 let t = refresh_tx.clone();
995 let r = refresh_tx.clone();
996 let p = refresh_tx;
997 NotificationHandler::new()
998 .on_tools_changed(move || {
999 let _ = t.send(());
1000 })
1001 .on_resources_changed(move || {
1002 let _ = r.send(());
1003 })
1004 .on_prompts_changed(move || {
1005 let _ = p.send(());
1006 })
1007 .on_task_status_changed({
1008 let jobs = jobs.clone();
1009 move |params| jobs.observe_legacy(params)
1010 })
1011 .on_final_task_status_changed(move |params| jobs.observe_final(params))
1012 .on_progress({
1013 let output = output.clone();
1014 move |p| {
1015 let pct = match (p.progress, p.total) {
1016 (done, Some(total)) if total > 0.0 => {
1017 format!(" {:.0}%", 100.0 * done / total)
1018 }
1019 _ => String::new(),
1020 };
1021 output.line(format!(
1022 "{} {}",
1023 tag(Style::new().fg(Color::Cyan), &format!("progress{pct}")),
1024 p.message.as_deref().unwrap_or("")
1025 ));
1026 }
1027 })
1028 .on_resource_updated({
1032 let output = output.clone();
1033 move |uri| {
1034 let known = if subscribe::contains(&uri) {
1035 String::new()
1036 } else {
1037 format!(" {}", paint(Style::new().dimmed(), "(not subscribed here)"))
1038 };
1039 output.line(format!(
1040 "{} {uri}{known}",
1041 tag(Style::new().fg(Color::Cyan), "resource updated")
1042 ));
1043 }
1044 })
1045 .on_log_message(move |m| {
1046 output.line(format!(
1047 "{} {}",
1048 tag(log_level_style(m.level), &format!("log {}", m.level)),
1049 m.data
1050 ));
1051 })
1052}
1053
1054fn forward_child_stderr(stderr: tokio::process::ChildStderr, output: AsyncOutput) {
1056 tokio::spawn(async move {
1057 let mut lines = BufReader::new(stderr).lines();
1058 loop {
1059 match lines.next_line().await {
1060 Ok(Some(line)) => output.line(line),
1061 Ok(None) => break,
1062 Err(error) => {
1063 output.line(format!("warning: reading server stderr failed: {error}"));
1064 break;
1065 }
1066 }
1067 }
1068 });
1069}
1070
1071fn watch_task(session: Arc<Session>, jobs: Arc<Jobs>, task_id: String, poll_interval: Option<u64>) {
1076 if !jobs.automatic_updates_enabled() || jobs.is_terminal(&task_id) {
1077 return;
1078 }
1079 tokio::spawn(async move {
1080 let client = session.client();
1081 let _subscription =
1082 if client.selected_protocol_version().await.as_deref() == Some("2026-07-28") {
1083 match client
1084 .listen_subscriptions(SubscriptionFilter {
1085 task_ids: Some(vec![task_id.clone()]),
1086 ..Default::default()
1087 })
1088 .await
1089 {
1090 Ok(mut handle) => match handle.acknowledged().await {
1091 Ok(accepted)
1092 if accepted
1093 .task_ids
1094 .as_ref()
1095 .is_some_and(|ids| ids.iter().any(|id| id == &task_id)) =>
1096 {
1097 Some(handle)
1098 }
1099 _ => None,
1100 },
1101 Err(_) => None,
1102 }
1103 } else {
1104 None
1105 };
1106 let mut interval_ms = poll_interval.unwrap_or(1000).clamp(50, 30_000);
1107 let mut consecutive_errors = 0;
1108 loop {
1109 tokio::time::sleep(Duration::from_millis(interval_ms)).await;
1110 if jobs.is_terminal(&task_id) {
1111 break;
1112 }
1113 match session.client().task_get(&task_id).await {
1114 Ok(task) => {
1115 consecutive_errors = 0;
1116 interval_ms = task.poll_interval.unwrap_or(1000).clamp(50, 30_000);
1117 let terminal = task.status.is_terminal();
1118 jobs.observe_task(&task);
1119 if terminal {
1120 break;
1121 }
1122 }
1123 Err(_) => {
1124 consecutive_errors += 1;
1125 if consecutive_errors >= 3 {
1126 break;
1127 }
1128 }
1129 }
1130 }
1131 });
1132}
1133
1134#[derive(Clone)]
1142struct OAuthRuntime {
1143 flow: OAuthAuthorizationFlow,
1144 scopes: Vec<String>,
1145}
1146
1147fn http_transport(
1148 url: String,
1149 config: HttpClientConfig,
1150 oauth: Option<OAuthRuntime>,
1151) -> HttpClientTransport {
1152 let transport = HttpClientTransport::with_config(url, config);
1153 match oauth {
1154 Some(oauth) => transport.with_scope_aware_token_provider(
1155 oauth.flow,
1156 OAuthScopeEscalationConfig::new(oauth.scopes).max_attempts(2),
1157 ),
1158 None => transport,
1159 }
1160}
1161
1162fn http_connector(
1163 url: String,
1164 config: HttpClientConfig,
1165 oauth: Option<OAuthRuntime>,
1166 make_handler: Arc<dyn Fn() -> ReplClientHandler + Send + Sync>,
1167 protocol: ProtocolMode,
1168) -> Connector {
1169 Box::new(move || {
1170 let (url, config, oauth, handler) =
1171 (url.clone(), config.clone(), oauth.clone(), make_handler());
1172 Box::pin(async move {
1173 let client = client_builder(protocol)
1174 .map_err(|error| tower_mcp::Error::Transport(error.to_string()))?
1175 .connect(
1176 TracingTransport::new(http_transport(url, config, oauth)),
1177 handler,
1178 )
1179 .await?;
1180 establish_connection(&client, protocol).await?;
1181 Ok(client)
1182 })
1183 })
1184}
1185
1186fn load_config(explicit: Option<&str>) -> config::Config {
1189 let Some((path, explicit)) = config::config_path(explicit) else {
1190 return config::Config::default();
1191 };
1192 match config::Config::load(&path, explicit) {
1193 Ok(c) => c,
1194 Err(e) => {
1195 exit_with_error(ExitStatus::Usage, &e);
1196 }
1197 }
1198}
1199
1200async fn handle_oauth_profile_action(
1201 args: &Args,
1202 profiles: &config::Config,
1203 config_file: Option<&std::path::Path>,
1204) -> bool {
1205 let Some(name) = args.login.as_deref().or(args.logout.as_deref()) else {
1206 if !args.oauth_scopes.is_empty()
1207 || args.oauth_client_id_metadata_document.is_some()
1208 || args.oauth_authorization_server.is_some()
1209 {
1210 exit_with_error(
1211 ExitStatus::Usage,
1212 "--oauth-scope, --oauth-client-id-metadata-document, and \
1213 --oauth-authorization-server apply only to --login",
1214 );
1215 }
1216 return false;
1217 };
1218 oauth_profile::validate_name(name)
1219 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
1220 if args.demo
1221 || !args.command.is_empty()
1222 || !args.exec.is_empty()
1223 || args.json
1224 || args.list_servers
1225 || args.bearer.is_some()
1226 || !args.headers.is_empty()
1227 || args.oauth.is_some()
1228 {
1229 exit_with_error(
1230 ExitStatus::Usage,
1231 "--login/--logout are standalone credential operations; do not combine them with \
1232 a command, --demo, --exec/--json, --list-servers, --bearer, --header, or --oauth",
1233 );
1234 }
1235 let path = config_file.unwrap_or_else(|| {
1236 exit_with_error(
1237 ExitStatus::Usage,
1238 "no config file location is available; set HOME/XDG_CONFIG_HOME or pass --config",
1239 )
1240 });
1241
1242 if args.logout.is_some() {
1243 let store = oauth_profile::CredentialStore::keyring(name)
1244 .unwrap_or_else(|error| exit_with_error(ExitStatus::Auth, &error));
1245 store
1246 .clear()
1247 .await
1248 .unwrap_or_else(|error| exit_with_error(ExitStatus::Auth, &error));
1249 oauth_profile::remove_metadata(path, name)
1250 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
1251 println!("removed OAuth profile {name:?} and its stored credentials");
1252 return true;
1253 }
1254
1255 let existing = profiles.oauth.get(name).cloned().unwrap_or_default();
1256 let server_url = args.server.as_deref().map(|server_name| {
1257 let profile = profiles
1258 .profile(server_name)
1259 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
1260 match profile.transport() {
1261 Ok(config::Transport::Http) => profile
1262 .url
1263 .clone()
1264 .or_else(|| {
1265 profile
1266 .oauth
1267 .as_deref()
1268 .and_then(|oauth| profiles.oauth.get(oauth))
1269 .map(|metadata| metadata.url.clone())
1270 })
1271 .unwrap_or_else(|| {
1272 exit_with_error(
1273 ExitStatus::Usage,
1274 &format!("server profile {server_name:?} has no HTTP URL"),
1275 )
1276 }),
1277 Ok(config::Transport::Stdio) => exit_with_error(
1278 ExitStatus::Usage,
1279 &format!("server profile {server_name:?} is stdio; OAuth requires HTTP"),
1280 ),
1281 Err(error) => exit_with_error(ExitStatus::Usage, &error),
1282 }
1283 });
1284 let url = args
1285 .http
1286 .clone()
1287 .or(server_url)
1288 .or_else(|| (!existing.url.is_empty()).then(|| existing.url.clone()))
1289 .unwrap_or_else(|| {
1290 exit_with_error(
1291 ExitStatus::Usage,
1292 "a new OAuth profile needs --http URL (or --server with an HTTP profile)",
1293 )
1294 });
1295 let scopes = if args.oauth_scopes.is_empty() {
1296 existing.scopes
1297 } else {
1298 args.oauth_scopes
1299 .iter()
1300 .flat_map(|scope| scope.split_ascii_whitespace())
1301 .map(str::to_string)
1302 .fold(Vec::new(), |mut scopes, scope| {
1303 if !scope.is_empty() && !scopes.contains(&scope) {
1304 scopes.push(scope);
1305 }
1306 scopes
1307 })
1308 };
1309 let metadata = config::OAuthProfile {
1310 url: url.clone(),
1311 scopes,
1312 client_id_metadata_document: args
1313 .oauth_client_id_metadata_document
1314 .clone()
1315 .or(existing.client_id_metadata_document),
1316 authorization_server: args
1317 .oauth_authorization_server
1318 .clone()
1319 .or(existing.authorization_server),
1320 };
1321 let (flow, store) = oauth_profile::build_flow(name, &url, &metadata, true, !args.no_browser)
1322 .unwrap_or_else(|error| exit_with_error(ExitStatus::Auth, &error));
1323 if let Err(error) = flow.authorize(metadata.scopes.clone()).await {
1324 if matches!(error, OAuthClientError::TokenRequest(_)) {
1325 store
1326 .clear_tokens()
1327 .await
1328 .unwrap_or_else(|store_error| exit_with_error(ExitStatus::Auth, &store_error));
1329 let (retry, _) =
1330 oauth_profile::build_flow(name, &url, &metadata, true, !args.no_browser)
1331 .unwrap_or_else(|build_error| exit_with_error(ExitStatus::Auth, &build_error));
1332 retry
1333 .authorize(metadata.scopes.clone())
1334 .await
1335 .unwrap_or_else(|retry_error| {
1336 exit_with_error(ExitStatus::Auth, &retry_error.to_string())
1337 });
1338 } else {
1339 exit_with_error(ExitStatus::Auth, &error.to_string());
1340 }
1341 }
1342 if let Err(error) = oauth_profile::save_metadata(path, name, &metadata) {
1343 let _ = store.clear().await;
1344 exit_with_error(ExitStatus::Usage, &error);
1345 }
1346 println!(
1347 "saved OAuth profile {name:?}; credentials are in the operating-system credential store"
1348 );
1349 true
1350}
1351
1352fn print_servers(config: &config::Config) {
1354 if config.servers.is_empty() {
1355 println!("no server profiles configured");
1356 return;
1357 }
1358 let width = config.names().iter().map(|n| n.len()).max().unwrap_or(0);
1359 for (name, profile) in &config.servers {
1360 println!(
1361 "{:width$} {}",
1362 paint(Style::new().fg(Color::Cyan), name),
1363 paint(Style::new().dimmed(), &profile.summary()),
1364 );
1365 }
1366}
1367
1368fn resolve_profile(args: &Args, config: &config::Config) -> Option<(String, config::Connection)> {
1373 let name = args
1374 .server
1375 .clone()
1376 .or_else(|| match args.command.as_slice() {
1377 [only] if config.servers.contains_key(only) => Some(only.clone()),
1378 _ => None,
1379 })?;
1380 let profile = match config.profile(&name) {
1381 Ok(p) => p,
1382 Err(e) => {
1383 exit_with_error(ExitStatus::Usage, &e);
1384 }
1385 };
1386 if profile.bearer.is_some() {
1387 eprintln!(
1388 "warning: profile {name:?} stores a literal `bearer` token; prefer \
1389 `bearer_env = \"VAR\"` so the token is not kept in the config file"
1390 );
1391 }
1392 match config.resolve_profile_with(&name, |var| std::env::var(var).ok()) {
1393 Ok(connection) => Some((name, connection)),
1394 Err(e) => {
1395 exit_with_error(ExitStatus::Usage, &format!("server profile {name:?}: {e}"));
1396 }
1397 }
1398}
1399
1400fn resolve_import(args: &Args) -> Option<import_config::ImportedConnection> {
1404 let candidate = match args.server.as_deref() {
1405 Some(server) => server,
1406 None => match args.command.as_slice() {
1407 [only] => only,
1408 _ => return None,
1409 },
1410 };
1411 let selector = match import_config::parse_selector(candidate)? {
1412 Ok(selector) => selector,
1413 Err(error) => exit_with_error(ExitStatus::Usage, &error),
1414 };
1415 Some(
1416 import_config::load_with(selector, |variable| std::env::var(variable).ok())
1417 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error)),
1418 )
1419}
1420
1421fn log_level_style(level: LogLevel) -> Style {
1422 match level {
1423 LogLevel::Emergency | LogLevel::Alert | LogLevel::Critical | LogLevel::Error => {
1424 Style::new().fg(Color::Red)
1425 }
1426 LogLevel::Warning => Style::new().fg(Color::Yellow),
1427 LogLevel::Notice | LogLevel::Info => Style::new().fg(Color::Green),
1428 _ => Style::new().dimmed(),
1429 }
1430}
1431
1432#[tokio::main]
1438pub async fn run_cli() {
1439 tracing_subscriber::fmt()
1440 .with_writer(std::io::stderr)
1441 .with_env_filter(
1442 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()),
1443 )
1444 .init();
1445 let args = Args::parse();
1446 style::init(args.color);
1447 wire::init(args.trace);
1448 JSON_OUTPUT.store(args.json, Ordering::Relaxed);
1449
1450 if let Err(error) = run(args).await {
1451 exit_with_error(ExitStatus::from_mcp_error(&error), &error.to_string());
1452 }
1453}
1454
1455async fn run(args: Args) -> tower_mcp::Result<()> {
1456 let config_file = config::config_path(args.config.as_deref()).map(|(path, _)| path);
1459 let profiles = if args.login.is_some() || args.logout.is_some() {
1460 config_file
1461 .as_deref()
1462 .map(|path| {
1463 config::Config::load(path, false)
1464 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error))
1465 })
1466 .unwrap_or_default()
1467 } else {
1468 load_config(args.config.as_deref())
1469 };
1470 if handle_oauth_profile_action(&args, &profiles, config_file.as_deref()).await {
1471 return Ok(());
1472 }
1473 if args.list_servers {
1474 print_servers(&profiles);
1475 return Ok(());
1476 }
1477 let schema_contracts =
1478 schema_contract::ContractSet::load(&args.schema_contracts, args.schema_mode)
1479 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
1480 let imported = resolve_import(&args);
1481 let profile = if imported.is_none() {
1482 resolve_profile(&args, &profiles)
1483 } else {
1484 None
1485 };
1486 let one_shot = !args.exec.is_empty();
1489 let quiet = one_shot && (!args.verbose || args.json);
1492
1493 let at_prompt = Arc::new(AtomicBool::new(false));
1497 let async_output = AsyncOutput::new(at_prompt.clone(), !one_shot);
1498 let jobs = Arc::new(Jobs::new(
1502 async_output.clone(),
1503 automatic_task_updates(one_shot, args.json),
1504 ));
1505
1506 let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
1508
1509 let make_handler: Arc<dyn Fn() -> ReplClientHandler + Send + Sync> = {
1512 let refresh_tx = refresh_tx.clone();
1513 let at_prompt = at_prompt.clone();
1514 let async_output = async_output.clone();
1515 let jobs = jobs.clone();
1516 Arc::new(move || {
1517 ReplClientHandler::new(
1518 notification_handler(refresh_tx.clone(), async_output.clone(), jobs.clone()),
1519 at_prompt.clone(),
1520 )
1521 })
1522 };
1523 drop(refresh_tx);
1524 sampling::init(sampling::resolve(args.sampling, one_shot));
1528
1529 let (profile_name, import_label, connection) = match (imported, profile) {
1533 (Some(imported), _) => (None, Some(imported.label()), Some(imported.connection)),
1534 (None, Some((name, connection))) => (Some(name), None, Some(connection)),
1535 (None, None) => (None, None, None),
1536 };
1537
1538 let aliases = Arc::new(RwLock::new(Aliases::new(
1541 profiles.aliases.clone(),
1542 profile_name
1543 .as_ref()
1544 .and_then(|name| profiles.servers.get(name))
1545 .map(|p| p.aliases.clone())
1546 .unwrap_or_default(),
1547 profile_name.clone(),
1548 config_file,
1549 )));
1550
1551 let connection = match (args.http.clone(), connection) {
1552 (
1553 Some(url),
1554 Some(config::Connection::Http {
1555 bearer,
1556 headers,
1557 oauth,
1558 ..
1559 }),
1560 ) => Some(config::Connection::Http {
1561 url,
1562 bearer,
1563 headers,
1564 oauth,
1565 }),
1566 (Some(url), _) => Some(config::Connection::Http {
1567 url,
1568 bearer: None,
1569 headers: Vec::new(),
1570 oauth: None,
1571 }),
1572 (None, Some(c)) => Some(c),
1573 (None, None) if args.command.is_empty() && args.oauth.is_some() => {
1574 let name = args.oauth.as_deref().expect("guarded above");
1575 let metadata = profiles.oauth.get(name).unwrap_or_else(|| {
1576 exit_with_error(
1577 ExitStatus::Usage,
1578 &format!("no OAuth profile named {name:?}; create it with --login"),
1579 )
1580 });
1581 Some(config::Connection::Http {
1582 url: metadata.url.clone(),
1583 bearer: None,
1584 headers: Vec::new(),
1585 oauth: Some(name.to_string()),
1586 })
1587 }
1588 (None, None) if !args.command.is_empty() => Some(config::Connection::Stdio {
1589 command: args.command.clone(),
1590 env: std::collections::BTreeMap::new(),
1591 cwd: None,
1592 }),
1593 (None, None) => None,
1594 };
1595
1596 let over_http = matches!(connection, Some(config::Connection::Http { .. }));
1597 if !over_http && (args.bearer.is_some() || !args.headers.is_empty()) {
1598 eprintln!("warning: --bearer/--header apply only to HTTP servers; ignoring them here");
1599 }
1600 if !over_http && args.oauth.is_some() {
1601 exit_with_error(ExitStatus::Usage, "--oauth applies only to HTTP servers");
1602 }
1603 if let Some(name) = &profile_name
1604 && !quiet
1605 {
1606 println!(
1607 "{}",
1608 tag(Style::new().fg(Color::Cyan), &format!("profile {name}"))
1609 );
1610 } else if let Some(label) = &import_label
1611 && !quiet
1612 {
1613 println!(
1614 "{}",
1615 tag(Style::new().fg(Color::Cyan), &format!("import {label}"))
1616 );
1617 }
1618
1619 let builder = client_builder(args.protocol)
1627 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error.to_string()));
1628 let mut connector: Option<Connector> = None;
1632 let client = if args.demo {
1633 builder
1634 .connect(
1635 TracingTransport::new(ChannelTransport::new(demo_router())),
1636 make_handler(),
1637 )
1638 .await?
1639 } else {
1640 match connection {
1641 Some(config::Connection::Http {
1642 url,
1643 bearer,
1644 headers,
1645 oauth: profile_oauth,
1646 }) => {
1647 let oauth_name = selected_oauth_profile(
1648 args.oauth.as_deref(),
1649 profile_oauth.as_deref(),
1650 args.bearer.is_some(),
1651 &args.headers,
1652 );
1653 let cli_authorization = oauth_name.is_none()
1654 && (args.bearer.is_some()
1655 || args.headers.iter().any(|header| {
1656 header.split_once(':').is_some_and(|(name, _)| {
1657 name.trim().eq_ignore_ascii_case("authorization")
1658 })
1659 }));
1660 if cli_authorization && (args.oauth.is_some() || profile_oauth.is_some()) && !quiet
1661 {
1662 eprintln!(
1663 "warning: explicit --bearer/--header Authorization takes precedence over OAuth"
1664 );
1665 }
1666 let profile_headers = if oauth_name.is_some() {
1667 headers
1668 .into_iter()
1669 .filter(|(name, _)| !name.eq_ignore_ascii_case("authorization"))
1670 .collect::<Vec<_>>()
1671 } else {
1672 headers
1673 };
1674 let config = if oauth_name.is_some() {
1675 build_http_config_with_env(
1676 args.bearer.clone(),
1677 &args.headers,
1678 None,
1679 &profile_headers,
1680 None,
1681 )
1682 } else {
1683 build_http_config(args.bearer.clone(), &args.headers, bearer, &profile_headers)
1684 }
1685 .unwrap_or_else(|error| exit_with_error(ExitStatus::Usage, &error));
1686 let oauth = if let Some(name) = oauth_name {
1687 let metadata = profiles.oauth.get(&name).unwrap_or_else(|| {
1688 exit_with_error(
1689 ExitStatus::Usage,
1690 &format!(
1691 "no OAuth profile named {name:?}; create it with \
1692 `mcp-repl --login {name} --http {url}`"
1693 ),
1694 )
1695 });
1696 let interactive = !one_shot && !args.json;
1697 let (flow, store) = oauth_profile::build_flow(
1698 &name,
1699 &url,
1700 metadata,
1701 interactive,
1702 interactive && !args.no_browser,
1703 )
1704 .unwrap_or_else(|error| exit_with_error(ExitStatus::Auth, &error));
1705 if interactive {
1706 flow.authorize(metadata.scopes.clone())
1707 .await
1708 .map_err(|error| {
1709 tower_mcp::Error::Transport(format!(
1710 "OAuth authorization failed for profile {name:?}: {error}. \
1711 Run `mcp-repl --login {name} --http {url}` to reauthorize"
1712 ))
1713 })?;
1714 } else {
1715 if !store.has_tokens().await.map_err(|error| {
1716 tower_mcp::Error::Transport(format!(
1717 "OAuth credential restore failed for profile {name:?}: {error}"
1718 ))
1719 })? {
1720 return Err(tower_mcp::Error::Transport(format!(
1721 "OAuth login required for profile {name:?}; run \
1722 `mcp-repl --login {name} --http {url}` before using --exec/--json"
1723 )));
1724 }
1725 match flow.begin(metadata.scopes.clone()).await.map_err(|error| {
1726 tower_mcp::Error::Transport(format!(
1727 "OAuth credential restore failed for profile {name:?}: {error}. \
1728 Run `mcp-repl --login {name} --http {url}` to reauthorize"
1729 ))
1730 })? {
1731 OAuthAuthorizationStart::Authorized { .. } => {}
1732 OAuthAuthorizationStart::Pending(_) => {
1733 return Err(tower_mcp::Error::Transport(format!(
1734 "OAuth login required for profile {name:?}; run \
1735 `mcp-repl --login {name} --http {url}` before using --exec/--json"
1736 )));
1737 }
1738 _ => {
1739 return Err(tower_mcp::Error::Transport(format!(
1740 "OAuth login required for profile {name:?}; run \
1741 `mcp-repl --login {name} --http {url}` before using --exec/--json"
1742 )));
1743 }
1744 }
1745 }
1746 Some(OAuthRuntime {
1747 flow,
1748 scopes: metadata.scopes.clone(),
1749 })
1750 } else {
1751 None
1752 };
1753 if !args.no_reconnect {
1754 connector = Some(http_connector(
1755 url.clone(),
1756 config.clone(),
1757 oauth.clone(),
1758 make_handler.clone(),
1759 args.protocol,
1760 ));
1761 }
1762 builder
1763 .connect(
1764 TracingTransport::new(http_transport(url, config, oauth)),
1765 make_handler(),
1766 )
1767 .await?
1768 }
1769 Some(config::Connection::Stdio { command, env, cwd }) => {
1770 let mut cmd = tokio::process::Command::new(&command[0]);
1771 cmd.args(&command[1..]);
1772 cmd.envs(env);
1773 if let Some(cwd) = cwd {
1774 cmd.current_dir(cwd);
1775 }
1776 cmd.stderr(std::process::Stdio::piped());
1777 let mut transport = StdioClientTransport::spawn_command(&mut cmd).await?;
1778 if let Some(stderr) = transport.take_stderr() {
1779 forward_child_stderr(stderr, async_output.clone());
1780 }
1781 builder
1782 .connect(TracingTransport::new(transport), make_handler())
1783 .await?
1784 }
1785 None => {
1786 exit_with_error(
1787 ExitStatus::Usage,
1788 "usage: mcp-repl <server command...> | --http <url> | \
1789 --server <name> | --demo",
1790 );
1791 }
1792 }
1793 };
1794
1795 let info = establish_connection(&client, args.protocol).await?;
1796 let server_name = info.server_info.name.clone();
1797 if !quiet {
1798 print_banner(&info);
1799 }
1800 let session = Arc::new(Session::new(client, connector));
1801 let client = session.client();
1802
1803 let surface = Arc::new(RwLock::new(fetch_surface_initial(&client).await));
1804 if !quiet {
1805 let s = surface.read().unwrap();
1806 print_counts(&s);
1807 let instructions_list_tools = info
1811 .instructions
1812 .as_deref()
1813 .is_some_and(|instr| s.tools.first().is_some_and(|t| instr.contains(&t.name)));
1814 if !instructions_list_tools {
1815 print_tool_overview(&s);
1816 }
1817 }
1818
1819 if one_shot {
1822 for cmd in &args.exec {
1823 if handle_line(
1824 &session,
1825 &surface,
1826 &aliases,
1827 &jobs,
1828 &schema_contracts,
1829 cmd.trim(),
1830 )
1831 .await
1832 {
1833 break;
1834 }
1835 }
1836 let status = exit_status::current().code();
1837 drop(client);
1838 let session = Arc::try_unwrap(session).unwrap_or_else(|_| {
1839 panic!("one-shot MCP session is still shared after all commands completed")
1840 });
1841 session.shutdown().await?;
1842 std::process::exit(status);
1843 }
1844
1845 let _surface_subscription = (args.protocol == ProtocolMode::Final).then(|| {
1850 surface_subscription::SurfaceSubscription::start(session.clone(), async_output.clone())
1851 });
1852
1853 let (line_tx, mut line_rx) = tokio::sync::mpsc::channel::<String>(1);
1855 let (ack_tx, ack_rx) = std::sync::mpsc::channel::<()>();
1856 editor::spawn_readline_thread(
1857 server_name,
1858 surface.clone(),
1859 session.clone(),
1860 aliases.clone(),
1861 tokio::runtime::Handle::current(),
1862 line_tx,
1863 ack_rx,
1864 at_prompt,
1865 async_output
1866 .external_printer()
1867 .expect("interactive sessions have an external printer"),
1868 !args.no_history,
1869 );
1870
1871 loop {
1872 tokio::select! {
1873 Some(()) = refresh_rx.recv() => {
1874 let fresh = fetch_surface(&session.client()).await;
1875 async_output.line(format!("{} {} tools, {} prompts, {} resources",
1876 tag(Style::new().fg(Color::Cyan), "surface changed"),
1877 fresh.tools.len(), fresh.prompts.len(), fresh.resources.len()));
1878 *surface.write().unwrap() = fresh;
1879 }
1880 maybe_line = line_rx.recv() => {
1881 let Some(line) = maybe_line else { break };
1882 let quit = handle_line(
1883 &session,
1884 &surface,
1885 &aliases,
1886 &jobs,
1887 &schema_contracts,
1888 line.trim(),
1889 )
1890 .await;
1891 let _ = ack_tx.send(());
1892 if quit {
1893 break;
1894 }
1895 }
1896 }
1897 }
1898 Ok(())
1899}
1900
1901async fn handle_line(
1902 session: &Arc<Session>,
1903 surface: &Arc<RwLock<Surface>>,
1904 aliases: &Arc<RwLock<Aliases>>,
1905 jobs: &Arc<Jobs>,
1906 schema_contracts: &schema_contract::ContractSet,
1907 line: &str,
1908) -> bool {
1909 if line.is_empty() {
1910 if json_output() {
1911 report_error(ExitStatus::Usage, "empty command");
1912 }
1913 return false;
1914 }
1915 let expanded;
1919 let line = match aliases.read().unwrap().expand(line) {
1920 Ok(None) => line,
1921 Ok(Some(text)) => {
1922 expanded = text;
1923 expanded.trim()
1924 }
1925 Err(e) => {
1926 report_error(ExitStatus::Usage, &e);
1927 return false;
1928 }
1929 };
1930 let (output, routed) = vars::route(line);
1935 let command = match vars::substitute(routed) {
1936 Ok(c) => c,
1937 Err(e) => {
1938 report_error(ExitStatus::Usage, &e);
1939 return false;
1940 }
1941 };
1942 let line = command.as_str();
1943 let client = session.client();
1944 let parsed = match command::parse(line) {
1945 Ok(parsed) => parsed,
1946 Err(e) => {
1947 report_error(ExitStatus::Usage, &e);
1948 return false;
1949 }
1950 };
1951 let background = parsed.background;
1952 let tokens: Vec<&str> = parsed.words.iter().map(String::as_str).collect();
1953 if tokens.is_empty() {
1954 if json_output() {
1955 report_error(ExitStatus::Usage, "empty command");
1956 }
1957 return false;
1958 }
1959 let cmd = tokens[0];
1960 let rest = &tokens[1..];
1961
1962 match cmd {
1963 "quit" | "exit" => {
1964 if json_output() {
1965 print_json(&serde_json::json!({ "exit": true }));
1966 }
1967 return true;
1968 }
1969 "help" => {
1970 if json_output() {
1971 let s = surface.read().unwrap();
1972 print_json(&serde_json::json!({
1973 "builtins": BUILTINS
1974 .iter()
1975 .map(|(name, description)| serde_json::json!({
1976 "name": name,
1977 "description": description,
1978 }))
1979 .collect::<Vec<_>>(),
1980 "tools": s.tools,
1981 }));
1982 return false;
1983 }
1984 println!("built-ins:");
1985 println!(" tools | prompts | resources | templates list the server surface");
1986 println!(" find <keyword> search the surface");
1987 println!(" describe <name> schemas and metadata");
1988 println!(" snapshot <name> [path] export a schema contract");
1989 println!(" validate <path> [mode] check a schema contract");
1990 println!(" read <uri> read a resource");
1991 println!(" subscribe <uri> | unsubscribe <uri> watch a resource for updates");
1992 println!(" subscriptions list active subscriptions");
1993 println!(" prompt <name> [k=v...] get a prompt");
1994 println!(" call <tool> <json> call a tool with raw JSON");
1995 println!(" bench <tool> [k=v...] [--n N] [--concurrency C] time repeated calls");
1996 println!(" <tool> [k=v...] call a tool (schema-coerced)");
1997 println!(" <tool> [k=v...] & run task-augmented (SEP-2663)");
1998 println!(" jobs | task <id> | wait <id> | cancel <id> manage tasks");
1999 println!(" alias [<name>=<expansion>] | unalias <name> command aliases");
2000 println!(" wire [on|off] trace raw JSON-RPC frames");
2001 println!(" last reprint the previous exchange");
2002 println!(
2003 " vars | unset <name> list or clear captured variables"
2004 );
2005 println!(
2006 " name = <cmd> [| <path>] capture a result (filter with | path)"
2007 );
2008 println!(" $name.path in args reference a captured value");
2009 println!(" refresh | info | quit");
2010 let s = surface.read().unwrap();
2011 if !s.tools.is_empty() {
2012 println!("tools:");
2013 for t in &s.tools {
2014 println!(
2015 " {:24} {}",
2016 paint(Style::new().fg(Color::Green), &t.name),
2017 t.description.as_deref().unwrap_or("")
2018 );
2019 }
2020 }
2021 }
2022 "tools" | "prompts" | "resources" | "templates" => {
2023 let s = surface.read().unwrap();
2024 if json_output() {
2025 let v = match cmd {
2026 "tools" => serde_json::to_value(&s.tools),
2027 "prompts" => serde_json::to_value(&s.prompts),
2028 "resources" => serde_json::to_value(&s.resources),
2029 _ => serde_json::to_value(&s.templates),
2030 }
2031 .unwrap_or_default();
2032 print_json(&v);
2033 return false;
2034 }
2035 match cmd {
2036 "tools" => {
2037 for t in &s.tools {
2038 println!(
2039 "{:24} {}",
2040 paint(Style::new().fg(Color::Green), &t.name),
2041 t.description.as_deref().unwrap_or("")
2042 );
2043 }
2044 }
2045 "prompts" => {
2046 for p in &s.prompts {
2047 let args: Vec<String> = p
2048 .arguments
2049 .iter()
2050 .map(|a| {
2051 if a.required {
2052 format!("<{}>", a.name)
2053 } else {
2054 format!("[{}]", a.name)
2055 }
2056 })
2057 .collect();
2058 println!(
2059 "{:24} {} {}",
2060 paint(Style::new().fg(Color::Green), &p.name),
2061 paint(Style::new().fg(Color::Cyan), &args.join(" ")),
2062 p.description.as_deref().unwrap_or("")
2063 );
2064 }
2065 }
2066 "resources" => {
2067 for r in &s.resources {
2068 println!(
2069 "{:40} {}",
2070 paint(Style::new().fg(Color::Green), &r.uri),
2071 r.name
2072 );
2073 }
2074 if !s.templates.is_empty() {
2077 println!(
2078 "{}",
2079 paint(
2080 Style::new().dimmed(),
2081 &format!(
2082 "(+ {} resource template(s) with variables, see `templates`)",
2083 s.templates.len()
2084 )
2085 )
2086 );
2087 }
2088 }
2089 _ => {
2090 for t in &s.templates {
2091 println!(
2092 "{:40} {}",
2093 paint(Style::new().fg(Color::Green), &t.uri_template),
2094 t.name
2095 );
2096 }
2097 if !s.resources.is_empty() {
2098 println!(
2099 "{}",
2100 paint(
2101 Style::new().dimmed(),
2102 &format!(
2103 "(+ {} concrete resource(s), see `resources`)",
2104 s.resources.len()
2105 )
2106 )
2107 );
2108 }
2109 }
2110 }
2111 }
2112 "find" => {
2113 let query = rest.join(" ");
2116 if query.is_empty() {
2117 command_error("usage: find <keyword>");
2118 return false;
2119 }
2120 print_find(&surface.read().unwrap(), &query);
2121 }
2122 "describe" => {
2123 let Some(name) = rest.first() else {
2124 command_error("usage: describe <tool|prompt|resource|template>");
2125 return false;
2126 };
2127 let surface = surface.read().unwrap();
2128 if json_output() {
2129 match describe_value(&surface, name) {
2130 Some(value) => print_json(&value),
2131 None => report_error(
2132 ExitStatus::NoMatch,
2133 &format!("nothing on the surface named `{name}`"),
2134 ),
2135 }
2136 } else {
2137 describe(&surface, name);
2138 }
2139 }
2140 "snapshot" => {
2141 let Some(name) = rest.first() else {
2142 command_error("usage: snapshot <tool|prompt> [path]");
2143 return false;
2144 };
2145 if rest.len() > 2 {
2146 command_error("usage: snapshot <tool|prompt> [path]");
2147 return false;
2148 }
2149 let snapshot = {
2150 let surface = surface.read().unwrap();
2151 schema_contract::Snapshot::from_surface(&surface.tools, &surface.prompts, name)
2152 };
2153 let snapshot = match snapshot {
2154 Ok(snapshot) => snapshot,
2155 Err(error) => {
2156 report_error(ExitStatus::Usage, &error);
2157 return false;
2158 }
2159 };
2160 let Some(snapshot) = snapshot else {
2161 report_error(
2162 ExitStatus::NoMatch,
2163 &format!("no tool or prompt named `{name}`"),
2164 );
2165 return false;
2166 };
2167 if let Some(path) = rest.get(1) {
2168 let path = std::path::Path::new(path);
2169 match snapshot.write(path) {
2170 Ok(()) if json_output() => print_json(&serde_json::json!({
2171 "kind": snapshot.kind,
2172 "name": snapshot.name,
2173 "path": path,
2174 })),
2175 Ok(()) => println!(
2176 "saved {} {:?} schema snapshot to {}",
2177 snapshot.kind,
2178 snapshot.name,
2179 path.display()
2180 ),
2181 Err(error) => report_error(ExitStatus::Usage, &error),
2182 }
2183 } else if json_output() {
2184 print_json(&snapshot.canonical_value());
2185 } else {
2186 print!("{}", snapshot.to_pretty_json());
2187 }
2188 }
2189 "validate" => {
2190 let Some(path) = rest.first() else {
2191 command_error("usage: validate <snapshot-path> [strict|compatible|ignore]");
2192 return false;
2193 };
2194 if rest.len() > 2 {
2195 command_error("usage: validate <snapshot-path> [strict|compatible|ignore]");
2196 return false;
2197 }
2198 let mode = match rest.get(1) {
2199 Some(mode) => match schema_contract::ValidationMode::from_str(mode, true) {
2200 Ok(mode) => mode,
2201 Err(_) => {
2202 command_error(
2203 "validation mode must be `strict`, `compatible`, or `ignore`",
2204 );
2205 return false;
2206 }
2207 },
2208 None => schema_contracts.mode(),
2209 };
2210 let snapshot = match schema_contract::Snapshot::load(std::path::Path::new(path)) {
2211 Ok(snapshot) => snapshot,
2212 Err(error) => {
2213 report_error(ExitStatus::Usage, &error);
2214 return false;
2215 }
2216 };
2217 let current = {
2218 let surface = surface.read().unwrap();
2219 snapshot.matching_surface(&surface.tools, &surface.prompts)
2220 };
2221 let report = schema_contract::validate(&snapshot, current.as_ref(), mode);
2222 render_validation_report(&report, true);
2223 }
2224 "read" => {
2225 let Some(uri) = rest.first() else {
2226 command_error("usage: read <uri>");
2227 return false;
2228 };
2229 let started = std::time::Instant::now();
2230 match with_reconnect(
2231 session,
2232 surface,
2233 |c| async move { c.read_resource(uri).await },
2234 )
2235 .await
2236 {
2237 Ok(result) if json_output() => {
2238 print_json(&serde_json::to_value(&result).unwrap_or_default())
2239 }
2240 Ok(result) => {
2241 for c in result.contents {
2242 if let Some(text) = c.text {
2243 let is_md = c
2244 .mime_type
2245 .as_deref()
2246 .is_some_and(|m| m.contains("markdown"))
2247 || style::looks_like_markdown(&text);
2248 if style::colors_enabled() && is_md {
2249 println!("{}", style::render_markdown(&text));
2250 } else {
2251 println!("{text}");
2252 }
2253 } else if let Some(blob) = c.blob {
2254 println!(
2255 "{}",
2256 tag(Style::new(), &format!("binary {} base64 chars", blob.len()))
2257 );
2258 }
2259 }
2260 }
2261 Err(e) => report_mcp_error(&e),
2262 }
2263 if !json_output() {
2264 println!("{}", timing(started.elapsed()));
2265 }
2266 }
2267 "subscribe" | "unsubscribe" => {
2268 let Some(uri) = rest.first() else {
2269 command_error(&format!("usage: {cmd} <uri>"));
2270 return false;
2271 };
2272 handle_subscription(&client, cmd, uri).await;
2273 }
2274 "subscriptions" => {
2275 let active = subscribe::list();
2276 if json_output() {
2277 print_json(&serde_json::json!(active));
2278 return false;
2279 }
2280 if active.is_empty() {
2281 println!("no active subscriptions (try `subscribe <uri>`)");
2282 return false;
2283 }
2284 for uri in &active {
2285 println!("{}", paint(Style::new().fg(Color::Green), uri));
2286 }
2287 }
2288 "prompt" => {
2289 let Some(name) = rest.first() else {
2290 command_error("usage: prompt <name> [k=v...]");
2291 return false;
2292 };
2293 if !enforce_prompt_contract(schema_contracts, surface, name) {
2294 return false;
2295 }
2296 let mut prompt_args = HashMap::new();
2297 for t in &rest[1..] {
2298 if let Some((k, v)) = t.split_once('=') {
2299 prompt_args.insert(k.to_string(), v.to_string());
2300 }
2301 }
2302 let started = std::time::Instant::now();
2303 match with_reconnect(session, surface, |c| {
2304 let prompt_args = prompt_args.clone();
2305 async move { c.get_prompt(name, Some(prompt_args)).await }
2306 })
2307 .await
2308 {
2309 Ok(result) if json_output() => {
2310 print_json(&serde_json::to_value(&result).unwrap_or_default())
2311 }
2312 Ok(result) => {
2313 for m in result.messages {
2314 let v = serde_json::to_value(&m).unwrap_or_default();
2315 let role = v.get("role").and_then(|r| r.as_str()).unwrap_or("?");
2316 let text = v
2317 .pointer("/content/text")
2318 .and_then(|t| t.as_str())
2319 .map(str::to_string)
2320 .unwrap_or_else(|| {
2321 v.get("content").map(|c| c.to_string()).unwrap_or_default()
2322 });
2323 println!("{} {}", tag(Style::new().fg(Color::Cyan), role), text);
2324 }
2325 }
2326 Err(e) => report_mcp_error(&e),
2327 }
2328 if !json_output() {
2329 println!("{}", timing(started.elapsed()));
2330 }
2331 }
2332 "call" => {
2333 let Some(name) = rest.first() else {
2334 command_error("usage: call <tool> <json>");
2335 return false;
2336 };
2337 let json = rest[1..].join(" ");
2338 let arguments: serde_json::Value = match serde_json::from_str(&json) {
2339 Ok(v) => v,
2340 Err(e) => {
2341 report_error(ExitStatus::Usage, &format!("invalid JSON: {e}"));
2342 return false;
2343 }
2344 };
2345 run_tool(
2346 session,
2347 surface,
2348 jobs,
2349 schema_contracts,
2350 name,
2351 arguments,
2352 background,
2353 &output,
2354 )
2355 .await;
2356 }
2357 "bench" => {
2358 handle_bench(&client, surface, schema_contracts, rest, background).await;
2359 }
2360 "jobs" => {
2361 if json_output() {
2362 let mut rendered = Vec::new();
2363 for job in jobs.list() {
2364 match client.task_get(&job.task_id).await {
2365 Ok(task) => {
2366 jobs.sync(&job.task_id, task.status, task.status_message.clone());
2367 rendered.push(serde_json::json!({
2368 "taskId": job.task_id,
2369 "tool": job.tool,
2370 "task": task,
2371 }));
2372 }
2373 Err(error) => {
2374 let status = ExitStatus::from_mcp_error(&error);
2375 note_error(status);
2376 rendered.push(serde_json::json!({
2377 "taskId": job.task_id,
2378 "tool": job.tool,
2379 "error": error.to_string(),
2380 "kind": status.label(),
2381 "exitStatus": status.code(),
2382 }));
2383 }
2384 }
2385 }
2386 print_json(&serde_json::Value::Array(rendered));
2387 return false;
2388 }
2389 if jobs.is_empty() {
2390 println!("no background tasks");
2391 }
2392 for job in jobs.list() {
2393 match client.task_get(&job.task_id).await {
2394 Ok(task) => {
2395 jobs.sync(&job.task_id, task.status, task.status_message.clone());
2396 println!(
2397 "{} {} {}",
2398 job.task_id,
2399 job.tool,
2400 paint(task_status_style(task.status), &task.status.to_string())
2401 );
2402 }
2403 Err(error) => {
2404 note_error(ExitStatus::from_mcp_error(&error));
2405 println!("{} {} (gone)", job.task_id, job.tool);
2406 }
2407 }
2408 }
2409 }
2410 "task" | "wait" | "cancel" => {
2414 let Some(id) = rest.first() else {
2415 command_error(&format!("usage: {cmd} <task-id>"));
2416 return false;
2417 };
2418 let outcome = match cmd {
2419 "task" => client.task_get(id).await,
2420 "wait" => client.task_wait(id).await,
2421 _ => match client.task_cancel(id, None).await {
2422 Ok(()) => {
2423 if !json_output() {
2424 println!("cancel acknowledged");
2425 }
2426 client.task_get(id).await
2427 }
2428 Err(e) => Err(e),
2429 },
2430 };
2431 match outcome {
2432 Ok(task) if json_output() => {
2433 jobs.sync(id, task.status, task.status_message.clone());
2434 print_json(&serde_json::to_value(&task).unwrap_or_default());
2435 }
2436 Ok(task) => {
2437 jobs.sync(id, task.status, task.status_message.clone());
2438 render_task(&task);
2439 }
2440 Err(e) => report_mcp_error(&e),
2441 }
2442 }
2443 "alias" | "unalias" => {
2444 let raw = line.strip_prefix(cmd).unwrap_or("").trim();
2447 handle_alias(aliases, surface, cmd, raw);
2448 }
2449 "wire" => {
2450 match rest.first().copied() {
2451 Some("on") => wire().set_trace(true),
2452 Some("off") => wire().set_trace(false),
2453 None => {}
2454 Some(other) => {
2455 command_error(&format!("usage: wire [on|off] (got `{other}`)"));
2456 return false;
2457 }
2458 }
2459 let enabled = wire().trace_enabled();
2460 if json_output() {
2461 print_json(&serde_json::json!({ "wire": enabled }));
2462 } else if enabled {
2463 println!("wire tracing on (frames print to stderr)");
2464 } else {
2465 println!("wire tracing off");
2466 }
2467 }
2468 "last" => match wire().last_exchange() {
2471 None => {
2472 note_error(ExitStatus::NoMatch);
2473 if json_output() {
2474 print_json(&error_json(ExitStatus::NoMatch, "no exchange yet"));
2475 } else {
2476 println!("no request has been sent yet");
2477 }
2478 }
2479 Some((request, response)) => {
2480 if json_output() {
2481 print_json(&serde_json::json!({
2482 "request": request.json,
2483 "response": response.map(|r| r.json),
2484 }));
2485 } else {
2486 println!("{}", wire::render(wire::Direction::Sent, &request));
2487 match response {
2488 Some(response) => {
2489 println!("{}", wire::render(wire::Direction::Received, &response));
2490 }
2491 None => println!("(no response recorded for it)"),
2492 }
2493 }
2494 }
2495 },
2496 "refresh" => {
2497 let fresh = refresh_surface(session).await;
2498 if json_output() {
2499 print_json(&serde_json::json!({
2500 "tools": fresh.tools.len(),
2501 "prompts": fresh.prompts.len(),
2502 "resources": fresh.resources.len(),
2503 "templates": fresh.templates.len(),
2504 }));
2505 } else {
2506 println!(
2507 "{} tools, {} prompts, {} resources, {} templates",
2508 fresh.tools.len(),
2509 fresh.prompts.len(),
2510 fresh.resources.len(),
2511 fresh.templates.len()
2512 );
2513 }
2514 *surface.write().unwrap() = fresh;
2515 }
2516 "info" => match connection_info(&client).await {
2517 Some(info) => {
2518 if json_output() {
2519 print_json(&serde_json::json!({
2520 "protocolVersion": info.protocol_version,
2521 "serverInfo": info.server_info,
2522 "capabilities": info.capabilities,
2523 "instructions": info.instructions,
2524 "sampling": sampling::mode().as_str(),
2525 }));
2526 return false;
2527 }
2528 print_banner(&info);
2530 print_counts(&surface.read().unwrap());
2531 let caps = serde_json::to_value(&info.capabilities).unwrap_or_default();
2532 println!("capabilities: {}", json_pretty(&caps));
2533 println!(
2535 "{}",
2536 paint(
2537 Style::new().dimmed(),
2538 &format!("sampling: {}", sampling::mode().as_str())
2539 )
2540 );
2541 }
2542 None => report_error(ExitStatus::Transport, "not initialized"),
2543 },
2544 "vars" => {
2545 let all = vars::list();
2546 if json_output() {
2547 let map: serde_json::Map<String, serde_json::Value> = all.into_iter().collect();
2548 print_json(&serde_json::Value::Object(map));
2549 } else if all.is_empty() {
2550 println!("{}", paint(Style::new().dimmed(), "no variables"));
2551 } else {
2552 for (name, value) in all {
2553 println!(
2554 "{} {}",
2555 paint(Style::new().fg(Color::Cyan), &format!("${name} =")),
2556 value_summary(&value)
2557 );
2558 }
2559 }
2560 }
2561 "unset" => match rest.first() {
2562 Some(name) => {
2563 if vars::unset(name) {
2564 if json_output() {
2565 print_json(&serde_json::json!({ "unset": name }));
2566 } else {
2567 println!("unset ${name}");
2568 }
2569 } else {
2570 command_error(&format!("no such variable `${name}`"));
2571 }
2572 }
2573 None => command_error("usage: unset <name>"),
2574 },
2575 tool_name => {
2576 let schema = {
2577 let s = surface.read().unwrap();
2578 s.tools
2579 .iter()
2580 .find(|t| t.name == tool_name)
2581 .map(|t| t.input_schema.clone())
2582 };
2583 let Some(schema) = schema else {
2584 note_error(ExitStatus::Usage);
2585 let suggestion = find::did_you_mean(&surface.read().unwrap(), tool_name);
2586 if json_output() {
2587 let mut value =
2588 error_json(ExitStatus::Usage, &format!("unknown command: {tool_name}"));
2589 if let Some(near) = &suggestion {
2590 value["didYouMean"] = serde_json::json!(near);
2591 }
2592 print_json(&value);
2593 } else {
2594 let name = paint(Style::new().fg(Color::Red), tool_name);
2595 match suggestion {
2596 Some(near) => println!(
2597 "unknown command: {name}; did you mean `{}`?",
2598 paint(Style::new().fg(Color::Green), &near)
2599 ),
2600 None => println!("unknown command: {name} (try `help`)"),
2601 }
2602 }
2603 return false;
2604 };
2605 let arguments = parse_kv_args(&schema, rest);
2606 run_tool(
2607 session,
2608 surface,
2609 jobs,
2610 schema_contracts,
2611 tool_name,
2612 arguments,
2613 background,
2614 &output,
2615 )
2616 .await;
2617 }
2618 }
2619 false
2620}
2621
2622async fn handle_bench(
2627 client: &Arc<McpClient>,
2628 surface: &Arc<RwLock<Surface>>,
2629 schema_contracts: &schema_contract::ContractSet,
2630 rest: &[&str],
2631 background: bool,
2632) {
2633 if background {
2636 command_error("bench cannot run task-augmented; drop the trailing `&`");
2637 return;
2638 }
2639 let plan = match bench::parse(rest) {
2640 Ok(plan) => plan,
2641 Err(e) => {
2642 command_error(&e);
2643 return;
2644 }
2645 };
2646 let schema = {
2647 let s = surface.read().unwrap();
2648 s.tools
2649 .iter()
2650 .find(|t| t.name == plan.tool)
2651 .map(|t| t.input_schema.clone())
2652 };
2653 let Some(schema) = schema else {
2654 command_error(&format!("no tool named `{}` (try `tools`)", plan.tool));
2655 return;
2656 };
2657 if !enforce_tool_contract(schema_contracts, surface, &plan.tool) {
2658 return;
2659 }
2660 let arg_tokens: Vec<&str> = plan.args.iter().map(String::as_str).collect();
2661 let arguments = parse_kv_args(&schema, &arg_tokens);
2662
2663 let outcome = bench::run(client, &plan.tool, arguments, plan.n, plan.concurrency).await;
2664 if outcome.errors > 0 {
2667 note_error(ExitStatus::Server);
2668 }
2669 if json_output() {
2670 print_json(&bench::render_json(&plan, &outcome));
2671 return;
2672 }
2673 println!("{}", bench::render(&plan, &outcome));
2674 if let Some(message) = &outcome.first_error {
2675 println!(
2676 "{} {}",
2677 tag(Style::new().fg(Color::Red), "first error"),
2678 message
2679 );
2680 }
2681 println!("{}", timing(outcome.total));
2682}
2683
2684async fn handle_subscription(client: &Arc<McpClient>, cmd: &str, uri: &str) {
2688 if cmd == "subscribe"
2691 && let Some(info) = connection_info(client).await
2692 && !subscribe::server_supports(
2693 &serde_json::to_value(&info.capabilities).unwrap_or_default(),
2694 )
2695 {
2696 eprintln!(
2697 "warning: {} does not advertise resources.subscribe; the request will \
2698 probably be rejected",
2699 info.server_info.name
2700 );
2701 }
2702 let started = std::time::Instant::now();
2703 let outcome = if cmd == "subscribe" {
2704 client.subscribe_resource(uri).await
2705 } else {
2706 client.unsubscribe_resource(uri).await
2707 };
2708 match outcome {
2709 Ok(()) => {
2710 let changed = if cmd == "subscribe" {
2711 subscribe::add(uri)
2712 } else {
2713 subscribe::remove(uri)
2714 };
2715 if json_output() {
2716 print_json(&serde_json::json!({
2717 cmd: uri,
2718 "alreadyInEffect": !changed,
2719 }));
2720 } else {
2721 let note = if changed {
2722 String::new()
2723 } else {
2724 format!(" {}", paint(Style::new().dimmed(), "(already in effect)"))
2725 };
2726 println!("{cmd}d {}{note}", paint(Style::new().fg(Color::Green), uri));
2727 }
2728 }
2729 Err(e) => report_mcp_error(&e),
2730 }
2731 if !json_output() {
2732 println!("{}", timing(started.elapsed()));
2733 }
2734}
2735
2736fn handle_alias(
2742 aliases: &Arc<RwLock<Aliases>>,
2743 surface: &Arc<RwLock<Surface>>,
2744 cmd: &str,
2745 raw: &str,
2746) {
2747 let (global, rest) = match raw.strip_prefix("--global") {
2750 Some(r) if r.is_empty() || r.starts_with(char::is_whitespace) => (true, r.trim_start()),
2751 _ => (false, raw),
2752 };
2753 let rest = rest.trim();
2754
2755 if cmd == "unalias" {
2756 if rest.is_empty() || rest.contains(char::is_whitespace) {
2757 command_error("usage: unalias [--global] <name>");
2758 return;
2759 }
2760 match aliases.write().unwrap().remove(rest, global) {
2761 Ok(applied) => {
2762 report_alias_warning(applied.warning.as_deref());
2763 if json_output() {
2764 print_json(&serde_json::json!({
2765 "removed": rest,
2766 "expansion": applied.previous,
2767 "scope": applied.scope.label(),
2768 }));
2769 } else {
2770 println!(
2771 "removed {} {}",
2772 paint(Style::new().fg(Color::Cyan), rest),
2773 paint(
2774 Style::new().dimmed(),
2775 &format!("({})", applied.scope.label())
2776 )
2777 );
2778 }
2779 }
2780 Err(e) => command_error(&e),
2781 }
2782 return;
2783 }
2784
2785 if rest.is_empty() {
2787 let aliases = aliases.read().unwrap();
2788 let entries = aliases.entries();
2789 if json_output() {
2790 let rendered: Vec<serde_json::Value> = entries
2791 .iter()
2792 .map(|e| {
2793 serde_json::json!({
2794 "name": e.name,
2795 "expansion": e.expansion,
2796 "scope": e.scope.label(),
2797 })
2798 })
2799 .collect();
2800 print_json(&serde_json::Value::Array(rendered));
2801 return;
2802 }
2803 if entries.is_empty() {
2804 println!("no aliases defined (try `alias t=tools`)");
2805 return;
2806 }
2807 let width = entries.iter().map(|e| e.name.len()).max().unwrap_or(0);
2808 for e in &entries {
2809 println!(
2810 "{:width$} {} {}",
2811 paint(Style::new().fg(Color::Cyan), &e.name),
2812 e.expansion,
2813 paint(Style::new().dimmed(), &format!("({})", e.scope.label()))
2814 );
2815 }
2816 return;
2817 }
2818
2819 let Some((name, expansion)) = rest.split_once('=') else {
2821 let aliases = aliases.read().unwrap();
2822 match aliases.lookup(rest) {
2823 Some((expansion, scope)) if json_output() => print_json(&serde_json::json!({
2824 "name": rest,
2825 "expansion": expansion,
2826 "scope": scope.label(),
2827 })),
2828 Some((expansion, scope)) => println!(
2829 "{} = {} {}",
2830 paint(Style::new().fg(Color::Cyan), rest),
2831 expansion,
2832 paint(Style::new().dimmed(), &format!("({})", scope.label()))
2833 ),
2834 None => command_error(&format!(
2835 "no alias named `{rest}` (define one with `alias {rest}=<expansion>`)"
2836 )),
2837 }
2838 return;
2839 };
2840 let name = name.trim();
2841 match aliases
2842 .write()
2843 .unwrap()
2844 .define(name, expansion.trim(), global)
2845 {
2846 Ok(applied) => {
2847 report_alias_warning(applied.warning.as_deref());
2848 if json_output() {
2849 print_json(&serde_json::json!({
2850 "name": name,
2851 "expansion": expansion.trim(),
2852 "scope": applied.scope.label(),
2853 "replaced": applied.previous,
2854 }));
2855 return;
2856 }
2857 println!(
2858 "{} = {} {}",
2859 paint(Style::new().fg(Color::Cyan), name),
2860 expansion.trim(),
2861 paint(
2862 Style::new().dimmed(),
2863 &format!("({})", applied.scope.label())
2864 )
2865 );
2866 if surface.read().unwrap().tools.iter().any(|t| t.name == name) {
2869 println!(
2870 "{}",
2871 paint(
2872 Style::new().dimmed(),
2873 &format!("note: this shadows the tool `{name}` on this server")
2874 )
2875 );
2876 }
2877 }
2878 Err(e) => command_error(&e),
2879 }
2880}
2881
2882fn report_alias_warning(warning: Option<&str>) {
2885 if let Some(w) = warning {
2886 eprintln!("warning: {w}");
2887 }
2888}
2889
2890fn command_error(message: &str) {
2891 report_error(ExitStatus::Usage, message);
2892}
2893
2894fn render_validation_report(
2897 report: &schema_contract::ValidationReport,
2898 render_success: bool,
2899) -> bool {
2900 if report.compatible && !render_success {
2901 return true;
2902 }
2903 if !report.compatible {
2904 note_error(ExitStatus::NoMatch);
2905 }
2906 if json_output() {
2907 print_json(&serde_json::to_value(report).unwrap_or_default());
2908 } else if report.compatible {
2909 println!(
2910 "{} {:?} is compatible under {} validation",
2911 report.kind, report.name, report.mode
2912 );
2913 } else {
2914 println!(
2915 "{} {:?} is incompatible under {} validation:",
2916 report.kind, report.name, report.mode
2917 );
2918 for issue in &report.issues {
2919 println!(" {} [{}] {}", issue.path, issue.code, issue.message);
2920 }
2921 }
2922 report.compatible
2923}
2924
2925fn enforce_tool_contract(
2926 contracts: &schema_contract::ContractSet,
2927 surface: &Arc<RwLock<Surface>>,
2928 name: &str,
2929) -> bool {
2930 let report = {
2931 let surface = surface.read().unwrap();
2932 surface
2933 .tools
2934 .iter()
2935 .find(|definition| definition.name == name)
2936 .and_then(|definition| contracts.check_tool(definition))
2937 };
2938 report
2939 .as_ref()
2940 .is_none_or(|report| render_validation_report(report, false))
2941}
2942
2943fn enforce_prompt_contract(
2944 contracts: &schema_contract::ContractSet,
2945 surface: &Arc<RwLock<Surface>>,
2946 name: &str,
2947) -> bool {
2948 let report = {
2949 let surface = surface.read().unwrap();
2950 surface
2951 .prompts
2952 .iter()
2953 .find(|definition| definition.name == name)
2954 .and_then(|definition| contracts.check_prompt(definition))
2955 };
2956 report
2957 .as_ref()
2958 .is_none_or(|report| render_validation_report(report, false))
2959}
2960
2961fn describe_value(surface: &Surface, name: &str) -> Option<serde_json::Value> {
2962 surface
2963 .tools
2964 .iter()
2965 .find(|definition| definition.name == name)
2966 .map(|definition| {
2967 serde_json::json!({
2968 "kind": "tool",
2969 "definition": definition,
2970 })
2971 })
2972 .or_else(|| {
2973 surface
2974 .prompts
2975 .iter()
2976 .find(|definition| definition.name == name)
2977 .map(|definition| {
2978 serde_json::json!({
2979 "kind": "prompt",
2980 "definition": definition,
2981 })
2982 })
2983 })
2984 .or_else(|| {
2985 surface
2986 .resources
2987 .iter()
2988 .find(|definition| definition.name == name || definition.uri == name)
2989 .map(|definition| {
2990 serde_json::json!({
2991 "kind": "resource",
2992 "definition": definition,
2993 })
2994 })
2995 })
2996 .or_else(|| {
2997 surface
2998 .templates
2999 .iter()
3000 .find(|definition| definition.name == name || definition.uri_template == name)
3001 .map(|definition| {
3002 serde_json::json!({
3003 "kind": "resourceTemplate",
3004 "definition": definition,
3005 })
3006 })
3007 })
3008}
3009
3010fn describe(surface: &Surface, name: &str) {
3013 if let Some(t) = surface.tools.iter().find(|t| t.name == name) {
3014 println!(
3015 "tool {} {}",
3016 paint(Style::new().fg(Color::Green).bold(), &t.name),
3017 t.description.as_deref().unwrap_or("")
3018 );
3019 if let Some(a) = &t.annotations {
3020 let mut hints = Vec::new();
3021 if a.read_only_hint {
3022 hints.push("read-only");
3023 }
3024 if a.idempotent_hint {
3025 hints.push("idempotent");
3026 }
3027 if a.destructive_hint && !a.read_only_hint {
3028 hints.push("destructive");
3029 }
3030 if a.open_world_hint {
3031 hints.push("open-world");
3032 }
3033 if !hints.is_empty() {
3034 println!(" hints: {}", hints.join(", "));
3035 }
3036 }
3037 if let Some(e) = &t.execution {
3038 let v = serde_json::to_value(e).unwrap_or_default();
3039 if let Some(mode) = v.get("taskSupport").and_then(|m| m.as_str()) {
3040 println!(" task support: {mode}");
3041 }
3042 }
3043 println!("input schema:");
3044 println!("{}", json_pretty(&t.input_schema));
3045 if let Some(out) = &t.output_schema {
3046 println!("output schema:");
3047 println!("{}", json_pretty(out));
3048 }
3049 return;
3050 }
3051 if let Some(p) = surface.prompts.iter().find(|p| p.name == name) {
3052 println!(
3053 "prompt {} {}",
3054 paint(Style::new().fg(Color::Green).bold(), &p.name),
3055 p.description.as_deref().unwrap_or("")
3056 );
3057 if p.arguments.is_empty() {
3058 println!(" (no arguments)");
3059 } else {
3060 println!("arguments:");
3061 for a in &p.arguments {
3062 println!(
3063 " {:20} {:10} {}",
3064 paint(Style::new().fg(Color::Cyan), &a.name),
3065 if a.required { "required" } else { "optional" },
3066 a.description.as_deref().unwrap_or("")
3067 );
3068 }
3069 }
3070 return;
3071 }
3072 if let Some(r) = surface
3073 .resources
3074 .iter()
3075 .find(|r| r.uri == name || r.name == name)
3076 {
3077 println!(
3078 "resource {}",
3079 paint(Style::new().fg(Color::Green).bold(), &r.uri)
3080 );
3081 println!(" name: {}", r.name);
3082 if let Some(t) = &r.title {
3083 println!(" title: {t}");
3084 }
3085 if let Some(d) = &r.description {
3086 println!(" description: {d}");
3087 }
3088 if let Some(m) = &r.mime_type {
3089 println!(" mimeType: {m}");
3090 }
3091 if let Some(s) = r.size {
3092 println!(" size: {s} bytes");
3093 }
3094 return;
3095 }
3096 if let Some(t) = surface
3097 .templates
3098 .iter()
3099 .find(|t| t.uri_template == name || t.name == name)
3100 {
3101 println!(
3102 "template {}",
3103 paint(Style::new().fg(Color::Green).bold(), &t.uri_template)
3104 );
3105 println!(" name: {}", t.name);
3106 if let Some(d) = &t.description {
3107 println!(" description: {d}");
3108 }
3109 if let Some(m) = &t.mime_type {
3110 println!(" mimeType: {m}");
3111 }
3112 if !t.arguments.is_empty() {
3113 println!("arguments:");
3114 for a in &t.arguments {
3115 println!(
3116 " {:20} {:10} {}",
3117 paint(Style::new().fg(Color::Cyan), &a.name),
3118 if a.required { "required" } else { "optional" },
3119 a.description.as_deref().unwrap_or("")
3120 );
3121 }
3122 }
3123 return;
3124 }
3125 note_error(ExitStatus::NoMatch);
3126 println!("nothing on the surface named `{name}` (try `tools`, `prompts`, `resources`)");
3127}
3128
3129#[allow(clippy::too_many_arguments)]
3130async fn run_tool(
3131 session: &Arc<Session>,
3132 surface: &Arc<RwLock<Surface>>,
3133 jobs: &Arc<Jobs>,
3134 schema_contracts: &schema_contract::ContractSet,
3135 name: &str,
3136 arguments: serde_json::Value,
3137 background: bool,
3138 output: &vars::Output,
3139) {
3140 if !enforce_tool_contract(schema_contracts, surface, name) {
3141 return;
3142 }
3143 if background {
3144 match with_reconnect(session, surface, |c| {
3145 let arguments = arguments.clone();
3146 async move { c.call_tool_as_task(name, arguments, None).await }
3147 })
3148 .await
3149 {
3150 Ok(created) => {
3151 let task_id = created.task.task_id.clone();
3152 let poll_interval = created.task.poll_interval;
3153 if json_output() {
3154 print_json(&serde_json::to_value(&created).unwrap_or_default());
3155 } else {
3156 println!(
3157 "{} started",
3158 tag(
3159 Style::new().fg(Color::Yellow),
3160 &format!("task {}", created.task.task_id)
3161 )
3162 );
3163 }
3164 jobs.register(
3165 created.task.task_id,
3166 name.to_string(),
3167 created.task.status,
3168 created.task.status_message,
3169 );
3170 watch_task(session.clone(), jobs.clone(), task_id, poll_interval);
3171 }
3172 Err(e) => report_mcp_error(&e),
3173 }
3174 return;
3175 }
3176 let started = std::time::Instant::now();
3177 match with_reconnect(session, surface, |c| {
3178 let arguments = arguments.clone();
3179 async move { c.call_tool(name, arguments).await }
3180 })
3181 .await
3182 {
3183 Ok(result) => {
3184 if result.is_error {
3185 note_error(ExitStatus::Server);
3186 }
3187 if output.is_plain() {
3188 if json_output() {
3189 print_json(&serde_json::to_value(&result).unwrap_or_default());
3190 } else {
3191 if result.is_error {
3192 println!("{}", tag(Style::new().fg(Color::Red), "tool error"));
3193 }
3194 render_content(&result.content);
3195 }
3196 } else {
3197 emit_result(result_value(&result), output);
3198 }
3199 }
3200 Err(e) => report_mcp_error(&e),
3201 }
3202 if !json_output() {
3203 println!("{}", timing(started.elapsed()));
3204 }
3205}
3206
3207fn result_value(result: &tower_mcp::CallToolResult) -> serde_json::Value {
3210 if let Some(structured) = &result.structured_content {
3211 return structured.clone();
3212 }
3213 if let [Content::Text { text, .. }] = result.content.as_slice() {
3214 return serde_json::from_str(text)
3215 .unwrap_or_else(|_| serde_json::Value::String(text.clone()));
3216 }
3217 serde_json::to_value(&result.content).unwrap_or_default()
3218}
3219
3220fn emit_result(mut value: serde_json::Value, output: &vars::Output) {
3223 if let Some(path) = &output.filter {
3224 match vars::get_path(&value, path) {
3225 Some(selected) => value = selected,
3226 None => {
3227 command_error(&format!("path `{path}` not found in result"));
3228 return;
3229 }
3230 }
3231 }
3232 if let Some(name) = &output.capture {
3233 vars::set(name, value.clone());
3234 if json_output() {
3235 print_json(&value);
3236 } else {
3237 println!(
3238 "{} {}",
3239 paint(Style::new().fg(Color::Cyan), &format!("${name} =")),
3240 value_summary(&value)
3241 );
3242 }
3243 } else if json_output() {
3244 print_json(&value);
3245 } else {
3246 render_value(&value);
3247 }
3248}
3249
3250fn value_summary(value: &serde_json::Value) -> String {
3251 match value {
3252 serde_json::Value::String(s) => format!("{s:?}"),
3253 serde_json::Value::Array(a) => format!("[{} items]", a.len()),
3254 serde_json::Value::Object(o) => format!("{{{} fields}}", o.len()),
3255 other => other.to_string(),
3256 }
3257}
3258
3259fn render_value(value: &serde_json::Value) {
3260 match value {
3261 serde_json::Value::String(s) => println!("{s}"),
3262 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
3263 println!("{}", json_pretty(value))
3264 }
3265 other => println!("{other}"),
3266 }
3267}
3268
3269#[cfg(test)]
3270mod tests {
3271 use super::*;
3272 use std::sync::Mutex;
3273
3274 use async_trait::async_trait;
3275 use tower_mcp::client::ClientTransport;
3276
3277 struct DiscoveryTransport {
3279 result: serde_json::Value,
3280 incoming_tx: tokio::sync::mpsc::Sender<String>,
3281 incoming_rx: tokio::sync::mpsc::Receiver<String>,
3282 outgoing: Arc<Mutex<Vec<serde_json::Value>>>,
3283 connected: bool,
3284 }
3285
3286 impl DiscoveryTransport {
3287 fn new(result: serde_json::Value) -> (Self, Arc<Mutex<Vec<serde_json::Value>>>) {
3288 let (incoming_tx, incoming_rx) = tokio::sync::mpsc::channel(4);
3289 let outgoing = Arc::new(Mutex::new(Vec::new()));
3290 (
3291 Self {
3292 result,
3293 incoming_tx,
3294 incoming_rx,
3295 outgoing: outgoing.clone(),
3296 connected: true,
3297 },
3298 outgoing,
3299 )
3300 }
3301 }
3302
3303 #[async_trait]
3304 impl ClientTransport for DiscoveryTransport {
3305 async fn send(&mut self, message: &str) -> tower_mcp::Result<()> {
3306 let request: serde_json::Value = serde_json::from_str(message)?;
3307 self.outgoing.lock().unwrap().push(request.clone());
3308 if let Some(id) = request.get("id") {
3309 self.incoming_tx
3310 .send(
3311 serde_json::json!({
3312 "jsonrpc": "2.0",
3313 "id": id,
3314 "result": self.result,
3315 })
3316 .to_string(),
3317 )
3318 .await
3319 .map_err(|error| tower_mcp::Error::Transport(error.to_string()))?;
3320 }
3321 Ok(())
3322 }
3323
3324 async fn recv(&mut self) -> tower_mcp::Result<Option<String>> {
3325 Ok(self.incoming_rx.recv().await)
3326 }
3327
3328 fn is_connected(&self) -> bool {
3329 self.connected
3330 }
3331
3332 async fn close(&mut self) -> tower_mcp::Result<()> {
3333 self.connected = false;
3334 Ok(())
3335 }
3336 }
3337
3338 fn jsonrpc(code: i32, message: &str) -> tower_mcp::Error {
3339 tower_mcp::Error::JsonRpc(tower_mcp::error::JsonRpcError {
3340 code,
3341 message: message.to_string(),
3342 data: None,
3343 })
3344 }
3345
3346 #[test]
3347 fn protocol_selection_is_stable_by_default_and_final_is_exact() {
3348 let stable = Args::try_parse_from(["mcp-repl", "--demo"]).unwrap();
3349 assert_eq!(stable.protocol, ProtocolMode::Stable);
3350 assert_eq!(
3351 stable.protocol.support().unwrap().versions(),
3352 tower_mcp::protocol::SUPPORTED_PROTOCOL_VERSIONS
3353 );
3354
3355 for value in ["2026-07-28", "final"] {
3356 let final_args =
3357 Args::try_parse_from(["mcp-repl", "--protocol", value, "--demo"]).unwrap();
3358 assert_eq!(final_args.protocol, ProtocolMode::Final);
3359 assert_eq!(
3360 final_args.protocol.support().unwrap().versions(),
3361 ["2026-07-28"]
3362 );
3363 }
3364 }
3365
3366 #[test]
3367 fn oauth_cli_parses_standalone_and_connection_workflows() {
3368 let login = Args::try_parse_from([
3369 "mcp-repl",
3370 "--login",
3371 "work",
3372 "--http",
3373 "https://mcp.example/mcp",
3374 "--oauth-scope",
3375 "openid",
3376 "--oauth-scope",
3377 "offline_access",
3378 "--no-browser",
3379 ])
3380 .unwrap();
3381 assert_eq!(login.login.as_deref(), Some("work"));
3382 assert_eq!(login.oauth_scopes, ["openid", "offline_access"]);
3383 assert!(login.no_browser);
3384
3385 let connection = Args::try_parse_from([
3386 "mcp-repl",
3387 "--oauth",
3388 "work",
3389 "--http",
3390 "https://mcp.example/mcp",
3391 "--exec",
3392 "tools",
3393 "--json",
3394 ])
3395 .unwrap();
3396 assert_eq!(connection.oauth.as_deref(), Some("work"));
3397 assert_eq!(connection.exec, ["tools"]);
3398
3399 assert!(Args::try_parse_from(["mcp-repl", "--login", "work", "--logout", "work"]).is_err());
3400 }
3401
3402 #[tokio::test]
3403 async fn stable_selection_uses_initialize() {
3404 let client = client_builder(ProtocolMode::Stable)
3405 .unwrap()
3406 .connect_simple(ChannelTransport::new(demo_router()))
3407 .await
3408 .unwrap();
3409 let info = establish_connection(&client, ProtocolMode::Stable)
3410 .await
3411 .unwrap();
3412
3413 assert_eq!(info.server_info.name, "mcp-repl-demo");
3414 assert_eq!(
3415 info.protocol_version,
3416 tower_mcp::protocol::LATEST_PROTOCOL_VERSION
3417 );
3418 assert!(client.server_info().await.is_some());
3419 assert!(client.discovery().await.is_none());
3420 }
3421
3422 #[tokio::test]
3423 async fn final_selection_uses_discover_with_required_metadata() {
3424 let (transport, outgoing) = DiscoveryTransport::new(serde_json::json!({
3425 "resultType": "complete",
3426 "supportedVersions": ["2026-07-28"],
3427 "capabilities": {"tools": {}},
3428 "ttlMs": 0,
3429 "cacheScope": "private",
3430 "_meta": {
3431 "io.modelcontextprotocol/serverInfo": {
3432 "name": "final-test-server",
3433 "version": "1.0.0"
3434 }
3435 }
3436 }));
3437 let client = client_builder(ProtocolMode::Final)
3438 .unwrap()
3439 .connect_simple(transport)
3440 .await
3441 .unwrap();
3442 let info = establish_connection(&client, ProtocolMode::Final)
3443 .await
3444 .unwrap();
3445
3446 assert_eq!(info.server_info.name, "final-test-server");
3447 assert_eq!(info.protocol_version, "2026-07-28");
3448 assert!(client.server_info().await.is_none());
3449 assert!(client.discovery().await.is_some());
3450
3451 let sent = outgoing.lock().unwrap();
3452 assert_eq!(sent.len(), 1);
3453 assert_eq!(sent[0]["method"], "server/discover");
3454 assert_eq!(
3455 sent[0]["params"]["_meta"]["io.modelcontextprotocol/protocolVersion"],
3456 "2026-07-28"
3457 );
3458 assert!(
3459 sent[0]["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"].is_object()
3460 );
3461 assert!(
3462 sent[0]["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"]["extensions"]
3463 [tower_mcp::protocol::TASKS_EXTENSION_ID]
3464 .is_object()
3465 );
3466 assert_eq!(
3467 sent[0]["params"]["_meta"]["io.modelcontextprotocol/clientInfo"]["name"],
3468 "mcp-repl"
3469 );
3470 }
3471
3472 #[test]
3473 fn build_http_config_sets_bearer_and_trims_headers() {
3474 let cfg = build_http_config(
3475 Some("tok".into()),
3476 &["X-Api-Key: abc".into(), "X-Trim : v ".into()],
3477 None,
3478 &[],
3479 )
3480 .unwrap();
3481 assert_eq!(
3482 cfg.headers.get("Authorization").map(String::as_str),
3483 Some("Bearer tok")
3484 );
3485 assert_eq!(
3486 cfg.headers.get("X-Api-Key").map(String::as_str),
3487 Some("abc")
3488 );
3489 assert_eq!(cfg.headers.get("X-Trim").map(String::as_str), Some("v"));
3490 }
3491
3492 #[test]
3493 fn profile_auth_applies_and_flags_override_it() {
3494 let profile_headers = [
3495 ("X-Api-Key".to_string(), "from-profile".to_string()),
3496 ("X-Kept".to_string(), "profile".to_string()),
3497 ];
3498 let cfg =
3500 build_http_config(None, &[], Some("profile-tok".into()), &profile_headers).unwrap();
3501 assert_eq!(
3502 cfg.headers.get("Authorization").map(String::as_str),
3503 Some("Bearer profile-tok")
3504 );
3505 assert_eq!(
3506 cfg.headers.get("X-Api-Key").map(String::as_str),
3507 Some("from-profile")
3508 );
3509
3510 let cfg = build_http_config(
3512 Some("flag-tok".into()),
3513 &["X-Api-Key: from-flag".into()],
3514 Some("profile-tok".into()),
3515 &profile_headers,
3516 )
3517 .unwrap();
3518 assert_eq!(
3519 cfg.headers.get("Authorization").map(String::as_str),
3520 Some("Bearer flag-tok")
3521 );
3522 assert_eq!(
3523 cfg.headers.get("X-Api-Key").map(String::as_str),
3524 Some("from-flag")
3525 );
3526 assert_eq!(
3527 cfg.headers.get("X-Kept").map(String::as_str),
3528 Some("profile")
3529 );
3530 }
3531
3532 #[test]
3533 fn oauth_precedence_is_explicit_static_then_cli_then_server_profile() {
3534 assert_eq!(
3535 selected_oauth_profile(Some("cli"), Some("server"), false, &[]),
3536 Some("cli".to_string())
3537 );
3538 assert_eq!(
3539 selected_oauth_profile(None, Some("server"), false, &[]),
3540 Some("server".to_string())
3541 );
3542 assert_eq!(
3543 selected_oauth_profile(Some("cli"), Some("server"), true, &[]),
3544 None
3545 );
3546 assert_eq!(
3547 selected_oauth_profile(
3548 Some("cli"),
3549 Some("server"),
3550 false,
3551 &["authorization: Basic explicit".to_string()],
3552 ),
3553 None
3554 );
3555 assert_eq!(
3556 selected_oauth_profile(
3557 Some("cli"),
3558 Some("server"),
3559 false,
3560 &["X-Tenant: acme".to_string()],
3561 ),
3562 Some("cli".to_string())
3563 );
3564 }
3565
3566 #[test]
3567 fn selected_authorization_header_beats_environment_bearer() {
3568 let selected_headers = [("authorization".to_string(), "Basic selected".to_string())];
3569 let cfg = build_http_config_with_env(
3570 None,
3571 &[],
3572 None,
3573 &selected_headers,
3574 Some("ambient-token".into()),
3575 )
3576 .unwrap();
3577 assert_eq!(
3578 cfg.headers.get("authorization").map(String::as_str),
3579 Some("Basic selected")
3580 );
3581
3582 let cfg = build_http_config_with_env(
3583 Some("explicit-token".into()),
3584 &[],
3585 None,
3586 &selected_headers,
3587 Some("ambient-token".into()),
3588 )
3589 .unwrap();
3590 assert_eq!(
3591 cfg.headers.get("Authorization").map(String::as_str),
3592 Some("Bearer explicit-token")
3593 );
3594 }
3595
3596 #[test]
3597 fn explicit_oauth_suppresses_profile_and_environment_bearers() {
3598 let selected = selected_oauth_profile(Some("work"), None, false, &[]);
3599 assert_eq!(selected.as_deref(), Some("work"));
3600
3601 let cfg = build_http_config_with_env(
3602 None,
3603 &[],
3604 selected.is_none().then(|| "profile-token".to_string()),
3605 &[],
3606 selected.is_none().then(|| "environment-token".to_string()),
3607 )
3608 .unwrap();
3609 assert!(!cfg.headers.contains_key("Authorization"));
3610 }
3611
3612 #[test]
3613 fn build_http_config_rejects_header_without_colon() {
3614 let err = build_http_config(Some("tok".into()), &["nope".into()], None, &[]).unwrap_err();
3615 assert!(
3616 err.contains("nope"),
3617 "error should name the bad header: {err}"
3618 );
3619 assert!(
3620 err.contains("Name: Value"),
3621 "error should show the format: {err}"
3622 );
3623 }
3624
3625 #[test]
3626 fn timing_formats_sub_second_and_seconds() {
3627 assert!(timing(Duration::from_millis(142)).contains("[142ms]"));
3628 assert!(timing(Duration::from_millis(2500)).contains("[2.50s]"));
3629 }
3630
3631 #[test]
3635 fn bench_is_a_listed_builtin() {
3636 assert!(BUILTINS.iter().any(|(name, _)| *name == "bench"));
3637 }
3638
3639 #[test]
3642 fn find_is_a_completable_builtin() {
3643 assert!(BUILTINS.iter().any(|(name, _)| *name == "find"));
3644 }
3645
3646 #[test]
3647 fn error_json_is_a_valid_object() {
3648 let v = error_json(ExitStatus::Usage, "boom: it broke");
3649 assert_eq!(v["error"], "boom: it broke");
3650 assert_eq!(v["kind"], "usage");
3651 assert_eq!(v["exitStatus"], 2);
3652 }
3653
3654 #[test]
3655 fn automatic_task_updates_are_interactive_text_only() {
3656 assert!(automatic_task_updates(false, false));
3657 assert!(!automatic_task_updates(true, false));
3658 assert!(!automatic_task_updates(true, true));
3659 assert!(!automatic_task_updates(false, true));
3660 }
3661
3662 #[test]
3663 fn quoted_task_arguments_reach_schema_coercion_intact() {
3664 let parsed = command::parse(
3665 r#"run.start instruction="Reply with exactly hello" mode=interactive &"#,
3666 )
3667 .unwrap();
3668 let tokens: Vec<&str> = parsed.words[1..].iter().map(String::as_str).collect();
3669 let schema = serde_json::json!({
3670 "type": "object",
3671 "properties": {
3672 "instruction": { "type": "string" },
3673 "mode": { "type": "string" }
3674 }
3675 });
3676
3677 assert!(parsed.background);
3678 assert_eq!(
3679 parse_kv_args(&schema, &tokens),
3680 serde_json::json!({
3681 "instruction": "Reply with exactly hello",
3682 "mode": "interactive"
3683 })
3684 );
3685 }
3686
3687 #[test]
3692 fn file_backed_history_writes_on_sync() {
3693 use reedline::{FileBackedHistory, History, HistoryItem};
3694 let path = std::env::temp_dir().join(format!("mcp-repl-hist-{}.txt", std::process::id()));
3695 let _ = std::fs::remove_file(&path);
3696 {
3697 let mut h = FileBackedHistory::with_file(10, path.clone()).unwrap();
3698 h.save(HistoryItem::from_command_line("echo persisted"))
3699 .unwrap();
3700 h.sync().unwrap();
3701 }
3702 let contents = std::fs::read_to_string(&path).unwrap();
3703 assert!(
3704 contents.contains("echo persisted"),
3705 "history was not written to disk: {contents:?}"
3706 );
3707 let _ = std::fs::remove_file(&path);
3708 }
3709
3710 async fn demo_client() -> McpClient {
3713 let client = McpClient::builder()
3714 .connect_simple(ChannelTransport::new(demo_router()))
3715 .await
3716 .unwrap();
3717 client.initialize("mcp-repl-test", "0").await.unwrap();
3718 client
3719 }
3720
3721 #[tokio::test(flavor = "multi_thread")]
3722 async fn bundled_slow_task_announces_completion_without_manual_polling() {
3723 let session = Arc::new(Session::new(demo_client().await, None));
3724 let surface = Arc::new(RwLock::new(Surface::default()));
3725 let output = AsyncOutput::new(Arc::new(AtomicBool::new(true)), true);
3726 let printer = output.external_printer().unwrap();
3727 let jobs = Arc::new(Jobs::new(output, true));
3728 let schema_contracts = schema_contract::ContractSet::default();
3729
3730 run_tool(
3731 &session,
3732 &surface,
3733 &jobs,
3734 &schema_contracts,
3735 "slow_add",
3736 serde_json::json!({ "a": 2, "b": 3 }),
3737 true,
3738 &vars::Output::default(),
3739 )
3740 .await;
3741
3742 let line = tokio::time::timeout(Duration::from_secs(6), async {
3743 loop {
3744 if let Some(line) = printer.get_line() {
3745 break line;
3746 }
3747 tokio::time::sleep(Duration::from_millis(25)).await;
3748 }
3749 })
3750 .await
3751 .expect("the task watcher should observe slow_add completion");
3752
3753 assert!(line.contains("completed"), "{line}");
3754 assert_eq!(
3755 jobs.list()[0].status,
3756 tower_mcp::protocol::TaskStatus::Completed
3757 );
3758 }
3759
3760 async fn demo_session() -> (Arc<Session>, Arc<std::sync::atomic::AtomicUsize>) {
3763 let connects = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3764 let counter = connects.clone();
3765 let connector: Connector = Box::new(move || {
3766 let counter = counter.clone();
3767 Box::pin(async move {
3768 counter.fetch_add(1, Ordering::SeqCst);
3769 Ok(demo_client().await)
3770 })
3771 });
3772 (
3773 Arc::new(Session::new(demo_client().await, Some(connector))),
3774 connects,
3775 )
3776 }
3777
3778 #[tokio::test(flavor = "multi_thread")]
3782 async fn dropped_session_is_rebuilt_and_the_command_retried() {
3783 let (session, connects) = demo_session().await;
3784 let surface = Arc::new(RwLock::new(Surface::default()));
3785 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3786 let dead = Arc::as_ptr(&session.client()) as usize;
3787 let seen: Arc<RwLock<Vec<usize>>> = Arc::new(RwLock::new(Vec::new()));
3788
3789 let (calls, saw) = (attempts.clone(), seen.clone());
3790 let result = with_reconnect(&session, &surface, |c| {
3791 let (calls, saw) = (calls.clone(), saw.clone());
3792 async move {
3793 saw.write().unwrap().push(Arc::as_ptr(&c) as usize);
3794 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
3796 return Err(jsonrpc(
3797 -32600,
3798 "Client must send notifications/initialized before making requests",
3799 ));
3800 }
3801 c.call_tool("echo", serde_json::json!({ "message": "alive" }))
3802 .await
3803 }
3804 })
3805 .await
3806 .expect("the retried call should succeed on the rebuilt session");
3807
3808 assert_eq!(attempts.load(Ordering::SeqCst), 2, "one retry, not a loop");
3809 let seen = seen.read().unwrap();
3811 assert_eq!(seen[0], dead);
3812 assert_ne!(seen[1], dead, "the retry reused the dead client");
3813 assert_eq!(
3814 connects.load(Ordering::SeqCst),
3815 1,
3816 "reconnected exactly once"
3817 );
3818 assert_eq!(session.generation(), 1);
3819 match result.content.first() {
3820 Some(Content::Text { text, .. }) => assert_eq!(text, "alive"),
3821 other => panic!("unexpected content: {other:?}"),
3822 }
3823 assert!(
3825 !surface.read().unwrap().tools.is_empty(),
3826 "surface should be refreshed after reconnect"
3827 );
3828 }
3829
3830 #[tokio::test(flavor = "multi_thread")]
3831 async fn a_still_dead_server_surfaces_the_error_after_one_retry() {
3832 let (session, connects) = demo_session().await;
3833 let surface = Arc::new(RwLock::new(Surface::default()));
3834 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3835
3836 let calls = attempts.clone();
3837 let err = with_reconnect(&session, &surface, |_c| {
3838 let calls = calls.clone();
3839 async move {
3840 calls.fetch_add(1, Ordering::SeqCst);
3841 Err::<(), _>(tower_mcp::Error::Transport(
3842 "HTTP 503 Service Unavailable from server: ".into(),
3843 ))
3844 }
3845 })
3846 .await
3847 .unwrap_err();
3848
3849 assert!(is_session_lost(&err));
3850 assert_eq!(attempts.load(Ordering::SeqCst), 2, "bounded to one retry");
3851 assert_eq!(connects.load(Ordering::SeqCst), 1);
3852 }
3853
3854 #[tokio::test(flavor = "multi_thread")]
3855 async fn ordinary_errors_do_not_reconnect() {
3856 let (session, connects) = demo_session().await;
3857 let surface = Arc::new(RwLock::new(Surface::default()));
3858 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3859
3860 let calls = attempts.clone();
3861 let err = with_reconnect(&session, &surface, |_c| {
3862 let calls = calls.clone();
3863 async move {
3864 calls.fetch_add(1, Ordering::SeqCst);
3865 Err::<(), _>(jsonrpc(-32602, "Invalid params"))
3866 }
3867 })
3868 .await
3869 .unwrap_err();
3870
3871 assert!(matches!(err, tower_mcp::Error::JsonRpc(j) if j.code == -32602));
3872 assert_eq!(attempts.load(Ordering::SeqCst), 1, "no retry");
3873 assert_eq!(connects.load(Ordering::SeqCst), 0, "no reconnect");
3874 }
3875
3876 #[tokio::test(flavor = "multi_thread")]
3879 async fn a_session_without_a_connector_never_retries() {
3880 let session = Arc::new(Session::new(demo_client().await, None));
3881 let surface = Arc::new(RwLock::new(Surface::default()));
3882 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3883
3884 assert!(!session.can_reconnect());
3885 let calls = attempts.clone();
3886 let err = with_reconnect(&session, &surface, |_c| {
3887 let calls = calls.clone();
3888 async move {
3889 calls.fetch_add(1, Ordering::SeqCst);
3890 Err::<(), _>(tower_mcp::Error::SessionExpired)
3891 }
3892 })
3893 .await
3894 .unwrap_err();
3895
3896 assert!(matches!(err, tower_mcp::Error::SessionExpired));
3897 assert_eq!(attempts.load(Ordering::SeqCst), 1);
3898 }
3899}