trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
//! CLI handlers for `tm mcp add|remove|list|get`.
//!
//! Why: register user-level MCP servers into tm's OWN tm-owned
//! `CLAUDE_CONFIG_DIR` — the top-level `mcpServers` map of that dir's
//! `.claude.json`, which stock `claude mcp add` cannot target. Every tm-managed
//! session then sees the server (user scope, "available in all your projects";
//! no approval dialog). The CRUD itself lives in
//! [`trusty_mpm::core::mcp_config`]; these handlers are the thin CLI shell that
//! validates transport-specific flags, resolves the config dir, and formats
//! output — shaped like `commands::standalone`'s `*_cmd(&ManagedPaths, …)`.
//! Test: `cli_parses_mcp_*` in `tests.rs`; parse helpers unit-tested below;
//! CRUD in `core::mcp_config`.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde_json::{Map, Value};

use crate::cli::McpTransportArg;
use trusty_mpm::core::mcp_config::{
    self, McpTransport, build_remote_entry, build_stdio_entry, strip_arg_separator,
};
use trusty_mpm::core::mcp_test::{self, McpTestResult};

/// Resolve the `.claude.json`-bearing config dir for a `tm mcp` invocation.
///
/// Why: the default target is the DAEMON-managed dir
/// (`~/.trusty-tools/trusty-mpm/claude-config/`) so servers reach every managed
/// session; `--root` switches to a STANDALONE root via the same precedence chain
/// `tm register`/`tm ls` use, reusing that resolver verbatim.
/// What: with `root = None` returns
/// [`trusty_mpm::core::trusty_tools_config::managed_claude_config_dir`]; with
/// `root = Some(_)` returns `resolve_managed_paths(root)?.claude_config_dir`.
/// Test: exercised end-to-end via the `tm mcp` handlers.
fn resolve_config_dir(root: Option<&str>) -> Result<PathBuf> {
    match root {
        Some(_) => Ok(super::managed_root::resolve_managed_paths(root)?.claude_config_dir),
        None => trusty_mpm::core::trusty_tools_config::managed_claude_config_dir()
            .context("cannot resolve home directory for the managed claude config dir"),
    }
}

/// Parse `KEY=VALUE` pairs into a JSON object.
///
/// Why: `-e KEY=VALUE` (repeatable) mirrors `claude mcp add -e`.
/// What: splits each item on the FIRST `=`; errors on a missing `=`.
/// Test: `parse_kv_env_splits_on_first_equals`.
fn parse_env(pairs: &[String]) -> Result<Map<String, Value>> {
    let mut map = Map::new();
    for p in pairs {
        let (k, v) = p
            .split_once('=')
            .with_context(|| format!("invalid --env '{p}': expected KEY=VALUE"))?;
        map.insert(k.to_string(), Value::String(v.to_string()));
    }
    Ok(map)
}

/// Parse `Name: Value` headers into a JSON object.
///
/// Why: `-H "Name: Value"` (repeatable) mirrors `claude mcp add --header`.
/// What: splits each item on the FIRST `:`, trimming surrounding whitespace from
/// the value; errors on a missing `:`.
/// Test: `parse_headers_splits_on_first_colon`.
fn parse_headers(items: &[String]) -> Result<Map<String, Value>> {
    let mut map = Map::new();
    for h in items {
        let (k, v) = h
            .split_once(':')
            .with_context(|| format!("invalid --header '{h}': expected 'Name: Value'"))?;
        map.insert(k.trim().to_string(), Value::String(v.trim().to_string()));
    }
    Ok(map)
}

