erdos-renyi 0.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: erdos-renyi
3
+ Version: 0.0.0
4
+ Summary: MCP connector: run Erdos queries on Reyni-registered EHR exports.
5
+ Author-email: Krv Labs <team@krv.ai>
6
+ License: Apache-2.0
7
+ Requires-Python: <3.15,>=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: fastmcp>=2.0
10
+ Requires-Dist: polars>=1.0.0
11
+ Requires-Dist: sqlglot>=23.0.0
12
+ Requires-Dist: marimo>=0.8.0
13
+ Requires-Dist: pyarrow>=15.0.0
14
+ Requires-Dist: pydantic>=2.0.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.3; extra == "dev"
17
+ Requires-Dist: ruff>=0.9; extra == "dev"
18
+
19
+ # erdos-renyi
20
+
21
+ `erdos-renyi` is an MCP server providing the privacy-preserving handshake between Erdos and Reyni. Clinicians register fine-grained EHR cohort exports with Reyni, and agents rewrite Erdos predictive SQL queries against tokenized schema names (`tok_…`) without ever accessing raw cell values or PHI. Results are staged locally in Arrow IPC format, gated by k-anonymity for agent-visible counts, and rendered in a private localhost Marimo dashboard for clinician review.
22
+
23
+ ## Quickstart
24
+
25
+ ```bash
26
+ # Install dependencies
27
+ uv sync --extra dev
28
+
29
+ # Run test suite
30
+ uv run pytest
31
+
32
+ # Run the MCP server over stdio
33
+ uv run er-mcp
34
+ ```
35
+
36
+ ## Reyni Gateway Setup
37
+
38
+ Add `erdos-renyi` to `~/Library/Application Support/reyni/servers.toml` (Windows: `%APPDATA%\reyni\servers.toml`):
39
+
40
+ ```toml
41
+ [[server]]
42
+ name = "erdos"
43
+ launch = ["uv", "tool", "run", "--python", "3.12", "--from", "erdos-renyi", "er-mcp"]
44
+ phi_access = true
45
+ ```
46
+
47
+ During local development, point `--from` to the local repository path:
48
+
49
+ ```toml
50
+ [[server]]
51
+ name = "erdos"
52
+ launch = ["uv", "tool", "run", "--python", "3.12", "--from", "/path/to/erdos-renyi", "er-mcp"]
53
+ phi_access = true
54
+ ```
55
+
56
+ Agent-visible Erdos tools: `erdos__get_schema`, `erdos__stage_query`. After staging, call `reyni__register_dashboard` (Reyni gateway). `launch_dashboard` is gateway-internal only.
57
+
58
+ ## Agent Workflow
59
+
60
+ 1. **`reyni__list_datasets`**: Obtain the `safe_path` for the tokenized twin export.
61
+ 2. **`erdos__get_schema(safe_path)`**: Inspect column names and data types (no row values or samples).
62
+ 3. **Rewrite SQL**: Adapt the Erdos predictive query to target the local schema. The table name in Polars SQL is always **`cohort`**.
63
+ 4. **`erdos__stage_query(safe_path, sql)`**: Sanitize and execute SQL. If the match count is below the k-anonymity threshold ($k < 5$), the exact count is withheld (`below_threshold`), but the session is staged for the clinician.
64
+ 5. **`reyni__register_dashboard(session_id)`**: Reyni launches the Marimo dashboard internally, reads a clinician-only sidecar (`session_{id}.launch.json`), and stores the URL encrypted — the agent receives only a `dashboard_ref`.
65
+
66
+ ## Security Guarantees
67
+
68
+ - **Zero cell egress**: MCP tools only return column metadata and aggregate match counts.
69
+ - **SQL Sanitization**: Enforces read-only `SELECT` queries, rejects external filesystem functions (`read_csv`, `eval`), and clamps `LIMIT` to 1000 rows.
70
+ - **Localhost Enclave**: Staging files use Arrow IPC; dashboards bind to `127.0.0.1`. Launch URLs are written to mode-`0600` sidecars, not returned over MCP.
@@ -0,0 +1,52 @@
1
+ # erdos-renyi
2
+
3
+ `erdos-renyi` is an MCP server providing the privacy-preserving handshake between Erdos and Reyni. Clinicians register fine-grained EHR cohort exports with Reyni, and agents rewrite Erdos predictive SQL queries against tokenized schema names (`tok_…`) without ever accessing raw cell values or PHI. Results are staged locally in Arrow IPC format, gated by k-anonymity for agent-visible counts, and rendered in a private localhost Marimo dashboard for clinician review.
4
+
5
+ ## Quickstart
6
+
7
+ ```bash
8
+ # Install dependencies
9
+ uv sync --extra dev
10
+
11
+ # Run test suite
12
+ uv run pytest
13
+
14
+ # Run the MCP server over stdio
15
+ uv run er-mcp
16
+ ```
17
+
18
+ ## Reyni Gateway Setup
19
+
20
+ Add `erdos-renyi` to `~/Library/Application Support/reyni/servers.toml` (Windows: `%APPDATA%\reyni\servers.toml`):
21
+
22
+ ```toml
23
+ [[server]]
24
+ name = "erdos"
25
+ launch = ["uv", "tool", "run", "--python", "3.12", "--from", "erdos-renyi", "er-mcp"]
26
+ phi_access = true
27
+ ```
28
+
29
+ During local development, point `--from` to the local repository path:
30
+
31
+ ```toml
32
+ [[server]]
33
+ name = "erdos"
34
+ launch = ["uv", "tool", "run", "--python", "3.12", "--from", "/path/to/erdos-renyi", "er-mcp"]
35
+ phi_access = true
36
+ ```
37
+
38
+ Agent-visible Erdos tools: `erdos__get_schema`, `erdos__stage_query`. After staging, call `reyni__register_dashboard` (Reyni gateway). `launch_dashboard` is gateway-internal only.
39
+
40
+ ## Agent Workflow
41
+
42
+ 1. **`reyni__list_datasets`**: Obtain the `safe_path` for the tokenized twin export.
43
+ 2. **`erdos__get_schema(safe_path)`**: Inspect column names and data types (no row values or samples).
44
+ 3. **Rewrite SQL**: Adapt the Erdos predictive query to target the local schema. The table name in Polars SQL is always **`cohort`**.
45
+ 4. **`erdos__stage_query(safe_path, sql)`**: Sanitize and execute SQL. If the match count is below the k-anonymity threshold ($k < 5$), the exact count is withheld (`below_threshold`), but the session is staged for the clinician.
46
+ 5. **`reyni__register_dashboard(session_id)`**: Reyni launches the Marimo dashboard internally, reads a clinician-only sidecar (`session_{id}.launch.json`), and stores the URL encrypted — the agent receives only a `dashboard_ref`.
47
+
48
+ ## Security Guarantees
49
+
50
+ - **Zero cell egress**: MCP tools only return column metadata and aggregate match counts.
51
+ - **SQL Sanitization**: Enforces read-only `SELECT` queries, rejects external filesystem functions (`read_csv`, `eval`), and clamps `LIMIT` to 1000 rows.
52
+ - **Localhost Enclave**: Staging files use Arrow IPC; dashboards bind to `127.0.0.1`. Launch URLs are written to mode-`0600` sidecars, not returned over MCP.
@@ -0,0 +1,76 @@
1
+ import os
2
+ import sys
3
+ import tempfile
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import marimo
8
+ import polars as pl
9
+
10
+ __generated_with = "0.24.0"
11
+ app = marimo.App(width="full", app_title="Erdos-Renyi Cohort Review")
12
+
13
+ SCORE_CANDIDATES = ("score", "rank", "fit", "probability", "prob")
14
+
15
+
16
+ def _extract_session_id() -> str | None:
17
+ for arg in sys.argv:
18
+ if arg.startswith("--session="):
19
+ return arg.split("=", 1)[1]
20
+ return None
21
+
22
+
23
+ def _find_sort_col(columns: list[str]) -> str | None:
24
+ for col in columns:
25
+ if any(c in col.lower() for c in SCORE_CANDIDATES):
26
+ return col
27
+ return None
28
+
29
+
30
+ def _load_and_render(session_id: str, staging_dir: Path, mo: Any) -> Any:
31
+ ipc_path = staging_dir / f"session_{session_id}.arrow"
32
+ if not ipc_path.is_file():
33
+ return mo.md(f"### ⚠️ Session `{session_id}` data file not found or expired.")
34
+ try:
35
+ df = pl.read_ipc(ipc_path)
36
+ matched = _find_sort_col(df.columns)
37
+ if matched:
38
+ df = df.sort(matched, descending=True)
39
+ sort_info = (
40
+ f"- **Sorted by:** `{matched}` (descending)"
41
+ if matched
42
+ else "- **Sorted by:** (original order)"
43
+ )
44
+ header = mo.md(
45
+ f"""
46
+ # 🔬 Erdos-Renyi Cohort Review
47
+ - **Session ID:** `{session_id}`
48
+ - **Matched Records:** `{len(df)}`
49
+ {sort_info}
50
+ """
51
+ )
52
+ return mo.vstack([header, mo.ui.table(df, selection=None)])
53
+ except Exception as e:
54
+ return mo.md(f"### ⚠️ Failed to read session dataset: {e}")
55
+
56
+
57
+ @app.cell
58
+ def _():
59
+ import marimo as mo
60
+
61
+ session_id = _extract_session_id()
62
+ if not session_id:
63
+ output = mo.md("### ⚠️ No session ID provided to dashboard.")
64
+ else:
65
+ staging_dir = Path(
66
+ os.getenv("ERDOS_RENYI_STAGING_DIR")
67
+ or Path(tempfile.gettempdir()) / "erdos-renyi-sessions"
68
+ )
69
+ output = _load_and_render(session_id, staging_dir, mo)
70
+
71
+ output
72
+ return
73
+
74
+
75
+ if __name__ == "__main__":
76
+ app.run()
@@ -0,0 +1,3 @@
1
+ """erdos-renyi: MCP connector to run Erdos queries on Reyni-registered EHR exports."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,40 @@
1
+ """Configuration and settings for erdos-renyi."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class Settings(BaseModel):
13
+ """Runtime configuration settings."""
14
+
15
+ max_row_limit: int = Field(
16
+ default_factory=lambda: int(os.getenv("ERDOS_RENYI_MAX_ROWS", "1000"))
17
+ )
18
+ k_anonymity: int = Field(default_factory=lambda: int(os.getenv("ERDOS_RENYI_K", "5")))
19
+ session_ttl_minutes: int = Field(
20
+ default_factory=lambda: int(os.getenv("ERDOS_RENYI_TTL_MIN", "15"))
21
+ )
22
+ dashboard_host: str = Field(
23
+ default_factory=lambda: os.getenv("ERDOS_RENYI_DASHBOARD_HOST", "127.0.0.1")
24
+ )
25
+ secret: str = Field(default_factory=lambda: os.getenv("ERDOS_RENYI_SECRET", ""))
26
+ staging_dir: Path = Field(
27
+ default_factory=lambda: Path(
28
+ os.getenv("ERDOS_RENYI_STAGING_DIR")
29
+ or Path(tempfile.gettempdir()) / "erdos-renyi-sessions"
30
+ )
31
+ )
32
+
33
+ model_config = {"frozen": True}
34
+
35
+
36
+ def get_settings() -> Settings:
37
+ """Return application settings, ensuring staging directory exists."""
38
+ settings = Settings()
39
+ settings.staging_dir.mkdir(parents=True, exist_ok=True)
40
+ return settings
@@ -0,0 +1,250 @@
1
+ """Localhost Marimo dashboard launcher with HMAC authentication and TTL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import hashlib
7
+ import json
8
+ import hmac
9
+ import secrets
10
+ import socket
11
+ from subprocess import DEVNULL, Popen, SubprocessError
12
+ import sys
13
+ import time
14
+ from abc import ABC, abstractmethod
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from pydantic import BaseModel, Field
19
+
20
+ from er.config import Settings, get_settings
21
+ from er.data import ArrowSessionStore, drop_session
22
+
23
+ _LOCAL_SECRET: str = secrets.token_hex(32)
24
+
25
+
26
+ def resolve_dashboard_app() -> Path:
27
+ """Resolve path to the Marimo app.py file."""
28
+ candidates = [
29
+ Path(__file__).resolve().parent.parent / "dashboard" / "app.py",
30
+ Path(__file__).resolve().parent / "dashboard" / "app.py",
31
+ Path(__file__).resolve().parent / "app.py",
32
+ ]
33
+ for c in candidates:
34
+ if c.is_file():
35
+ return c
36
+ raise FileNotFoundError(
37
+ f"Marimo dashboard script app.py not found in {[str(c) for c in candidates]}"
38
+ )
39
+
40
+
41
+ def find_free_port() -> int:
42
+ """Find an available port on 127.0.0.1."""
43
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
44
+ s.bind(("127.0.0.1", 0))
45
+ return s.getsockname()[1]
46
+
47
+
48
+ class ActiveSession(BaseModel):
49
+ """Metadata and process reference for an active dashboard session."""
50
+
51
+ session_id: str
52
+ port: int
53
+ token: str
54
+ process: Popen[Any] = Field(exclude=True)
55
+ expires_at: float
56
+
57
+ model_config = {"arbitrary_types_allowed": True}
58
+
59
+
60
+ class DashboardRunner(ABC):
61
+ """Abstract base class for running localhost dashboards."""
62
+
63
+ @abstractmethod
64
+ def launch(self, session_id: str) -> dict[str, Any]:
65
+ """Launch a dashboard instance for the given session ID."""
66
+
67
+ @abstractmethod
68
+ def cleanup_expired(self) -> None:
69
+ """Clean up expired dashboard processes and staged data."""
70
+
71
+ @abstractmethod
72
+ def stop_all(self) -> None:
73
+ """Stop all running dashboard processes."""
74
+
75
+
76
+
77
+ def write_launch_sidecar(staging_dir: Path, session_id: str, url: str, token: str, expires_at: float) -> None:
78
+ """Write clinician-only launch metadata; never returned over MCP."""
79
+ staging_dir.mkdir(parents=True, exist_ok=True)
80
+ sidecar = staging_dir / f"session_{session_id}.launch.json"
81
+ payload = {
82
+ "dashboard_url": url,
83
+ "token": token,
84
+ "expires_at": expires_at,
85
+ }
86
+ sidecar.write_text(json.dumps(payload), encoding="utf-8")
87
+ try:
88
+ sidecar.chmod(0o600)
89
+ except OSError:
90
+ pass
91
+
92
+
93
+ def agent_launch_response(
94
+ session_id: str, expires_in_minutes: int
95
+ ) -> dict[str, Any]:
96
+ """MCP-safe launch payload — no localhost URL."""
97
+ return {
98
+ "status": "launched",
99
+ "session_id": session_id,
100
+ "expires_in_minutes": expires_in_minutes,
101
+ "instructions": "Call reyni__register_dashboard so the clinician can open this review in the Reyni app.",
102
+ }
103
+
104
+
105
+ class MarimoDashboardRunner(DashboardRunner):
106
+ """Manages lifecycle of ephemeral Marimo dashboard subprocesses."""
107
+
108
+ def __init__(self, settings: Settings | None = None) -> None:
109
+ self.settings = settings or get_settings()
110
+ self.sessions: dict[str, ActiveSession] = {}
111
+ atexit.register(self.stop_all)
112
+
113
+ def _get_secret(self) -> str:
114
+ return self.settings.secret if self.settings.secret else _LOCAL_SECRET
115
+
116
+ def _generate_token(self, session_id: str, expires_at: float) -> str:
117
+ secret = self._get_secret()
118
+ msg = f"{session_id}:{int(expires_at)}".encode()
119
+ return hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
120
+
121
+ def _terminate_and_drop(self, session: ActiveSession) -> None:
122
+ try:
123
+ session.process.terminate()
124
+ session.process.wait(timeout=2)
125
+ except (OSError, SubprocessError):
126
+ try:
127
+ session.process.kill()
128
+ except (OSError, SubprocessError):
129
+ pass
130
+ drop_session(self.settings.staging_dir, session.session_id)
131
+
132
+ def cleanup_expired(self) -> None:
133
+ now = time.time()
134
+ expired_ids = [
135
+ sid
136
+ for sid, s in self.sessions.items()
137
+ if now >= s.expires_at or s.process.poll() is not None
138
+ ]
139
+ for sid in expired_ids:
140
+ session = self.sessions.pop(sid, None)
141
+ if session:
142
+ self._terminate_and_drop(session)
143
+
144
+ def stop_all(self) -> None:
145
+ for sid in list(self.sessions.keys()):
146
+ session = self.sessions.pop(sid, None)
147
+ if session:
148
+ self._terminate_and_drop(session)
149
+
150
+ def launch(self, session_id: str) -> dict[str, Any]:
151
+ self.cleanup_expired()
152
+
153
+ store = ArrowSessionStore(self.settings.staging_dir)
154
+ if not store.exists(session_id):
155
+ return {
156
+ "status": "error",
157
+ "message": f"Session data not found or expired: {session_id}",
158
+ }
159
+
160
+ # If already running, return existing URL if not expired
161
+ if session_id in self.sessions:
162
+ existing = self.sessions[session_id]
163
+ if existing.process.poll() is None and time.time() < existing.expires_at:
164
+ url = (
165
+ f"http://{self.settings.dashboard_host}:{existing.port}/?token={existing.token}"
166
+ )
167
+ write_launch_sidecar(
168
+ self.settings.staging_dir,
169
+ session_id,
170
+ url,
171
+ existing.token,
172
+ existing.expires_at,
173
+ )
174
+ return agent_launch_response(session_id, self.settings.session_ttl_minutes)
175
+ else:
176
+ self.sessions.pop(session_id, None)
177
+
178
+ try:
179
+ app_py = resolve_dashboard_app()
180
+ except FileNotFoundError as exc:
181
+ return {"status": "error", "message": str(exc)}
182
+
183
+ port = find_free_port()
184
+ expires_at = time.time() + (self.settings.session_ttl_minutes * 60)
185
+ token = self._generate_token(session_id, expires_at)
186
+
187
+ cmd = [
188
+ sys.executable,
189
+ "-m",
190
+ "marimo",
191
+ "run",
192
+ str(app_py),
193
+ "--host",
194
+ "127.0.0.1",
195
+ "--port",
196
+ str(port),
197
+ "--headless",
198
+ "--",
199
+ f"--session={session_id}",
200
+ f"--token={token}",
201
+ ]
202
+
203
+ try:
204
+ proc = Popen(
205
+ cmd,
206
+ stdout=DEVNULL,
207
+ stderr=DEVNULL,
208
+ )
209
+ except (OSError, SubprocessError) as exc:
210
+ return {
211
+ "status": "error",
212
+ "message": f"Failed to spawn dashboard process: {exc}",
213
+ }
214
+
215
+ active = ActiveSession(
216
+ session_id=session_id,
217
+ port=port,
218
+ token=token,
219
+ process=proc,
220
+ expires_at=expires_at,
221
+ )
222
+ self.sessions[session_id] = active
223
+
224
+ url = f"http://{self.settings.dashboard_host}:{port}/?token={token}"
225
+ write_launch_sidecar(
226
+ self.settings.staging_dir,
227
+ session_id,
228
+ url,
229
+ token,
230
+ expires_at,
231
+ )
232
+ return agent_launch_response(session_id, self.settings.session_ttl_minutes)
233
+
234
+
235
+ # Default global dashboard runner instance
236
+ _runner: MarimoDashboardRunner | None = None
237
+
238
+
239
+ def get_dashboard_runner() -> MarimoDashboardRunner:
240
+ """Return singleton Marimo dashboard runner."""
241
+ global _runner
242
+ if _runner is None:
243
+ _runner = MarimoDashboardRunner()
244
+ return _runner
245
+
246
+
247
+ def launch_dashboard(session_id: str, settings: Settings | None = None) -> dict[str, Any]:
248
+ """Launch dashboard for session_id."""
249
+ runner = MarimoDashboardRunner(settings) if settings else get_dashboard_runner()
250
+ return runner.launch(session_id)
@@ -0,0 +1,126 @@
1
+ """Dataset scanning, SQL execution on Polars, and Arrow session staging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from abc import ABC, abstractmethod
7
+ from pathlib import Path
8
+
9
+ import polars as pl
10
+
11
+
12
+ class DatasetScanner(ABC):
13
+ """Abstract base class for scanning local datasets."""
14
+
15
+ @abstractmethod
16
+ def scan(self, path: Path) -> pl.LazyFrame:
17
+ """Scan a dataset file into a LazyFrame without loading all rows into memory."""
18
+
19
+
20
+ class CSVDatasetScanner(DatasetScanner):
21
+ """Scanner for CSV datasets (Reyni tokenized twins)."""
22
+
23
+ def scan(self, path: Path) -> pl.LazyFrame:
24
+ return pl.scan_csv(path, infer_schema_length=1000)
25
+
26
+
27
+ class ParquetDatasetScanner(DatasetScanner):
28
+ """Scanner for Parquet datasets."""
29
+
30
+ def scan(self, path: Path) -> pl.LazyFrame:
31
+ return pl.scan_parquet(path)
32
+
33
+
34
+ def get_scanner(path: str | Path) -> DatasetScanner:
35
+ """Return the appropriate dataset scanner for a given path."""
36
+ p = Path(path)
37
+ if p.suffix.lower() == ".parquet":
38
+ return ParquetDatasetScanner()
39
+ return CSVDatasetScanner()
40
+
41
+
42
+ def scan_path(path: str | Path) -> pl.LazyFrame:
43
+ """Scan a dataset path into a Polars LazyFrame.
44
+
45
+ Raises:
46
+ FileNotFoundError: If the file does not exist or is not a regular file.
47
+ """
48
+ p = Path(path)
49
+ if not p.is_file():
50
+ raise FileNotFoundError(f"Dataset file not found: {path}")
51
+ scanner = get_scanner(p)
52
+ return scanner.scan(p)
53
+
54
+
55
+ def schema_of(path: str | Path) -> list[dict[str, str]]:
56
+ """Return column names and dtypes of the dataset without reading cell values."""
57
+ sch = scan_path(path).collect_schema()
58
+ return [{"name": name, "dtype": str(dtype)} for name, dtype in sch.items()]
59
+
60
+
61
+ class SessionStore(ABC):
62
+ """Abstract base class for staging cohort session results."""
63
+
64
+ @abstractmethod
65
+ def stage(self, session_id: str, df: pl.DataFrame) -> Path:
66
+ """Stage query results under the session ID."""
67
+
68
+ @abstractmethod
69
+ def get_path(self, session_id: str) -> Path | None:
70
+ """Get path to staged session data if it exists."""
71
+
72
+ @abstractmethod
73
+ def exists(self, session_id: str) -> bool:
74
+ """Check if staged session data exists."""
75
+
76
+ @abstractmethod
77
+ def drop(self, session_id: str) -> None:
78
+ """Drop staged session data."""
79
+
80
+
81
+ class ArrowSessionStore(SessionStore):
82
+ """Stores query results as Arrow IPC files."""
83
+
84
+ def __init__(self, staging_dir: Path) -> None:
85
+ self.staging_dir = Path(staging_dir)
86
+
87
+ def _file_path(self, session_id: str) -> Path:
88
+ return self.staging_dir / f"session_{session_id}.arrow"
89
+
90
+ def stage(self, session_id: str, df: pl.DataFrame) -> Path:
91
+ self.staging_dir.mkdir(parents=True, exist_ok=True)
92
+ dest = self._file_path(session_id)
93
+ df.write_ipc(dest)
94
+ return dest
95
+
96
+ def get_path(self, session_id: str) -> Path | None:
97
+ p = self._file_path(session_id)
98
+ return p if p.is_file() else None
99
+
100
+ def exists(self, session_id: str) -> bool:
101
+ return self._file_path(session_id).is_file()
102
+
103
+ def drop(self, session_id: str) -> None:
104
+ p = self._file_path(session_id)
105
+ if p.exists():
106
+ p.unlink()
107
+
108
+
109
+ def execute_and_stage(path: str | Path, sql: str, staging_dir: Path) -> tuple[str, int]:
110
+ """Execute SQL query on the dataset and stage results into an Arrow IPC file.
111
+
112
+ Returns:
113
+ tuple[str, int]: (session_id, row_count)
114
+ """
115
+ session_id = secrets.token_hex(8)
116
+ ctx = pl.SQLContext(cohort=scan_path(path), eager=False)
117
+ df = ctx.execute(sql).collect()
118
+ store = ArrowSessionStore(staging_dir)
119
+ store.stage(session_id, df)
120
+ return session_id, len(df)
121
+
122
+
123
+ def drop_session(staging_dir: Path, session_id: str) -> None:
124
+ """Drop staged Arrow IPC session file."""
125
+ store = ArrowSessionStore(staging_dir)
126
+ store.drop(session_id)
@@ -0,0 +1,15 @@
1
+ """Privacy rules and k-anonymity gates."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def agent_count(row_count: int, k: int = 5) -> int | str:
7
+ """Gate the agent-visible count with k-anonymity.
8
+
9
+ If 0 < row_count < k, returns 'below_threshold' to prevent
10
+ identifying small cohort sizes (e.g. rare disease queries).
11
+ If count is 0 or >= k, returns the exact integer count.
12
+ """
13
+ if 0 < row_count < k:
14
+ return "below_threshold"
15
+ return row_count
@@ -0,0 +1,102 @@
1
+ """SQL sanitization and preparation for Polars execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlglot
6
+ from sqlglot import exp
7
+
8
+
9
+ class SQLSanitizerError(Exception):
10
+ """Raised when an SQL query fails sanitization or validation."""
11
+
12
+
13
+ FORBIDDEN_EXPRESSIONS = (
14
+ exp.Insert,
15
+ exp.Update,
16
+ exp.Delete,
17
+ exp.Drop,
18
+ exp.Alter,
19
+ exp.Create,
20
+ exp.Command,
21
+ exp.Pragma,
22
+ exp.Transaction,
23
+ exp.ReadCSV,
24
+ )
25
+
26
+ FORBIDDEN_FUNCTIONS = {
27
+ "read_csv",
28
+ "read_parquet",
29
+ "scan_parquet",
30
+ "scan_csv",
31
+ "scan_ndjson",
32
+ "read_json",
33
+ "read_ndjson",
34
+ "write_file",
35
+ "eval",
36
+ "readcsv",
37
+ "readparquet",
38
+ "scanparquet",
39
+ "scancsv",
40
+ "writefile",
41
+ "readjson",
42
+ }
43
+
44
+
45
+ def _check_forbidden_nodes(parsed: exp.Expression) -> None:
46
+ for node in parsed.walk():
47
+ if isinstance(node, FORBIDDEN_EXPRESSIONS):
48
+ raise SQLSanitizerError(f"Forbidden SQL statement type: {type(node).__name__}")
49
+ if isinstance(node, (exp.Anonymous, exp.Func)):
50
+ name = (getattr(node, "name", "") or "").lower()
51
+ cls = type(node).__name__.lower()
52
+ if name in FORBIDDEN_FUNCTIONS or cls in FORBIDDEN_FUNCTIONS or cls.replace("_", "") in FORBIDDEN_FUNCTIONS:
53
+ raise SQLSanitizerError(f"Forbidden SQL function: {name or cls}")
54
+
55
+
56
+ def _clamp_limit(parsed: exp.Select, max_row_limit: int) -> exp.Select:
57
+ limit_node = parsed.args.get("limit")
58
+ if limit_node is None or limit_node.expression is None:
59
+ return parsed.limit(max_row_limit)
60
+
61
+ expr = limit_node.expression
62
+ if isinstance(expr, exp.Literal) and expr.is_int:
63
+ val = int(expr.this)
64
+ if 0 < val <= max_row_limit:
65
+ return parsed
66
+
67
+ limit_node.set("expression", exp.Literal.number(max_row_limit))
68
+ return parsed
69
+
70
+
71
+ def _parse_select(sql: str) -> exp.Select:
72
+ if not sql or not sql.strip():
73
+ raise SQLSanitizerError("Empty SQL query")
74
+ try:
75
+ parsed = sqlglot.parse_one(sql, read="postgres")
76
+ except Exception as exc:
77
+ raise SQLSanitizerError(f"SQL parsing error: {exc}") from exc
78
+
79
+ if not isinstance(parsed, exp.Select):
80
+ raise SQLSanitizerError(
81
+ f"Query root must be a SELECT statement, got {type(parsed).__name__}"
82
+ )
83
+ return parsed
84
+
85
+
86
+ def sanitize_and_prepare_sql(sql: str, max_row_limit: int = 1000) -> str:
87
+ """Sanitize and clamp an SQL query for safe local execution.
88
+
89
+ Args:
90
+ sql: Raw SQL text provided by the agent.
91
+ max_row_limit: Maximum allowed rows in the LIMIT clause.
92
+
93
+ Returns:
94
+ Cleaned SQL string in postgres dialect with clamped LIMIT.
95
+
96
+ Raises:
97
+ SQLSanitizerError: If query violates safety constraints.
98
+ """
99
+ parsed = _parse_select(sql)
100
+ _check_forbidden_nodes(parsed)
101
+ clamped = _clamp_limit(parsed, max_row_limit)
102
+ return clamped.sql(dialect="duckdb")
@@ -0,0 +1,167 @@
1
+ """FastMCP server entrypoint for erdos-renyi."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import polars as pl
8
+ from fastmcp import FastMCP
9
+ from pydantic import BaseModel
10
+
11
+ from er.config import get_settings
12
+ from er.dashboard import launch_dashboard as do_launch_dashboard
13
+ from er.data import execute_and_stage, schema_of
14
+ from er.privacy import agent_count
15
+ from er.sanitizer import SQLSanitizerError, sanitize_and_prepare_sql
16
+
17
+
18
+ class ColumnInfo(BaseModel):
19
+ """Metadata for a single column."""
20
+
21
+ name: str
22
+ dtype: str
23
+
24
+
25
+ class SchemaResponse(BaseModel):
26
+ """Schema inspection return model."""
27
+
28
+ table: str = "cohort"
29
+ path: str
30
+ n_columns: int
31
+ columns: list[ColumnInfo]
32
+ sql_notes: str = (
33
+ "FROM cohort. Identifier columns are Reyni tokens (tok_…), not raw IDs. "
34
+ "Rewrite the Erdos query against these names. Do not SELECT rows back through MCP — call stage_query."
35
+ )
36
+
37
+
38
+ class StagedResponse(BaseModel):
39
+ """Successful query staging return model."""
40
+
41
+ status: str = "staged"
42
+ session_id: str
43
+ matched_records: int
44
+ message: str = "Staged. Call reyni__register_dashboard with this session_id. Do not request rows."
45
+
46
+
47
+ class StagedSuppressedResponse(BaseModel):
48
+ """Suppressed count staging return model (k-anonymity)."""
49
+
50
+ status: str = "staged_suppressed"
51
+ session_id: str
52
+ matched_records: str = "below_threshold"
53
+ message: str = (
54
+ "Cohort is below the k-anonymity threshold. Count withheld. "
55
+ "Call reyni__register_dashboard so the clinician can review in the Reyni app."
56
+ )
57
+
58
+
59
+ class ErrorResponse(BaseModel):
60
+ """Error return model."""
61
+
62
+ status: str = "error"
63
+ error_type: str | None = None
64
+ message: str
65
+
66
+
67
+ mcp = FastMCP(
68
+ "er-mcp",
69
+ instructions=(
70
+ "Run Erdos trial-matching queries on a Reyni-registered export. "
71
+ "1) Reyni list_datasets → safe_path. "
72
+ "2) get_schema(safe_path). "
73
+ "3) Rewrite the Erdos SQL against table cohort using those column names. "
74
+ "4) stage_query(safe_path, sql). "
75
+ "5) reyni__register_dashboard(session_id) so the clinician opens the review in the Reyni app. "
76
+ "Never pass the original file path. Never return or request patient rows. "
77
+ "Never fetch dashboard URLs."
78
+ ),
79
+ )
80
+
81
+
82
+ @mcp.tool()
83
+ def get_schema(safe_path: str) -> dict[str, Any]:
84
+ """Lazy-scan the dataset at safe_path and return column names and dtypes.
85
+
86
+ Never exposes raw rows or cell values.
87
+
88
+ Args:
89
+ safe_path: Path to the registered dataset (from Reyni list_datasets).
90
+
91
+ Returns:
92
+ Dictionary with table name, column count, and column name/dtype pairs.
93
+ """
94
+ try:
95
+ cols_raw = schema_of(safe_path)
96
+ cols = [ColumnInfo(**c) for c in cols_raw]
97
+ resp = SchemaResponse(
98
+ path=str(safe_path),
99
+ n_columns=len(cols),
100
+ columns=cols,
101
+ )
102
+ return resp.model_dump()
103
+ except (FileNotFoundError, pl.exceptions.PolarsError, OSError, ValueError) as exc:
104
+ return ErrorResponse(message=str(exc)).model_dump(exclude_none=True)
105
+
106
+
107
+ @mcp.tool()
108
+ def stage_query(safe_path: str, sql: str) -> dict[str, Any]:
109
+ """Sanitize, execute SQL against safe_path dataset as table 'cohort', and stage results.
110
+
111
+ Stages the resulting cohort into a private Arrow IPC session for the localhost dashboard.
112
+ Applies k-anonymity gating to the matched record count reported to the agent.
113
+
114
+ Args:
115
+ safe_path: Path to the registered dataset (from Reyni list_datasets).
116
+ sql: SQL query to run against the 'cohort' table.
117
+
118
+ Returns:
119
+ Dictionary containing staging status, session_id, and matched records count.
120
+ """
121
+ settings = get_settings()
122
+
123
+ try:
124
+ sanitized_sql = sanitize_and_prepare_sql(sql, max_row_limit=settings.max_row_limit)
125
+ except SQLSanitizerError as exc:
126
+ return ErrorResponse(error_type="SQLSanitizerError", message=str(exc)).model_dump()
127
+
128
+ try:
129
+ session_id, row_count = execute_and_stage(
130
+ safe_path, sanitized_sql, staging_dir=settings.staging_dir
131
+ )
132
+ except (pl.exceptions.PolarsError, OSError, ValueError, RuntimeError) as exc:
133
+ return ErrorResponse(
134
+ error_type="ExecutionError",
135
+ message=f"Query execution failed: {exc}",
136
+ ).model_dump()
137
+
138
+ gated_count = agent_count(row_count, k=settings.k_anonymity)
139
+ if gated_count == "below_threshold":
140
+ return StagedSuppressedResponse(session_id=session_id).model_dump()
141
+
142
+ return StagedResponse(session_id=session_id, matched_records=row_count).model_dump()
143
+
144
+
145
+ @mcp.tool()
146
+ def launch_dashboard(session_id: str) -> dict[str, Any]:
147
+ """Spawn Marimo dashboard bound to 127.0.0.1 (Reyni gateway-internal).
148
+
149
+ Writes launch metadata to a clinician-only sidecar; MCP response has no URL.
150
+
151
+ Args:
152
+ session_id: Session ID returned by stage_query.
153
+
154
+ Returns:
155
+ Dictionary with status, session_id, TTL, and instructions (no dashboard_url).
156
+ """
157
+ settings = get_settings()
158
+ return do_launch_dashboard(session_id, settings=settings)
159
+
160
+
161
+ def main() -> None:
162
+ """Run FastMCP stdio server."""
163
+ mcp.run()
164
+
165
+
166
+ if __name__ == "__main__":
167
+ main()
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: erdos-renyi
3
+ Version: 0.0.0
4
+ Summary: MCP connector: run Erdos queries on Reyni-registered EHR exports.
5
+ Author-email: Krv Labs <team@krv.ai>
6
+ License: Apache-2.0
7
+ Requires-Python: <3.15,>=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: fastmcp>=2.0
10
+ Requires-Dist: polars>=1.0.0
11
+ Requires-Dist: sqlglot>=23.0.0
12
+ Requires-Dist: marimo>=0.8.0
13
+ Requires-Dist: pyarrow>=15.0.0
14
+ Requires-Dist: pydantic>=2.0.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.3; extra == "dev"
17
+ Requires-Dist: ruff>=0.9; extra == "dev"
18
+
19
+ # erdos-renyi
20
+
21
+ `erdos-renyi` is an MCP server providing the privacy-preserving handshake between Erdos and Reyni. Clinicians register fine-grained EHR cohort exports with Reyni, and agents rewrite Erdos predictive SQL queries against tokenized schema names (`tok_…`) without ever accessing raw cell values or PHI. Results are staged locally in Arrow IPC format, gated by k-anonymity for agent-visible counts, and rendered in a private localhost Marimo dashboard for clinician review.
22
+
23
+ ## Quickstart
24
+
25
+ ```bash
26
+ # Install dependencies
27
+ uv sync --extra dev
28
+
29
+ # Run test suite
30
+ uv run pytest
31
+
32
+ # Run the MCP server over stdio
33
+ uv run er-mcp
34
+ ```
35
+
36
+ ## Reyni Gateway Setup
37
+
38
+ Add `erdos-renyi` to `~/Library/Application Support/reyni/servers.toml` (Windows: `%APPDATA%\reyni\servers.toml`):
39
+
40
+ ```toml
41
+ [[server]]
42
+ name = "erdos"
43
+ launch = ["uv", "tool", "run", "--python", "3.12", "--from", "erdos-renyi", "er-mcp"]
44
+ phi_access = true
45
+ ```
46
+
47
+ During local development, point `--from` to the local repository path:
48
+
49
+ ```toml
50
+ [[server]]
51
+ name = "erdos"
52
+ launch = ["uv", "tool", "run", "--python", "3.12", "--from", "/path/to/erdos-renyi", "er-mcp"]
53
+ phi_access = true
54
+ ```
55
+
56
+ Agent-visible Erdos tools: `erdos__get_schema`, `erdos__stage_query`. After staging, call `reyni__register_dashboard` (Reyni gateway). `launch_dashboard` is gateway-internal only.
57
+
58
+ ## Agent Workflow
59
+
60
+ 1. **`reyni__list_datasets`**: Obtain the `safe_path` for the tokenized twin export.
61
+ 2. **`erdos__get_schema(safe_path)`**: Inspect column names and data types (no row values or samples).
62
+ 3. **Rewrite SQL**: Adapt the Erdos predictive query to target the local schema. The table name in Polars SQL is always **`cohort`**.
63
+ 4. **`erdos__stage_query(safe_path, sql)`**: Sanitize and execute SQL. If the match count is below the k-anonymity threshold ($k < 5$), the exact count is withheld (`below_threshold`), but the session is staged for the clinician.
64
+ 5. **`reyni__register_dashboard(session_id)`**: Reyni launches the Marimo dashboard internally, reads a clinician-only sidecar (`session_{id}.launch.json`), and stores the URL encrypted — the agent receives only a `dashboard_ref`.
65
+
66
+ ## Security Guarantees
67
+
68
+ - **Zero cell egress**: MCP tools only return column metadata and aggregate match counts.
69
+ - **SQL Sanitization**: Enforces read-only `SELECT` queries, rejects external filesystem functions (`read_csv`, `eval`), and clamps `LIMIT` to 1000 rows.
70
+ - **Localhost Enclave**: Staging files use Arrow IPC; dashboards bind to `127.0.0.1`. Launch URLs are written to mode-`0600` sidecars, not returned over MCP.
@@ -0,0 +1,21 @@
1
+ README.md
2
+ pyproject.toml
3
+ dashboard/app.py
4
+ er/__init__.py
5
+ er/config.py
6
+ er/dashboard.py
7
+ er/data.py
8
+ er/privacy.py
9
+ er/sanitizer.py
10
+ er/server.py
11
+ erdos_renyi.egg-info/PKG-INFO
12
+ erdos_renyi.egg-info/SOURCES.txt
13
+ erdos_renyi.egg-info/dependency_links.txt
14
+ erdos_renyi.egg-info/entry_points.txt
15
+ erdos_renyi.egg-info/requires.txt
16
+ erdos_renyi.egg-info/top_level.txt
17
+ tests/test_dashboard.py
18
+ tests/test_privacy.py
19
+ tests/test_sanitizer.py
20
+ tests/test_schema.py
21
+ tests/test_stage.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ er-mcp = er.server:main
@@ -0,0 +1,10 @@
1
+ fastmcp>=2.0
2
+ polars>=1.0.0
3
+ sqlglot>=23.0.0
4
+ marimo>=0.8.0
5
+ pyarrow>=15.0.0
6
+ pydantic>=2.0.0
7
+
8
+ [dev]
9
+ pytest>=8.3
10
+ ruff>=0.9
@@ -0,0 +1,2 @@
1
+ dashboard
2
+ er
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "erdos-renyi"
3
+ version = "0.0.0"
4
+ description = "MCP connector: run Erdos queries on Reyni-registered EHR exports."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11,<3.15"
7
+ authors = [{ name = "Krv Labs", email = "team@krv.ai" }]
8
+ license = { text = "Apache-2.0" }
9
+ dependencies = [
10
+ "fastmcp>=2.0",
11
+ "polars>=1.0.0",
12
+ "sqlglot>=23.0.0",
13
+ "marimo>=0.8.0",
14
+ "pyarrow>=15.0.0",
15
+ "pydantic>=2.0.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = ["pytest>=8.3", "ruff>=0.9"]
20
+
21
+ [project.scripts]
22
+ er-mcp = "er.server:main"
23
+
24
+ [build-system]
25
+ requires = ["setuptools>=61.0"]
26
+ build-backend = "setuptools.build_meta"
27
+
28
+ [tool.setuptools.packages.find]
29
+ include = ["er", "er.*", "dashboard"]
30
+
31
+ [tool.setuptools]
32
+ include-package-data = true
33
+
34
+ [tool.setuptools.package-data]
35
+ er = ["*.py", "dashboard/*.py"]
36
+ dashboard = ["*.py"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py312"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,59 @@
1
+ """Unit tests for localhost dashboard lifecycle."""
2
+
3
+ from pathlib import Path
4
+
5
+ from er.config import Settings
6
+ from er.dashboard import MarimoDashboardRunner
7
+ from er.server import launch_dashboard
8
+
9
+
10
+ def test_launch_dashboard_missing_session():
11
+ result = launch_dashboard("nonexistent_session_id")
12
+ assert result["status"] == "error"
13
+ assert "not found" in result["message"].lower()
14
+
15
+
16
+ def test_launch_dashboard_success(sample_csv_path: Path, tmp_path: Path):
17
+ settings = Settings(staging_dir=tmp_path / "staging")
18
+ sql = "SELECT patient_id, score FROM cohort WHERE bmi > 30"
19
+ runner = MarimoDashboardRunner(settings=settings)
20
+ # Stage session into runner's staging dir for test
21
+ from er.data import execute_and_stage
22
+ from er.sanitizer import sanitize_and_prepare_sql
23
+
24
+ staged_sid, _ = execute_and_stage(
25
+ sample_csv_path, sanitize_and_prepare_sql(sql), settings.staging_dir
26
+ )
27
+
28
+ res = runner.launch(staged_sid)
29
+ try:
30
+ assert res["status"] == "launched"
31
+ assert res["session_id"] == staged_sid
32
+ assert "dashboard_url" not in res
33
+ assert res["expires_in_minutes"] == 15
34
+ assert "register_dashboard" in res["instructions"].lower()
35
+ sidecar = settings.staging_dir / f"session_{staged_sid}.launch.json"
36
+ assert sidecar.is_file()
37
+ assert "127.0.0.1" in sidecar.read_text()
38
+ finally:
39
+ runner.stop_all()
40
+
41
+
42
+ def test_dashboard_table_with_polars(sample_csv_path: Path, tmp_path: Path):
43
+ import marimo as mo
44
+ import polars as pl
45
+
46
+ from er.data import execute_and_stage
47
+ from er.sanitizer import sanitize_and_prepare_sql
48
+
49
+ staging_dir = tmp_path / "staging"
50
+ sql = "SELECT patient_id, score FROM cohort WHERE bmi > 30"
51
+ staged_sid, _ = execute_and_stage(
52
+ sample_csv_path, sanitize_and_prepare_sql(sql), staging_dir
53
+ )
54
+
55
+ ipc_path = staging_dir / f"session_{staged_sid}.arrow"
56
+ df = pl.read_ipc(ipc_path)
57
+ table = mo.ui.table(df, selection=None)
58
+ assert table is not None
59
+
@@ -0,0 +1,19 @@
1
+ """Unit tests for k-anonymity privacy gate."""
2
+
3
+ from er.privacy import agent_count
4
+
5
+
6
+ def test_privacy_below_threshold():
7
+ assert agent_count(1, 5) == "below_threshold"
8
+ assert agent_count(3, 5) == "below_threshold"
9
+ assert agent_count(4, 5) == "below_threshold"
10
+
11
+
12
+ def test_privacy_zero_allowed():
13
+ assert agent_count(0, 5) == 0
14
+
15
+
16
+ def test_privacy_at_and_above_threshold():
17
+ assert agent_count(5, 5) == 5
18
+ assert agent_count(10, 5) == 10
19
+ assert agent_count(100, 5) == 100
@@ -0,0 +1,63 @@
1
+ """Unit tests for SQL sanitizer."""
2
+
3
+ import pytest
4
+
5
+ from er.sanitizer import SQLSanitizerError, sanitize_and_prepare_sql
6
+
7
+
8
+ def test_select_passes():
9
+ sql = "SELECT patient_id, bmi FROM cohort WHERE bmi > 30"
10
+ sanitized = sanitize_and_prepare_sql(sql)
11
+ assert "SELECT" in sanitized
12
+ assert "LIMIT 1000" in sanitized
13
+
14
+
15
+ def test_insert_rejected():
16
+ sql = "INSERT INTO cohort (patient_id, bmi) VALUES ('x', 25.0)"
17
+ with pytest.raises(SQLSanitizerError):
18
+ sanitize_and_prepare_sql(sql)
19
+
20
+
21
+ def test_drop_rejected():
22
+ sql = "DROP TABLE cohort"
23
+ with pytest.raises(SQLSanitizerError):
24
+ sanitize_and_prepare_sql(sql)
25
+
26
+
27
+ def test_update_rejected():
28
+ sql = "UPDATE cohort SET bmi = 20.0 WHERE patient_id = 'x'"
29
+ with pytest.raises(SQLSanitizerError):
30
+ sanitize_and_prepare_sql(sql)
31
+
32
+
33
+ def test_read_csv_rejected():
34
+ sql = "SELECT * FROM read_csv('secret.csv')"
35
+ with pytest.raises(SQLSanitizerError):
36
+ sanitize_and_prepare_sql(sql)
37
+
38
+
39
+ def test_scan_parquet_rejected():
40
+ sql = "SELECT * FROM scan_parquet('secret.parquet')"
41
+ with pytest.raises(SQLSanitizerError):
42
+ sanitize_and_prepare_sql(sql)
43
+
44
+
45
+ def test_limit_clamping():
46
+ sql = "SELECT * FROM cohort LIMIT 10000"
47
+ sanitized = sanitize_and_prepare_sql(sql, max_row_limit=1000)
48
+ assert "LIMIT 1000" in sanitized
49
+ assert "10000" not in sanitized
50
+
51
+
52
+ def test_missing_limit_added():
53
+ sql = "SELECT * FROM cohort"
54
+ sanitized = sanitize_and_prepare_sql(sql, max_row_limit=500)
55
+ assert "LIMIT 500" in sanitized
56
+
57
+
58
+ def test_negative_or_zero_limit_clamped():
59
+ sql_zero = "SELECT * FROM cohort LIMIT 0"
60
+ assert "LIMIT 1000" in sanitize_and_prepare_sql(sql_zero, max_row_limit=1000)
61
+
62
+ sql_neg = "SELECT * FROM cohort LIMIT -1"
63
+ assert "LIMIT 1000" in sanitize_and_prepare_sql(sql_neg, max_row_limit=1000)
@@ -0,0 +1,32 @@
1
+ """Unit tests for schema inspection tool."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from er.server import get_schema
7
+
8
+
9
+ def test_get_schema_structure(sample_csv_path: Path):
10
+ result = get_schema(str(sample_csv_path))
11
+
12
+ assert result["table"] == "cohort"
13
+ assert result["path"] == str(sample_csv_path)
14
+ assert result["n_columns"] == 4
15
+
16
+ col_names = [c["name"] for c in result["columns"]]
17
+ assert col_names == ["patient_id", "icd10", "bmi", "score"]
18
+ assert all(set(c.keys()) == {"name", "dtype"} for c in result["columns"])
19
+
20
+
21
+ def test_get_schema_no_cell_leak(sample_csv_path: Path):
22
+ result = get_schema(str(sample_csv_path))
23
+ dumped = json.dumps(result)
24
+ assert "secret-mrn-1" not in dumped
25
+ assert "E11.9" not in dumped
26
+ assert "32.5" not in dumped
27
+
28
+
29
+ def test_get_schema_nonexistent_file():
30
+ result = get_schema("/nonexistent/path/cohort.csv")
31
+ assert result["status"] == "error"
32
+ assert "not found" in result["message"].lower()
@@ -0,0 +1,69 @@
1
+ """Unit tests for query staging and privacy gating."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import polars as pl
7
+
8
+ from er.config import get_settings
9
+ from er.server import stage_query
10
+
11
+
12
+ def test_stage_query_happy_path(sample_csv_path: Path):
13
+ sql = "SELECT patient_id, score FROM cohort WHERE bmi > 30 ORDER BY score DESC"
14
+ result = stage_query(str(sample_csv_path), sql)
15
+
16
+ assert result["status"] == "staged"
17
+ assert "session_id" in result
18
+ assert isinstance(result["matched_records"], int)
19
+ assert result["matched_records"] == 11
20
+
21
+ # Ensure no patient data/cells leaked in MCP return
22
+ dumped = json.dumps(result)
23
+ assert "secret-mrn-1" not in dumped
24
+ assert "32.5" not in dumped
25
+
26
+ # Verify staged Arrow IPC exists and holds the rows
27
+ staging_dir = get_settings().staging_dir
28
+ session_id = result["session_id"]
29
+ ipc_file = staging_dir / f"session_{session_id}.arrow"
30
+ assert ipc_file.exists()
31
+
32
+ df = pl.read_ipc(ipc_file)
33
+ assert len(df) == 11
34
+ assert set(df.columns) == {"patient_id", "score"}
35
+
36
+
37
+ def test_stage_query_k_anonymity_suppression(sample_csv_path: Path):
38
+ # Matches 2 rows in fixture (row 7: 26.4, row 13: 27.1)
39
+ sql = "SELECT patient_id, score FROM cohort WHERE icd10 = 'C34.9' AND bmi < 28"
40
+ result = stage_query(str(sample_csv_path), sql)
41
+
42
+ assert result["status"] == "staged_suppressed"
43
+ assert "session_id" in result
44
+ assert result["matched_records"] == "below_threshold"
45
+
46
+ # Arrow IPC must still be written for the clinician
47
+ staging_dir = get_settings().staging_dir
48
+ session_id = result["session_id"]
49
+ ipc_file = staging_dir / f"session_{session_id}.arrow"
50
+ assert ipc_file.exists()
51
+
52
+ df = pl.read_ipc(ipc_file)
53
+ assert len(df) == 2
54
+
55
+
56
+ def test_stage_query_sanitizer_error(sample_csv_path: Path):
57
+ sql = "DROP TABLE cohort"
58
+ result = stage_query(str(sample_csv_path), sql)
59
+
60
+ assert result["status"] == "error"
61
+ assert result["error_type"] == "SQLSanitizerError"
62
+
63
+
64
+ def test_stage_query_execution_error(sample_csv_path: Path):
65
+ sql = "SELECT nonexistent_column FROM cohort"
66
+ result = stage_query(str(sample_csv_path), sql)
67
+
68
+ assert result["status"] == "error"
69
+ assert result["error_type"] == "ExecutionError"