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