/// Handle `tm mcp add <name> …`.
///
/// Why: the primary verb — upsert a user-scope MCP server into the tm config dir.
/// What: validates transport-specific inputs (stdio takes `-e`/args + a command
/// and WARNS (never blocks) when the command is absent from `PATH`, matching
/// `claude mcp add`; http/sse take `-H` + a URL), builds the entry, and calls
/// [`mcp_config::add_server`]. Prints whether the server was added/updated or
/// already present. `command_and_args` is the raw trailing-var-arg slice from
/// clap: its first element is the command (stdio) or URL (http/sse); any
/// remaining elements are stdio subprocess args (rejected for http/sse).
/// Test: `cli_parses_mcp_add`, `cli_parses_mcp_add_http`; CRUD in `core::mcp_config`.
///
/// Dispatch one `tm mcp <verb>` invocation.
///
/// Why (#7672): the verb list grew past what `main`'s single match arm could
/// hold without pushing that file over the 500-SLOC production cap, and the
/// arm was pure routing with no `main`-level concern in it. Keeping the routing
/// beside the handlers it routes to also means a new verb touches one file.
/// What: maps each [`crate::cli::McpCmd`] variant onto its handler below.
///
/// # Errors
///
/// Propagates the selected handler's error unchanged.
/// Test: `cli_parses_mcp_add`, `cli_parses_mcp_share`, `cli_parses_mcp_unshare`,
/// `cli_parses_mcp_remove_list_get`, `share_is_a_no_op_under_the_claude_code_standard`.
pub(crate) async fn dispatch(cmd: crate::cli::McpCmd) -> Result<()> {
    use crate::cli::McpCmd;
    match cmd {
        McpCmd::Add {
            name,
            transport,
            env,
            header,
            command_and_args,
            root,
            project,
            share_with_projects,
        } => add_cmd(
            root.as_deref(),
            &name,
            transport,
            &env,
            &header,
            &command_and_args,
            AddPlacement {
                project,
                share_with_projects,
            },
        ),
        // #7892: retired to a notice, not removed — scripts and docs still
        // spell these verbs.
        McpCmd::Share { name, .. } => share_cmd(&name, true),
        McpCmd::Unshare { name, .. } => share_cmd(&name, false),
        McpCmd::Remove { name, root } => remove_cmd(root.as_deref(), &name),
        McpCmd::List { json, root } => list_cmd(root.as_deref(), json),
        McpCmd::Get { name, json, root } => get_cmd(root.as_deref(), &name, json),
        McpCmd::Test { name, json, root } => test_cmd(root.as_deref(), name.as_deref(), json).await,
    }
}

/// Where `tm mcp add` puts the server, and who may load it.
///
/// Why: clap hands these two independent booleans down together, and an
/// argument list of eight trips `clippy::too_many_arguments`. Grouping them
/// also keeps the two flags that decide WHO can load the server in one place.
/// What: `project` writes the project's own `.mcp.json` instead of the user
/// scope; `share_with_projects` is retired and only prints a notice (#7892).
/// Test: `cli_parses_mcp_share`, `cli_parses_mcp_unshare`.
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct AddPlacement {
    /// Write the server into THIS project's `.mcp.json` (`--project`).
    pub project: bool,
    /// `--share-with-projects`, retired by #7892: a user-scope server already
    /// loads in every project, so there is nothing left to share.
    pub share_with_projects: bool,
}

