F6: kimi-cli command parity — kigi acp, mcp auth, --mcp-config-file

- `kigi acp`: top-level alias for the stdio ACP server (kimi-cli `acp`).
  AgentArgs.mode is now optional — bare `kigi agent` and `kigi acp` both
  default to stdio at dispatch; `kigi acp <mode>` is rejected.
- `kigi mcp auth <name>`: authorize an OAuth-enabled remote MCP server
  (kimi-cli `mcp auth`). Reuses the doctor's interactive connection path:
  starts the named server with OauthInteractivity::Interactive (browser
  flow when required), completes the handshake, and reports the tool
  count. Stdio servers and unknown names fail with actionable errors.
- `--mcp-config-file <PATH>` (repeatable, global): extra MCP config files
  in the .mcp.json shape ({"mcpServers": {...}}), kimi-cli semantics.
  Files are validated at parse time (fail fast on unreadable/invalid
  JSON), carried across the TUI -> shell boundary via
  KIGI_MCP_CONFIG_FILES, and merged at HIGHEST priority — an explicitly
  passed file overrides every config scope. Covered by loader unit tests
  and verified live: an injected server surfaces in the session's
  x.ai/mcp/servers_updated notification via both the flag and the env.

The stale bare-agent-requires-mode CLI test is re-contracted to the new
default-to-stdio behavior.
This commit is contained in:
2026-07-17 20:15:56 -04:00
parent 74b210535e
commit a3f062b522
6 changed files with 246 additions and 12 deletions
@@ -513,6 +513,50 @@ async fn check_server(
// ── Entry point ─────────────────────────────────────────────────
/// `kigi mcp auth <name>` (kimi-cli `mcp auth` parity): start the named
/// remote server with interactive OAuth — the browser flow opens when the
/// server requires it — complete the handshake, and return the discovered
/// tool count. Errors are user-facing strings.
pub async fn run_auth(cwd: &Path, name: &str) -> Result<usize, String> {
let (_sources, discovered) = discover_servers(cwd);
let all_names: Vec<String> = discovered
.iter()
.map(|d| mcp_servers::mcp_server_name(&d.server).to_string())
.collect();
let Some(found) = discovered
.into_iter()
.find(|d| mcp_servers::mcp_server_name(&d.server) == name)
else {
let hint = if all_names.is_empty() {
String::new()
} else {
format!(" Available servers: {}", all_names.join(", "))
};
return Err(format!("MCP server '{name}' not found.{hint}"));
};
if matches!(found.server, agent_client_protocol::McpServer::Stdio(_)) {
return Err(format!(
"MCP server '{name}' is a local stdio server; OAuth applies to \
remote (http/sse) servers only."
));
}
let fail = |c: Check| {
let mut msg = c.label;
if let Some(detail) = c.detail {
msg = format!("{msg}: {detail}");
}
msg
};
let (client, _started) = check_server_start(found.server, cwd).await.map_err(fail)?;
let (service, _handshake) = check_handshake(&client).await.map_err(fail)?;
use kigi_mcp::rmcp::model::PaginatedRequestParams;
let tools = service
.list_tools(Some(PaginatedRequestParams::default()))
.await
.map_err(|e| format!("tools/list failed after authorization: {e}"))?;
Ok(tools.tools.len())
}
pub async fn run_doctor(cwd: &Path, name_filter: Option<&str>) -> DoctorReport {
let (mut sources, mut discovered) = discover_servers(cwd);