sealg 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ # SealGate gateway coordinates. Export these in your shell (or your runtime sets
2
+ # them). All config is read from the environment only.
3
+
4
+ # Gateway origin (no /mcp suffix). Defaults to http://localhost:3000 when unset.
5
+ SEALGATE_URL=https://mcp.sealgate.ai
6
+
7
+ # SealGate API key from https://dashboard.sealgate.ai. Optional: leave unset when
8
+ # an upstream proxy injects Authorization. When set, it is embedded in the
9
+ # /mcp/{key}/ path.
10
+ # SEALGATE_API_KEY=ew_live_...
11
+
12
+ # Zero-knowledge secret key, only needed for tools that decrypt stored secrets.
13
+ # Generate it at https://dashboard.sealgate.ai/dashboard/settings.
14
+ # SEALGATE_SECRET_KEY=...
15
+
16
+ # Stable conversation id for audit/trifecta continuity. Falls back to
17
+ # CENTAUR_THREAD_KEY, which some agent runtimes set automatically.
18
+ # SEALGATE_CONVERSATION_ID=...
19
+
20
+ # CA bundle for a MITM egress proxy (first of these that is set wins), so sealg
21
+ # trusts the proxy's CA.
22
+ # SSL_CERT_FILE=/etc/ssl/proxy-ca.pem
23
+ # REQUESTS_CA_BUNDLE=/etc/ssl/proxy-ca.pem
24
+ # NODE_EXTRA_CA_CERTS=/etc/ssl/proxy-ca.pem
sealg-0.1.0/.gitignore ADDED
@@ -0,0 +1,31 @@
1
+ .DS_Store
2
+
3
+ old_scripts/
4
+
5
+ # Agent session files
6
+ session-*.md
7
+
8
+ # Overwrite global config
9
+ .global_config.yaml
10
+
11
+ # Node / Bun
12
+ node_modules/
13
+ docs/node_modules/
14
+ dist/
15
+ docs/.vite/
16
+
17
+ # Rust
18
+ target/
19
+ src-tauri/target/
20
+
21
+ # Bun lock cache
22
+ bun.lockb
23
+ docs/bun.lockb
24
+
25
+ # Environments & secrets
26
+ .env
27
+ .prod.env
28
+ opencode.json
29
+
30
+ # Agent session files
31
+ .claude/scheduled_tasks.lock
sealg-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Eito Miyamura
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
sealg-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.5
2
+ Name: sealg
3
+ Version: 0.1.0
4
+ Summary: Python client for the SealGate gateway - governed access to every tool via one CLI
5
+ Project-URL: Homepage, https://sealgate.ai
6
+ Project-URL: Repository, https://github.com/Edison-Watch/cli
7
+ Project-URL: Issues, https://github.com/Edison-Watch/cli/issues
8
+ Author: Eito Miyamura
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,cli,gateway,mcp,sealgate,security
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Security
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx>=0.27.0
20
+ Requires-Dist: typer>=0.12.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # sealg (Python)
24
+
25
+ A Python client for the SealGate gateway, exposing the same `sealg` CLI as the
26
+ Rust binary in this repo. It is a thin MCP-over-HTTP client: `sealg list` /
27
+ `sealg call` forward `tools/list` / `tools/call` to your per-user gateway
28
+ endpoint, where all policy and enforcement live. It carries no policy of its own.
29
+
30
+ It exists alongside the Rust `sealg` because a `uvx`-installable Python package
31
+ drops into environments that reach for `uvx`/`pip` rather than a native binary.
32
+ The two clients are kept from drifting by `scripts/check_wire_contract.py` (a
33
+ pre-commit + CI check): every shared wire constant lives once in
34
+ `sealg/contract.py` and is checked against the Rust source in `crates/`.
35
+
36
+ ## Install and run
37
+
38
+ Published on PyPI, so no checkout is needed:
39
+
40
+ ```
41
+ uvx sealg doctor # run without installing
42
+ uv tool install sealg # or install it on PATH
43
+ ```
44
+
45
+ From a local checkout, or to pin to an unreleased commit:
46
+
47
+ ```
48
+ uvx --from python/ sealg doctor # from a checkout
49
+ uvx --from 'git+https://github.com/Edison-Watch/cli#subdirectory=python' sealg list
50
+ ```
51
+
52
+ Or `pip install sealg` (or `pip install ./python`) into a virtualenv, then run
53
+ `sealg`.
54
+
55
+ ## Commands
56
+
57
+ ```
58
+ sealg doctor # resolved gateway env + reachability probe
59
+ sealg list [--json] # tools your org has authorized
60
+ sealg call <tool> [--args '{}'] # invoke one tool
61
+ ```
62
+
63
+ Exit codes mirror the Rust CLI: `0` ok, `1` client/transport error, `6` the
64
+ gateway returned an MCP tool error (`isError: true`).
65
+
66
+ ## Configuration
67
+
68
+ All from the environment (see `.env.example`): `SEALGATE_URL`,
69
+ `SEALGATE_API_KEY` (optional - keyless when auth is injected upstream by a
70
+ proxy), `SEALGATE_SECRET_KEY`, `SEALGATE_CONVERSATION_ID` (with a
71
+ `CENTAUR_THREAD_KEY` fallback that some agent runtimes set automatically), and a
72
+ CA bundle via `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` for
73
+ a MITM egress proxy.
sealg-0.1.0/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # sealg (Python)
2
+
3
+ A Python client for the SealGate gateway, exposing the same `sealg` CLI as the
4
+ Rust binary in this repo. It is a thin MCP-over-HTTP client: `sealg list` /
5
+ `sealg call` forward `tools/list` / `tools/call` to your per-user gateway
6
+ endpoint, where all policy and enforcement live. It carries no policy of its own.
7
+
8
+ It exists alongside the Rust `sealg` because a `uvx`-installable Python package
9
+ drops into environments that reach for `uvx`/`pip` rather than a native binary.
10
+ The two clients are kept from drifting by `scripts/check_wire_contract.py` (a
11
+ pre-commit + CI check): every shared wire constant lives once in
12
+ `sealg/contract.py` and is checked against the Rust source in `crates/`.
13
+
14
+ ## Install and run
15
+
16
+ Published on PyPI, so no checkout is needed:
17
+
18
+ ```
19
+ uvx sealg doctor # run without installing
20
+ uv tool install sealg # or install it on PATH
21
+ ```
22
+
23
+ From a local checkout, or to pin to an unreleased commit:
24
+
25
+ ```
26
+ uvx --from python/ sealg doctor # from a checkout
27
+ uvx --from 'git+https://github.com/Edison-Watch/cli#subdirectory=python' sealg list
28
+ ```
29
+
30
+ Or `pip install sealg` (or `pip install ./python`) into a virtualenv, then run
31
+ `sealg`.
32
+
33
+ ## Commands
34
+
35
+ ```
36
+ sealg doctor # resolved gateway env + reachability probe
37
+ sealg list [--json] # tools your org has authorized
38
+ sealg call <tool> [--args '{}'] # invoke one tool
39
+ ```
40
+
41
+ Exit codes mirror the Rust CLI: `0` ok, `1` client/transport error, `6` the
42
+ gateway returned an MCP tool error (`isError: true`).
43
+
44
+ ## Configuration
45
+
46
+ All from the environment (see `.env.example`): `SEALGATE_URL`,
47
+ `SEALGATE_API_KEY` (optional - keyless when auth is injected upstream by a
48
+ proxy), `SEALGATE_SECRET_KEY`, `SEALGATE_CONVERSATION_ID` (with a
49
+ `CENTAUR_THREAD_KEY` fallback that some agent runtimes set automatically), and a
50
+ CA bundle via `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` for
51
+ a MITM egress proxy.
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "sealg"
3
+ description = "Python client for the SealGate gateway - governed access to every tool via one CLI"
4
+ version = "0.1.0"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ authors = [{ name = "Eito Miyamura" }]
10
+ keywords = ["sealgate", "mcp", "gateway", "agent", "security", "cli"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Security",
17
+ "Topic :: Software Development :: Libraries",
18
+ ]
19
+ dependencies = [
20
+ "httpx>=0.27.0",
21
+ "typer>=0.12.0",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://sealgate.ai"
26
+ Repository = "https://github.com/Edison-Watch/cli"
27
+ Issues = "https://github.com/Edison-Watch/cli/issues"
28
+
29
+ # The command this package installs: `sealg list`, `sealg call ...`, `sealg doctor`.
30
+ [project.scripts]
31
+ sealg = "sealg.cli:app"
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["sealg"]
@@ -0,0 +1 @@
1
+ """SealGate CLI (sealg) - the Python client for the SealGate gateway."""
@@ -0,0 +1,151 @@
1
+ """CLI for SealGate - a thin MCP client to the SealGate gateway.
2
+
3
+ ``sealg list`` / ``sealg call`` forward ``tools/list`` / ``tools/call`` to the
4
+ per-user gateway endpoint; all policy and enforcement live in the gateway.
5
+ ``sealg doctor`` reports the resolved environment and probes reachability. This
6
+ is the Python client; it mirrors the Rust ``sealg`` binary's surface and exit
7
+ codes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import platform
15
+
16
+ import typer
17
+
18
+ from .client import GatewayClient, GatewayConfig, redact_url
19
+ from .contract import ENV_CA_BUNDLE, EXIT_ERROR, EXIT_TOOL_ERROR
20
+
21
+ app = typer.Typer(
22
+ name="sealg",
23
+ help="Command-line interface for SealGate, the agentic data firewall",
24
+ no_args_is_help=True,
25
+ add_completion=False,
26
+ )
27
+
28
+
29
+ def _emit(value: object) -> None:
30
+ print(json.dumps(value, indent=2, ensure_ascii=False, default=str))
31
+
32
+
33
+ @app.command("doctor")
34
+ def doctor(
35
+ gateway_url: str = typer.Option(
36
+ None, "--gateway-url", help="Override the gateway base URL."
37
+ ),
38
+ ) -> None:
39
+ """Report the resolved gateway environment and probe reachability."""
40
+ cfg = GatewayConfig.from_env(url_override=gateway_url)
41
+ report: dict[str, object] = {
42
+ "tool": "sealg",
43
+ "os": platform.system().lower(),
44
+ "arch": platform.machine(),
45
+ "gateway_url": redact_url(cfg.mcp_url(), cfg.api_key),
46
+ "auth": cfg.auth_mode(),
47
+ "secret_key_set": cfg.secret_key is not None,
48
+ "conversation_id_set": cfg.conversation_id is not None,
49
+ "ca_bundle": cfg.ca_bundle,
50
+ # Which env var actually supplied the bundle (first non-blank, matching
51
+ # GatewayConfig's precedence), or None.
52
+ "ca_bundle_source": next(
53
+ (k for k in ENV_CA_BUNDLE if os.environ.get(k, "").strip()), None
54
+ ),
55
+ }
56
+ # Separate "could we reach + initialize the gateway" (reachable) from "did
57
+ # the tools/list probe succeed" (readiness), so a connect that succeeds but
58
+ # a probe that fails isn't reported as unreachable.
59
+ try:
60
+ client = GatewayClient(cfg).connect()
61
+ except Exception as exc: # noqa: BLE001 - doctor reports failures, never raises on them
62
+ report["reachable"] = False
63
+ report["error"] = str(exc)
64
+ else:
65
+ report["reachable"] = True
66
+ try:
67
+ report["tool_count"] = len(client.tools_list())
68
+ except Exception as exc: # noqa: BLE001
69
+ report["probe_error"] = str(exc)
70
+ finally:
71
+ client.close()
72
+
73
+ _emit(report) # doctor is a diagnostic; always structured JSON
74
+ if not report["reachable"]:
75
+ raise typer.Exit(EXIT_ERROR)
76
+
77
+
78
+ @app.command("list")
79
+ def list_tools(
80
+ json_out: bool = typer.Option(
81
+ False, "--json", help="Output as a JSON array of {name, description}."
82
+ ),
83
+ gateway_url: str = typer.Option(
84
+ None, "--gateway-url", help="Override the gateway base URL."
85
+ ),
86
+ ) -> None:
87
+ """List the user's tools from the live SealGate gateway."""
88
+ cfg = GatewayConfig.from_env(url_override=gateway_url)
89
+ try:
90
+ client = GatewayClient(cfg).connect()
91
+ try:
92
+ tools = client.tools_list()
93
+ finally:
94
+ client.close()
95
+ except Exception as exc:
96
+ typer.echo(f"error: {exc}", err=True)
97
+ raise typer.Exit(EXIT_ERROR) from exc
98
+
99
+ if json_out:
100
+ _emit([{"name": t.name, "description": t.description} for t in tools])
101
+ else:
102
+ for t in tools:
103
+ # The em dash keeps this list output identical to the Rust client's;
104
+ # written as a backslash-u2014 escape because the repo ai-writing
105
+ # check bans a literal U+2014 in source.
106
+ typer.echo(f"{t.name} \u2014 {t.description}")
107
+
108
+
109
+ @app.command("call")
110
+ def call_tool(
111
+ tool: str = typer.Argument(..., help="Tool name as advertised by `sealg list`."),
112
+ args: str = typer.Option(
113
+ "{}", "--args", help="JSON arguments object to pass to the tool."
114
+ ),
115
+ gateway_url: str = typer.Option(
116
+ None, "--gateway-url", help="Override the gateway base URL."
117
+ ),
118
+ ) -> None:
119
+ """Call a tool on the live SealGate gateway."""
120
+ try:
121
+ arguments = json.loads(args)
122
+ except json.JSONDecodeError as exc:
123
+ typer.echo(f"error: invalid --args JSON: {exc}", err=True)
124
+ raise typer.Exit(EXIT_ERROR) from exc
125
+ # MCP arguments must be an object. Reject a valid-JSON non-object locally
126
+ # (e.g. --args '5' or '"x"') with a clear error rather than forwarding an
127
+ # invalid tools/call. null is allowed; the client maps it to {}.
128
+ if arguments is not None and not isinstance(arguments, dict):
129
+ typer.echo("error: --args must be a JSON object", err=True)
130
+ raise typer.Exit(EXIT_ERROR)
131
+
132
+ cfg = GatewayConfig.from_env(url_override=gateway_url)
133
+ try:
134
+ client = GatewayClient(cfg).connect()
135
+ try:
136
+ result = client.tools_call(tool, arguments)
137
+ finally:
138
+ client.close()
139
+ except Exception as exc:
140
+ typer.echo(f"error: {exc}", err=True)
141
+ raise typer.Exit(EXIT_ERROR) from exc
142
+
143
+ _emit(result)
144
+ # Mirror the MCP tool-call response: an `isError: true` result is a failed
145
+ # call and must not exit 0.
146
+ if isinstance(result, dict) and result.get("isError") is True:
147
+ raise typer.Exit(EXIT_TOOL_ERROR)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ app()
@@ -0,0 +1,341 @@
1
+ """A thin MCP-over-HTTP client to the SealGate gateway.
2
+
3
+ A faithful Python port of the Rust ``sealg`` transport in this repo
4
+ (``crates/engine/src/gateway/``). It speaks the small slice of MCP it needs -
5
+ ``initialize``, ``tools/list``, ``tools/call`` - directly to the gateway's
6
+ ``/mcp/{api_key}/`` endpoint. It carries no policy: the gateway enforces access
7
+ control, lethal-trifecta blocking, and audit. All the wire constants come from
8
+ :mod:`contract` so this client and the Rust one cannot drift (the pre-commit
9
+ guard verifies it).
10
+
11
+ Config resolves purely from the environment (matching the Rust binary), so the
12
+ CLI stays stateless and drops cleanly into any shell, CI job, or agent sandbox.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import json
19
+ import os
20
+ import ssl
21
+ from collections.abc import Mapping
22
+ from dataclasses import dataclass
23
+ from importlib.metadata import PackageNotFoundError, version
24
+ from typing import Any, Self
25
+
26
+ import httpx
27
+
28
+ from .contract import (
29
+ ACCEPT,
30
+ CONVERSATION_ID_HEADER,
31
+ DEFAULT_URL,
32
+ ENV_API_KEY,
33
+ ENV_CA_BUNDLE,
34
+ ENV_CONVERSATION_ID,
35
+ ENV_CONVERSATION_ID_FALLBACK,
36
+ ENV_SECRET_KEY,
37
+ ENV_URL,
38
+ MCP_PATH_KEYLESS,
39
+ MCP_PATH_WITH_KEY,
40
+ PROTOCOL_VERSION,
41
+ PROTOCOL_VERSION_HEADER,
42
+ SECRET_KEY_HEADER,
43
+ )
44
+
45
+ DEFAULT_TIMEOUT = 30.0
46
+
47
+
48
+ class GatewayError(Exception):
49
+ """Any failure reaching or talking to the gateway."""
50
+
51
+
52
+ class RpcError(GatewayError):
53
+ """A JSON-RPC error returned by the gateway (mirrors the MCP error shape)."""
54
+
55
+ def __init__(self, code: int, message: str, data: object = None) -> None:
56
+ super().__init__(f"rpc error {code}: {message}")
57
+ self.code = code
58
+ self.message = message
59
+ self.data = data
60
+
61
+
62
+ @dataclass
63
+ class ToolInfo:
64
+ name: str
65
+ description: str
66
+ input_schema: object
67
+
68
+
69
+ @dataclass
70
+ class GatewayConfig:
71
+ """Everything needed to reach the gateway, resolved once from the env."""
72
+
73
+ base_url: str
74
+ api_key: str | None
75
+ secret_key: str | None
76
+ conversation_id: str | None
77
+ ca_bundle: str | None
78
+
79
+ @staticmethod
80
+ def _get(env: Mapping[str, str], key: str) -> str | None:
81
+ """Return the raw env value if non-blank, else None. strip() is only the
82
+ blank test - the value itself is returned unmodified, matching the Rust
83
+ client (`.filter(|v| !v.trim().is_empty())`), so a padded key or id is
84
+ sent identically by both clients."""
85
+ val = env.get(key)
86
+ if val is None or not val.strip():
87
+ return None
88
+ return val
89
+
90
+ @classmethod
91
+ def from_env(
92
+ cls, env: Mapping[str, str] | None = None, url_override: str | None = None
93
+ ) -> GatewayConfig:
94
+ env = os.environ if env is None else env
95
+
96
+ base_url = (cls._get(env, ENV_URL) or DEFAULT_URL).rstrip("/")
97
+ if url_override is not None:
98
+ trimmed = url_override.strip().rstrip("/")
99
+ if trimmed:
100
+ base_url = trimmed
101
+
102
+ conversation_id = cls._get(env, ENV_CONVERSATION_ID) or cls._get(
103
+ env, ENV_CONVERSATION_ID_FALLBACK
104
+ )
105
+ ca_bundle = next((v for k in ENV_CA_BUNDLE if (v := cls._get(env, k))), None)
106
+
107
+ return cls(
108
+ base_url=base_url,
109
+ api_key=cls._get(env, ENV_API_KEY),
110
+ secret_key=cls._get(env, ENV_SECRET_KEY),
111
+ conversation_id=conversation_id,
112
+ ca_bundle=ca_bundle,
113
+ )
114
+
115
+ def mcp_url(self) -> str:
116
+ """``{base}/mcp/{key}/`` with a key, else ``{base}/mcp/`` (auth injected).
117
+
118
+ The path shapes come from :mod:`contract` (``MCP_PATH_*``) so the drift
119
+ guard can compare them against the Rust ``mcp_url``.
120
+ """
121
+ if self.api_key:
122
+ return self.base_url + MCP_PATH_WITH_KEY.format(key=self.api_key)
123
+ return self.base_url + MCP_PATH_KEYLESS
124
+
125
+ def auth_mode(self) -> str:
126
+ return "env-key" if self.api_key else "proxy-injected"
127
+
128
+
129
+ def redact_url(url: str, api_key: str | None) -> str:
130
+ """Replace the ``/{key}/`` path segment with ``/***/`` so errors don't leak
131
+ the key (it rides in the ``/mcp/{key}/`` path). Only the delimited segment
132
+ is replaced, never a blanket substring swap."""
133
+ if api_key:
134
+ return url.replace(f"/{api_key}/", "/***/")
135
+ return url
136
+
137
+
138
+ def _extract_rpc_result(content_type: str, body: str, want_id: int) -> Any:
139
+ """Extract the JSON-RPC ``result`` for ``want_id`` from a JSON or SSE body.
140
+
141
+ Raises :class:`RpcError` on a JSON-RPC error, :class:`GatewayError` on a
142
+ protocol problem. Pure, so it is unit-tested directly.
143
+ """
144
+ if "text/event-stream" in content_type:
145
+ msg = _sse_find_response(body, want_id)
146
+ if msg is None:
147
+ raise GatewayError("no JSON-RPC response in SSE stream")
148
+ else:
149
+ try:
150
+ msg = json.loads(body.strip())
151
+ except json.JSONDecodeError as e:
152
+ raise GatewayError(f"invalid JSON response: {e}") from e
153
+ return _rpc_message_to_result(msg)
154
+
155
+
156
+ def _rpc_message_to_result(msg: object) -> Any:
157
+ # A valid-JSON scalar (e.g. `5`) is a protocol error, not a crash.
158
+ if not isinstance(msg, dict):
159
+ raise GatewayError("JSON-RPC response was not an object")
160
+ if "error" in msg and msg["error"] is not None:
161
+ err = msg["error"]
162
+ if not isinstance(err, dict):
163
+ raise GatewayError("JSON-RPC error was not an object")
164
+ raise RpcError(
165
+ code=int(err.get("code", 0)),
166
+ message=str(err.get("message", "unknown error")),
167
+ data=err.get("data"),
168
+ )
169
+ if "result" in msg:
170
+ return msg["result"]
171
+ raise GatewayError("JSON-RPC response had neither result nor error")
172
+
173
+
174
+ def _sse_find_response(body: str, want_id: int) -> dict | None:
175
+ """Scan SSE ``data:`` frames for the JSON-RPC response matching ``want_id``."""
176
+ fallback: dict | None = None
177
+ for line in body.splitlines():
178
+ line = line.lstrip()
179
+ if not line.startswith("data:"):
180
+ continue
181
+ try:
182
+ v = json.loads(line[len("data:") :].strip())
183
+ except json.JSONDecodeError:
184
+ continue
185
+ if not isinstance(v, dict) or ("result" not in v and "error" not in v):
186
+ continue # notifications carry neither
187
+ if v.get("id") == want_id:
188
+ return v
189
+ if fallback is None:
190
+ fallback = v
191
+ return fallback
192
+
193
+
194
+ class GatewayClient:
195
+ """A live connection to the gateway's per-user MCP endpoint."""
196
+
197
+ def __init__(self, cfg: GatewayConfig, timeout: float = DEFAULT_TIMEOUT) -> None:
198
+ self.cfg = cfg
199
+ self.url = cfg.mcp_url()
200
+ _u = httpx.URL(self.url)
201
+ self._origin = (_u.scheme, _u.host, _u.port)
202
+ self._session_id: str | None = None
203
+ self._next_id = 0
204
+ # verify: add the MITM CA to the system trust store (additive, matching
205
+ # the Rust client's add_root_certificate) rather than replacing it. A
206
+ # missing/invalid bundle surfaces as GatewayError, not a raw ssl/OSError.
207
+ verify: ssl.SSLContext | bool = True
208
+ if cfg.ca_bundle:
209
+ ctx = ssl.create_default_context()
210
+ try:
211
+ ctx.load_verify_locations(cafile=cfg.ca_bundle)
212
+ except (OSError, ssl.SSLError) as e:
213
+ raise GatewayError(f"CA bundle {cfg.ca_bundle}: {e}") from e
214
+ verify = ctx
215
+ # trust_env=True (default) honors HTTPS_PROXY/HTTP_PROXY. follow_redirects
216
+ # mirrors reqwest (the Rust client follows up to 10; cap it the same so
217
+ # redirect-exhaustion behavior matches). The request hook strips the
218
+ # credential headers on any cross-origin redirect so a redirect to a
219
+ # different host can't carry sealg's secret key or session id off the
220
+ # configured gateway origin.
221
+ self._http = httpx.Client(
222
+ timeout=timeout,
223
+ verify=verify,
224
+ follow_redirects=True,
225
+ max_redirects=10,
226
+ event_hooks={"request": [self._strip_creds_off_origin]},
227
+ )
228
+
229
+ def _strip_creds_off_origin(self, request: httpx.Request) -> None:
230
+ origin = (request.url.scheme, request.url.host, request.url.port)
231
+ if origin != self._origin:
232
+ for header in (SECRET_KEY_HEADER, CONVERSATION_ID_HEADER, "Mcp-Session-Id"):
233
+ request.headers.pop(header, None)
234
+
235
+ def __enter__(self) -> Self:
236
+ self.connect()
237
+ return self
238
+
239
+ def __exit__(self, *exc: object) -> None:
240
+ self.close()
241
+
242
+ def close(self) -> None:
243
+ self._http.close()
244
+
245
+ def connect(self) -> GatewayClient:
246
+ """Run the MCP ``initialize`` handshake and capture any session id."""
247
+ params = {
248
+ "protocolVersion": PROTOCOL_VERSION,
249
+ "capabilities": {},
250
+ "clientInfo": {"name": "sealg", "version": _version()},
251
+ }
252
+ _, session_id = self._rpc_capture_session("initialize", params)
253
+ self._session_id = session_id
254
+ # Best-effort readiness notification; stateless servers may ignore it.
255
+ with contextlib.suppress(GatewayError):
256
+ self._notify("notifications/initialized", {})
257
+ return self
258
+
259
+ def tools_list(self) -> list[ToolInfo]:
260
+ result = self._rpc("tools/list", {})
261
+ tools = result.get("tools") if isinstance(result, dict) else None
262
+ if not isinstance(tools, list):
263
+ raise GatewayError("tools/list missing `tools` array")
264
+ return [
265
+ ToolInfo(
266
+ name=t.get("name", ""),
267
+ description=t.get("description", ""),
268
+ input_schema=t.get("inputSchema"),
269
+ )
270
+ for t in tools
271
+ ]
272
+
273
+ def tools_call(self, name: str, arguments: object) -> Any:
274
+ # Mirror the Rust client: only a null/None arguments becomes {}.
275
+ args = {} if arguments is None else arguments
276
+ return self._rpc("tools/call", {"name": name, "arguments": args})
277
+
278
+ # --- transport ---------------------------------------------------------
279
+
280
+ def _bump_id(self) -> int:
281
+ self._next_id += 1
282
+ return self._next_id
283
+
284
+ def _headers(self) -> dict[str, str]:
285
+ h = {
286
+ "Content-Type": "application/json",
287
+ "Accept": ACCEPT,
288
+ PROTOCOL_VERSION_HEADER: PROTOCOL_VERSION,
289
+ }
290
+ if self._session_id:
291
+ h["Mcp-Session-Id"] = self._session_id
292
+ if self.cfg.secret_key:
293
+ h[SECRET_KEY_HEADER] = self.cfg.secret_key
294
+ if self.cfg.conversation_id:
295
+ h[CONVERSATION_ID_HEADER] = self.cfg.conversation_id
296
+ return h
297
+
298
+ def _rpc(self, method: str, params: object) -> Any:
299
+ return self._rpc_capture_session(method, params)[0]
300
+
301
+ def _rpc_capture_session(
302
+ self, method: str, params: object
303
+ ) -> tuple[Any, str | None]:
304
+ rpc_id = self._bump_id()
305
+ body = {"jsonrpc": "2.0", "id": rpc_id, "method": method, "params": params}
306
+ try:
307
+ resp = self._http.post(self.url, headers=self._headers(), json=body)
308
+ except httpx.TimeoutException as e:
309
+ raise GatewayError("timeout") from e
310
+ except httpx.HTTPError as e:
311
+ raise GatewayError(
312
+ f"POST {redact_url(self.url, self.cfg.api_key)}: {e}"
313
+ ) from e
314
+
315
+ session_id = resp.headers.get("mcp-session-id")
316
+ # Mirror reqwest's is_success(): only 2xx carries a JSON-RPC envelope.
317
+ # Anything else (an un-followed 3xx after redirect exhaustion, an auth
318
+ # 401) is an HTTP-level failure, not a body for the JSON-RPC parser.
319
+ if not (200 <= resp.status_code < 300):
320
+ # The body may echo the /mcp/{key}/ request URL, so redact the key
321
+ # before it reaches stderr/logs.
322
+ body = redact_url(resp.text[:512], self.cfg.api_key)
323
+ raise GatewayError(f"gateway returned HTTP {resp.status_code}: {body}")
324
+ result = _extract_rpc_result(
325
+ resp.headers.get("content-type", ""), resp.text, rpc_id
326
+ )
327
+ return result, session_id
328
+
329
+ def _notify(self, method: str, params: object) -> None:
330
+ body = {"jsonrpc": "2.0", "method": method, "params": params}
331
+ try:
332
+ self._http.post(self.url, headers=self._headers(), json=body)
333
+ except httpx.HTTPError as e:
334
+ raise GatewayError(str(e)) from e
335
+
336
+
337
+ def _version() -> str:
338
+ try:
339
+ return version("sealg")
340
+ except PackageNotFoundError:
341
+ return "0.0.0"
@@ -0,0 +1,63 @@
1
+ """Single source of truth for the SealGate gateway wire contract.
2
+
3
+ This Python client and the Rust ``sealg`` binary in this repo talk to the same
4
+ MCP-over-HTTP gateway endpoint, so everything the two must agree on lives here
5
+ as plain literals. The drift guard (``scripts/check_wire_contract.py``, run on
6
+ pre-commit and in CI) loads this module in isolation and compares it against
7
+ the Rust source of truth in ``crates/engine/src/gateway/config.rs`` and
8
+ ``.../client.rs``.
9
+
10
+ Keep this module import-free and side-effect-free: the guard imports it
11
+ directly from its file path, so a stray relative import would break the check.
12
+ Do not edit a value here without changing the Rust constant it mirrors (or the
13
+ guard fails), and vice versa.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ # --- protocol (mirrors client.rs::PROTOCOL_VERSION) ------------------------
19
+ # The MCP Streamable HTTP protocol version advertised in ``initialize``. Must be
20
+ # a version the gateway's MCP server accepts; it rejects unknown versions with
21
+ # JSON-RPC -32600.
22
+ PROTOCOL_VERSION = "2025-06-18"
23
+
24
+ # --- headers (mirror config.rs SECRET_KEY_HEADER / CONVERSATION_ID_HEADER) --
25
+ # Carries the zero-knowledge secret key value.
26
+ SECRET_KEY_HEADER = "sealgate_secret_key"
27
+ # Carries the stable conversation id (gateway: SEALGATE_CONVERSATION_ID_HEADER
28
+ # in src/middleware/session_tokens.py).
29
+ CONVERSATION_ID_HEADER = "x-sealgate-conversation-id"
30
+ # Advertises the protocol version on every request.
31
+ PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
32
+ # The Accept value that lets the gateway answer with either a single JSON
33
+ # object or an SSE stream.
34
+ ACCEPT = "application/json, text/event-stream"
35
+
36
+ # --- environment keys (mirror config.rs::env_keys) -------------------------
37
+ ENV_URL = "SEALGATE_URL"
38
+ ENV_API_KEY = "SEALGATE_API_KEY"
39
+ ENV_SECRET_KEY = "SEALGATE_SECRET_KEY"
40
+ ENV_CONVERSATION_ID = "SEALGATE_CONVERSATION_ID"
41
+ # Fallback conversation-id source, set automatically by some agent runtimes.
42
+ # The Rust client honors it too; kept here for wire parity.
43
+ ENV_CONVERSATION_ID_FALLBACK = "CENTAUR_THREAD_KEY"
44
+ # CA bundle paths for a MITM egress proxy, tried in order (first set wins).
45
+ ENV_CA_BUNDLE = ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS")
46
+
47
+ # --- defaults & path shape (mirror config.rs::DEFAULT_URL / mcp_url) --------
48
+ # Default gateway origin when SEALGATE_URL is unset: the dev endpoint, never a
49
+ # guessed prod host. Prod deployments set the env var.
50
+ DEFAULT_URL = "http://localhost:3000"
51
+ # The MCP endpoint path. With a key it rides in the path; keyless when auth is
52
+ # injected upstream by a proxy.
53
+ MCP_PATH_WITH_KEY = "/mcp/{key}/"
54
+ MCP_PATH_KEYLESS = "/mcp/"
55
+
56
+ # --- exit codes (mirror gateway_cmd.rs) ------------------------------------
57
+ # Client/transport failure (bad config, network, protocol). A normal result is
58
+ # the framework default (0) and needs no constant.
59
+ EXIT_ERROR = 1
60
+ # The gateway returned an MCP tool error (``isError: true``); kept distinct
61
+ # from EXIT_ERROR so a caller can tell a failed tool call from a connection
62
+ # failure.
63
+ EXIT_TOOL_ERROR = 6
@@ -0,0 +1,236 @@
1
+ """Unit tests for the SealGate Python client's wire behavior.
2
+
3
+ Ported from the Rust client's tests (``crates/engine/src/gateway/config.rs`` and
4
+ ``client.rs``) so the two implementations behave identically on the parts that
5
+ matter: config resolution, key-in-path, conversation-id fallback, CA-bundle
6
+ precedence, JSON/SSE result extraction, redirect/status handling, and URL
7
+ redaction. The wire *constants* are covered separately by
8
+ ``scripts/check_wire_contract.py``; these cover the *logic*.
9
+
10
+ Run: ``uv run --with httpx --with pytest pytest python/tests``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import httpx
19
+ import pytest
20
+
21
+ # Import the package straight from the source dir (no install needed).
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
23
+
24
+ from sealg import client
25
+ from sealg.client import GatewayConfig, GatewayError, RpcError, redact_url
26
+
27
+ _extract = client._extract_rpc_result
28
+
29
+
30
+ # --- config resolution (ports config.rs tests) -----------------------------
31
+
32
+
33
+ def test_defaults_when_unset():
34
+ c = GatewayConfig.from_env(env={})
35
+ assert c.base_url == "http://localhost:3000"
36
+ assert c.api_key is None
37
+ assert c.mcp_url() == "http://localhost:3000/mcp/"
38
+ assert c.auth_mode() == "proxy-injected"
39
+
40
+
41
+ def test_key_goes_in_the_path_and_trailing_slash_is_trimmed():
42
+ c = GatewayConfig.from_env(
43
+ env={
44
+ "SEALGATE_URL": "https://dashboard.sealgate.ai/",
45
+ "SEALGATE_API_KEY": "ew_live_abc",
46
+ }
47
+ )
48
+ assert c.base_url == "https://dashboard.sealgate.ai"
49
+ assert c.mcp_url() == "https://dashboard.sealgate.ai/mcp/ew_live_abc/"
50
+ assert c.auth_mode() == "env-key"
51
+
52
+
53
+ def test_conversation_id_falls_back():
54
+ c = GatewayConfig.from_env(env={"CENTAUR_THREAD_KEY": "slack:C123:171.45"})
55
+ assert c.conversation_id == "slack:C123:171.45"
56
+ c = GatewayConfig.from_env(
57
+ env={"SEALGATE_CONVERSATION_ID": "explicit", "CENTAUR_THREAD_KEY": "fallback"}
58
+ )
59
+ assert c.conversation_id == "explicit"
60
+
61
+
62
+ def test_ca_bundle_tries_paths_in_order():
63
+ c = GatewayConfig.from_env(env={"REQUESTS_CA_BUNDLE": "/certs/ca.pem"})
64
+ assert c.ca_bundle == "/certs/ca.pem"
65
+ c = GatewayConfig.from_env(
66
+ env={"SSL_CERT_FILE": "/a.pem", "REQUESTS_CA_BUNDLE": "/b.pem"}
67
+ )
68
+ assert c.ca_bundle == "/a.pem"
69
+
70
+
71
+ def test_url_override_trims_and_ignores_blank():
72
+ c = GatewayConfig.from_env(env={}, url_override="https://gw.example.com/")
73
+ assert c.base_url == "https://gw.example.com"
74
+ for degenerate in (" ", "/", "///", " // "):
75
+ c = GatewayConfig.from_env(
76
+ env={"SEALGATE_URL": "https://keep.example"}, url_override=degenerate
77
+ )
78
+ assert c.base_url == "https://keep.example"
79
+
80
+
81
+ def test_blank_values_treated_as_unset():
82
+ c = GatewayConfig.from_env(env={"SEALGATE_URL": " ", "SEALGATE_API_KEY": ""})
83
+ assert c.base_url == "http://localhost:3000"
84
+ assert c.api_key is None
85
+
86
+
87
+ def test_nonblank_value_is_returned_unmodified():
88
+ # strip() is only the blank test; a padded key/id is preserved so the
89
+ # Python and Rust requests are byte-identical.
90
+ c = GatewayConfig.from_env(
91
+ env={"SEALGATE_API_KEY": " k ", "SEALGATE_SECRET_KEY": "s\t"}
92
+ )
93
+ assert c.api_key == " k "
94
+ assert c.secret_key == "s\t"
95
+
96
+
97
+ def test_cross_origin_redirect_strips_credential_headers():
98
+ from sealg.contract import CONVERSATION_ID_HEADER, SECRET_KEY_HEADER
99
+
100
+ cfg = GatewayConfig.from_env(
101
+ env={"SEALGATE_URL": "https://gw.test", "SEALGATE_SECRET_KEY": "s"}
102
+ )
103
+ gc = client.GatewayClient(cfg)
104
+ try:
105
+ same = httpx.Request(
106
+ "POST",
107
+ "https://gw.test/mcp/",
108
+ headers={SECRET_KEY_HEADER: "s", "Mcp-Session-Id": "x"},
109
+ )
110
+ gc._strip_creds_off_origin(same)
111
+ assert same.headers.get(SECRET_KEY_HEADER) == "s" # same origin: kept
112
+ other = httpx.Request(
113
+ "POST",
114
+ "https://evil.test/mcp/",
115
+ headers={
116
+ SECRET_KEY_HEADER: "s",
117
+ CONVERSATION_ID_HEADER: "c",
118
+ "Mcp-Session-Id": "x",
119
+ },
120
+ )
121
+ gc._strip_creds_off_origin(other)
122
+ assert SECRET_KEY_HEADER not in other.headers # cross-origin: stripped
123
+ assert CONVERSATION_ID_HEADER not in other.headers
124
+ assert "Mcp-Session-Id" not in other.headers
125
+ finally:
126
+ gc.close()
127
+
128
+
129
+ def test_scalar_json_response_is_protocol_error():
130
+ # A valid-JSON scalar must be a GatewayError, not an uncaught TypeError.
131
+ with pytest.raises(GatewayError):
132
+ _extract("application/json", "5", 1)
133
+
134
+
135
+ # --- result extraction (ports client.rs tests) -----------------------------
136
+
137
+
138
+ def test_plain_json_result():
139
+ r = _extract(
140
+ "application/json", '{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}', 1
141
+ )
142
+ assert r["tools"] == []
143
+
144
+
145
+ def test_json_rpc_error_maps_to_rpc_error():
146
+ body = '{"jsonrpc":"2.0","id":2,"error":{"code":-32000,"message":"blocked by policy","data":{"rule":"trifecta"}}}'
147
+ with pytest.raises(RpcError) as ei:
148
+ _extract("application/json", body, 2)
149
+ assert ei.value.code == -32000
150
+ assert ei.value.message == "blocked by policy"
151
+ assert ei.value.data == {"rule": "trifecta"}
152
+
153
+
154
+ def test_sse_stream_picks_matching_id():
155
+ body = 'event: message\ndata: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}\n\n'
156
+ assert _extract("text/event-stream; charset=utf-8", body, 7)["ok"] is True
157
+
158
+
159
+ def test_sse_skips_notifications_and_finds_response():
160
+ body = (
161
+ 'data: {"jsonrpc":"2.0","method":"notifications/message","params":{}}\n'
162
+ 'data: {"jsonrpc":"2.0","id":3,"result":{"done":1}}\n'
163
+ )
164
+ assert _extract("text/event-stream", body, 3)["done"] == 1
165
+
166
+
167
+ def test_invalid_json_is_protocol_error():
168
+ with pytest.raises(GatewayError):
169
+ _extract("application/json", "not json", 1)
170
+
171
+
172
+ # --- redaction (ports client.rs redact test) -------------------------------
173
+
174
+
175
+ def test_redact_url_hides_the_api_key():
176
+ out = redact_url("https://gw.example/mcp/ew_live_SECRET/", "ew_live_SECRET")
177
+ assert "ew_live_SECRET" not in out
178
+ assert out == "https://gw.example/mcp/***/"
179
+ assert redact_url("https://gw.example/mcp/", None) == "https://gw.example/mcp/"
180
+ assert (
181
+ redact_url("https://abc.example/mcp/abc/", "abc")
182
+ == "https://abc.example/mcp/***/"
183
+ )
184
+
185
+
186
+ # --- HTTP status / redirect handling (match reqwest) -----------------------
187
+
188
+
189
+ def _mock_client(handler):
190
+ cfg = GatewayConfig.from_env(
191
+ env={"SEALGATE_URL": "https://gw.test", "SEALGATE_API_KEY": "k"}
192
+ )
193
+ gc = client.GatewayClient(cfg)
194
+ gc._http.close() # close the real client __init__ opened before swapping it
195
+ gc._http = httpx.Client(
196
+ transport=httpx.MockTransport(handler), follow_redirects=True
197
+ )
198
+ return gc
199
+
200
+
201
+ def test_client_follows_redirects_by_default():
202
+ cfg = GatewayConfig.from_env(env={"SEALGATE_URL": "https://gw.test"})
203
+ gc = client.GatewayClient(cfg)
204
+ try:
205
+ assert gc._http.follow_redirects is True
206
+ finally:
207
+ gc.close()
208
+
209
+
210
+ def test_non_2xx_is_http_error_not_parse_error():
211
+ gc = _mock_client(lambda request: httpx.Response(500, text="boom"))
212
+ try:
213
+ with pytest.raises(GatewayError) as ei:
214
+ gc._rpc("tools/list", {})
215
+ assert "HTTP 500" in str(ei.value)
216
+ finally:
217
+ gc.close()
218
+
219
+
220
+ def test_3xx_is_followed_to_the_result():
221
+ def handler(request: httpx.Request) -> httpx.Response:
222
+ if request.url.path == "/mcp/k/":
223
+ return httpx.Response(307, headers={"location": "https://gw.test/mcp/k2/"})
224
+ return httpx.Response(
225
+ 200, json={"jsonrpc": "2.0", "id": 1, "result": {"tools": []}}
226
+ )
227
+
228
+ gc = _mock_client(handler)
229
+ try:
230
+ assert gc._rpc("tools/list", {}) == {"tools": []}
231
+ finally:
232
+ gc.close()
233
+
234
+
235
+ if __name__ == "__main__":
236
+ sys.exit(pytest.main([__file__, "-q"]))