pub(crate) fn add_cmd(
    root: Option<&str>,
    name: &str,
    transport: McpTransportArg,
    env: &[String],
    header: &[String],
    command_and_args: &[String],
    placement: AddPlacement,
) -> Result<()> {
    let AddPlacement {
        project,
        share_with_projects,
    } = placement;
    let config_dir = resolve_config_dir(root)?;
    let command_or_url = command_and_args.first().map(String::as_str);
    // Drop a leading `--` separator (clap's `trailing_var_arg` keeps it in the
    // captured tail after the command positional; persisting it verbatim breaks
    // every npx/uv-style stdio server). Only the first token is stripped — a
    // legitimate deeper `--` is preserved. See `mcp_config::strip_arg_separator`.
    let args = strip_arg_separator(command_and_args.get(1..).unwrap_or_default());

    let entry = match transport {
        McpTransportArg::Stdio => {
            if !header.is_empty() {
                bail!("--header is only valid for http/sse transports, not stdio");
            }
            let command = command_or_url
                .context("stdio transport requires a <command> positional argument")?;
            // Match `claude mcp add`: warn but do NOT block when the command is
            // not resolvable on PATH (it may be installed later or shadowed).
            if trusty_common::bin_resolve::resolve_binary(command).is_none() {
                tracing::warn!(
                    "command '{command}' for MCP server '{name}' was not found on PATH; \
                     adding anyway (it must resolve at session launch)"
                );
                eprintln!("warning: '{command}' not found on PATH — adding '{name}' anyway");
            }
            let env = parse_env(env)?;
            build_stdio_entry(command, args, &env)
        }
        McpTransportArg::Http | McpTransportArg::Sse => {
            if !env.is_empty() {
                bail!("--env is only valid for the stdio transport, not http/sse");
            }
            if !args.is_empty() {
                bail!("subprocess args (after `--`) are only valid for the stdio transport");
            }
            let url = command_or_url
                .context("http/sse transport requires a <url> positional argument")?;
            let headers = parse_headers(header)?;
            let t = if matches!(transport, McpTransportArg::Http) {
                McpTransport::Http
            } else {
                McpTransport::Sse
            };
            build_remote_entry(t, url, &headers)
        }
    };

    // `--project` writes the project's own `.mcp.json` — one declaration point,
    // per ADR-0042. // #7892: no trust gate. Whether a session CONNECTS that
    // entry is Claude Code's own approval decision, exactly as it is for a
    // `claude mcp add --scope project` run by hand.
    if project {
        let cwd = std::env::current_dir().context("cannot resolve the current directory")?;
        let target = cwd.join(mcp_config::MCP_JSON);
        let changed = mcp_config::add_project_server(&cwd, name, entry)?;
        if changed {
            println!("Added MCP server '{name}' to {}", target.display());
            println!(
                "  Claude Code approves project-scope servers itself — it prompts on the \n  \
                 next interactive session, or reads `enableAllProjectMcpServers` / \n  \
                 `enabledMcpjsonServers` from settings. `tm mcp list` shows that state."
            );
        } else {
            println!(
                "MCP server '{name}' already present in {} (no change)",
                target.display()
            );
        }
        return Ok(());
    }

    let changed = mcp_config::add_server(&config_dir, name, entry)?;
    if changed {
        println!(
            "Added MCP server '{name}' to {}",
            config_dir.join(".claude.json").display()
        );
        println!(
            "  This is USER scope: since #7892 it loads in every tm session, in every \n  \
             project, with no grant."
        );
    } else {
        println!("MCP server '{name}' already present (no change)");
    }
    if share_with_projects {
        println!(
            "  --share-with-projects is retired (#7892): a user-scope server already \n  \
             loads in every project, so there is nothing left to share."
        );
    }
    Ok(())
}

/// Handle `tm mcp share <name>` and `tm mcp unshare <name>` — retired (#7892).
///
/// Why: sharing existed because #7422 made a user-scope server invisible to an
/// untrusted project unless the operator lent it out by content. Under the
/// Claude Code standard every user-scope server already loads in every project,
/// so a grant would grant nothing. The verbs stay so scripts and runbooks that
/// spell them keep exiting zero; they print what changed instead of writing a
/// store this crate no longer has.
/// What: one notice line, then `Ok(())`. `--root` is accepted and ignored —
/// there is no per-root state left to address.
///
/// # Errors
///
/// None; the signature stays `Result` so the dispatch arm is unchanged.
/// Test: `share_is_a_no_op_under_the_claude_code_standard`, `cli_parses_mcp_share`,
/// `cli_parses_mcp_unshare`.
pub(crate) fn share_cmd(name: &str, share: bool) -> Result<()> {
    let verb = if share { "share" } else { "unshare" };
    println!(
        "`tm mcp {verb} {name}` does nothing: since #7892 tm follows the Claude Code \n           standard. A user-scope server in the protected config loads in every session, \n           in every project, with no grant. A project's own .mcp.json follows Claude \n           Code's approval (`enableAllProjectMcpServers` / `enabledMcpjsonServers`)."
    );
    Ok(())
}

/// Handle `tm mcp remove <name>`.
///
/// Why: drop a server the operator no longer wants in every managed session.
/// What: calls [`mcp_config::remove_server`] and says whether anything went.
/// // #7892: no share grant to settle — that store is gone.
/// Test: `cli_parses_mcp_remove`; CRUD in `core::mcp_config`.
pub(crate) fn remove_cmd(root: Option<&str>, name: &str) -> Result<()> {
    let config_dir = resolve_config_dir(root)?;
    if mcp_config::remove_server(&config_dir, name)? {
        println!("Removed MCP server '{name}'");
    } else {
        println!("MCP server '{name}' not found (no change)");
    }
    Ok(())
}

