diff --git a/crates/engine/src/gateway/client.rs b/crates/engine/src/gateway/client.rs new file mode 100644 index 0000000..552affb --- /dev/null +++ b/crates/engine/src/gateway/client.rs @@ -0,0 +1,351 @@ +//! Hand-rolled MCP client over Streamable HTTP. +//! +//! `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. +//! + +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"; + +/// A tool as advertised by the gateway's `tools/list`. +#[derive(Debug, Clone)] +pub struct ToolInfo { + pub name: String, + pub description: String, + /// The tool's JSON Schema for arguments (`inputSchema`). + pub input_schema: Value, +} + +#[derive(Debug, thiserror::Error)] +pub enum GatewayError { + #[error("config: {0}")] + Config(String), + #[error("network: {0}")] + Network(String), + #[error("timeout")] + Timeout, + /// A JSON-RPC error returned by the gateway. Mirrors the MCP error shape so + /// callers map it to exit codes without a bespoke taxonomy (design §5, B3). + #[error("rpc error {code}: {message}")] + Rpc { + code: i64, + message: String, + data: Value, + }, + #[error("protocol: {0}")] + Protocol(String), +} + +/// A live connection to the gateway's per-user MCP endpoint. +pub struct GatewayClient { + http: reqwest::Client, + url: String, + cfg: GatewayConfig, + session_id: Option, + next_id: AtomicU64, +} + +impl GatewayClient { + /// Build the HTTP client (honoring `HTTPS_PROXY` via reqwest's system proxy, + /// plus any MITM CA bundle named in the environment) and run the MCP + /// `initialize` handshake. + pub async fn connect(cfg: GatewayConfig, timeout: Duration) -> Result { + let mut builder = reqwest::Client::builder().timeout(timeout); + + if let Some(path) = &cfg.ca_bundle { + let pem = std::fs::read(path) + .map_err(|e| GatewayError::Config(format!("read CA bundle {path}: {e}")))?; + let certs = reqwest::Certificate::from_pem_bundle(&pem) + .map_err(|e| GatewayError::Config(format!("parse CA bundle {path}: {e}")))?; + for cert in certs { + builder = builder.add_root_certificate(cert); + } + } + + let http = builder + .build() + .map_err(|e| GatewayError::Network(format!("build HTTP client: {e}")))?; + + let url = cfg.mcp_url(); + let mut client = Self { + http, + url, + cfg, + session_id: None, + next_id: AtomicU64::new(1), + }; + client.initialize().await?; + Ok(client) + } + + async fn initialize(&mut self) -> Result<(), GatewayError> { + let params = json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "sealg", "version": env!("CARGO_PKG_VERSION") }, + }); + // initialize is the one call that may hand back a session id (header). + let (result, session_id) = self.rpc_capture_session("initialize", params).await?; + let _ = result; + self.session_id = session_id; + // Best-effort readiness notification; stateless servers may ignore it. + let _ = self.notify("notifications/initialized", json!({})).await; + Ok(()) + } + + /// `tools/list` → the gateway's per-user, policy-filtered tool set. + pub async fn tools_list(&self) -> Result, GatewayError> { + let result = self.rpc("tools/list", json!({})).await?; + let tools = result + .get("tools") + .and_then(Value::as_array) + .ok_or_else(|| GatewayError::Protocol("tools/list missing `tools` array".into()))?; + Ok(tools.iter().map(tool_from_value).collect()) + } + + /// `tools/call` → the raw MCP result value (content blocks, `isError`). + /// Returned verbatim so the caller mirrors the MCP response (design B3). + pub async fn tools_call(&self, name: &str, arguments: Value) -> Result { + let args = if arguments.is_null() { + json!({}) + } else { + arguments + }; + self.rpc("tools/call", json!({ "name": name, "arguments": args })) + .await + } + + // --- transport --------------------------------------------------------- + + fn next_id(&self) -> u64 { + self.next_id.fetch_add(1, Ordering::Relaxed) + } + + async fn rpc(&self, method: &str, params: Value) -> Result { + Ok(self.rpc_capture_session(method, params).await?.0) + } + + /// Send a JSON-RPC request and return (`result`, any `Mcp-Session-Id`). + async fn rpc_capture_session( + &self, + method: &str, + params: Value, + ) -> Result<(Value, Option), GatewayError> { + let id = self.next_id(); + let body = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }); + + let resp = self + .request(&body) + .send() + .await + .map_err(|e| self.map_send_err(e))?; + + let session_id = resp + .headers() + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + + let status = resp.status(); + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let text = resp + .text() + .await + .map_err(|e| GatewayError::Network(format!("reading response body: {e}")))?; + + if !status.is_success() { + // HTTP-level failure with no JSON-RPC envelope (auth, 404, 5xx). + return Err(GatewayError::Network(format!( + "gateway returned HTTP {}: {}", + status.as_u16(), + text.chars().take(512).collect::() + ))); + } + + let result = extract_rpc_result(&content_type, &text, id)?; + Ok((result, session_id)) + } + + async fn notify(&self, method: &str, params: Value) -> Result<(), GatewayError> { + // A notification has no `id` and expects no response body. + let body = json!({ "jsonrpc": "2.0", "method": method, "params": params }); + self.request(&body) + .send() + .await + .map_err(|e| self.map_send_err(e))?; + Ok(()) + } + + /// Build a POST with the MCP + SealGate headers attached. + fn request(&self, body: &Value) -> reqwest::RequestBuilder { + let mut req = self + .http + .post(&self.url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header( + reqwest::header::ACCEPT, + "application/json, text/event-stream", + ) + .header("MCP-Protocol-Version", PROTOCOL_VERSION) + .json(body); + + if let Some(sid) = &self.session_id { + req = req.header("Mcp-Session-Id", sid); + } + if let Some(secret) = &self.cfg.secret_key { + req = req.header(SECRET_KEY_HEADER, secret); + } + if let Some(conv) = &self.cfg.conversation_id { + req = req.header(CONVERSATION_ID_HEADER, conv); + } + req + } + + fn map_send_err(&self, e: reqwest::Error) -> GatewayError { + if e.is_timeout() { + GatewayError::Timeout + } else { + GatewayError::Network(format!("POST {}: {}", self.url, e)) + } + } +} + +fn tool_from_value(v: &Value) -> ToolInfo { + ToolInfo { + name: v + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + description: v + .get("description") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + input_schema: v.get("inputSchema").cloned().unwrap_or(Value::Null), + } +} + +/// Extract the JSON-RPC `result` for `want_id` from a response body that is +/// either `application/json` (one response object) or `text/event-stream` (SSE +/// frames, each `data:` line a JSON-RPC message). Pure, so it is unit-tested. +pub fn extract_rpc_result( + content_type: &str, + body: &str, + want_id: u64, +) -> Result { + let msg = if content_type.contains("text/event-stream") { + sse_find_response(body, want_id) + .ok_or_else(|| GatewayError::Protocol("no JSON-RPC response in SSE stream".into()))? + } else { + serde_json::from_str::(body.trim()) + .map_err(|e| GatewayError::Protocol(format!("invalid JSON response: {e}")))? + }; + rpc_message_to_result(&msg) +} + +/// Turn one JSON-RPC response object into `Ok(result)` / `Err(Rpc)`. +fn rpc_message_to_result(msg: &Value) -> Result { + if let Some(err) = msg.get("error") { + return Err(GatewayError::Rpc { + code: err.get("code").and_then(Value::as_i64).unwrap_or(0), + message: err + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown error") + .to_string(), + data: err.get("data").cloned().unwrap_or(Value::Null), + }); + } + msg.get("result").cloned().ok_or_else(|| { + GatewayError::Protocol("JSON-RPC response had neither result nor error".into()) + }) +} + +/// Scan SSE frames for the JSON-RPC response matching `want_id`. +fn sse_find_response(body: &str, want_id: u64) -> Option { + let mut fallback: Option = None; + for line in body.lines() { + let line = line.trim_start(); + let Some(data) = line.strip_prefix("data:") else { + continue; + }; + let Ok(v) = serde_json::from_str::(data.trim()) else { + continue; + }; + // Only responses carry result/error. + if v.get("result").is_none() && v.get("error").is_none() { + continue; + } + if v.get("id").and_then(Value::as_u64) == Some(want_id) { + return Some(v); + } + fallback.get_or_insert(v); + } + fallback +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_json_result() { + let body = r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#; + let r = extract_rpc_result("application/json", body, 1).unwrap(); + assert!(r.get("tools").unwrap().as_array().unwrap().is_empty()); + } + + #[test] + fn json_rpc_error_maps_to_rpc_variant() { + let body = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32000,"message":"blocked by policy","data":{"rule":"trifecta"}}}"#; + let err = extract_rpc_result("application/json", body, 2).unwrap_err(); + match err { + GatewayError::Rpc { + code, + message, + data, + } => { + assert_eq!(code, -32000); + assert_eq!(message, "blocked by policy"); + assert_eq!(data["rule"], "trifecta"); + } + other => panic!("expected Rpc, got {other:?}"), + } + } + + #[test] + fn sse_stream_picks_matching_id() { + let body = + "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"ok\":true}}\n\n"; + let r = extract_rpc_result("text/event-stream; charset=utf-8", body, 7).unwrap(); + assert_eq!(r["ok"], true); + } + + #[test] + fn sse_skips_notifications_and_finds_response() { + let body = + "data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\",\"params\":{}}\n\ + data: {\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"done\":1}}\n"; + let r = extract_rpc_result("text/event-stream", body, 3).unwrap(); + assert_eq!(r["done"], 1); + } + + #[test] + fn invalid_json_is_protocol_error() { + let err = extract_rpc_result("application/json", "not json", 1).unwrap_err(); + assert!(matches!(err, GatewayError::Protocol(_))); + } +} diff --git a/crates/engine/src/gateway/config.rs b/crates/engine/src/gateway/config.rs new file mode 100644 index 0000000..a476754 --- /dev/null +++ b/crates/engine/src/gateway/config.rs @@ -0,0 +1,172 @@ +//! Gateway connection config, resolved from the environment. +//! +//! `sealg` is a thin transport: it carries no policy, only the coordinates +//! needed to reach the SealGate gateway and let it enforce (see the gateway's +//! `dev-docs/architecture/multi-interface-design.md`). All of those coordinates +//! come from the environment so the binary stays stateless and drops cleanly +//! into any sandbox (Centaur injects them per pod). +//! +//! Resolution is written against a getter closure, not `std::env` directly, so +//! tests are deterministic (mirrors `AppContext::new` vs `::default`). + +/// Env var names. Kept in one place so the doc and the code cannot drift. +pub mod env_keys { + /// Gateway origin, e.g. `https://dashboard.sealgate.ai` or `http://localhost:3000`. + pub const URL: &str = "SEALGATE_URL"; + /// SealGate API key. Optional: when absent, auth is expected to be injected + /// upstream (Centaur iron-proxy attaches `Authorization`). + pub const API_KEY: &str = "SEALGATE_API_KEY"; + /// Zero-knowledge secret key value; sent as the `sealgate_secret_key` header. + pub const SECRET_KEY: &str = "SEALGATE_SECRET_KEY"; + /// Stable conversation id; sent as `x-sealgate-conversation-id`. + pub const CONVERSATION_ID: &str = "SEALGATE_CONVERSATION_ID"; + /// Centaur's per-conversation key, used as a fallback for CONVERSATION_ID + /// (`centaur-session-runtime` sets it on every tool pod). + pub const CENTAUR_THREAD_KEY: &str = "CENTAUR_THREAD_KEY"; + /// CA bundle paths for a MITM egress proxy, tried in order. reqwest+rustls + /// does not read these automatically, so we load them explicitly or TLS to + /// the gateway fails behind an intercepting proxy (e.g. Centaur iron-proxy). + pub const CA_BUNDLE: &[&str] = &["SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"]; +} + +/// The default gateway origin when `SEALGATE_URL` is unset. Deliberately the +/// dev endpoint, never a guessed prod host: prod deployments set the env var. +pub const DEFAULT_URL: &str = "http://localhost:3000"; + +/// The header carrying the zero-knowledge secret key (gateway contract). +pub const SECRET_KEY_HEADER: &str = "sealgate_secret_key"; +/// The header carrying the stable conversation id (gateway contract: +/// `SEALGATE_CONVERSATION_ID_HEADER` in `src/middleware/session_tokens.py`). +pub const CONVERSATION_ID_HEADER: &str = "x-sealgate-conversation-id"; + +/// Everything `sealg` needs to reach the gateway. Resolved once at startup. +#[derive(Debug, Clone)] +pub struct GatewayConfig { + /// Gateway origin with no trailing slash and no `/mcp` suffix. + pub base_url: String, + /// API key. `Some` → embedded in the MCP path (`/mcp/{key}/`). `None` → + /// path is `/mcp/` and auth is expected from an upstream injecting proxy. + pub api_key: Option, + /// Zero-knowledge secret key value, sent as the `sealgate_secret_key` header. + pub secret_key: Option, + /// Stable conversation id, sent as `x-sealgate-conversation-id`. + pub conversation_id: Option, + /// Path to a CA bundle to trust in addition to the built-in roots. + pub ca_bundle: Option, +} + +impl GatewayConfig { + /// Resolve from the process environment. + pub fn from_env() -> Self { + Self::from_getter(|k| std::env::var(k).ok().filter(|v| !v.trim().is_empty())) + } + + /// Resolve from an arbitrary getter (the test seam). + pub fn from_getter(get: impl Fn(&str) -> Option) -> Self { + let base_url = get(env_keys::URL) + .unwrap_or_else(|| DEFAULT_URL.to_string()) + .trim_end_matches('/') + .to_string(); + + let conversation_id = + get(env_keys::CONVERSATION_ID).or_else(|| get(env_keys::CENTAUR_THREAD_KEY)); + + let ca_bundle = env_keys::CA_BUNDLE.iter().find_map(|k| get(k)); + + Self { + base_url, + api_key: get(env_keys::API_KEY), + secret_key: get(env_keys::SECRET_KEY), + conversation_id, + ca_bundle, + } + } + + /// The MCP endpoint URL: `{base}/mcp/{key}/`, or `{base}/mcp/` when no key + /// is present (auth injected upstream). + pub fn mcp_url(&self) -> String { + match &self.api_key { + Some(key) => format!("{}/mcp/{}/", self.base_url, key), + None => format!("{}/mcp/", self.base_url), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn getter(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |k: &str| map.get(k).cloned() + } + + #[test] + fn defaults_when_unset() { + let c = GatewayConfig::from_getter(getter(&[])); + assert_eq!(c.base_url, DEFAULT_URL); + assert!(c.api_key.is_none()); + // No key → keyless path, auth injected upstream. + assert_eq!(c.mcp_url(), "http://localhost:3000/mcp/"); + } + + #[test] + fn key_goes_in_the_path_and_trailing_slash_is_trimmed() { + let c = GatewayConfig::from_getter(getter(&[ + (env_keys::URL, "https://dashboard.sealgate.ai/"), + (env_keys::API_KEY, "ew_live_abc"), + ])); + assert_eq!(c.base_url, "https://dashboard.sealgate.ai"); + assert_eq!( + c.mcp_url(), + "https://dashboard.sealgate.ai/mcp/ew_live_abc/" + ); + } + + #[test] + fn conversation_id_falls_back_to_centaur_thread_key() { + let c = GatewayConfig::from_getter(getter(&[( + env_keys::CENTAUR_THREAD_KEY, + "slack:C123:171.45", + )])); + assert_eq!(c.conversation_id.as_deref(), Some("slack:C123:171.45")); + + // Explicit SEALGATE_CONVERSATION_ID wins over the Centaur fallback. + let c = GatewayConfig::from_getter(getter(&[ + (env_keys::CONVERSATION_ID, "explicit"), + (env_keys::CENTAUR_THREAD_KEY, "fallback"), + ])); + assert_eq!(c.conversation_id.as_deref(), Some("explicit")); + } + + #[test] + fn ca_bundle_tries_paths_in_order() { + let c = GatewayConfig::from_getter(getter(&[("REQUESTS_CA_BUNDLE", "/certs/ca.pem")])); + assert_eq!(c.ca_bundle.as_deref(), Some("/certs/ca.pem")); + + // SSL_CERT_FILE has priority over REQUESTS_CA_BUNDLE. + let c = GatewayConfig::from_getter(getter(&[ + ("SSL_CERT_FILE", "/a.pem"), + ("REQUESTS_CA_BUNDLE", "/b.pem"), + ])); + assert_eq!(c.ca_bundle.as_deref(), Some("/a.pem")); + } + + #[test] + fn blank_values_are_treated_as_unset_via_from_env_filter() { + // from_getter itself does not filter, but from_env filters blanks; emulate. + let get = |k: &str| -> Option { + let raw: Option<&str> = match k { + "SEALGATE_URL" => Some(" "), + _ => None, + }; + raw.map(|s| s.to_string()).filter(|v| !v.trim().is_empty()) + }; + let c = GatewayConfig::from_getter(get); + assert_eq!(c.base_url, DEFAULT_URL); + } +} diff --git a/crates/engine/src/gateway/mod.rs b/crates/engine/src/gateway/mod.rs new file mode 100644 index 0000000..500179d --- /dev/null +++ b/crates/engine/src/gateway/mod.rs @@ -0,0 +1,16 @@ +//! The SealGate gateway transport. +//! +//! `sealg` is a thin client: it forwards tool calls to the per-user MCP endpoint +//! (`/mcp/{api_key}/`), where the gateway applies all policy, trifecta, and PII +//! enforcement. This module is the whole of that transport — config resolved +//! from the environment ([`config`]) and a hand-rolled MCP client ([`client`]). +//! +//! It deliberately does **not** live in the [`commands`](crate::commands) +//! registry: gateway tools are dynamic and per-user (discovered at runtime via +//! `tools/list`), the opposite of the registry's static, compile-time commands. + +pub mod client; +pub mod config; + +pub use client::{GatewayClient, GatewayError, ToolInfo, PROTOCOL_VERSION}; +pub use config::{GatewayConfig, CONVERSATION_ID_HEADER, DEFAULT_URL, SECRET_KEY_HEADER}; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e79ef1a..018c663 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,6 +8,7 @@ pub mod commands; pub mod context; pub mod doctor; mod env; +pub mod gateway; pub mod platform; pub mod probes; pub mod scenario; @@ -17,4 +18,5 @@ pub mod types; // Re-exports for convenience pub use commands::{Command, CommandError, CommandRegistry, CommandSchema, ErasedCommand, Expose}; pub use context::{AppContext, Ctx}; +pub use gateway::{GatewayClient, GatewayConfig, GatewayError, ToolInfo}; pub use types::{CommandResult, ErrorCode, ErrorInfo, Status};