haltsql-mcp 0.0.1__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,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: haltsql-mcp
3
+ Version: 0.0.1
4
+ Summary: MCP server that runs LLM-written SQL on PostgreSQL only after HaltSQL allows it
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: haltsql>=0.0.1
9
+ Requires-Dist: mcp>=1.10
10
+ Requires-Dist: psycopg[binary]>=3.2
11
+ Provides-Extra: yaml
12
+ Requires-Dist: PyYAML>=6; extra == "yaml"
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=7; extra == "test"
15
+ Requires-Dist: pytest-asyncio>=0.21; extra == "test"
16
+ Requires-Dist: uvicorn>=0.30; extra == "test"
17
+
18
+ # haltsql-mcp
19
+
20
+ The safe drop-in replacement for `@modelcontextprotocol/server-postgres`.
21
+ Same `query(sql)` tool, same positional connection string, plus column, tenant
22
+ and function guardrails, a row cap and a statement timeout.
23
+
24
+ ```sh
25
+ haltsql-mcp --local-policy policy.yaml postgresql://agent_ro@localhost/mydb
26
+ ```
27
+
28
+ Or with Docker (includes the `haltsql` CLI; no central server needed):
29
+
30
+ ```sh
31
+ docker run -i --rm --read-only -v "$PWD:/config:ro" ghcr.io/haltsql/haltsql-mcp \
32
+ postgresql://agent_ro@host.docker.internal/mydb # /config/policy.yaml is picked up
33
+ ```
34
+
35
+ Build it with `docker build -f clients/mcp/Dockerfile .` from the repository
36
+ root, and see `scripts/mcp-docker-smoke.sh` for the end-to-end test.
37
+
38
+ An MCP server that lets an agent query PostgreSQL only through
39
+ [HaltSQL](https://github.com/HaltSQL/HaltSQL): every SQL string is verified
40
+ against your policy, then exactly that string runs with server-bound
41
+ parameters in a read-only transaction (row cap, `statement_timeout`).
42
+
43
+ Tools: `query(sql)`, `explain_denial(sql)`, `list_tables()`, `describe_policy()`.
44
+ The tenant comes from server configuration (stdio) or the authenticated
45
+ bearer token (streamable HTTP), never from tool arguments.
46
+
47
+ ```sh
48
+ pip install ../python . # Python 3.10+
49
+ HALTSQL_URL=http://127.0.0.1:8080 HALTSQL_TOKEN=... \
50
+ HALTSQL_MCP_DATABASE_URL=postgresql://agent_ro@db/app \
51
+ HALTSQL_MCP_CONTEXT='{"tenant_id":"acme"}' haltsql-mcp
52
+ ```
53
+
54
+ See [docs/mcp.md](../../docs/mcp.md) for configuration, the tokens file, and
55
+ examples. `haltsql-mcp --help` lists every flag.
56
+
57
+ ## Tests
58
+
59
+ ```sh
60
+ python3 -m venv .venv && .venv/bin/pip install -e ../python -e '.[test,yaml]'
61
+ .venv/bin/pytest -q
62
+ ```
@@ -0,0 +1,45 @@
1
+ # haltsql-mcp
2
+
3
+ The safe drop-in replacement for `@modelcontextprotocol/server-postgres`.
4
+ Same `query(sql)` tool, same positional connection string, plus column, tenant
5
+ and function guardrails, a row cap and a statement timeout.
6
+
7
+ ```sh
8
+ haltsql-mcp --local-policy policy.yaml postgresql://agent_ro@localhost/mydb
9
+ ```
10
+
11
+ Or with Docker (includes the `haltsql` CLI; no central server needed):
12
+
13
+ ```sh
14
+ docker run -i --rm --read-only -v "$PWD:/config:ro" ghcr.io/haltsql/haltsql-mcp \
15
+ postgresql://agent_ro@host.docker.internal/mydb # /config/policy.yaml is picked up
16
+ ```
17
+
18
+ Build it with `docker build -f clients/mcp/Dockerfile .` from the repository
19
+ root, and see `scripts/mcp-docker-smoke.sh` for the end-to-end test.
20
+
21
+ An MCP server that lets an agent query PostgreSQL only through
22
+ [HaltSQL](https://github.com/HaltSQL/HaltSQL): every SQL string is verified
23
+ against your policy, then exactly that string runs with server-bound
24
+ parameters in a read-only transaction (row cap, `statement_timeout`).
25
+
26
+ Tools: `query(sql)`, `explain_denial(sql)`, `list_tables()`, `describe_policy()`.
27
+ The tenant comes from server configuration (stdio) or the authenticated
28
+ bearer token (streamable HTTP), never from tool arguments.
29
+
30
+ ```sh
31
+ pip install ../python . # Python 3.10+
32
+ HALTSQL_URL=http://127.0.0.1:8080 HALTSQL_TOKEN=... \
33
+ HALTSQL_MCP_DATABASE_URL=postgresql://agent_ro@db/app \
34
+ HALTSQL_MCP_CONTEXT='{"tenant_id":"acme"}' haltsql-mcp
35
+ ```
36
+
37
+ See [docs/mcp.md](../../docs/mcp.md) for configuration, the tokens file, and
38
+ examples. `haltsql-mcp --help` lists every flag.
39
+
40
+ ## Tests
41
+
42
+ ```sh
43
+ python3 -m venv .venv && .venv/bin/pip install -e ../python -e '.[test,yaml]'
44
+ .venv/bin/pytest -q
45
+ ```
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "haltsql-mcp"
7
+ version = "0.0.1"
8
+ description = "MCP server that runs LLM-written SQL on PostgreSQL only after HaltSQL allows it"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "haltsql>=0.0.1",
14
+ "mcp>=1.10",
15
+ "psycopg[binary]>=3.2",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ yaml = ["PyYAML>=6"]
20
+ test = ["pytest>=7", "pytest-asyncio>=0.21", "uvicorn>=0.30"]
21
+
22
+ [project.scripts]
23
+ haltsql-mcp = "haltsql_mcp.server:main"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ haltsql_mcp = ["py.typed"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ asyncio_mode = "auto"
34
+ asyncio_default_fixture_loop_scope = "function"
35
+ markers = [
36
+ "integration: needs the lab Postgres or haltsql binaries (skipped when unavailable)",
37
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,24 @@
1
+ """haltsql-mcp: an MCP server that runs SQL only after HaltSQL allows it."""
2
+
3
+ from .config import ConfigError, Settings, TokenEntry, hash_token, load_tokens_file
4
+ from .core import HaltSQLTools, ToolFailure, jsonable, summarize_policy
5
+ from .server import StaticTokenVerifier, build_server, build_tools, main
6
+
7
+ __version__ = "0.0.1"
8
+
9
+ __all__ = [
10
+ "__version__",
11
+ "Settings",
12
+ "ConfigError",
13
+ "TokenEntry",
14
+ "hash_token",
15
+ "load_tokens_file",
16
+ "HaltSQLTools",
17
+ "ToolFailure",
18
+ "jsonable",
19
+ "summarize_policy",
20
+ "StaticTokenVerifier",
21
+ "build_server",
22
+ "build_tools",
23
+ "main",
24
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .server import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,397 @@
1
+ """Configuration for haltsql-mcp: environment variables, overridable by flags.
2
+
3
+ Tenant context is configuration, never a tool argument:
4
+
5
+ * stdio: one static context for the whole process (``HALTSQL_MCP_CONTEXT`` or
6
+ ``--context-file``), set by whoever launches the server for one user.
7
+ * streamable HTTP: each bearer token in the tokens file maps to its own
8
+ context; the authenticated MCP session decides the tenant.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import hmac
15
+ import ipaddress
16
+ import json
17
+ import os
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Dict, List, Mapping, Optional
21
+
22
+ from haltsql import validate_context
23
+
24
+ __all__ = ["ConfigError", "TokenEntry", "Settings", "load_config_file", "load_tokens_file", "hash_token"]
25
+
26
+ _HEX64 = re.compile(r"^[0-9a-f]{64}$")
27
+ _SETTING_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_.]*$")
28
+ MAX_ROW_CAP = 10_000
29
+
30
+
31
+ class ConfigError(ValueError):
32
+ """Invalid configuration; the server refuses to start."""
33
+
34
+
35
+ def hash_token(token: str) -> str:
36
+ """Lower-case hex SHA-256 of a bearer token (what the tokens file stores)."""
37
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class TokenEntry:
42
+ """One accepted bearer token, stored only as its SHA-256."""
43
+
44
+ name: str
45
+ token_sha256: str
46
+ context: Mapping[str, Any]
47
+
48
+ def matches(self, token_hash: str) -> bool:
49
+ return hmac.compare_digest(self.token_sha256.encode(), token_hash.encode())
50
+
51
+
52
+ def load_tokens_file(path: str) -> List[TokenEntry]:
53
+ """Load ``{"tokens": [{"name", "token_sha256", "context"}]}`` (JSON)."""
54
+ try:
55
+ with open(path, encoding="utf-8") as f:
56
+ data = json.load(f)
57
+ except OSError as e:
58
+ raise ConfigError(f"cannot read tokens file {path}: {e.strerror}") from None
59
+ except ValueError as e:
60
+ raise ConfigError(f"tokens file {path} is not valid JSON: {e}") from None
61
+ if not isinstance(data, dict) or set(data) != {"tokens"} or not isinstance(data["tokens"], list):
62
+ raise ConfigError('tokens file must be {"tokens": [...]} with no other keys')
63
+ entries: List[TokenEntry] = []
64
+ names = set()
65
+ hashes = set()
66
+ for i, t in enumerate(data["tokens"]):
67
+ where = f"tokens[{i}]"
68
+ if not isinstance(t, dict) or set(t) - {"name", "token_sha256", "context"}:
69
+ raise ConfigError(f"{where}: expected keys name, token_sha256, context")
70
+ name = t.get("name")
71
+ h = t.get("token_sha256")
72
+ if not isinstance(name, str) or not name:
73
+ raise ConfigError(f"{where}.name must be a non-empty string")
74
+ if not isinstance(h, str) or not _HEX64.match(h):
75
+ raise ConfigError(f"{where}.token_sha256 must be 64 lower-case hex characters")
76
+ if name in names or h in hashes:
77
+ raise ConfigError(f"{where}: duplicate name or token")
78
+ try:
79
+ ctx = validate_context(t.get("context") or {})
80
+ except ValueError as e:
81
+ raise ConfigError(f"{where}.context: {e}") from None
82
+ names.add(name)
83
+ hashes.add(h)
84
+ entries.append(TokenEntry(name=name, token_sha256=h, context=ctx))
85
+ if not entries:
86
+ raise ConfigError("tokens file has no tokens")
87
+ return entries
88
+
89
+
90
+ def _env_int(env: Mapping[str, str], name: str, default: int) -> int:
91
+ raw = env.get(name)
92
+ if raw is None or raw == "":
93
+ return default
94
+ try:
95
+ return int(raw)
96
+ except ValueError:
97
+ raise ConfigError(f"{name} must be an integer") from None
98
+
99
+
100
+ def _json_obj(raw: str, what: str) -> Dict[str, Any]:
101
+ try:
102
+ data = json.loads(raw)
103
+ except ValueError as e:
104
+ raise ConfigError(f"{what} is not valid JSON: {e}") from None
105
+ if not isinstance(data, dict):
106
+ raise ConfigError(f"{what} must be a JSON object")
107
+ return data
108
+
109
+
110
+ # Config file keys -> (Settings attribute, kind). Paths are resolved relative
111
+ # to the config file's directory, so one file works natively and in Docker.
112
+ _FILE_KEYS: Dict[str, Any] = {
113
+ "database_url": ("database_url", "str"),
114
+ "policy": ("local_policy_file", "path"),
115
+ "policy_file": ("policy_file", "path"),
116
+ "context": ("context", "obj"),
117
+ "tokens_file": ("tokens_file", "path"),
118
+ "row_cap": ("row_cap", "int"),
119
+ "statement_timeout_ms": ("statement_timeout_ms", "int"),
120
+ "set_config": ("set_config", "obj"),
121
+ "transport": ("transport", "str"),
122
+ "host": ("host", "str"),
123
+ "port": ("port", "int"),
124
+ "public_url": ("public_url", "str"),
125
+ "haltsql_bin": ("haltsql_bin", "path"),
126
+ }
127
+ _SERVER_KEYS: Dict[str, Any] = {
128
+ "url": ("haltsql_url", "str"),
129
+ "token_file": ("haltsql_token", "secret_file"),
130
+ "policy": ("haltsql_policy", "str"),
131
+ "timeout": ("haltsql_timeout", "num"),
132
+ "ca_file": ("haltsql_ca_file", "path"),
133
+ "client_cert": ("haltsql_client_cert", "path"),
134
+ "client_key": ("haltsql_client_key", "path"),
135
+ }
136
+
137
+
138
+ def _parse_config_text(path: str, raw: str) -> Any:
139
+ if path.endswith(".json"):
140
+ try:
141
+ return json.loads(raw)
142
+ except ValueError as e:
143
+ raise ConfigError(f"config file {path} is not valid JSON: {e}") from None
144
+ try:
145
+ import yaml # type: ignore[import-untyped]
146
+ except ImportError:
147
+ try:
148
+ return json.loads(raw) # JSON is valid YAML
149
+ except ValueError:
150
+ raise ConfigError(
151
+ f"config file {path} is YAML; install PyYAML (pip install 'haltsql-mcp[yaml]') "
152
+ "or write it as JSON"
153
+ ) from None
154
+ try:
155
+ return yaml.safe_load(raw)
156
+ except yaml.YAMLError as e:
157
+ raise ConfigError(f"config file {path} is not valid YAML: {e}") from None
158
+
159
+
160
+ def _convert(value: Any, kind: str, where: str, base: str) -> Any:
161
+ if kind in ("str", "path", "secret_file"):
162
+ if not isinstance(value, str) or not value:
163
+ raise ConfigError(f"{where} must be a non-empty string")
164
+ if kind == "str":
165
+ return value
166
+ p = os.path.join(base, os.path.expanduser(value))
167
+ if kind == "path":
168
+ return p
169
+ try:
170
+ with open(p, encoding="utf-8") as f:
171
+ secret = f.read().strip()
172
+ except OSError as e:
173
+ raise ConfigError(f"cannot read {where} {p}: {e.strerror}") from None
174
+ if not secret:
175
+ raise ConfigError(f"{where} {p} is empty")
176
+ return secret
177
+ if kind == "int":
178
+ if isinstance(value, bool) or not isinstance(value, int):
179
+ raise ConfigError(f"{where} must be an integer")
180
+ return value
181
+ if kind == "num":
182
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
183
+ raise ConfigError(f"{where} must be a number")
184
+ return float(value)
185
+ if not isinstance(value, dict): # obj
186
+ raise ConfigError(f"{where} must be a mapping")
187
+ return dict(value)
188
+
189
+
190
+ def load_config_file(path: str) -> Dict[str, Any]:
191
+ """Read a haltsql-mcp config file and return ``{Settings attribute: value}``.
192
+
193
+ Unknown keys are errors (a typo must not silently drop a guardrail).
194
+ ``policy`` (local verification with the haltsql CLI) and ``server``
195
+ (a central haltsql-server) are mutually exclusive. The sidecar token is
196
+ read from ``server.token_file`` or ``HALTSQL_TOKEN``, never inline.
197
+ """
198
+ try:
199
+ with open(path, encoding="utf-8") as f:
200
+ raw = f.read()
201
+ except OSError as e:
202
+ raise ConfigError(f"cannot read config file {path}: {e.strerror}") from None
203
+ data = _parse_config_text(path, raw)
204
+ if data is None:
205
+ data = {}
206
+ if not isinstance(data, dict):
207
+ raise ConfigError(f"config file {path} must be a mapping")
208
+ base = os.path.dirname(os.path.abspath(path))
209
+ out: Dict[str, Any] = {}
210
+ for key, value in data.items():
211
+ if key == "server":
212
+ if not isinstance(value, dict):
213
+ raise ConfigError("server must be a mapping")
214
+ for skey, svalue in value.items():
215
+ if skey == "token":
216
+ raise ConfigError(
217
+ "server.token is not allowed in the config file; use server.token_file "
218
+ "or the HALTSQL_TOKEN environment variable"
219
+ )
220
+ if skey not in _SERVER_KEYS:
221
+ raise ConfigError(
222
+ f"unknown key server.{skey} in {path} (expected one of {', '.join(sorted(_SERVER_KEYS))})"
223
+ )
224
+ attr, kind = _SERVER_KEYS[skey]
225
+ out[attr] = _convert(svalue, kind, f"server.{skey}", base)
226
+ continue
227
+ if key not in _FILE_KEYS:
228
+ raise ConfigError(
229
+ f"unknown key {key!r} in {path} (expected one of {', '.join(sorted([*_FILE_KEYS, 'server']))})"
230
+ )
231
+ attr, kind = _FILE_KEYS[key]
232
+ out[attr] = _convert(value, kind, key, base)
233
+ if "local_policy_file" in out and "server" in data:
234
+ raise ConfigError("set either policy (verify locally) or server (central haltsql-server), not both")
235
+ if "set_config" in out:
236
+ out["set_config"] = {str(k): v for k, v in out["set_config"].items()}
237
+ return out
238
+
239
+
240
+ @dataclass
241
+ class Settings:
242
+ """Everything haltsql-mcp needs. See ``docs/mcp.md`` for each variable."""
243
+
244
+ # Verification: sidecar (default) or local CLI.
245
+ haltsql_url: str = "http://127.0.0.1:8080"
246
+ haltsql_token: Optional[str] = field(default=None, repr=False)
247
+ haltsql_policy: Optional[str] = None
248
+ haltsql_timeout: float = 2.0
249
+ haltsql_ca_file: Optional[str] = None
250
+ """CA bundle for an https sidecar with a private CA (default: system CAs)."""
251
+ haltsql_client_cert: Optional[str] = None
252
+ """Client certificate (PEM) for mutual TLS to the sidecar."""
253
+ haltsql_client_key: Optional[str] = field(default=None, repr=False)
254
+ """Private key for ``haltsql_client_cert`` when it is a separate file."""
255
+ local_policy_file: Optional[str] = None
256
+ haltsql_bin: Optional[str] = None
257
+ # Execution.
258
+ database_url: Optional[str] = field(default=None, repr=False)
259
+ row_cap: int = 200
260
+ statement_timeout_ms: int = 5000
261
+ set_config: Dict[str, str] = field(default_factory=dict)
262
+ """Postgres setting -> context key, applied with set_config(..., true) (for RLS)."""
263
+ # Policy description.
264
+ policy_file: Optional[str] = None
265
+ # Tenant context.
266
+ context: Dict[str, Any] = field(default_factory=dict)
267
+ tokens_file: Optional[str] = None
268
+ insecure_static_context: bool = False
269
+ # Transport.
270
+ transport: str = "stdio"
271
+ host: str = "127.0.0.1"
272
+ port: int = 8765
273
+ public_url: Optional[str] = None
274
+
275
+ @classmethod
276
+ def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "Settings":
277
+ """Defaults overridden by environment variables."""
278
+ s = cls()
279
+ s.apply_env(os.environ if env is None else env)
280
+ return s
281
+
282
+ @classmethod
283
+ def from_sources(
284
+ cls, env: Optional[Mapping[str, str]] = None, config_file: Optional[str] = None
285
+ ) -> "Settings":
286
+ """Defaults, then the config file (if any), then environment variables."""
287
+ e = os.environ if env is None else env
288
+ s = cls()
289
+ path = config_file or e.get("HALTSQL_MCP_CONFIG") or None
290
+ if path:
291
+ s.apply_file(path)
292
+ s.apply_env(e)
293
+ return s
294
+
295
+ def apply_env(self, e: Mapping[str, str]) -> None:
296
+ """Override settings with every environment variable that is set (non-empty)."""
297
+ def get(name: str) -> Optional[str]:
298
+ v = e.get(name)
299
+ return v if v else None
300
+
301
+ self.haltsql_url = get("HALTSQL_URL") or self.haltsql_url
302
+ self.haltsql_token = get("HALTSQL_TOKEN") or self.haltsql_token
303
+ self.haltsql_policy = get("HALTSQL_POLICY") or self.haltsql_policy
304
+ if get("HALTSQL_TIMEOUT"):
305
+ try:
306
+ self.haltsql_timeout = float(e["HALTSQL_TIMEOUT"])
307
+ except ValueError:
308
+ raise ConfigError("HALTSQL_TIMEOUT must be a number") from None
309
+ self.haltsql_ca_file = get("HALTSQL_CA_FILE") or self.haltsql_ca_file
310
+ self.haltsql_client_cert = get("HALTSQL_CLIENT_CERT") or self.haltsql_client_cert
311
+ self.haltsql_client_key = get("HALTSQL_CLIENT_KEY") or self.haltsql_client_key
312
+ self.local_policy_file = get("HALTSQL_MCP_LOCAL_POLICY") or self.local_policy_file
313
+ self.haltsql_bin = get("HALTSQL_BIN") or self.haltsql_bin
314
+ self.database_url = get("HALTSQL_MCP_DATABASE_URL") or get("DATABASE_URL") or self.database_url
315
+ self.row_cap = _env_int(e, "HALTSQL_MCP_ROW_CAP", self.row_cap)
316
+ self.statement_timeout_ms = _env_int(e, "HALTSQL_MCP_STATEMENT_TIMEOUT_MS", self.statement_timeout_ms)
317
+ if get("HALTSQL_MCP_SET_CONFIG"):
318
+ self.set_config = {
319
+ str(k): v for k, v in _json_obj(e["HALTSQL_MCP_SET_CONFIG"], "HALTSQL_MCP_SET_CONFIG").items()
320
+ }
321
+ self.policy_file = get("HALTSQL_MCP_POLICY_FILE") or self.policy_file
322
+ if get("HALTSQL_MCP_CONTEXT"):
323
+ self.context = _json_obj(e["HALTSQL_MCP_CONTEXT"], "HALTSQL_MCP_CONTEXT")
324
+ self.tokens_file = get("HALTSQL_MCP_TOKENS_FILE") or self.tokens_file
325
+ self.transport = get("HALTSQL_MCP_TRANSPORT") or self.transport
326
+ self.host = get("HALTSQL_MCP_HOST") or self.host
327
+ self.port = _env_int(e, "HALTSQL_MCP_PORT", self.port)
328
+ self.public_url = get("HALTSQL_MCP_PUBLIC_URL") or self.public_url
329
+
330
+ def apply_file(self, path: str) -> None:
331
+ """Load a config file (YAML or JSON). See ``load_config_file``."""
332
+ for attr, value in load_config_file(path).items():
333
+ setattr(self, attr, value)
334
+
335
+ def load_context_file(self, path: str) -> None:
336
+ try:
337
+ with open(path, encoding="utf-8") as f:
338
+ self.context = _json_obj(f.read(), f"context file {path}")
339
+ except OSError as e:
340
+ raise ConfigError(f"cannot read context file {path}: {e.strerror}") from None
341
+
342
+ def validate(self) -> None:
343
+ """Check every setting; raise :class:`ConfigError` on the first problem."""
344
+ if self.transport not in ("stdio", "http"):
345
+ raise ConfigError("transport must be 'stdio' or 'http'")
346
+ if not (1 <= self.row_cap <= MAX_ROW_CAP):
347
+ raise ConfigError(f"row cap must be between 1 and {MAX_ROW_CAP}")
348
+ if not (1 <= self.statement_timeout_ms <= 600_000):
349
+ raise ConfigError("statement timeout must be between 1 and 600000 ms")
350
+ if self.haltsql_timeout <= 0:
351
+ raise ConfigError("HALTSQL_TIMEOUT must be > 0")
352
+ for setting, key in self.set_config.items():
353
+ if not _SETTING_RE.match(setting):
354
+ raise ConfigError(
355
+ f"set_config setting {setting!r} must be a custom setting like app.tenant_id"
356
+ )
357
+ if not isinstance(key, str) or not key:
358
+ raise ConfigError(f"set_config[{setting!r}] must name a context key")
359
+ try:
360
+ self.context = validate_context(self.context)
361
+ except ValueError as e:
362
+ raise ConfigError(f"context: {e}") from None
363
+ if self.haltsql_client_key and not self.haltsql_client_cert:
364
+ raise ConfigError("HALTSQL_CLIENT_KEY needs HALTSQL_CLIENT_CERT")
365
+ for f in (
366
+ self.local_policy_file,
367
+ self.policy_file,
368
+ self.tokens_file,
369
+ self.haltsql_ca_file,
370
+ self.haltsql_client_cert,
371
+ self.haltsql_client_key,
372
+ ):
373
+ if f is not None and not os.path.isfile(f):
374
+ raise ConfigError(f"file not found: {f}")
375
+ if self.transport == "http":
376
+ if self.tokens_file is None and not self.insecure_static_context:
377
+ raise ConfigError(
378
+ "HTTP transport needs a tokens file (HALTSQL_MCP_TOKENS_FILE / --tokens-file) so "
379
+ "each client is authenticated and mapped to its tenant context; "
380
+ "--insecure-static-context exists for local development only"
381
+ )
382
+ if self.insecure_static_context and self.tokens_file is None:
383
+ try:
384
+ loopback = ipaddress.ip_address(self.host).is_loopback
385
+ except ValueError:
386
+ loopback = self.host == "localhost"
387
+ if not loopback:
388
+ raise ConfigError("--insecure-static-context is only allowed on a loopback host")
389
+ if not (1 <= self.port <= 65535):
390
+ raise ConfigError("port must be between 1 and 65535")
391
+ if self.tokens_file is not None and self.transport == "stdio":
392
+ raise ConfigError("a tokens file only applies to the HTTP transport")
393
+
394
+ @property
395
+ def describe_file(self) -> Optional[str]:
396
+ """The policy document used for describe_policy / list_tables."""
397
+ return self.policy_file or self.local_policy_file