/// Handle `tm mcp list [--json]`.
///
/// Why: an operator needs to know what a session in this directory will
/// actually connect. Since #7892 that is an additive answer with three sources,
/// so the command names all three rather than marking rows `scoped-out`.
/// What: the user-scope `mcpServers` map as a table (name → type + target), the
/// trusty-* builtins tm adds through `--mcp-config`, and each `.mcp.json` entry
/// with the approval state
/// [`trusty_mpm::core::project_mcp_approval::project_mcp_state`] reads out of
/// Claude Code's own settings. `--json` prints the raw user-scope map alone,
/// unchanged, because that is what a script consumes.
/// Test: `cli_parses_mcp_list`; CRUD in `core::mcp_config`.
pub(crate) fn list_cmd(root: Option<&str>, json: bool) -> Result<()> {
    let config_dir = resolve_config_dir(root)?;
    let servers = mcp_config::list_servers(&config_dir)?;
    if json {
        println!("{}", serde_json::to_string_pretty(&servers)?);
        return Ok(());
    }
    if servers.is_empty() {
        println!(
            "No user-scope MCP servers registered in {}",
            config_dir.display()
        );
        println!("  Add one with: tm mcp add <name> -- <command> [args...]");
    } else {
        let name_w = servers.keys().map(String::len).max().unwrap_or(4).max(4);
        println!(
            "User-scope MCP servers in {} ({}) — these load in EVERY tm session:",
            config_dir.display(),
            servers.len()
        );
        println!("  {:<name_w$}  TYPE   TARGET", "NAME");
        for (name, entry) in &servers {
            println!(
                "  {:<name_w$}  {:<5}  {}",
                name,
                entry_type(entry),
                entry_target(entry)
            );
        }
    }

    // #7892: the composed file tm passes to `--mcp-config`, additive on top of
    // the user scope above — and the one read that can degrade.
    let scope = trusty_mpm::core::session_mcp_scope::resolve_scope(&config_dir);
    println!();
    println!(
        "  tm adds {} builtin server(s) on top: {}",
        scope.included.len(),
        scope.included.join(", ")
    );
    if let Some(reason) = &scope.degraded {
        println!("  warning: {reason}");
    }

    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    let project = trusty_mpm::core::project_mcp_approval::project_mcp_state(&cwd, &config_dir);
    if !project.is_empty() {
        println!();
        println!(
            "  {}/{} declares {} server(s); Claude Code approves these itself:",
            cwd.display(),
            mcp_config::MCP_JSON,
            project.len()
        );
        for server in &project {
            println!("    {:<24}  {}", server.name, server.approval.label());
        }
        println!(
            "  Approve with `enableAllProjectMcpServers` or `enabledMcpjsonServers` in \n               settings, or answer the prompt on the next interactive session."
        );
    }
    Ok(())
}

/// Handle `tm mcp get <name> [--json]`.
///
/// Why: inspect a single server's full definition.
/// What: prints the entry as pretty JSON (`--json`) or a short field summary;
/// errors if the server is absent.
/// Test: `cli_parses_mcp_get`; CRUD in `core::mcp_config`.
pub(crate) fn get_cmd(root: Option<&str>, name: &str, json: bool) -> Result<()> {
    let config_dir = resolve_config_dir(root)?;
    let Some(entry) = mcp_config::get_server(&config_dir, name)? else {
        bail!("MCP server '{name}' not found in {}", config_dir.display());
    };
    if json {
        println!("{}", serde_json::to_string_pretty(&entry)?);
    } else {
        println!("{name}:");
        println!("  Type:   {}", entry_type(&entry));
        println!("  Target: {}", entry_target(&entry));
        println!("  Scope:  User config");
        // #7892: standard Claude Code user-scope semantics — every tm session
        // reading this config dir connects it, with no per-project grant.
        println!("          Loads in every tm session, in every project.");
    }
    Ok(())
}

