Skip to main content

mcp_repl/
lib.rs

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