haltsql-mcp 0.0.1__py3-none-any.whl

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
+ """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())
haltsql_mcp/config.py ADDED
@@ -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
haltsql_mcp/core.py ADDED
@@ -0,0 +1,354 @@
1
+ """Tool logic for haltsql-mcp, independent of the MCP SDK (easy to test).
2
+
3
+ Invariants:
4
+
5
+ * The tenant context comes from :attr:`HaltSQLTools.context_provider`
6
+ (server configuration or the authenticated session). No tool takes it as an
7
+ argument, so a prompt-injected model cannot choose a tenant.
8
+ * ``query`` executes only through :func:`haltsql.guard.execute_async`: verify,
9
+ then run exactly the verified string with the bound parameters, in a
10
+ ``READ ONLY`` transaction with ``statement_timeout`` and a row cap.
11
+ * Any verification failure means nothing runs.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import datetime as _dt
18
+ import decimal
19
+ import ipaddress
20
+ import json
21
+ import os
22
+ import subprocess
23
+ import uuid
24
+ from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
25
+
26
+ from haltsql import (
27
+ GuardError,
28
+ HaltSQLRequestError,
29
+ HaltSQLUnavailable,
30
+ QueryDenied,
31
+ Verdict,
32
+ guard,
33
+ )
34
+
35
+ __all__ = ["ToolFailure", "HaltSQLTools", "jsonable", "load_policy_document", "summarize_policy"]
36
+
37
+ MAX_SQL_CHARS = 100_000
38
+ MAX_CELL_CHARS = 10_000
39
+
40
+
41
+ class ToolFailure(Exception):
42
+ """A tool error whose message is safe to show to the model."""
43
+
44
+
45
+ ContextProvider = Callable[[], Mapping[str, Any]]
46
+ Connect = Callable[[], Awaitable[Any]]
47
+
48
+
49
+ def jsonable(value: Any) -> Any:
50
+ """Convert a database value into JSON-safe data."""
51
+ if value is None or isinstance(value, (bool, int, str)):
52
+ if isinstance(value, str) and len(value) > MAX_CELL_CHARS:
53
+ return value[:MAX_CELL_CHARS] + "...[truncated]"
54
+ return value
55
+ if isinstance(value, float):
56
+ return value if value == value and value not in (float("inf"), float("-inf")) else str(value)
57
+ if isinstance(value, decimal.Decimal):
58
+ return str(value)
59
+ if isinstance(value, (_dt.datetime, _dt.date, _dt.time)):
60
+ return value.isoformat()
61
+ if isinstance(value, _dt.timedelta):
62
+ return value.total_seconds()
63
+ if isinstance(value, (uuid.UUID, ipaddress.IPv4Address, ipaddress.IPv6Address,
64
+ ipaddress.IPv4Network, ipaddress.IPv6Network,
65
+ ipaddress.IPv4Interface, ipaddress.IPv6Interface)):
66
+ return str(value)
67
+ if isinstance(value, (bytes, bytearray, memoryview)):
68
+ return {"base64": base64.b64encode(bytes(value)[:MAX_CELL_CHARS]).decode()}
69
+ if isinstance(value, Mapping):
70
+ return {str(k): jsonable(v) for k, v in value.items()}
71
+ if isinstance(value, (list, tuple, set, frozenset)):
72
+ return [jsonable(v) for v in value]
73
+ return str(value)
74
+
75
+
76
+ def _reasons(verdict: Verdict) -> List[Dict[str, Any]]:
77
+ return [r.to_dict() for r in verdict.reasons]
78
+
79
+
80
+ def load_policy_document(path: str, haltsql_bin: Optional[str] = None) -> Dict[str, Any]:
81
+ """Read a policy (JSON, or YAML via PyYAML or ``haltsql convert``)."""
82
+ with open(path, "rb") as f:
83
+ raw = f.read()
84
+ if path.endswith(".json"):
85
+ data = json.loads(raw)
86
+ else:
87
+ try:
88
+ import yaml # type: ignore[import-untyped]
89
+
90
+ data = yaml.safe_load(raw)
91
+ except ImportError:
92
+ binary = haltsql_bin or os.environ.get("HALTSQL_BIN") or "haltsql"
93
+ proc = subprocess.run(
94
+ [binary, "convert", path, "--to", "json"],
95
+ capture_output=True, timeout=10, check=False,
96
+ )
97
+ if proc.returncode != 0:
98
+ raise ToolFailure("policy file is YAML; install PyYAML or the haltsql CLI to read it")
99
+ data = json.loads(proc.stdout)
100
+ if not isinstance(data, dict):
101
+ raise ToolFailure("policy file is not a mapping")
102
+ return data
103
+
104
+
105
+ def summarize_policy(doc: Mapping[str, Any]) -> Dict[str, Any]:
106
+ """The parts of a policy an agent needs to write accepted SQL.
107
+
108
+ Tenancy key *names* are included, never context values.
109
+ """
110
+ meta = doc.get("metadata") or {}
111
+ tenancy = doc.get("tenancy") or {}
112
+ keys = tenancy.get("keys") or {}
113
+ tables_out: List[Dict[str, Any]] = []
114
+ for name, spec in sorted((doc.get("tables") or {}).items()):
115
+ spec = spec or {}
116
+ entry: Dict[str, Any] = {"table": name, "columns": spec.get("columns", [])}
117
+ if "tenant_column" in spec:
118
+ entry["tenant_columns"] = {spec["tenant_column"]: next(iter(keys), "tenant")}
119
+ elif "tenant_columns" in spec:
120
+ entry["tenant_columns"] = dict(spec["tenant_columns"])
121
+ entry["tenant_scoped"] = "tenant_columns" in entry
122
+ if spec.get("writes"):
123
+ entry["writes"] = spec["writes"]
124
+ tables_out.append(entry)
125
+ out: Dict[str, Any] = {
126
+ "name": meta.get("name"),
127
+ "description": meta.get("description"),
128
+ "search_path": doc.get("search_path", []),
129
+ "statements": (doc.get("statements") or {}).get("allow", ["select"]),
130
+ "require_limit": doc.get("require_limit"),
131
+ "mode": doc.get("mode", "enforce"),
132
+ "tenancy": {"mode": tenancy.get("mode"), "keys": sorted(keys)} if tenancy else None,
133
+ "tables": tables_out,
134
+ "functions": doc.get("functions"),
135
+ }
136
+ return out
137
+
138
+
139
+ def _tenancy_guidance(summary: Optional[Mapping[str, Any]]) -> str:
140
+ base = (
141
+ "Only tables and columns listed here are readable. Every tenant-scoped table, in every "
142
+ "subquery, CTE and UNION branch, needs its own predicate on its tenant column."
143
+ )
144
+ ten = (summary or {}).get("tenancy") or {}
145
+ if ten.get("mode") == "bound":
146
+ return base + (" Write the predicate as alias.tenant_column = $1 (one $n per tenancy key, "
147
+ "or = ANY($n) for tenant sets); the server binds the values. Never write "
148
+ "tenant IDs into the SQL.")
149
+ if ten.get("mode") == "literal":
150
+ return base + " Write the predicate with the caller's tenant value as a literal."
151
+ return base
152
+
153
+
154
+ class HaltSQLTools:
155
+ """Implements the four tools. ``verifier`` may be sync or async.
156
+
157
+ Args:
158
+ verifier: :class:`haltsql.AsyncHaltSQLClient`, :class:`haltsql.AsyncLocalVerifier`, ...
159
+ context_provider: Returns the caller's context; raises
160
+ :class:`PermissionError` when the session is not authenticated.
161
+ connect: ``async () -> connection`` (psycopg ``AsyncConnection`` or
162
+ asyncpg); ``None`` disables ``query``.
163
+ row_cap / statement_timeout_ms / set_config: execution limits.
164
+ policy_file: Policy document for ``describe_policy`` / ``list_tables``.
165
+ policies_lister: ``async () -> list[PolicyInfo]`` (``/v1/policies``).
166
+ """
167
+
168
+ def __init__(
169
+ self,
170
+ verifier: Any,
171
+ context_provider: ContextProvider,
172
+ *,
173
+ connect: Optional[Connect] = None,
174
+ row_cap: int = 200,
175
+ statement_timeout_ms: int = 5000,
176
+ set_config: Optional[Mapping[str, str]] = None,
177
+ policy_file: Optional[str] = None,
178
+ policies_lister: Optional[Callable[[], Awaitable[Any]]] = None,
179
+ haltsql_bin: Optional[str] = None,
180
+ ) -> None:
181
+ self.verifier = verifier
182
+ self.context_provider = context_provider
183
+ self.connect = connect
184
+ self.row_cap = row_cap
185
+ self.statement_timeout_ms = statement_timeout_ms
186
+ self.set_config = dict(set_config or {})
187
+ self.policy_file = policy_file
188
+ self.policies_lister = policies_lister
189
+ self.haltsql_bin = haltsql_bin
190
+ self._summary: Optional[Dict[str, Any]] = None
191
+
192
+ # -- helpers -----------------------------------------------------------
193
+ def _context(self) -> Dict[str, Any]:
194
+ try:
195
+ ctx = self.context_provider()
196
+ except PermissionError as e:
197
+ raise ToolFailure(str(e) or "not authenticated") from None
198
+ return dict(ctx)
199
+
200
+ @staticmethod
201
+ def _check_sql(sql: Any) -> str:
202
+ if not isinstance(sql, str) or not sql.strip():
203
+ raise ToolFailure("sql must be a non-empty string")
204
+ if len(sql) > MAX_SQL_CHARS:
205
+ raise ToolFailure(f"sql is longer than {MAX_SQL_CHARS} characters")
206
+ return sql
207
+
208
+ async def _verify(self, sql: str, ctx: Mapping[str, Any]) -> Verdict:
209
+ try:
210
+ result = self.verifier.verify(sql, ctx)
211
+ if hasattr(result, "__await__"):
212
+ result = await result
213
+ except HaltSQLRequestError as e:
214
+ raise ToolFailure(f"verification refused ({e.code}); the query was not executed") from None
215
+ except HaltSQLUnavailable:
216
+ raise ToolFailure("verification is unavailable; the query was not executed") from None
217
+ except Exception: # noqa: BLE001 - any other failure also means deny
218
+ raise ToolFailure("verification failed; the query was not executed") from None
219
+ if not isinstance(result, Verdict) or not result.matches_sql(sql):
220
+ raise ToolFailure("verification returned an invalid verdict; the query was not executed")
221
+ return result
222
+
223
+ def _settings(self, ctx: Mapping[str, Any]) -> Dict[str, str]:
224
+ out: Dict[str, str] = {}
225
+ for setting, key in self.set_config.items():
226
+ if key not in ctx:
227
+ raise ToolFailure(f"server misconfigured: context has no {key!r} for {setting}")
228
+ v = ctx[key]
229
+ out[setting] = ",".join(str(x) for x in v) if isinstance(v, list) else str(v)
230
+ return out
231
+
232
+ def _policy_summary(self) -> Optional[Dict[str, Any]]:
233
+ if self.policy_file is None:
234
+ return None
235
+ if self._summary is None:
236
+ try:
237
+ self._summary = summarize_policy(load_policy_document(self.policy_file, self.haltsql_bin))
238
+ except ToolFailure:
239
+ raise
240
+ except Exception as e: # noqa: BLE001
241
+ raise ToolFailure(f"could not read the policy file: {type(e).__name__}") from None
242
+ return self._summary
243
+
244
+ # -- tools -------------------------------------------------------------
245
+ async def query(self, sql: str) -> Dict[str, Any]:
246
+ """Verify, then run the SQL read-only and return rows (or the denial)."""
247
+ sql = self._check_sql(sql)
248
+ if self.connect is None:
249
+ raise ToolFailure("no database is configured for this server")
250
+ ctx = self._context()
251
+ settings = self._settings(ctx)
252
+ verdict = await self._verify(sql, ctx)
253
+ if not verdict.allowed:
254
+ return {
255
+ "allowed": False,
256
+ "executed": False,
257
+ "reasons": _reasons(verdict),
258
+ "message": "HaltSQL denied the query. Fix it using the reasons and hints, then retry.",
259
+ }
260
+ try:
261
+ conn = await self.connect()
262
+ except Exception as e: # noqa: BLE001
263
+ raise ToolFailure(f"database connection failed: {type(e).__name__}") from None
264
+ try:
265
+ # The guard re-checks the verdict against this exact string.
266
+ result = await guard.execute_async(
267
+ conn, sql, ctx,
268
+ verdict=verdict,
269
+ read_only=True,
270
+ statement_timeout_ms=self.statement_timeout_ms,
271
+ settings=settings,
272
+ max_rows=self.row_cap,
273
+ )
274
+ except QueryDenied as e: # pragma: no cover - verdict already checked
275
+ return {"allowed": False, "executed": False, "reasons": _reasons(e.verdict)}
276
+ except (GuardError, HaltSQLUnavailable) as e:
277
+ raise ToolFailure(f"query not executed: {e}") from None
278
+ except Exception as e: # noqa: BLE001 - database error
279
+ raise ToolFailure(f"database error: {_db_error(e)}") from None
280
+ finally:
281
+ close = getattr(conn, "close", None)
282
+ if close is not None:
283
+ r = close()
284
+ if hasattr(r, "__await__"):
285
+ await r
286
+ return {
287
+ "allowed": True,
288
+ "executed": True,
289
+ "columns": result.columns,
290
+ "rows": [jsonable(list(r) if isinstance(r, tuple) else r) for r in result.rows],
291
+ "row_count": len(result.rows),
292
+ "truncated": result.truncated,
293
+ "row_cap": self.row_cap,
294
+ "shadow_reasons": [r.to_dict() for r in verdict.shadow_reasons],
295
+ }
296
+
297
+ async def explain_denial(self, sql: str) -> Dict[str, Any]:
298
+ """Verify only; explain why the SQL would be denied (never executes)."""
299
+ sql = self._check_sql(sql)
300
+ ctx = self._context()
301
+ verdict = await self._verify(sql, ctx)
302
+ out: Dict[str, Any] = {
303
+ "allowed": verdict.allowed,
304
+ "reasons": _reasons(verdict),
305
+ "shadow_reasons": [r.to_dict() for r in verdict.shadow_reasons],
306
+ "statement_kind": verdict.statement_kind,
307
+ "tables": list(verdict.tables),
308
+ "columns": list(verdict.columns),
309
+ "policy": {"name": verdict.policy.name, "version": verdict.policy.version},
310
+ }
311
+ if verdict.allowed:
312
+ out["message"] = "This query is allowed; run it with the query tool."
313
+ return out
314
+
315
+ async def list_tables(self) -> Dict[str, Any]:
316
+ """Tables and columns the policy allows."""
317
+ self._context() # require an authenticated session for schema info too
318
+ summary = self._policy_summary()
319
+ if summary is None:
320
+ raise ToolFailure("no policy file is configured, so the table list is unavailable")
321
+ return {
322
+ "tables": [
323
+ {k: t[k] for k in ("table", "columns", "tenant_scoped", "tenant_columns") if k in t}
324
+ for t in summary["tables"]
325
+ ],
326
+ "guidance": _tenancy_guidance(summary),
327
+ }
328
+
329
+ async def describe_policy(self) -> Dict[str, Any]:
330
+ """Policy name/version/mode (from /v1/policies) and its rules (from the policy file)."""
331
+ self._context()
332
+ out: Dict[str, Any] = {}
333
+ if self.policies_lister is not None:
334
+ try:
335
+ infos = await self.policies_lister()
336
+ out["policies"] = [{"name": p.name, "version": p.version, "mode": p.mode} for p in infos]
337
+ except (HaltSQLUnavailable, HaltSQLRequestError):
338
+ out["policies_error"] = "could not list policies from the HaltSQL sidecar"
339
+ summary = self._policy_summary()
340
+ if summary is not None:
341
+ out["policy"] = summary
342
+ out["guidance"] = _tenancy_guidance(summary)
343
+ if not out:
344
+ raise ToolFailure("no policy information is configured")
345
+ return out
346
+
347
+
348
+ def _db_error(e: BaseException) -> str:
349
+ sqlstate = getattr(e, "sqlstate", None)
350
+ diag = getattr(e, "diag", None)
351
+ primary = getattr(diag, "message_primary", None) if diag is not None else None
352
+ text = str(e).strip()
353
+ msg = primary or (text.splitlines()[0] if text else type(e).__name__)
354
+ return f"{sqlstate} {msg}" if sqlstate else msg
haltsql_mcp/py.typed ADDED
File without changes
haltsql_mcp/server.py ADDED
@@ -0,0 +1,318 @@
1
+ """The haltsql-mcp server: MCP tools over stdio or streamable HTTP.
2
+
3
+ Works with the official ``mcp`` SDK 2.x (``MCPServer``) and 1.x (``FastMCP``).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import logging
10
+ import sys
11
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
12
+
13
+ from haltsql import AsyncHaltSQLClient, AsyncLocalVerifier, RetryPolicy
14
+
15
+ from .config import ConfigError, Settings, TokenEntry, hash_token, load_tokens_file
16
+ from .core import HaltSQLTools, ToolFailure
17
+
18
+ __all__ = ["StaticTokenVerifier", "build_tools", "build_server", "main", "INSTRUCTIONS"]
19
+
20
+ log = logging.getLogger("haltsql_mcp")
21
+
22
+ INSTRUCTIONS = (
23
+ "SQL access to a PostgreSQL database guarded by HaltSQL. Call describe_policy or "
24
+ "list_tables first, write one SELECT with a LIMIT, and run it with query. If the query "
25
+ "is denied, read the reason codes and hints (or call explain_denial), fix the SQL, and "
26
+ "retry. The tenant is fixed by the server; never try to choose or change it."
27
+ )
28
+
29
+
30
+ def _sdk() -> Tuple[Any, Any, int]:
31
+ """(ServerClass, ToolError, major_version) for the installed mcp SDK."""
32
+ try:
33
+ from mcp.server.mcpserver import MCPServer
34
+ from mcp.server.mcpserver.exceptions import ToolError
35
+
36
+ return MCPServer, ToolError, 2
37
+ except ImportError: # pragma: no cover - mcp 1.x
38
+ from mcp.server.fastmcp import FastMCP # type: ignore[attr-defined, import-not-found, no-redef]
39
+ from mcp.server.fastmcp.exceptions import ToolError # type: ignore[import-not-found, no-redef]
40
+
41
+ return FastMCP, ToolError, 1
42
+
43
+
44
+ def _get_access_token() -> Any:
45
+ from mcp.server.auth.middleware.auth_context import get_access_token
46
+
47
+ return get_access_token()
48
+
49
+
50
+ class StaticTokenVerifier:
51
+ """MCP ``TokenVerifier`` for pre-shared bearer tokens stored as SHA-256.
52
+
53
+ Every entry is compared in constant time; the raw token is never stored
54
+ or logged. The returned ``AccessToken`` carries the entry name as
55
+ ``client_id``, which the context provider maps to that client's context.
56
+ """
57
+
58
+ def __init__(self, entries: List[TokenEntry]) -> None:
59
+ self._entries = list(entries)
60
+ self.by_name: Dict[str, TokenEntry] = {e.name: e for e in entries}
61
+
62
+ async def verify_token(self, token: str) -> Any:
63
+ from mcp.server.auth.provider import AccessToken
64
+
65
+ if not isinstance(token, str) or not token:
66
+ return None
67
+ h = hash_token(token)
68
+ match: Optional[TokenEntry] = None
69
+ for e in self._entries: # no early exit: constant work per entry
70
+ if e.matches(h):
71
+ match = e
72
+ if match is None:
73
+ return None
74
+ # Store the hash, not the token, in the request context.
75
+ return AccessToken(token=f"sha256:{h}", client_id=match.name, scopes=[])
76
+
77
+
78
+ def session_context_provider(verifier: StaticTokenVerifier) -> Callable[[], Mapping[str, Any]]:
79
+ """Context from the authenticated MCP session (HTTP transport)."""
80
+
81
+ def provider() -> Mapping[str, Any]:
82
+ tok = _get_access_token()
83
+ if tok is None:
84
+ raise PermissionError("not authenticated")
85
+ entry = verifier.by_name.get(getattr(tok, "client_id", None) or "")
86
+ if entry is None:
87
+ raise PermissionError("unknown client")
88
+ return dict(entry.context)
89
+
90
+ return provider
91
+
92
+
93
+ def static_context_provider(context: Mapping[str, Any]) -> Callable[[], Mapping[str, Any]]:
94
+ """One fixed context from server configuration (stdio transport)."""
95
+ frozen = dict(context)
96
+ return lambda: dict(frozen)
97
+
98
+
99
+ def build_tools(
100
+ settings: Settings,
101
+ context_provider: Callable[[], Mapping[str, Any]],
102
+ *,
103
+ connect: Optional[Callable[[], Any]] = None,
104
+ ) -> HaltSQLTools:
105
+ """Wire verifier, database, and limits from ``settings``."""
106
+ lister = None
107
+ if settings.local_policy_file:
108
+ verifier: Any = AsyncLocalVerifier(settings.local_policy_file, binary=settings.haltsql_bin)
109
+ else:
110
+ client = AsyncHaltSQLClient(
111
+ settings.haltsql_url,
112
+ settings.haltsql_token,
113
+ timeout=settings.haltsql_timeout,
114
+ policy=settings.haltsql_policy,
115
+ retry=RetryPolicy(),
116
+ verify=settings.haltsql_ca_file or True,
117
+ cert=(
118
+ (settings.haltsql_client_cert, settings.haltsql_client_key)
119
+ if settings.haltsql_client_cert and settings.haltsql_client_key
120
+ else settings.haltsql_client_cert
121
+ ),
122
+ )
123
+ verifier = client
124
+ lister = client.policies
125
+ if connect is None and settings.database_url:
126
+ dsn = settings.database_url
127
+
128
+ async def connect() -> Any: # type: ignore[no-redef]
129
+ import psycopg
130
+
131
+ return await psycopg.AsyncConnection.connect(dsn, autocommit=True)
132
+
133
+ return HaltSQLTools(
134
+ verifier,
135
+ context_provider,
136
+ connect=connect,
137
+ row_cap=settings.row_cap,
138
+ statement_timeout_ms=settings.statement_timeout_ms,
139
+ set_config=settings.set_config,
140
+ policy_file=settings.describe_file,
141
+ policies_lister=lister,
142
+ haltsql_bin=settings.haltsql_bin,
143
+ )
144
+
145
+
146
+ def build_server(
147
+ tools: HaltSQLTools,
148
+ *,
149
+ token_verifier: Optional[StaticTokenVerifier] = None,
150
+ public_url: str = "http://127.0.0.1:8765",
151
+ name: str = "haltsql-mcp",
152
+ ) -> Any:
153
+ """Create the MCP server object and register the four tools."""
154
+ Server, ToolError, major = _sdk()
155
+ kwargs: Dict[str, Any] = {"instructions": INSTRUCTIONS}
156
+ if token_verifier is not None:
157
+ from mcp.server.auth.settings import AuthSettings
158
+
159
+ kwargs["token_verifier"] = token_verifier
160
+ kwargs["auth"] = AuthSettings.model_validate({
161
+ "issuer_url": public_url,
162
+ "resource_server_url": public_url.rstrip("/") + "/mcp",
163
+ "validate_token_resource": False,
164
+ })
165
+ server = Server(name, **kwargs)
166
+
167
+ try:
168
+ from mcp.types import ToolAnnotations
169
+
170
+ ro = ToolAnnotations.model_validate({"readOnlyHint": True, "openWorldHint": False})
171
+ except Exception: # noqa: BLE001 # pragma: no cover
172
+ ro = None
173
+
174
+ def tool(fn_name: str, description: str) -> Callable[[Any], Any]:
175
+ if ro is not None:
176
+ return server.tool(name=fn_name, description=description, annotations=ro)
177
+ return server.tool(name=fn_name, description=description) # pragma: no cover
178
+
179
+ async def _call(coro: Any) -> Dict[str, Any]:
180
+ try:
181
+ return await coro
182
+ except ToolFailure as e:
183
+ raise ToolError(str(e)) from None
184
+
185
+ @tool(
186
+ "query",
187
+ "Run one read-only SQL query on PostgreSQL. HaltSQL verifies it against the policy for "
188
+ "your tenant first; if allowed, the exact SQL runs with server-bound $n parameters and "
189
+ "rows are returned (capped). If denied, the result has allowed=false and reasons with "
190
+ "hints; fix the SQL and retry.",
191
+ )
192
+ async def query(sql: str) -> Dict[str, Any]:
193
+ return await _call(tools.query(sql))
194
+
195
+ @tool(
196
+ "explain_denial",
197
+ "Check SQL against the HaltSQL policy without running it and return every reason code, "
198
+ "message, and hint, plus the tables and columns it touches.",
199
+ )
200
+ async def explain_denial(sql: str) -> Dict[str, Any]:
201
+ return await _call(tools.explain_denial(sql))
202
+
203
+ @tool("list_tables", "List the tables and columns the policy lets you read, and which are tenant-scoped.")
204
+ async def list_tables() -> Dict[str, Any]:
205
+ return await _call(tools.list_tables())
206
+
207
+ @tool(
208
+ "describe_policy",
209
+ "Describe the active HaltSQL policy: name, version, mode, allowed statements, LIMIT "
210
+ "rule, tenancy mode, tables, and functions.",
211
+ )
212
+ async def describe_policy() -> Dict[str, Any]:
213
+ return await _call(tools.describe_policy())
214
+
215
+ server._haltsql_sdk_major = major # noqa: SLF001 - informational
216
+ return server
217
+
218
+
219
+ def _parser() -> argparse.ArgumentParser:
220
+ p = argparse.ArgumentParser(
221
+ prog="haltsql-mcp",
222
+ description="MCP server that runs SQL on PostgreSQL only after HaltSQL allows it.",
223
+ formatter_class=argparse.RawDescriptionHelpFormatter,
224
+ epilog="""\
225
+ EXAMPLES
226
+ # stdio (one user per process); tenant fixed by the launcher:
227
+ HALTSQL_URL=http://127.0.0.1:8080 HALTSQL_TOKEN=... \\
228
+ HALTSQL_MCP_DATABASE_URL=postgresql://agent_ro@db/app \\
229
+ HALTSQL_MCP_CONTEXT='{"tenant_id":"acme"}' haltsql-mcp
230
+
231
+ # streamable HTTP; each bearer token maps to a tenant context:
232
+ haltsql-mcp --transport http --port 8765 --tokens-file tokens.json \\
233
+ --database-url postgresql://agent_ro@db/app --policy-file policy.yaml
234
+
235
+ # without a sidecar, verifying with the local haltsql CLI:
236
+ haltsql-mcp --local-policy policy.yaml --context-file ctx.json --database-url ...
237
+
238
+ # everything in one file (YAML or JSON); env and flags override it:
239
+ haltsql-mcp --config haltsql-mcp.yaml
240
+
241
+ # drop-in for @modelcontextprotocol/server-postgres (DSN as the last argument):
242
+ haltsql-mcp --local-policy policy.yaml postgresql://agent_ro@localhost/app
243
+
244
+ Exit status: 0 on shutdown, 2 on a configuration error.
245
+ See docs/mcp.md for every environment variable.""",
246
+ )
247
+ p.add_argument("--config", help="Config file (YAML or JSON); environment variables and flags override it. "
248
+ "Also HALTSQL_MCP_CONFIG.")
249
+ p.add_argument("--transport", choices=["stdio", "http"])
250
+ p.add_argument("--host")
251
+ p.add_argument("--port", type=int)
252
+ p.add_argument("--public-url", help="External URL of this server (OAuth resource metadata).")
253
+ p.add_argument("--haltsql-url")
254
+ p.add_argument("--policy", dest="haltsql_policy", help="Policy name to request from the sidecar.")
255
+ p.add_argument("--local-policy", help="Verify with the local haltsql CLI and this policy file.")
256
+ p.add_argument("--policy-file", help="Policy document for describe_policy/list_tables.")
257
+ p.add_argument("--database-url")
258
+ p.add_argument("dsn", nargs="?", metavar="DATABASE_URL",
259
+ help="libpq DSN, as a positional argument like @modelcontextprotocol/server-postgres "
260
+ "takes it (same as --database-url).")
261
+ p.add_argument("--row-cap", type=int)
262
+ p.add_argument("--statement-timeout-ms", type=int)
263
+ p.add_argument("--context-file", help="JSON context for stdio (overrides HALTSQL_MCP_CONTEXT).")
264
+ p.add_argument("--tokens-file", help="JSON tokens file for the HTTP transport.")
265
+ p.add_argument("--insecure-static-context", action="store_true",
266
+ help="HTTP without authentication, using the static context (loopback only; dev).")
267
+ return p
268
+
269
+
270
+ def settings_from_args(argv: Optional[List[str]] = None, env: Optional[Mapping[str, str]] = None) -> Settings:
271
+ args = _parser().parse_args(argv)
272
+ s = Settings.from_sources(env, config_file=args.config)
273
+ for attr in ("transport", "host", "port", "public_url", "haltsql_url", "haltsql_policy",
274
+ "local_policy", "policy_file", "database_url", "row_cap",
275
+ "statement_timeout_ms", "tokens_file"):
276
+ val = getattr(args, attr)
277
+ if val is not None:
278
+ setattr(s, "local_policy_file" if attr == "local_policy" else attr, val)
279
+ if args.dsn is not None:
280
+ if args.database_url is not None and args.database_url != args.dsn:
281
+ raise ConfigError("give the database URL once: positionally or with --database-url, not both")
282
+ s.database_url = args.dsn
283
+ if args.context_file:
284
+ s.load_context_file(args.context_file)
285
+ s.insecure_static_context = bool(args.insecure_static_context)
286
+ s.validate()
287
+ return s
288
+
289
+
290
+ def main(argv: Optional[List[str]] = None) -> int:
291
+ logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
292
+ try:
293
+ settings = settings_from_args(argv)
294
+ token_verifier: Optional[StaticTokenVerifier] = None
295
+ if settings.transport == "http" and settings.tokens_file:
296
+ token_verifier = StaticTokenVerifier(load_tokens_file(settings.tokens_file))
297
+ provider = session_context_provider(token_verifier)
298
+ else:
299
+ provider = static_context_provider(settings.context)
300
+ tools = build_tools(settings, provider)
301
+ except ConfigError as e:
302
+ print(f"haltsql-mcp: {e}", file=sys.stderr)
303
+ return 2
304
+ public_url = settings.public_url or f"http://{settings.host}:{settings.port}"
305
+ server = build_server(tools, token_verifier=token_verifier, public_url=public_url)
306
+ if settings.transport == "stdio":
307
+ server.run("stdio")
308
+ elif getattr(server, "_haltsql_sdk_major", 2) >= 2:
309
+ server.run("streamable-http", host=settings.host, port=settings.port)
310
+ else: # pragma: no cover - mcp 1.x
311
+ server.settings.host = settings.host
312
+ server.settings.port = settings.port
313
+ server.run("streamable-http")
314
+ return 0
315
+
316
+
317
+ if __name__ == "__main__": # pragma: no cover
318
+ sys.exit(main())
@@ -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,11 @@
1
+ haltsql_mcp/__init__.py,sha256=9HTku6HjsdL5XNY3NjLszASk9PZMehRiLP0VlefsbuM,616
2
+ haltsql_mcp/__main__.py,sha256=WAhr-TR-SUgzU8lRacTT70n91sBZgxPy6yff-Zyeu3E,55
3
+ haltsql_mcp/config.py,sha256=3tPK-Sl1PC_QOGlnrrexs8qgMWe-lzNRT5tme4EjlXw,16985
4
+ haltsql_mcp/core.py,sha256=PUYPf3u65Ty4-xuXEsxLVxJqj4IsHdDJHfifMjtu7XE,14881
5
+ haltsql_mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ haltsql_mcp/server.py,sha256=gyNO2FG6oQXvrFcx58Soer9Mxo2c_i0Qe_24CuyaEV4,13024
7
+ haltsql_mcp-0.0.1.dist-info/METADATA,sha256=1nLXAOPjW6T3cQly4p5TvMeJ5nElGabaLYSjzXPVWSU,2222
8
+ haltsql_mcp-0.0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ haltsql_mcp-0.0.1.dist-info/entry_points.txt,sha256=IphR83W-nz7V3LmiUKcxZJ2xfc1UO-dQlubXPu0irpU,56
10
+ haltsql_mcp-0.0.1.dist-info/top_level.txt,sha256=nhRefq_5ENZo6lagDAo78GT8VztboguBgevtPktIFys,12
11
+ haltsql_mcp-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ haltsql-mcp = haltsql_mcp.server:main
@@ -0,0 +1 @@
1
+ haltsql_mcp