haltsql-mcp 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- haltsql_mcp-0.0.1/PKG-INFO +62 -0
- haltsql_mcp-0.0.1/README.md +45 -0
- haltsql_mcp-0.0.1/pyproject.toml +37 -0
- haltsql_mcp-0.0.1/setup.cfg +4 -0
- haltsql_mcp-0.0.1/src/haltsql_mcp/__init__.py +24 -0
- haltsql_mcp-0.0.1/src/haltsql_mcp/__main__.py +5 -0
- haltsql_mcp-0.0.1/src/haltsql_mcp/config.py +397 -0
- haltsql_mcp-0.0.1/src/haltsql_mcp/core.py +354 -0
- haltsql_mcp-0.0.1/src/haltsql_mcp/py.typed +0 -0
- haltsql_mcp-0.0.1/src/haltsql_mcp/server.py +318 -0
- haltsql_mcp-0.0.1/src/haltsql_mcp.egg-info/PKG-INFO +62 -0
- haltsql_mcp-0.0.1/src/haltsql_mcp.egg-info/SOURCES.txt +16 -0
- haltsql_mcp-0.0.1/src/haltsql_mcp.egg-info/dependency_links.txt +1 -0
- haltsql_mcp-0.0.1/src/haltsql_mcp.egg-info/entry_points.txt +2 -0
- haltsql_mcp-0.0.1/src/haltsql_mcp.egg-info/requires.txt +11 -0
- haltsql_mcp-0.0.1/src/haltsql_mcp.egg-info/top_level.txt +1 -0
- haltsql_mcp-0.0.1/tests/test_core.py +164 -0
- haltsql_mcp-0.0.1/tests/test_server.py +400 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: haltsql-mcp
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: MCP server that runs LLM-written SQL on PostgreSQL only after HaltSQL allows it
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: haltsql>=0.0.1
|
|
9
|
+
Requires-Dist: mcp>=1.10
|
|
10
|
+
Requires-Dist: psycopg[binary]>=3.2
|
|
11
|
+
Provides-Extra: yaml
|
|
12
|
+
Requires-Dist: PyYAML>=6; extra == "yaml"
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "test"
|
|
16
|
+
Requires-Dist: uvicorn>=0.30; extra == "test"
|
|
17
|
+
|
|
18
|
+
# haltsql-mcp
|
|
19
|
+
|
|
20
|
+
The safe drop-in replacement for `@modelcontextprotocol/server-postgres`.
|
|
21
|
+
Same `query(sql)` tool, same positional connection string, plus column, tenant
|
|
22
|
+
and function guardrails, a row cap and a statement timeout.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
haltsql-mcp --local-policy policy.yaml postgresql://agent_ro@localhost/mydb
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or with Docker (includes the `haltsql` CLI; no central server needed):
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
docker run -i --rm --read-only -v "$PWD:/config:ro" ghcr.io/haltsql/haltsql-mcp \
|
|
32
|
+
postgresql://agent_ro@host.docker.internal/mydb # /config/policy.yaml is picked up
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Build it with `docker build -f clients/mcp/Dockerfile .` from the repository
|
|
36
|
+
root, and see `scripts/mcp-docker-smoke.sh` for the end-to-end test.
|
|
37
|
+
|
|
38
|
+
An MCP server that lets an agent query PostgreSQL only through
|
|
39
|
+
[HaltSQL](https://github.com/HaltSQL/HaltSQL): every SQL string is verified
|
|
40
|
+
against your policy, then exactly that string runs with server-bound
|
|
41
|
+
parameters in a read-only transaction (row cap, `statement_timeout`).
|
|
42
|
+
|
|
43
|
+
Tools: `query(sql)`, `explain_denial(sql)`, `list_tables()`, `describe_policy()`.
|
|
44
|
+
The tenant comes from server configuration (stdio) or the authenticated
|
|
45
|
+
bearer token (streamable HTTP), never from tool arguments.
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
pip install ../python . # Python 3.10+
|
|
49
|
+
HALTSQL_URL=http://127.0.0.1:8080 HALTSQL_TOKEN=... \
|
|
50
|
+
HALTSQL_MCP_DATABASE_URL=postgresql://agent_ro@db/app \
|
|
51
|
+
HALTSQL_MCP_CONTEXT='{"tenant_id":"acme"}' haltsql-mcp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
See [docs/mcp.md](../../docs/mcp.md) for configuration, the tokens file, and
|
|
55
|
+
examples. `haltsql-mcp --help` lists every flag.
|
|
56
|
+
|
|
57
|
+
## Tests
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
python3 -m venv .venv && .venv/bin/pip install -e ../python -e '.[test,yaml]'
|
|
61
|
+
.venv/bin/pytest -q
|
|
62
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# haltsql-mcp
|
|
2
|
+
|
|
3
|
+
The safe drop-in replacement for `@modelcontextprotocol/server-postgres`.
|
|
4
|
+
Same `query(sql)` tool, same positional connection string, plus column, tenant
|
|
5
|
+
and function guardrails, a row cap and a statement timeout.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
haltsql-mcp --local-policy policy.yaml postgresql://agent_ro@localhost/mydb
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or with Docker (includes the `haltsql` CLI; no central server needed):
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
docker run -i --rm --read-only -v "$PWD:/config:ro" ghcr.io/haltsql/haltsql-mcp \
|
|
15
|
+
postgresql://agent_ro@host.docker.internal/mydb # /config/policy.yaml is picked up
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Build it with `docker build -f clients/mcp/Dockerfile .` from the repository
|
|
19
|
+
root, and see `scripts/mcp-docker-smoke.sh` for the end-to-end test.
|
|
20
|
+
|
|
21
|
+
An MCP server that lets an agent query PostgreSQL only through
|
|
22
|
+
[HaltSQL](https://github.com/HaltSQL/HaltSQL): every SQL string is verified
|
|
23
|
+
against your policy, then exactly that string runs with server-bound
|
|
24
|
+
parameters in a read-only transaction (row cap, `statement_timeout`).
|
|
25
|
+
|
|
26
|
+
Tools: `query(sql)`, `explain_denial(sql)`, `list_tables()`, `describe_policy()`.
|
|
27
|
+
The tenant comes from server configuration (stdio) or the authenticated
|
|
28
|
+
bearer token (streamable HTTP), never from tool arguments.
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install ../python . # Python 3.10+
|
|
32
|
+
HALTSQL_URL=http://127.0.0.1:8080 HALTSQL_TOKEN=... \
|
|
33
|
+
HALTSQL_MCP_DATABASE_URL=postgresql://agent_ro@db/app \
|
|
34
|
+
HALTSQL_MCP_CONTEXT='{"tenant_id":"acme"}' haltsql-mcp
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
See [docs/mcp.md](../../docs/mcp.md) for configuration, the tokens file, and
|
|
38
|
+
examples. `haltsql-mcp --help` lists every flag.
|
|
39
|
+
|
|
40
|
+
## Tests
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
python3 -m venv .venv && .venv/bin/pip install -e ../python -e '.[test,yaml]'
|
|
44
|
+
.venv/bin/pytest -q
|
|
45
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "haltsql-mcp"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "MCP server that runs LLM-written SQL on PostgreSQL only after HaltSQL allows it"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"haltsql>=0.0.1",
|
|
14
|
+
"mcp>=1.10",
|
|
15
|
+
"psycopg[binary]>=3.2",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
yaml = ["PyYAML>=6"]
|
|
20
|
+
test = ["pytest>=7", "pytest-asyncio>=0.21", "uvicorn>=0.30"]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
haltsql-mcp = "haltsql_mcp.server:main"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.package-data]
|
|
29
|
+
haltsql_mcp = ["py.typed"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
asyncio_mode = "auto"
|
|
34
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
35
|
+
markers = [
|
|
36
|
+
"integration: needs the lab Postgres or haltsql binaries (skipped when unavailable)",
|
|
37
|
+
]
|
|
@@ -0,0 +1,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,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
|