diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 933d4e39..4a2e4e81 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -1,3 +1,4 @@ +mod mcp_cli; mod memory_lint; mod objective_cli; mod plugin_cli; @@ -18,6 +19,7 @@ enum Mode { Help, MemoryLintHelp, MemoryLint(LintCliOptions), + Mcp(mcp_cli::McpCliCommand), Plugin(plugin_cli::PluginCliCommand), Objective(objective_cli::ObjectiveCli), Session(session_cli::SessionCli), @@ -70,6 +72,13 @@ async fn main() -> ExitCode { ExitCode::FAILURE } }, + Mode::Mcp(command) => match mcp_cli::run(command) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("yoi mcp: {e}"); + ExitCode::FAILURE + } + }, Mode::Plugin(command) => match plugin_cli::run(command) { Ok(()) => ExitCode::SUCCESS, Err(e) => { @@ -186,6 +195,10 @@ fn parse_args_slice(args: &[String]) -> Result { let plugin_cli = parse_plugin_args(&args[1..])?; return Ok(Mode::Plugin(plugin_cli)); } + "mcp" => { + let mcp_cli = parse_mcp_args(&args[1..])?; + return Ok(Mode::Mcp(mcp_cli)); + } "panel" => { return Ok(Mode::Tui { mode: LaunchMode::Panel, @@ -593,6 +606,147 @@ fn plugin_usage() -> &'static str { "usage: yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show [--workspace PATH] [--profile REF] [--json]" } +fn parse_mcp_args(args: &[String]) -> Result { + let Some((subcommand, rest)) = args.split_first() else { + return Err(ParseError( + "yoi mcp requires `list`, `show `, `tools [server]`, `resources [server]`, or `prompts [server]`".to_string(), + )); + }; + match subcommand.as_str() { + "list" => { + let (mcp_args, positional) = parse_mcp_common_args(rest)?; + if !positional.is_empty() { + return Err(ParseError( + "yoi mcp list does not accept positional arguments".to_string(), + )); + } + Ok(mcp_cli::McpCliCommand::List(mcp_args)) + } + "show" => { + let (mcp_args, positional) = parse_mcp_common_args(rest)?; + match positional.as_slice() { + [server] => Ok(mcp_cli::McpCliCommand::Show { + server: server.clone(), + args: mcp_args, + }), + [] => Err(ParseError( + "yoi mcp show requires a server name".to_string(), + )), + _ => Err(ParseError( + "yoi mcp show accepts exactly one server name".to_string(), + )), + } + } + "tools" => { + let (mcp_args, positional) = parse_mcp_common_args(rest)?; + match positional.as_slice() { + [] => Ok(mcp_cli::McpCliCommand::Tools { + server: None, + args: mcp_args, + }), + [server] => Ok(mcp_cli::McpCliCommand::Tools { + server: Some(server.clone()), + args: mcp_args, + }), + _ => Err(ParseError( + "yoi mcp tools accepts at most one server name".to_string(), + )), + } + } + "resources" => { + let (mcp_args, positional) = parse_mcp_common_args(rest)?; + match positional.as_slice() { + [] => Ok(mcp_cli::McpCliCommand::Resources { + server: None, + args: mcp_args, + }), + [server] => Ok(mcp_cli::McpCliCommand::Resources { + server: Some(server.clone()), + args: mcp_args, + }), + _ => Err(ParseError( + "yoi mcp resources accepts at most one server name".to_string(), + )), + } + } + "prompts" => { + let (mcp_args, positional) = parse_mcp_common_args(rest)?; + match positional.as_slice() { + [] => Ok(mcp_cli::McpCliCommand::Prompts { + server: None, + args: mcp_args, + }), + [server] => Ok(mcp_cli::McpCliCommand::Prompts { + server: Some(server.clone()), + args: mcp_args, + }), + _ => Err(ParseError( + "yoi mcp prompts accepts at most one server name".to_string(), + )), + } + } + "--help" | "-h" => Err(ParseError(mcp_usage().to_string())), + other => Err(ParseError(format!("unknown yoi mcp command: {other}"))), + } +} + +fn parse_mcp_common_args( + args: &[String], +) -> Result<(mcp_cli::McpCliArgs, Vec), ParseError> { + let mut mcp_args = mcp_cli::McpCliArgs::default(); + let mut positional = Vec::new(); + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--json" { + mcp_args.json = true; + index += 1; + } else if arg == "--workspace" { + let value = args + .get(index + 1) + .ok_or_else(|| ParseError("--workspace requires a value".to_string()))?; + if value.starts_with('-') { + return Err(ParseError("--workspace requires a value".to_string())); + } + mcp_args.workspace = Some(PathBuf::from(value)); + index += 2; + } else if let Some(value) = arg.strip_prefix("--workspace=") { + if value.is_empty() { + return Err(ParseError("--workspace requires a value".to_string())); + } + mcp_args.workspace = Some(PathBuf::from(value)); + index += 1; + } else if arg == "--profile" { + let value = args + .get(index + 1) + .ok_or_else(|| ParseError("--profile requires a value".to_string()))?; + if value.starts_with('-') { + return Err(ParseError("--profile requires a value".to_string())); + } + mcp_args.profile = Some(value.clone()); + index += 2; + } else if let Some(value) = arg.strip_prefix("--profile=") { + if value.is_empty() { + return Err(ParseError("--profile requires a value".to_string())); + } + mcp_args.profile = Some(value.to_string()); + index += 1; + } else if arg == "--help" || arg == "-h" { + return Err(ParseError(mcp_usage().to_string())); + } else if arg.starts_with('-') { + return Err(ParseError(format!("unknown yoi mcp argument: {arg}"))); + } else { + positional.push(arg.clone()); + index += 1; + } + } + Ok((mcp_args, positional)) +} + +fn mcp_usage() -> &'static str { + "usage: yoi mcp list [--workspace PATH] [--profile REF] [--json]\n yoi mcp show [--workspace PATH] [--profile REF] [--json]\n yoi mcp tools [server] [--workspace PATH] [--profile REF] [--json]\n yoi mcp resources [server] [--workspace PATH] [--profile REF] [--json]\n yoi mcp prompts [server] [--workspace PATH] [--profile REF] [--json]" +} + fn parse_panel_workspace(args: &[String]) -> Result { match args { [] => std::env::current_dir() @@ -623,7 +777,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, --resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n -r, --resume Open the Pod Console picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, --resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n -r, --resume Open the Pod Console picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } @@ -814,6 +968,57 @@ mod tests { } } + #[test] + fn parse_mcp_commands() { + match parse_args_from(["mcp", "list", "--workspace=/tmp/ws", "--json"]).unwrap() { + Mode::Mcp(mcp_cli::McpCliCommand::List(options)) => { + assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws"))); + assert!(options.json); + } + _ => panic!("expected MCP list mode"), + } + + match parse_args_from(["mcp", "show", "filesystem", "--profile", "project:mcp"]).unwrap() { + Mode::Mcp(mcp_cli::McpCliCommand::Show { server, args }) => { + assert_eq!(server, "filesystem"); + assert_eq!(args.profile.as_deref(), Some("project:mcp")); + } + _ => panic!("expected MCP show mode"), + } + + match parse_args_from(["mcp", "tools", "filesystem"]).unwrap() { + Mode::Mcp(mcp_cli::McpCliCommand::Tools { server, .. }) => { + assert_eq!(server.as_deref(), Some("filesystem")); + } + _ => panic!("expected MCP tools mode"), + } + + match parse_args_from(["mcp", "resources"]).unwrap() { + Mode::Mcp(mcp_cli::McpCliCommand::Resources { server, .. }) => { + assert!(server.is_none()); + } + _ => panic!("expected MCP resources mode"), + } + + match parse_args_from(["mcp", "prompts", "filesystem"]).unwrap() { + Mode::Mcp(mcp_cli::McpCliCommand::Prompts { server, .. }) => { + assert_eq!(server.as_deref(), Some("filesystem")); + } + _ => panic!("expected MCP prompts mode"), + } + } + + #[test] + fn parse_mcp_rejects_usage_errors() { + let err = parse_args_from(["mcp", "show"]).unwrap_err(); + assert_eq!(err.to_string(), "yoi mcp show requires a server name"); + let err = parse_args_from(["mcp", "list", "extra"]).unwrap_err(); + assert_eq!( + err.to_string(), + "yoi mcp list does not accept positional arguments" + ); + } + #[test] fn parse_memory_lint_rejects_usage_errors() { let err = parse_args_from(["memory", "lint", "--workspace"]).unwrap_err(); diff --git a/crates/yoi/src/mcp_cli.rs b/crates/yoi/src/mcp_cli.rs new file mode 100644 index 00000000..a898e374 --- /dev/null +++ b/crates/yoi/src/mcp_cli.rs @@ -0,0 +1,1232 @@ +use std::error::Error; +use std::fmt::Write as _; +use std::fs; +use std::path::PathBuf; + +use manifest::{ + McpConfig, McpEnvConfig, McpEnvValue, McpStdioCwdPolicy, McpStdioServerConfig, + ProfileResolveOptions, ProfileResolver, ProfileSelector, +}; +use serde::Serialize; + +pub(crate) type Result = std::result::Result>; + +const MAX_SERVERS: usize = 128; +const MAX_DIAGNOSTICS: usize = 48; +const MAX_TEXT_CHARS: usize = 240; +const MCP_STATIC_NOT_LIVE_REASON: &str = "CLI inspection reads resolved static MCP config only; provider-discovered state is unavailable without live Pod/runtime MCP state, and this command does not start MCP server processes."; + +#[derive(Clone, Debug, Default)] +pub(crate) struct McpCliArgs { + pub workspace: Option, + pub profile: Option, + pub json: bool, +} + +#[derive(Clone, Debug)] +pub(crate) enum McpCliCommand { + List(McpCliArgs), + Show { + server: String, + args: McpCliArgs, + }, + Tools { + server: Option, + args: McpCliArgs, + }, + Resources { + server: Option, + args: McpCliArgs, + }, + Prompts { + server: Option, + args: McpCliArgs, + }, +} + +pub(crate) fn run(command: McpCliCommand) -> Result<()> { + let rendered = match command { + McpCliCommand::List(args) => render_list(&args)?, + McpCliCommand::Show { server, args } => render_show(&server, &args)?, + McpCliCommand::Tools { server, args } => render_tools(server.as_deref(), &args)?, + McpCliCommand::Resources { server, args } => render_resources(server.as_deref(), &args)?, + McpCliCommand::Prompts { server, args } => render_prompts(server.as_deref(), &args)?, + }; + print!("{rendered}"); + Ok(()) +} + +fn render_list(args: &McpCliArgs) -> Result { + let snapshot = inspect_static_config(args); + let report = ListReport::from_snapshot(snapshot); + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); + } + render_list_human(&report) +} + +fn render_show(server: &str, args: &McpCliArgs) -> Result { + let snapshot = inspect_static_config(args); + let report = ShowReport::from_snapshot(server, snapshot); + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); + } + render_show_human(&report) +} + +fn render_tools(server: Option<&str>, args: &McpCliArgs) -> Result { + let snapshot = inspect_static_config(args); + let report = ToolsReport::from_snapshot(server, snapshot); + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); + } + render_tools_human(&report) +} + +fn render_resources(server: Option<&str>, args: &McpCliArgs) -> Result { + let snapshot = inspect_static_config(args); + let report = ResourceLikeReport::from_snapshot(ResourceKind::Resources, server, snapshot); + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); + } + render_resource_like_human(&report) +} + +fn render_prompts(server: Option<&str>, args: &McpCliArgs) -> Result { + let snapshot = inspect_static_config(args); + let report = ResourceLikeReport::from_snapshot(ResourceKind::Prompts, server, snapshot); + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); + } + render_resource_like_human(&report) +} + +fn inspect_static_config(args: &McpCliArgs) -> StaticConfigSnapshot { + let workspace_input = match &args.workspace { + Some(path) => path.clone(), + None => match std::env::current_dir() { + Ok(path) => path, + Err(error) => { + return StaticConfigSnapshot::invalid( + PathBuf::from("."), + profile_label(args), + DiagnosticReport::error( + "current_dir_unavailable", + format!("failed to resolve current directory: {error}"), + ), + ); + } + }, + }; + + let workspace = match fs::canonicalize(&workspace_input) { + Ok(path) => path, + Err(error) => { + return StaticConfigSnapshot::invalid( + workspace_input.clone(), + profile_label(args), + DiagnosticReport::error( + "workspace_unavailable", + format!( + "workspace `{}` is unavailable: {error}", + workspace_input.display() + ), + ), + ); + } + }; + + let selector = args + .profile + .as_deref() + .map(ProfileSelector::parse_cli) + .unwrap_or(ProfileSelector::Default); + let profile = selector.display_label(); + match ProfileResolver::new() + .with_workspace_base(&workspace) + .resolve( + &selector, + ProfileResolveOptions::with_pod_name("mcp-inspect"), + ) { + Ok(resolved) => { + let mut diagnostics = vec![DiagnosticReport::info( + "static_only", + "static profile/config resolution completed; no MCP server process was started", + )]; + diagnostics.push(DiagnosticReport::info( + "live_state_unavailable", + MCP_STATIC_NOT_LIVE_REASON, + )); + StaticConfigSnapshot { + status: "ok".to_string(), + workspace, + profile, + mcp: Some(resolved.manifest.mcp), + diagnostics, + } + } + Err(error) => StaticConfigSnapshot::invalid( + workspace, + profile, + DiagnosticReport::error( + "profile_resolution_failed", + format!("failed to resolve profile/MCP config: {error}"), + ), + ), + } +} + +fn profile_label(args: &McpCliArgs) -> String { + args.profile + .clone() + .unwrap_or_else(|| "default".to_string()) +} + +#[derive(Debug)] +struct StaticConfigSnapshot { + status: String, + workspace: PathBuf, + profile: String, + mcp: Option, + diagnostics: Vec, +} + +impl StaticConfigSnapshot { + fn invalid(workspace: PathBuf, profile: String, diagnostic: DiagnosticReport) -> Self { + Self { + status: "invalid_config".to_string(), + workspace, + profile, + mcp: None, + diagnostics: vec![ + diagnostic, + DiagnosticReport::info("live_state_unavailable", MCP_STATIC_NOT_LIVE_REASON), + ], + } + } +} + +#[derive(Debug, Serialize)] +struct ListReport { + command: &'static str, + status: String, + workspace: String, + profile: String, + inspection_mode: InspectionModeReport, + live_state: LiveStateReport, + summary: SummaryReport, + servers: Vec, + diagnostics: Vec, +} + +impl ListReport { + fn from_snapshot(mut snapshot: StaticConfigSnapshot) -> Self { + let (servers, mut diagnostics) = server_reports(snapshot.mcp.as_ref()); + diagnostics.splice(0..0, snapshot.diagnostics.drain(..).take(MAX_DIAGNOSTICS)); + diagnostics.truncate(MAX_DIAGNOSTICS); + let summary = SummaryReport { + configured_servers: servers.len(), + truncated: snapshot + .mcp + .as_ref() + .is_some_and(|mcp| mcp.stdio_servers.len() > MAX_SERVERS), + provider_discovery: "not_live".to_string(), + }; + Self { + command: "list", + status: snapshot.status, + workspace: snapshot.workspace.display().to_string(), + profile: snapshot.profile, + inspection_mode: InspectionModeReport::static_config(), + live_state: LiveStateReport::not_live(), + summary, + servers, + diagnostics, + } + } +} + +#[derive(Debug, Serialize)] +struct ShowReport { + command: &'static str, + status: String, + workspace: String, + profile: String, + inspection_mode: InspectionModeReport, + live_state: LiveStateReport, + server: Option, + diagnostics: Vec, +} + +impl ShowReport { + fn from_snapshot(server_name: &str, snapshot: StaticConfigSnapshot) -> Self { + let mut diagnostics = snapshot.diagnostics; + let server = snapshot.mcp.as_ref().and_then(|mcp| { + mcp.stdio_servers + .iter() + .find(|server| server.name == server_name) + .map(ServerReport::from_stdio_server) + }); + let status = if snapshot.status != "ok" { + snapshot.status + } else if server.is_some() { + "ok".to_string() + } else { + diagnostics.push(DiagnosticReport::error( + "server_not_configured", + format!( + "MCP server `{}` is not configured", + bounded_text(server_name) + ), + )); + "missing_server".to_string() + }; + truncate_diagnostics(&mut diagnostics); + Self { + command: "show", + status, + workspace: snapshot.workspace.display().to_string(), + profile: snapshot.profile, + inspection_mode: InspectionModeReport::static_config(), + live_state: LiveStateReport::not_live(), + server, + diagnostics, + } + } +} + +#[derive(Debug, Serialize)] +struct ToolsReport { + command: &'static str, + status: String, + workspace: String, + profile: String, + inspection_mode: InspectionModeReport, + live_state: LiveStateReport, + server_filter: Option, + summary: DiscoverySummaryReport, + servers: Vec, + tools: Vec, + diagnostics: Vec, +} + +impl ToolsReport { + fn from_snapshot(server_filter: Option<&str>, mut snapshot: StaticConfigSnapshot) -> Self { + let (servers, mut diagnostics, missing) = + discovery_servers(snapshot.mcp.as_ref(), server_filter, DiscoveryKind::Tools); + diagnostics.splice(0..0, snapshot.diagnostics.drain(..).take(MAX_DIAGNOSTICS)); + let status = if snapshot.status != "ok" { + snapshot.status + } else if missing { + "missing_server".to_string() + } else { + "not_live".to_string() + }; + truncate_diagnostics(&mut diagnostics); + Self { + command: "tools", + status, + workspace: snapshot.workspace.display().to_string(), + profile: snapshot.profile, + inspection_mode: InspectionModeReport::static_config(), + live_state: LiveStateReport::not_live(), + server_filter: server_filter.map(ToString::to_string), + summary: DiscoverySummaryReport { + configured_servers: servers.len(), + discovered_items: 0, + discovery_status: "unavailable".to_string(), + reason: MCP_STATIC_NOT_LIVE_REASON.to_string(), + }, + servers, + tools: Vec::new(), + diagnostics, + } + } +} + +#[derive(Debug, Serialize)] +struct ResourceLikeReport { + command: &'static str, + status: String, + workspace: String, + profile: String, + inspection_mode: InspectionModeReport, + live_state: LiveStateReport, + server_filter: Option, + summary: DiscoverySummaryReport, + servers: Vec, + operations: Vec, + items: Vec, + diagnostics: Vec, +} + +impl ResourceLikeReport { + fn from_snapshot( + kind: ResourceKind, + server_filter: Option<&str>, + mut snapshot: StaticConfigSnapshot, + ) -> Self { + let (servers, mut diagnostics, missing) = + discovery_servers(snapshot.mcp.as_ref(), server_filter, kind.discovery_kind()); + diagnostics.splice(0..0, snapshot.diagnostics.drain(..).take(MAX_DIAGNOSTICS)); + let status = if snapshot.status != "ok" { + snapshot.status + } else if missing { + "missing_server".to_string() + } else { + "not_live".to_string() + }; + let operations = servers + .iter() + .flat_map(|server| operation_reports_for_server(kind, &server.server_name)) + .collect::>(); + truncate_diagnostics(&mut diagnostics); + Self { + command: kind.command_name(), + status, + workspace: snapshot.workspace.display().to_string(), + profile: snapshot.profile, + inspection_mode: InspectionModeReport::static_config(), + live_state: LiveStateReport::not_live(), + server_filter: server_filter.map(ToString::to_string), + summary: DiscoverySummaryReport { + configured_servers: servers.len(), + discovered_items: 0, + discovery_status: "unavailable".to_string(), + reason: MCP_STATIC_NOT_LIVE_REASON.to_string(), + }, + servers, + operations, + items: Vec::new(), + diagnostics, + } + } +} + +#[derive(Debug, Serialize)] +struct InspectionModeReport { + kind: &'static str, + starts_servers: bool, + calls_tools: bool, + fetches_resource_or_prompt_content: bool, +} + +impl InspectionModeReport { + fn static_config() -> Self { + Self { + kind: "static_config", + starts_servers: false, + calls_tools: false, + fetches_resource_or_prompt_content: false, + } + } +} + +#[derive(Debug, Serialize)] +struct LiveStateReport { + status: &'static str, + available: bool, + reason: &'static str, +} + +impl LiveStateReport { + fn not_live() -> Self { + Self { + status: "not_live", + available: false, + reason: MCP_STATIC_NOT_LIVE_REASON, + } + } +} + +#[derive(Debug, Serialize)] +struct SummaryReport { + configured_servers: usize, + truncated: bool, + provider_discovery: String, +} + +#[derive(Debug, Clone, Serialize)] +struct ServerReport { + name: String, + status: &'static str, + transport: TransportReport, + cwd: CwdReport, + env_policy: EnvPolicyReport, + trust_policy: TrustPolicyReport, + capabilities: CapabilitySummaryReport, + diagnostics: Vec, +} + +impl ServerReport { + fn from_stdio_server(server: &McpStdioServerConfig) -> Self { + Self { + name: bounded_text(&server.name), + status: "configured", + transport: TransportReport { + kind: "stdio", + command: bounded_text(&server.command), + arg_count: server.args.len(), + args_redacted: true, + }, + cwd: CwdReport::from_policy(server.cwd.as_ref()), + env_policy: EnvPolicyReport::from_env(&server.env), + trust_policy: TrustPolicyReport::read_only_inspection(), + capabilities: CapabilitySummaryReport::not_live(), + diagnostics: vec![DiagnosticReport::info( + "provider_discovery_unavailable", + MCP_STATIC_NOT_LIVE_REASON, + )], + } + } +} + +#[derive(Debug, Clone, Serialize)] +struct TransportReport { + kind: &'static str, + command: String, + arg_count: usize, + args_redacted: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct CwdReport { + kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, +} + +impl CwdReport { + fn from_policy(policy: Option<&McpStdioCwdPolicy>) -> Self { + match policy { + Some(McpStdioCwdPolicy::Inherit) => Self { + kind: "inherit", + path: None, + }, + Some(McpStdioCwdPolicy::Path { path }) => Self { + kind: "path", + path: Some(path.display().to_string()), + }, + None => Self { + kind: "unspecified", + path: None, + }, + } + } +} + +#[derive(Debug, Clone, Serialize)] +struct EnvPolicyReport { + inherit_count: usize, + set_count: usize, + value_kinds: EnvValueKindCounts, + values_redacted: bool, +} + +impl EnvPolicyReport { + fn from_env(env: &McpEnvConfig) -> Self { + let mut value_kinds = EnvValueKindCounts::default(); + for value in env.set.values() { + match value { + McpEnvValue::Literal { .. } => value_kinds.literal += 1, + McpEnvValue::SecretRef { .. } => value_kinds.secret_ref += 1, + McpEnvValue::EnvRef { .. } => value_kinds.env_ref += 1, + } + } + Self { + inherit_count: env.inherit.len(), + set_count: env.set.len(), + value_kinds, + values_redacted: true, + } + } +} + +#[derive(Debug, Clone, Default, Serialize)] +struct EnvValueKindCounts { + literal: usize, + secret_ref: usize, + env_ref: usize, +} + +#[derive(Debug, Clone, Serialize)] +struct TrustPolicyReport { + process_start: &'static str, + tool_calls: &'static str, + resource_prompt_content: &'static str, + server_metadata: &'static str, +} + +impl TrustPolicyReport { + fn read_only_inspection() -> Self { + Self { + process_start: "not_started_by_cli_inspection", + tool_calls: "not_called_by_cli_inspection", + resource_prompt_content: "not_fetched_by_cli_inspection", + server_metadata: "untrusted_when_available", + } + } +} + +#[derive(Debug, Clone, Serialize)] +struct CapabilitySummaryReport { + tools: &'static str, + resources: &'static str, + prompts: &'static str, + diagnostics: Vec, +} + +impl CapabilitySummaryReport { + fn not_live() -> Self { + Self { + tools: "unavailable_not_live", + resources: "unavailable_not_live", + prompts: "unavailable_not_live", + diagnostics: vec![DiagnosticReport::info( + "capabilities_require_live_initialize", + "MCP capabilities are server-provided during initialize and are unavailable in static config inspection", + )], + } + } +} + +#[derive(Debug, Clone, Serialize)] +struct DiagnosticReport { + level: &'static str, + code: &'static str, + message: String, +} + +impl DiagnosticReport { + fn info(code: &'static str, message: impl AsRef) -> Self { + Self { + level: "info", + code, + message: bounded_text(message.as_ref()), + } + } + fn warning(code: &'static str, message: impl AsRef) -> Self { + Self { + level: "warning", + code, + message: bounded_text(message.as_ref()), + } + } + fn error(code: &'static str, message: impl AsRef) -> Self { + Self { + level: "error", + code, + message: bounded_text(message.as_ref()), + } + } +} + +#[derive(Debug, Serialize)] +struct DiscoverySummaryReport { + configured_servers: usize, + discovered_items: usize, + discovery_status: String, + reason: String, +} + +#[derive(Debug, Clone, Serialize)] +struct DiscoveryServerReport { + server_name: String, + transport_kind: &'static str, + capability_status: &'static str, + discovery_status: &'static str, + registration_status: &'static str, + diagnostics: Vec, +} + +#[derive(Debug, Serialize)] +struct ToolItemReport { + yoi_stable_tool_name: Option, + server_name: String, + mcp_tool_name: Option, + schema_availability: &'static str, + registration_status: &'static str, + diagnostics: Vec, +} + +#[derive(Debug, Serialize)] +struct ResourceItemReport { + server_name: String, + kind: &'static str, + identifier: Option, + name: Option, + description: Option, + eligibility: &'static str, + content_state: &'static str, + diagnostics: Vec, +} + +#[derive(Debug, Serialize)] +struct OperationReport { + server_name: String, + method: &'static str, + yoi_stable_tool_name: String, + registration_status: &'static str, + eligibility: &'static str, + content_state: &'static str, + diagnostics: Vec, +} + +#[derive(Clone, Copy)] +enum DiscoveryKind { + Tools, + Resources, + Prompts, +} + +impl DiscoveryKind { + fn label(self) -> &'static str { + match self { + Self::Tools => "tools", + Self::Resources => "resources", + Self::Prompts => "prompts", + } + } +} + +#[derive(Clone, Copy)] +enum ResourceKind { + Resources, + Prompts, +} + +impl ResourceKind { + fn command_name(self) -> &'static str { + match self { + Self::Resources => "resources", + Self::Prompts => "prompts", + } + } + fn discovery_kind(self) -> DiscoveryKind { + match self { + Self::Resources => DiscoveryKind::Resources, + Self::Prompts => DiscoveryKind::Prompts, + } + } +} + +fn server_reports(config: Option<&McpConfig>) -> (Vec, Vec) { + let Some(config) = config else { + return (Vec::new(), Vec::new()); + }; + let mut diagnostics = Vec::new(); + if config.stdio_servers.len() > MAX_SERVERS { + diagnostics.push(DiagnosticReport::warning( + "server_list_truncated", + format!( + "configured MCP server list truncated from {} to {} entries", + config.stdio_servers.len(), + MAX_SERVERS + ), + )); + } + let servers = config + .stdio_servers + .iter() + .take(MAX_SERVERS) + .map(ServerReport::from_stdio_server) + .collect(); + (servers, diagnostics) +} + +fn selected_stdio_servers<'a>( + config: Option<&'a McpConfig>, + server_filter: Option<&str>, +) -> (Vec<&'a McpStdioServerConfig>, bool) { + let Some(config) = config else { + return (Vec::new(), false); + }; + match server_filter { + Some(filter) => { + let servers = config + .stdio_servers + .iter() + .filter(|server| server.name == filter) + .collect::>(); + let missing = servers.is_empty(); + (servers, missing) + } + None => ( + config.stdio_servers.iter().take(MAX_SERVERS).collect(), + false, + ), + } +} + +fn discovery_servers( + config: Option<&McpConfig>, + server_filter: Option<&str>, + kind: DiscoveryKind, +) -> (Vec, Vec, bool) { + let (servers, missing) = selected_stdio_servers(config, server_filter); + let mut diagnostics = Vec::new(); + if missing { + if let Some(filter) = server_filter { + diagnostics.push(DiagnosticReport::error( + "server_not_configured", + format!("MCP server `{}` is not configured", bounded_text(filter)), + )); + } + } else if let Some(config) = + config.filter(|config| server_filter.is_none() && config.stdio_servers.len() > MAX_SERVERS) + { + diagnostics.push(DiagnosticReport::warning( + "server_list_truncated", + format!( + "configured MCP server list truncated from {} to {} entries", + config.stdio_servers.len(), + MAX_SERVERS + ), + )); + } + + let reports = servers + .into_iter() + .map(|server| DiscoveryServerReport { + server_name: bounded_text(&server.name), + transport_kind: "stdio", + capability_status: "unavailable_not_live", + discovery_status: "unavailable", + registration_status: "not_live", + diagnostics: vec![DiagnosticReport::info( + "provider_discovery_unavailable", + format!( + "MCP {} discovery for server `{}` is unavailable in static CLI inspection; no server process was started", + kind.label(), + bounded_text(&server.name) + ), + )], + }) + .collect(); + (reports, diagnostics, missing) +} + +fn operation_reports_for_server(kind: ResourceKind, server_name: &str) -> Vec { + let operations: &[(&str, &str)] = match kind { + ResourceKind::Resources => &[ + ("resources/list", "resources_list"), + ("resources/read", "resources_read"), + ], + ResourceKind::Prompts => &[ + ("prompts/list", "prompts_list"), + ("prompts/get", "prompts_get"), + ], + }; + let server_namespace = sanitize_segment(server_name); + operations + .iter() + .map(|(method, segment)| OperationReport { + server_name: server_name.to_string(), + method, + yoi_stable_tool_name: format!("Mcp_{server_namespace}_{segment}"), + registration_status: "not_live", + eligibility: "unknown_until_live_capability_discovery", + content_state: "not_fetched", + diagnostics: vec![DiagnosticReport::info( + "operation_requires_live_capability", + format!( + "{} operation registration requires server capability discovery; CLI inspection did not initialize the server", + method + ), + )], + }) + .collect() +} + +fn render_list_human(report: &ListReport) -> Result { + let mut out = String::new(); + writeln!( + out, + "mcp servers (workspace: {}, profile: {})", + report.workspace, report.profile + )?; + writeln!(out, " status: {}", report.status)?; + writeln!(out, " mode: static config inspection")?; + writeln!(out, " live: not live / unavailable")?; + if report.servers.is_empty() { + if report.status == "ok" { + writeln!(out, " no MCP servers configured")?; + } else { + writeln!(out, " MCP server config unavailable or invalid")?; + } + } else { + for server in &report.servers { + writeln!(out, " - {} [{}]", server.name, server.transport.kind)?; + writeln!(out, " command: {}", server.transport.command)?; + writeln!(out, " args: {} redacted", server.transport.arg_count)?; + writeln!(out, " cwd: {}", cwd_human(&server.cwd))?; + writeln!( + out, + " env: inherit_count={} set_count={} values=redacted", + server.env_policy.inherit_count, server.env_policy.set_count + )?; + writeln!(out, " capabilities: unavailable (not live)")?; + } + } + render_diagnostics_human(&mut out, &report.diagnostics)?; + Ok(out) +} + +fn render_show_human(report: &ShowReport) -> Result { + let mut out = String::new(); + writeln!( + out, + "mcp server (workspace: {}, profile: {})", + report.workspace, report.profile + )?; + writeln!(out, " status: {}", report.status)?; + writeln!(out, " live: not live / unavailable")?; + match &report.server { + Some(server) => { + writeln!(out, " name: {}", server.name)?; + writeln!(out, " transport: {}", server.transport.kind)?; + writeln!(out, " command: {}", server.transport.command)?; + writeln!(out, " args: {} redacted", server.transport.arg_count)?; + writeln!(out, " cwd: {}", cwd_human(&server.cwd))?; + writeln!( + out, + " env: inherit_count={} set_count={} values=redacted", + server.env_policy.inherit_count, server.env_policy.set_count + )?; + writeln!(out, " trust:")?; + writeln!( + out, + " process_start: {}", + server.trust_policy.process_start + )?; + writeln!(out, " tool_calls: {}", server.trust_policy.tool_calls)?; + writeln!( + out, + " resource_prompt_content: {}", + server.trust_policy.resource_prompt_content + )?; + writeln!(out, " capabilities: unavailable (not live)")?; + } + None => { + writeln!(out, " server: missing or invalid")?; + } + } + render_diagnostics_human(&mut out, &report.diagnostics)?; + Ok(out) +} + +fn render_tools_human(report: &ToolsReport) -> Result { + let mut out = String::new(); + writeln!( + out, + "mcp tools (workspace: {}, profile: {})", + report.workspace, report.profile + )?; + writeln!(out, " status: {}", report.status)?; + writeln!(out, " live: not live / unavailable")?; + if let Some(filter) = &report.server_filter { + writeln!(out, " server filter: {filter}")?; + } + if report.tools.is_empty() { + writeln!(out, " provider-discovered tools: unavailable (not live)")?; + } + for server in &report.servers { + writeln!( + out, + " - server {}: discovery={}, registration={}", + server.server_name, server.discovery_status, server.registration_status + )?; + } + render_diagnostics_human(&mut out, &report.diagnostics)?; + Ok(out) +} + +fn render_resource_like_human(report: &ResourceLikeReport) -> Result { + let mut out = String::new(); + writeln!( + out, + "mcp {} (workspace: {}, profile: {})", + report.command, report.workspace, report.profile + )?; + writeln!(out, " status: {}", report.status)?; + writeln!(out, " live: not live / unavailable")?; + if let Some(filter) = &report.server_filter { + writeln!(out, " server filter: {filter}")?; + } + if report.items.is_empty() { + writeln!( + out, + " provider-discovered {}: unavailable (not live); content not fetched", + report.command + )?; + } + for server in &report.servers { + writeln!( + out, + " - server {}: discovery={}, registration={}", + server.server_name, server.discovery_status, server.registration_status + )?; + } + if !report.operations.is_empty() { + writeln!( + out, + " explicit operation tools (names are static; eligibility unknown until live discovery):" + )?; + for op in &report.operations { + writeln!( + out, + " - {} -> {} ({}, content={})", + op.method, op.yoi_stable_tool_name, op.registration_status, op.content_state + )?; + } + } + render_diagnostics_human(&mut out, &report.diagnostics)?; + Ok(out) +} + +fn cwd_human(cwd: &CwdReport) -> String { + match &cwd.path { + Some(path) => format!("{} ({path})", cwd.kind), + None => cwd.kind.to_string(), + } +} + +fn render_diagnostics_human(out: &mut String, diagnostics: &[DiagnosticReport]) -> Result<()> { + if diagnostics.is_empty() { + return Ok(()); + } + writeln!(out, " diagnostics:")?; + for diagnostic in diagnostics.iter().take(MAX_DIAGNOSTICS) { + writeln!( + out, + " - {} [{}]: {}", + diagnostic.level, diagnostic.code, diagnostic.message + )?; + } + Ok(()) +} + +fn truncate_diagnostics(diagnostics: &mut Vec) { + diagnostics.truncate(MAX_DIAGNOSTICS); +} + +fn sanitize_segment(input: &str) -> String { + let mut output = String::new(); + let mut last_underscore = false; + for ch in input.chars() { + let normalized = if ch.is_ascii_alphanumeric() { ch } else { '_' }; + if normalized == '_' { + if last_underscore { + continue; + } + last_underscore = true; + } else { + last_underscore = false; + } + output.push(normalized); + if output.len() >= 48 { + break; + } + } + let output = output.trim_matches('_').to_string(); + if output.is_empty() { + "unnamed".to_string() + } else { + output + } +} + +fn bounded_text(input: &str) -> String { + let mut out = String::new(); + let mut previous_space = false; + for ch in input.chars() { + let normalized = if ch.is_control() { ' ' } else { ch }; + if normalized.is_whitespace() { + if previous_space { + continue; + } + previous_space = true; + out.push(' '); + } else { + previous_space = false; + out.push(normalized); + } + if out.chars().count() >= MAX_TEXT_CHARS { + out.push('…'); + break; + } + } + out.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + use std::path::Path; + use tempfile::TempDir; + + fn write_profile(dir: &Path, name: &str, body: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, body).unwrap(); + path + } + + fn workspace_and_profile(body: &str) -> (TempDir, PathBuf, PathBuf) { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let profile = write_profile(tmp.path(), "mcp.lua", body); + (tmp, workspace, profile) + } + + fn mcp_profile() -> String { + r#" +local profile = require("yoi.profile") +return profile { + slug = "mcp-test", + model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" }, + mcp = { + stdio_server = { + { + name = "filesystem", + command = "definitely-not-spawned-during-cli-inspection", + args = { "--root", ".", "--token", "ARG_SECRET_DO_NOT_PRINT" }, + env = { + inherit = { "PATH" }, + set = { + SAFE_MODE = { kind = "literal", value = "SUPER_SECRET_LITERAL_DO_NOT_PRINT" }, + API_TOKEN = { kind = "secret_ref", ref = "providers/mcp-token" }, + FROM_ENV = { kind = "env_ref", name = "SECRET_ENV_NAME_DO_NOT_PRINT" }, + }, + }, + }, + }, + }, +} +"# + .to_string() + } + + fn args(workspace: &Path, profile: &Path, json: bool) -> McpCliArgs { + McpCliArgs { + workspace: Some(workspace.to_path_buf()), + profile: Some(format!("path:{}", profile.display())), + json, + } + } + + #[test] + fn list_json_reports_resolved_servers_without_secret_values_or_args() { + let (_tmp, workspace, profile) = workspace_and_profile(&mcp_profile()); + let output = render_list(&args(&workspace, &profile, true)).unwrap(); + assert!(output.contains("filesystem")); + assert!(output.contains("definitely-not-spawned-during-cli-inspection")); + assert!(!output.contains("ARG_SECRET_DO_NOT_PRINT")); + assert!(!output.contains("SUPER_SECRET_LITERAL_DO_NOT_PRINT")); + assert!(!output.contains("providers/mcp-token")); + assert!(!output.contains("SECRET_ENV_NAME_DO_NOT_PRINT")); + + let json: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(json["command"], "list"); + assert_eq!(json["status"], "ok"); + assert_eq!(json["servers"][0]["transport"]["kind"], "stdio"); + assert_eq!(json["servers"][0]["transport"]["arg_count"], 4); + assert_eq!(json["servers"][0]["env_policy"]["set_count"], 3); + assert_eq!(json["live_state"]["status"], "not_live"); + assert_eq!(json["inspection_mode"]["starts_servers"], false); + } + + #[test] + fn show_json_reports_missing_server_explicitly() { + let (_tmp, workspace, profile) = workspace_and_profile(&mcp_profile()); + let output = render_show("missing", &args(&workspace, &profile, true)).unwrap(); + let json: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(json["command"], "show"); + assert_eq!(json["status"], "missing_server"); + assert!(json["server"].is_null()); + assert!(output.contains("server_not_configured")); + } + + #[test] + fn tools_json_reports_provider_discovery_unavailable_not_stale() { + let (_tmp, workspace, profile) = workspace_and_profile(&mcp_profile()); + let output = render_tools(None, &args(&workspace, &profile, true)).unwrap(); + let json: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(json["command"], "tools"); + assert_eq!(json["status"], "not_live"); + assert_eq!(json["tools"].as_array().unwrap().len(), 0); + assert_eq!(json["servers"][0]["discovery_status"], "unavailable"); + assert_eq!(json["servers"][0]["registration_status"], "not_live"); + assert!(output.contains("no server process was started")); + } + + #[test] + fn resources_and_prompts_json_report_operation_eligibility_without_content() { + let (_tmp, workspace, profile) = workspace_and_profile(&mcp_profile()); + let resource_output = + render_resources(Some("filesystem"), &args(&workspace, &profile, true)).unwrap(); + let resources: Value = serde_json::from_str(&resource_output).unwrap(); + assert_eq!(resources["command"], "resources"); + assert_eq!(resources["status"], "not_live"); + assert_eq!(resources["items"].as_array().unwrap().len(), 0); + assert_eq!( + resources["operations"][0]["yoi_stable_tool_name"], + "Mcp_filesystem_resources_list" + ); + assert_eq!(resources["operations"][0]["content_state"], "not_fetched"); + assert!(!resource_output.contains("RESOURCE_CONTENT_DO_NOT_PRINT")); + + let prompt_output = + render_prompts(Some("filesystem"), &args(&workspace, &profile, true)).unwrap(); + let prompts: Value = serde_json::from_str(&prompt_output).unwrap(); + assert_eq!(prompts["command"], "prompts"); + assert_eq!(prompts["status"], "not_live"); + assert_eq!(prompts["items"].as_array().unwrap().len(), 0); + assert_eq!( + prompts["operations"][1]["yoi_stable_tool_name"], + "Mcp_filesystem_prompts_get" + ); + assert_eq!(prompts["operations"][1]["content_state"], "not_fetched"); + assert!(!prompt_output.contains("PROMPT_CONTENT_DO_NOT_PRINT")); + } + + #[test] + fn invalid_config_is_reported_as_invalid_not_silently_empty() { + let body = r#" +local profile = require("yoi.profile") +return profile { + slug = "bad-mcp", + model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" }, + mcp = { + stdio_server = { + { name = "dup", command = "one" }, + { name = "dup", command = "two" }, + }, + }, +} +"#; + let (_tmp, workspace, profile) = workspace_and_profile(body); + let output = render_list(&args(&workspace, &profile, true)).unwrap(); + let json: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(json["status"], "invalid_config"); + assert_eq!(json["servers"].as_array().unwrap().len(), 0); + assert!(output.contains("profile_resolution_failed")); + assert!(output.contains("duplicate stdio server name")); + } + + #[test] + fn human_output_distinguishes_empty_and_unavailable() { + let body = r#" +local profile = require("yoi.profile") +return profile { + slug = "empty-mcp", + model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" }, +} +"#; + let (_tmp, workspace, profile) = workspace_and_profile(body); + let output = render_list(&args(&workspace, &profile, false)).unwrap(); + assert!(output.contains("no MCP servers configured")); + assert!(output.contains("live: not live / unavailable")); + } +}