From 209a722d83a8ed6ed850b76721ad462cf5e06477 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:03:32 +0000 Subject: [PATCH 1/5] feat(cli): wire gateway-backed `list` and `gw-call` subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a small `gateway_cmd` module to the `sealg` binary that drives the merged `engine::gateway` transport: - `sealg list [--json]` runs initialize + tools/list against the live gateway and prints `name — description` (or a JSON array). - `sealg gw-call [--args ''] [--json]` runs tools/call and prints the pretty JSON result. Both resolve coordinates via `GatewayConfig::from_env()` and exit non-zero with `error: ` on failure. Existing demo commands and diagnostics.rs are untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y --- crates/cli/src/gateway_cmd.rs | 66 +++++++++++++++++++++++++++++++++++ crates/cli/src/main.rs | 22 ++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 crates/cli/src/gateway_cmd.rs diff --git a/crates/cli/src/gateway_cmd.rs b/crates/cli/src/gateway_cmd.rs new file mode 100644 index 0000000..ecb856a --- /dev/null +++ b/crates/cli/src/gateway_cmd.rs @@ -0,0 +1,66 @@ +//! 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); + +/// `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 ''] [--json]` — call one gateway tool. +pub async fn cmd_call(tool: &str, args: &str, json_out: bool) { + if let Err(e) = run_call(tool, args, json_out).await { + eprintln!("error: {e}"); + std::process::exit(1); + } +} + +async fn run_call(tool: &str, args: &str, _json_out: bool) -> Result<(), GatewayError> { + 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()) + ); + Ok(()) +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 57a1849..b9c3c97 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,25 @@ 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, + /// Output as JSON (result is always emitted as pretty JSON). + #[arg(long)] + json: bool, + }, + /// Collect environment facts and emit an env summary. #[cfg(feature = "cli")] Doctor { @@ -130,6 +150,8 @@ async fn main() { } } Commands::Mcp => mcp::run(), + Commands::List { json } => gateway_cmd::cmd_list(json).await, + Commands::GwCall { tool, args, json } => gateway_cmd::cmd_call(&tool, &args, json).await, #[cfg(feature = "cli")] Commands::Doctor { json, out } => diagnostics::cmd_doctor(json, out).await, #[cfg(feature = "cli")] From 5ca0c040891f357a2081a0ddea72b47b625d884a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:08:39 +0000 Subject: [PATCH 2/5] fix(engine): advertise a supported MCP protocol version (2025-06-18) The gateway client hardcoded protocolVersion "2026-07-28", which the SealGate gateway's FastMCP server rejects with JSON-RPC -32600 ("Unsupported protocol version ... Supported versions: 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25"). This made every `sealg list` / `sealg gw-call` fail at initialize with an HTTP 400. Switch to "2025-06-18", the current stable MCP spec the gateway accepts. Verified end-to-end against a live local gateway: initialize, tools/list (13 tools), and tools/call (builtin_whoami, builtin_get_security_status) all round-trip successfully. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y --- crates/engine/src/gateway/client.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/gateway/client.rs b/crates/engine/src/gateway/client.rs index 552affb..db33769 100644 --- a/crates/engine/src/gateway/client.rs +++ b/crates/engine/src/gateway/client.rs @@ -3,16 +3,19 @@ //! `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 current stable MCP spec. +pub const PROTOCOL_VERSION: &str = "2025-06-18"; /// A tool as advertised by the gateway's `tools/list`. #[derive(Debug, Clone)] From fe1621ebad0be61fd0dc26e47ee66a1208b3201b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:25:51 +0000 Subject: [PATCH 3/5] fix(cli): redact API key in gateway errors; nonzero exit on tool isError Address cubic review on #15: - P1 (security): gateway error messages embedded the request URL, which carries the API key in the /mcp/{key}/ path, leaking it to stderr. Redact the key via a pure redact_url() before logging; unit-tested. - P1 (correctness): sealg gw-call printed an MCP isError:true result and exited 0. Now prints the error content and exits nonzero (6), mirroring the MCP tool-call response. - P3: reword the PROTOCOL_VERSION comment (drop the inaccurate 'current stable MCP spec' claim; it is the latest version the gateway's FastMCP accepts). - Cleanup: drop the no-op --json flag on gw-call. fmt + clippy clean; 11 engine unit tests pass. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y --- crates/cli/src/gateway_cmd.rs | 22 ++++++++++++++----- crates/cli/src/main.rs | 5 +---- crates/engine/src/gateway/client.rs | 34 +++++++++++++++++++++++++++-- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/crates/cli/src/gateway_cmd.rs b/crates/cli/src/gateway_cmd.rs index ecb856a..b7d62d5 100644 --- a/crates/cli/src/gateway_cmd.rs +++ b/crates/cli/src/gateway_cmd.rs @@ -43,14 +43,20 @@ async fn run_list(json_out: bool) -> Result<(), GatewayError> { } /// `sealg gw-call [--args ''] [--json]` — call one gateway tool. -pub async fn cmd_call(tool: &str, args: &str, json_out: bool) { - if let Err(e) = run_call(tool, args, json_out).await { - eprintln!("error: {e}"); - std::process::exit(1); +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); + } } } -async fn run_call(tool: &str, args: &str, _json_out: bool) -> Result<(), GatewayError> { +/// 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}")))?; @@ -62,5 +68,9 @@ async fn run_call(tool: &str, args: &str, _json_out: bool) -> Result<(), Gateway "{}", serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()) ); - Ok(()) + + // 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 { 6 } else { 0 }) } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index b9c3c97..6ca42c0 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -56,9 +56,6 @@ enum Commands { /// JSON arguments object to pass to the tool. #[arg(long, default_value = "{}")] args: String, - /// Output as JSON (result is always emitted as pretty JSON). - #[arg(long)] - json: bool, }, /// Collect environment facts and emit an env summary. @@ -151,7 +148,7 @@ async fn main() { } Commands::Mcp => mcp::run(), Commands::List { json } => gateway_cmd::cmd_list(json).await, - Commands::GwCall { tool, args, json } => gateway_cmd::cmd_call(&tool, &args, 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 db33769..18807f3 100644 --- a/crates/engine/src/gateway/client.rs +++ b/crates/engine/src/gateway/client.rs @@ -14,7 +14,8 @@ use std::time::Duration; /// 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 current stable MCP spec. +/// 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`. @@ -220,9 +221,25 @@ impl GatewayClient { if e.is_timeout() { GatewayError::Timeout } else { - GatewayError::Network(format!("POST {}: {}", self.url, e)) + GatewayError::Network(format!("POST {}: {}", self.display_url(), e)) } } + + /// 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()) + } +} + +/// Replace the API key wherever it appears in `url` with `***`. Pure, so the +/// redaction guarantee is unit-tested without a live client. +fn redact_url(url: &str, api_key: Option<&str>) -> String { + match api_key { + Some(key) if !key.is_empty() => url.replace(key, "***"), + _ => url.to_string(), + } } fn tool_from_value(v: &Value) -> ToolInfo { @@ -351,4 +368,17 @@ 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/" + ); + } } From 8f5bf7b09f357f85f9dd8a9b5620cc0a759222bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:39:23 +0000 Subject: [PATCH 4/5] fix: strip URL from reqwest error (key leak), targeted redaction, exit-code const Address cubic re-review on #15: - P1 (security): reqwest::Error's Display re-embeds the request URL (with the API key) even after we redact self.url; strip it with without_url(). - P3: redact_url now replaces the delimited /{key}/ path segment instead of a blanket replace-all, so a host/path containing the key text isn't corrupted; added a test case for that. - P3: drop the stale [--json] from cmd_call's doc comment. - P3: replace the magic exit 6 with a named EXIT_TOOL_ERROR constant documented against the design doc's exit-code taxonomy. fmt + clippy clean; 11 engine unit tests pass. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y --- crates/cli/src/gateway_cmd.rs | 10 ++++++++-- crates/engine/src/gateway/client.rs | 19 +++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/cli/src/gateway_cmd.rs b/crates/cli/src/gateway_cmd.rs index b7d62d5..01e92fd 100644 --- a/crates/cli/src/gateway_cmd.rs +++ b/crates/cli/src/gateway_cmd.rs @@ -12,6 +12,12 @@ 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; the value follows the CLI exit-code taxonomy in the +/// gateway design doc (`dev-docs/architecture/multi-interface-design.md` §5: +/// 6 = upstream tool error), distinct from 1 (client/transport) and 2 (usage). +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 { @@ -42,7 +48,7 @@ async fn run_list(json_out: bool) -> Result<(), GatewayError> { Ok(()) } -/// `sealg gw-call [--args ''] [--json]` — call one gateway tool. +/// `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), @@ -72,5 +78,5 @@ async fn run_call(tool: &str, args: &str) -> Result { // 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 { 6 } else { 0 }) + Ok(if is_error { EXIT_TOOL_ERROR } else { 0 }) } diff --git a/crates/engine/src/gateway/client.rs b/crates/engine/src/gateway/client.rs index 18807f3..5ef06c0 100644 --- a/crates/engine/src/gateway/client.rs +++ b/crates/engine/src/gateway/client.rs @@ -221,7 +221,10 @@ impl GatewayClient { if e.is_timeout() { GatewayError::Timeout } else { - GatewayError::Network(format!("POST {}: {}", self.display_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())) } } @@ -233,11 +236,13 @@ impl GatewayClient { } } -/// Replace the API key wherever it appears in `url` with `***`. Pure, so the -/// redaction guarantee is unit-tested without a live client. +/// 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(key, "***"), + Some(key) if !key.is_empty() => url.replace(&format!("/{key}/"), "/***/"), _ => url.to_string(), } } @@ -380,5 +385,11 @@ mod tests { 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/***/" + ); } } From 33e0646e42f668ce7d6d256a252e164159e07fd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:49:43 +0000 Subject: [PATCH 5/5] docs(cli): reword EXIT_TOOL_ERROR comment to not cite an out-of-repo path cubic re-review on #15: the comment cited the design doc by a path that lives in the gateway repo, not this one. State the intent directly instead. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y --- crates/cli/src/gateway_cmd.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/gateway_cmd.rs b/crates/cli/src/gateway_cmd.rs index 01e92fd..e5cfedb 100644 --- a/crates/cli/src/gateway_cmd.rs +++ b/crates/cli/src/gateway_cmd.rs @@ -13,9 +13,9 @@ use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(30); /// Exit code when a gateway tool call returns an MCP error (`isError: true`). -/// Named rather than magic; the value follows the CLI exit-code taxonomy in the -/// gateway design doc (`dev-docs/architecture/multi-interface-design.md` §5: -/// 6 = upstream tool error), distinct from 1 (client/transport) and 2 (usage). +/// 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.