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.
- haltsql_mcp/__init__.py +24 -0
- haltsql_mcp/__main__.py +5 -0
- haltsql_mcp/config.py +397 -0
- haltsql_mcp/core.py +354 -0
- haltsql_mcp/py.typed +0 -0
- haltsql_mcp/server.py +318 -0
- haltsql_mcp-0.0.1.dist-info/METADATA +62 -0
- haltsql_mcp-0.0.1.dist-info/RECORD +11 -0
- haltsql_mcp-0.0.1.dist-info/WHEEL +5 -0
- haltsql_mcp-0.0.1.dist-info/entry_points.txt +2 -0
- haltsql_mcp-0.0.1.dist-info/top_level.txt +1 -0
haltsql_mcp/__init__.py
ADDED
|
@@ -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
|
+
]
|
haltsql_mcp/__main__.py
ADDED
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 @@
|
|
|
1
|
+
haltsql_mcp
|