diff --git a/crates/cli/src/gateway_cmd.rs b/crates/cli/src/gateway_cmd.rs new file mode 100644 index 0000000..e5cfedb --- /dev/null +++ b/crates/cli/src/gateway_cmd.rs @@ -0,0 +1,82 @@ +//! Gateway-backed subcommands for `sealg`. +//! +//! `sealg` is a thin MCP client: these subcommands resolve the gateway +//! coordinates from the environment ([`GatewayConfig::from_env`]), run the MCP +//! `initialize` handshake, and forward `tools/list` / `tools/call` to the +//! per-user gateway endpoint. All policy and enforcement lives in the gateway. + +use engine::{GatewayClient, GatewayConfig, GatewayError}; +use serde_json::{json, Value}; +use std::time::Duration; + +/// Timeout for the connect + a single request round-trip. +const TIMEOUT: Duration = Duration::from_secs(30); + +/// Exit code when a gateway tool call returns an MCP error (`isError: true`). +/// Named rather than magic: `6` denotes an upstream tool error, kept distinct +/// from `1` (client/transport failure) and `2` (clap's usage-error code) so a +/// caller can tell a failed tool call apart from a CLI or connection failure. +const EXIT_TOOL_ERROR: i32 = 6; + +/// `sealg list [--json]` — list the user's gateway tools. +pub async fn cmd_list(json_out: bool) { + if let Err(e) = run_list(json_out).await { + eprintln!("error: {e}"); + std::process::exit(1); + } +} + +async fn run_list(json_out: bool) -> Result<(), GatewayError> { + let cfg = GatewayConfig::from_env(); + let client = GatewayClient::connect(cfg, TIMEOUT).await?; + let tools = client.tools_list().await?; + + if json_out { + let arr: Vec = tools + .iter() + .map(|t| json!({ "name": t.name, "description": t.description })) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&Value::Array(arr)).unwrap_or_else(|_| "[]".to_string()) + ); + } else { + for t in &tools { + println!("{} — {}", t.name, t.description); + } + } + Ok(()) +} + +/// `sealg gw-call [--args '']` — call one gateway tool. +pub async fn cmd_call(tool: &str, args: &str) { + match run_call(tool, args).await { + Ok(exit_code) => std::process::exit(exit_code), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } +} + +/// Returns the process exit code: `0` on a normal result, non-zero when the +/// gateway returns an MCP tool error (`isError: true`) — the result is still +/// printed so the caller sees the error content. +async fn run_call(tool: &str, args: &str) -> Result { + let arguments: Value = serde_json::from_str(args) + .map_err(|e| GatewayError::Config(format!("invalid --args JSON: {e}")))?; + + let cfg = GatewayConfig::from_env(); + let client = GatewayClient::connect(cfg, TIMEOUT).await?; + let result = client.tools_call(tool, arguments).await?; + + println!( + "{}", + serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()) + ); + + // Mirror the MCP tool-call response: an `isError: true` result is a failed + // call and must not exit 0. + let is_error = result.get("isError").and_then(Value::as_bool) == Some(true); + Ok(if is_error { EXIT_TOOL_ERROR } else { 0 }) +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 57a1849..6ca42c0 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -6,6 +6,7 @@ #[cfg(feature = "cli")] mod diagnostics; +mod gateway_cmd; mod init; mod mcp; mod scaffold; @@ -41,6 +42,22 @@ enum Commands { /// (stub) Serve the registry over MCP - designed-for, not yet implemented. Mcp, + /// List the user's tools from the live SealGate gateway. + List { + /// Output as a JSON array of {name, description}. + #[arg(long)] + json: bool, + }, + + /// Call a tool on the live SealGate gateway. + GwCall { + /// Tool name as advertised by `sealg list`. + tool: String, + /// JSON arguments object to pass to the tool. + #[arg(long, default_value = "{}")] + args: String, + }, + /// Collect environment facts and emit an env summary. #[cfg(feature = "cli")] Doctor { @@ -130,6 +147,8 @@ async fn main() { } } Commands::Mcp => mcp::run(), + Commands::List { json } => gateway_cmd::cmd_list(json).await, + Commands::GwCall { tool, args } => gateway_cmd::cmd_call(&tool, &args).await, #[cfg(feature = "cli")] Commands::Doctor { json, out } => diagnostics::cmd_doctor(json, out).await, #[cfg(feature = "cli")] diff --git a/crates/engine/src/gateway/client.rs b/crates/engine/src/gateway/client.rs index 552affb..5ef06c0 100644 --- a/crates/engine/src/gateway/client.rs +++ b/crates/engine/src/gateway/client.rs @@ -3,16 +3,20 @@ //! `sealg` speaks the small slice of MCP it needs — `initialize`, `tools/list`, //! `tools/call` — directly to the gateway's `/mcp/{api_key}/` endpoint. No //! `rmcp` dependency: the binary stays thin and cold-starts fast (see the -//! design doc §5A). Transport: MCP Streamable HTTP, 2026-07-28. -//! +//! design doc §5A). Transport: MCP Streamable HTTP, protocol `2025-06-18` +//! (the version the gateway's FastMCP server accepts). +//! use super::config::{GatewayConfig, CONVERSATION_ID_HEADER, SECRET_KEY_HEADER}; use serde_json::{json, Value}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; -/// Protocol version `sealg` advertises in `initialize`. -pub const PROTOCOL_VERSION: &str = "2026-07-28"; +/// Protocol version `sealg` advertises in `initialize`. Must be a version the +/// gateway's MCP server (FastMCP) actually supports — it rejects unknown +/// versions with JSON-RPC -32600. `2025-06-18` is the latest version the +/// gateway's FastMCP server accepts. +pub const PROTOCOL_VERSION: &str = "2025-06-18"; /// A tool as advertised by the gateway's `tools/list`. #[derive(Debug, Clone)] @@ -217,9 +221,30 @@ impl GatewayClient { if e.is_timeout() { GatewayError::Timeout } else { - GatewayError::Network(format!("POST {}: {}", self.url, e)) + // `reqwest::Error`'s Display embeds the original request URL, which + // carries the API key in its `/mcp/{key}/` path — strip it with + // `without_url()` so only our already-redacted URL is shown. + GatewayError::Network(format!("POST {}: {}", self.display_url(), e.without_url())) } } + + /// The endpoint URL with the API-key path segment redacted, for safe + /// logging. The key rides in the `/mcp/{key}/` path, so an un-redacted URL + /// in an error message would leak the secret to stderr / logs. + fn display_url(&self) -> String { + redact_url(&self.url, self.cfg.api_key.as_deref()) + } +} + +/// Redact the API key from `url` by replacing the `/{key}/` path segment with +/// `/***/`. Targeting the delimited segment (rather than a blanket replace of +/// the key substring) avoids corrupting an unrelated host/path that happens to +/// contain the key text. Pure, so the redaction guarantee is unit-tested. +fn redact_url(url: &str, api_key: Option<&str>) -> String { + match api_key { + Some(key) if !key.is_empty() => url.replace(&format!("/{key}/"), "/***/"), + _ => url.to_string(), + } } fn tool_from_value(v: &Value) -> ToolInfo { @@ -348,4 +373,23 @@ mod tests { let err = extract_rpc_result("application/json", "not json", 1).unwrap_err(); assert!(matches!(err, GatewayError::Protocol(_))); } + + #[test] + fn redact_url_hides_the_api_key() { + let url = "https://gw.example/mcp/ew_live_SECRET/"; + let out = redact_url(url, Some("ew_live_SECRET")); + assert!(!out.contains("ew_live_SECRET"), "key leaked: {out}"); + assert_eq!(out, "https://gw.example/mcp/***/"); + // No key configured (upstream-injected auth) → URL unchanged. + assert_eq!( + redact_url("https://gw.example/mcp/", None), + "https://gw.example/mcp/" + ); + // Only the `/{key}/` path segment is redacted: a host containing the + // key text is left intact (no blanket replace-all corruption). + assert_eq!( + redact_url("https://abc.example/mcp/abc/", Some("abc")), + "https://abc.example/mcp/***/" + ); + } }