/// Handle `tm mcp test [<name>] [--json]`.
///
/// Why: registration only proves a server's config is well-formed, not that it
/// starts and speaks MCP. `test` spawns each server and runs the real handshake
/// so operators (and CI) get a definitive pass/fail.
/// What: resolves the config dir, selects targets (one named server, or the
/// full user-scope + built-in union when `name` is `None`), probes each with a
/// bounded timeout via [`mcp_test::run_all`], then prints a table (or `--json`).
/// Exits with status 1 if ANY server failed (so it is usable as a CI gate);
/// a not-found named server is a hard error. Async because the stdio handshake
/// and http reachability check are I/O-bound.
/// Test: `cli_parses_mcp_test` in `tests.rs`; probe/selection logic in
/// `core::mcp_test`.
pub(crate) async fn test_cmd(root: Option<&str>, name: Option<&str>, json: bool) -> Result<()> {
    let config_dir = resolve_config_dir(root)?;
    let servers = mcp_config::list_servers(&config_dir)?;
    let targets = mcp_test::select_targets(&servers, name)?;
    let results = mcp_test::run_all(targets, mcp_test::DEFAULT_PROBE_TIMEOUT).await;

    if json {
        let arr: Vec<Value> = results.iter().map(McpTestResult::to_json).collect();
        println!("{}", serde_json::to_string_pretty(&Value::Array(arr))?);
    } else {
        print_test_table(&results, &config_dir);
    }

    // Non-zero exit if any server failed, matching `tm services`' convention so
    // `tm mcp test` slots straight into CI. Exit here (not a returned Err) to
    // avoid printing anyhow's error chrome after the report.
    if mcp_test::any_failed(&results) {
        std::process::exit(1);
    }
    Ok(())
}

/// Render the human-readable results table for `tm mcp test`.
fn print_test_table(results: &[McpTestResult], config_dir: &Path) {
    if results.is_empty() {
        println!("No MCP servers to test in {}", config_dir.display());
        return;
    }
    println!(
        "Testing {} MCP server(s) from {}:",
        results.len(),
        config_dir.display()
    );
    let name_w = results
        .iter()
        .map(|r| r.name.len())
        .max()
        .unwrap_or(4)
        .max(4);
    println!("  {:<name_w$}  TRANSPORT  RESULT  DETAIL", "NAME");
    for r in results {
        let (label, detail) = r.render();
        println!(
            "  {:<name_w$}  {:<9}  {:<6}  {}",
            r.name,
            r.transport.as_str(),
            label,
            detail
        );
    }
    let passed = results.iter().filter(|r| r.passed()).count();
    println!("\n{passed}/{} passed", results.len());
}

/// The `type` discriminant of an entry, defaulting to `stdio` when absent.
fn entry_type(entry: &Value) -> &str {
    entry.get("type").and_then(Value::as_str).unwrap_or("stdio")
}

