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.
- sealg-0.1.0/.env.example +24 -0
- sealg-0.1.0/.gitignore +31 -0
- sealg-0.1.0/LICENSE +21 -0
- sealg-0.1.0/PKG-INFO +73 -0
- sealg-0.1.0/README.md +51 -0
- sealg-0.1.0/pyproject.toml +38 -0
- sealg-0.1.0/sealg/__init__.py +1 -0
- sealg-0.1.0/sealg/cli.py +151 -0
- sealg-0.1.0/sealg/client.py +341 -0
- sealg-0.1.0/sealg/contract.py +63 -0
- sealg-0.1.0/tests/test_wire.py +236 -0
sealg-0.1.0/.env.example
ADDED
|
@@ -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."""
|
sealg-0.1.0/sealg/cli.py
ADDED
|
@@ -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"]))
|