Skip to main content

mcp_repl/
lib.rs

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