/// A one-line human summary of an entry's connection target (command+args or URL).
fn entry_target(entry: &Value) -> String {
    if let Some(url) = entry.get("url").and_then(Value::as_str) {
        return url.to_string();
    }
    let cmd = entry.get("command").and_then(Value::as_str).unwrap_or("");
    let mut args: Vec<&str> = entry
        .get("args")
        .and_then(Value::as_array)
        .map(|a| a.iter().filter_map(Value::as_str).collect())
        .unwrap_or_default();
    // Show the EFFECTIVE argv: drop a legacy stored leading `--` separator so
    // `tm mcp list/get` reflect what actually gets spawned (mirrors the spawn-path
    // and write-path normalisation in `mcp_config::strip_arg_separator`).
    if args.first() == Some(&"--") {
        args.remove(0);
    }
    if args.is_empty() {
        cmd.to_string()
    } else {
        format!("{cmd} {}", args.join(" "))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_kv_env_splits_on_first_equals() {
        let m = parse_env(&["A=1".into(), "B=x=y".into()]).unwrap();
        assert_eq!(m["A"], "1");
        assert_eq!(m["B"], "x=y", "only the first '=' splits");
    }

    #[test]
    fn parse_env_rejects_missing_equals() {
        assert!(parse_env(&["NOEQ".into()]).is_err());
    }

    #[test]
    fn parse_headers_splits_on_first_colon() {
        let m = parse_headers(&["Authorization: Bearer a:b".into()]).unwrap();
        assert_eq!(
            m["Authorization"], "Bearer a:b",
            "value trimmed, first ':' splits"
        );
    }

    #[test]
    fn parse_headers_rejects_missing_colon() {
        assert!(parse_headers(&["NoColon".into()]).is_err());
    }

    #[test]
    fn entry_target_formats_command_and_url() {
        let stdio = serde_json::json!({"type":"stdio","command":"echo","args":["hi","there"]});
        assert_eq!(entry_target(&stdio), "echo hi there");
        let http = serde_json::json!({"type":"http","url":"https://x/mcp"});
        assert_eq!(entry_target(&http), "https://x/mcp");
    }

    #[test]
    fn entry_target_shows_effective_argv_dropping_legacy_separator() {
        // A registry entry written before the fix still displays the effective
        // argv (no leading `--`) in `tm mcp list/get`.
        let legacy = serde_json::json!({
            "type":"stdio","command":"npx",
            "args":["--","-y","@modelcontextprotocol/server-github"]
        });
        assert_eq!(
            entry_target(&legacy),
            "npx -y @modelcontextprotocol/server-github"
        );
    }

    /// The npx shape `tm mcp add github npx -- -y @scope/pkg` must persist WITHOUT
    /// the `--` sentinel (clap's `trailing_var_arg` captures it as the first tail
    /// token). `--root` is tier-1 precedence so the write lands in
    /// `<tempdir>/claude-config`, letting us assert the stored args round-trip.
    #[test]
    fn add_strips_leading_separator_before_persisting() {
        let tmp = tempfile::TempDir::new().unwrap();
        let root = tmp.path().to_str().unwrap();
        let command_and_args = vec![
            "npx".to_string(),
            "--".to_string(),
            "-y".to_string(),
            "@modelcontextprotocol/server-github".to_string(),
        ];
        add_cmd(
            Some(root),
            "github",
            McpTransportArg::Stdio,
            &[],
            &[],
            &command_and_args,
            AddPlacement::default(),
        )
        .unwrap();

        let cfg = std::path::Path::new(root).join("claude-config");
        let entry = mcp_config::get_server(&cfg, "github")
            .unwrap()
            .expect("server persisted");
        assert_eq!(entry["command"], "npx");
        assert_eq!(
            entry["args"],
            serde_json::json!(["-y", "@modelcontextprotocol/server-github"]),
            "the `--` sentinel must not be persisted into stored args"
        );
    }

    /// Round-trip proof that only the LEADING `--` is stripped: a deeper `--`
    /// (a real inner separator some CLIs need) survives into the stored args.
    #[test]
    fn add_preserves_deeper_separator() {
        let tmp = tempfile::TempDir::new().unwrap();
        let root = tmp.path().to_str().unwrap();
        // `tm mcp add x uv -- run -- tool` → tail = ["uv","--","run","--","tool"].
        let command_and_args = vec![
            "uv".to_string(),
            "--".to_string(),
            "run".to_string(),
            "--".to_string(),
            "tool".to_string(),
        ];
        add_cmd(
            Some(root),
            "x",
            McpTransportArg::Stdio,
            &[],
            &[],
            &command_and_args,
            AddPlacement::default(),
        )
        .unwrap();

        let cfg = std::path::Path::new(root).join("claude-config");
        let entry = mcp_config::get_server(&cfg, "x").unwrap().unwrap();
        assert_eq!(entry["command"], "uv");
        assert_eq!(
            entry["args"],
            serde_json::json!(["run", "--", "tool"]),
            "leading `--` stripped once; the deeper `--` is preserved"
        );
    }

    /// #7892: the retired verbs stay callable and say why they do nothing, so a
    /// runbook or script that still spells them keeps exiting zero.
    #[test]
    fn share_is_a_no_op_under_the_claude_code_standard() {
        share_cmd("apex", true).expect("share must not fail");
        share_cmd("apex", false).expect("unshare must not fail");
    }

    /// #7892, inverted from the `--project` trust gate: writing a project-scope
    /// server no longer needs a `tm project trust` grant, because Claude Code's
    /// own approval decides whether the session connects it.
    #[test]
    fn add_project_needs_no_trust_grant() {
        let tmp = tempfile::TempDir::new().unwrap();
        let cwd = tmp.path().join("repo");
        std::fs::create_dir_all(&cwd).unwrap();

        // `add_project_server` is the write `add_cmd --project` performs; call
        // it directly, because `add_cmd` resolves the process-wide cwd and this
        // binary's `env_isolation_tests` ratchet bans changing it.
        let changed = mcp_config::add_project_server(
            &cwd,
            "apex",
            serde_json::json!({"type": "http", "url": "https://example.invalid/mcp"}),
        )
        .expect("an untrusted project must still take the write");

        assert!(changed);
        let state = trusty_mpm::core::project_mcp_approval::project_mcp_state(&cwd, tmp.path());
        assert_eq!(state.len(), 1);
        assert_eq!(
            state[0].approval,
            trusty_mpm::core::project_mcp_approval::Approval::Prompt,
            "with no settings tier naming it, Claude Code decides, not tm"
        );
    }
}