Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions crates/cli/src/gateway_cmd.rs
Original file line number Diff line number Diff line change
@@ -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}");
Comment thread
Miyamura80 marked this conversation as resolved.
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<Value> = 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 <tool> [--args '<json>']` — call one gateway tool.
pub async fn cmd_call(tool: &str, args: &str) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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<i32, 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?;
Comment thread
Miyamura80 marked this conversation as resolved.
Comment thread
Miyamura80 marked this conversation as resolved.

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 })
}
19 changes: 19 additions & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

#[cfg(feature = "cli")]
mod diagnostics;
mod gateway_cmd;
mod init;
mod mcp;
mod scaffold;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")]
Expand Down
54 changes: 49 additions & 5 deletions crates/engine/src/gateway/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//! <https://modelcontextprotocol.io/specification/2026-07-28>
//! design doc §5A). Transport: MCP Streamable HTTP, protocol `2025-06-18`
//! (the version the gateway's FastMCP server accepts).
//! <https://modelcontextprotocol.io/specification/2025-06-18>

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)]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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/***/"
);
}
}