data-platform-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ """Read-only BigQuery MCP server."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ # Read from the installed distribution rather than hardcoding a literal, so
7
+ # `--version` cannot drift from what pyproject.toml declares. That leaves
8
+ # two places to bump for a release -- pyproject.toml and server.json -- and
9
+ # the release workflow refuses a tag where those disagree.
10
+ __version__ = version("data-platform-mcp")
11
+ except PackageNotFoundError: # a source tree that was never installed
12
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,4 @@
1
+ from .server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,74 @@
1
+ """BigQuery clients, built once per environment and reused.
2
+
3
+ A ``bigquery.Client`` performs credential discovery on construction, so building
4
+ one per tool call adds latency to every query for no benefit.
5
+
6
+ **Impersonation is what makes "read-only" a property of the identity.** The
7
+ SELECT-only guard and the ``readOnlyHint`` annotations are promises about this
8
+ code; pointing the server at a service account holding only
9
+ ``roles/bigquery.jobUser`` and a dataset-scoped ``roles/bigquery.dataViewer``
10
+ makes it a fact about the credentials, enforced by IAM whatever the code does
11
+ and whatever the operator's own roles allow. It also moves the dataset
12
+ allowlist from an ``if`` statement in this process to a grant Google enforces.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from functools import lru_cache
18
+
19
+ from .config import Environment
20
+
21
+ _SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
22
+
23
+
24
+ @lru_cache(maxsize=8)
25
+ def get_credentials(impersonate: str = ""):
26
+ """Credentials for an environment: ADC, optionally impersonating.
27
+
28
+ The caller running this needs ``roles/iam.serviceAccountTokenCreator`` on
29
+ the target account; without it, impersonation fails at token refresh with a
30
+ 403 that names neither the account nor the role, which errors.py translates.
31
+ """
32
+ import google.auth
33
+
34
+ source, _ = google.auth.default(scopes=_SCOPES)
35
+ if not impersonate:
36
+ return source
37
+
38
+ from google.auth import impersonated_credentials
39
+
40
+ return impersonated_credentials.Credentials(
41
+ source_credentials=source,
42
+ target_principal=impersonate,
43
+ target_scopes=_SCOPES,
44
+ )
45
+
46
+
47
+ @lru_cache(maxsize=8)
48
+ def _client_for(project: str, location: str, impersonate: str):
49
+ from google.cloud import bigquery
50
+
51
+ return bigquery.Client(
52
+ project=project,
53
+ location=location,
54
+ credentials=get_credentials(impersonate),
55
+ )
56
+
57
+
58
+ def get_bigquery_client(env: Environment):
59
+ """Return the cached client for this environment."""
60
+ return _client_for(env.project, env.location, env.impersonate)
61
+
62
+
63
+ def reset_clients() -> None:
64
+ """Drop cached clients and credentials. Used by tests and after a config change.
65
+
66
+ Tolerates either function having been replaced: this runs in test teardown,
67
+ where a test may still have ``get_credentials`` monkeypatched to a plain
68
+ function with no cache. Failing there would report a fixture-ordering
69
+ detail as a test error and hide whatever the test actually found.
70
+ """
71
+ for cached in (_client_for, get_credentials):
72
+ clear = getattr(cached, "cache_clear", None)
73
+ if clear is not None:
74
+ clear()
@@ -0,0 +1,443 @@
1
+ """Runtime settings: a registry of named BigQuery environments.
2
+
3
+ One server process answers questions about several targets -- a warehouse and a
4
+ staging copy, or two regions of the same project -- so every tool takes an
5
+ optional ``environment`` argument that is resolved here. Omitting it uses the
6
+ configured default.
7
+
8
+ Configuration comes from, in order of precedence:
9
+
10
+ 1. ``BQ_MCP_ENVIRONMENTS``, a JSON object mapping name to settings.
11
+ 2. A TOML file at ``~/.config/data-platform-mcp/config.toml`` (or wherever
12
+ ``BQ_MCP_CONFIG`` points), which is far kinder than a JSON blob squeezed
13
+ into an environment variable and can be committed and shared by a team.
14
+ 3. The original single-project variables (``BQ_PROJECT`` and friends), exposed
15
+ as one environment named ``default``. An existing setup keeps working
16
+ unchanged, which is the point.
17
+
18
+ Two properties matter more than the values:
19
+
20
+ **Nothing is read at import time.** Configuration used to be read at module
21
+ scope, so an unset project killed the process before it could answer
22
+ ``initialize`` -- which a client reports only as "server failed to start", with
23
+ no tools and no message the user can act on.
24
+
25
+ **An unknown environment name is an error, never a fallback.** Silently
26
+ resolving a typo to the default would answer a question about one warehouse
27
+ from another, which no reader of the answer could detect.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ import tomllib
35
+ from dataclasses import dataclass, field
36
+ from functools import lru_cache
37
+ from pathlib import Path
38
+
39
+ from .formatting import human_bytes
40
+
41
+ GIB = 1024**3
42
+
43
+ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "data-platform-mcp" / "config.toml"
44
+
45
+ # Hard cap on bytes scanned per query. A query estimated above this never runs,
46
+ # even with confirmation.
47
+ DEFAULT_MAX_BYTES_BILLED = 5 * GIB
48
+
49
+ # Soft "this is costly" threshold. Above it, run_query reports the estimate and
50
+ # asks the caller to come back with confirm_expensive=True.
51
+ DEFAULT_WARN_BYTES = 1 * GIB
52
+
53
+ # On-demand price used only to render a friendly cost estimate. BigQuery US
54
+ # on-demand analysis is ~$6.25 per TiB scanned.
55
+ DEFAULT_COST_PER_TIB_USD = 6.25
56
+
57
+ # Default row cap returned to the model so responses stay small.
58
+ DEFAULT_ROW_LIMIT = 200
59
+
60
+ DEFAULT_LOCATION = "US"
61
+
62
+ _PROJECT_ENV_VARS = ("BQ_PROJECT", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT")
63
+
64
+ # Spoken shorthands an agent picks up from a prompt ("check prod"). Listed in
65
+ # both directions so an environment can be named either way, and only applied
66
+ # when they do not collide with a real environment name.
67
+ _BUILTIN_ALIASES = {
68
+ "prod": "production",
69
+ "prd": "production",
70
+ "live": "production",
71
+ "production": "prod",
72
+ "stg": "staging",
73
+ "stage": "staging",
74
+ "staging": "stage",
75
+ "dev": "development",
76
+ "development": "dev",
77
+ "test": "testing",
78
+ "testing": "test",
79
+ }
80
+
81
+ # With no explicit default, prefer a target where a mistake is cheap over one
82
+ # where it is not, rather than whichever key happened to be listed first.
83
+ _PREFERRED_DEFAULTS = ("staging", "development", "dev", "test", "testing")
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class Environment:
88
+ """One resolvable BigQuery target and the limits applied to it."""
89
+
90
+ name: str
91
+ project: str
92
+ location: str = DEFAULT_LOCATION
93
+ impersonate: str = ""
94
+ dataset_allowlist: frozenset[str] = frozenset()
95
+ max_bytes_billed: int = DEFAULT_MAX_BYTES_BILLED
96
+ warn_bytes: int = DEFAULT_WARN_BYTES
97
+ row_limit: int = DEFAULT_ROW_LIMIT
98
+ cost_per_tib_usd: float = DEFAULT_COST_PER_TIB_USD
99
+ aliases: tuple[str, ...] = field(default_factory=tuple)
100
+
101
+ def dataset_allowed(self, dataset_id: str) -> bool:
102
+ return not self.dataset_allowlist or dataset_id in self.dataset_allowlist
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class Settings:
107
+ environments: tuple[Environment, ...]
108
+ default_environment: str
109
+
110
+
111
+ def _env_int(name: str, default: int) -> int:
112
+ """Read a positive int from the environment, ignoring unusable values.
113
+
114
+ A typo in a limit must not stop the server from starting; falling back to
115
+ the documented default and carrying on is the safer failure.
116
+ """
117
+ raw = os.environ.get(name, "").strip()
118
+ if not raw:
119
+ return default
120
+ try:
121
+ value = int(raw)
122
+ except ValueError:
123
+ return default
124
+ return value if value > 0 else default
125
+
126
+
127
+ def _env_float(name: str, default: float) -> float:
128
+ raw = os.environ.get(name, "").strip()
129
+ if not raw:
130
+ return default
131
+ try:
132
+ value = float(raw)
133
+ except ValueError:
134
+ return default
135
+ return value if value > 0 else default
136
+
137
+
138
+ def _as_allowlist(value) -> frozenset[str]:
139
+ """Accept either a comma-separated string or a list of dataset ids."""
140
+ if not value:
141
+ return frozenset()
142
+ if isinstance(value, str):
143
+ value = value.split(",")
144
+ return frozenset(str(v).strip() for v in value if str(v).strip())
145
+
146
+
147
+ def _adc_project() -> str:
148
+ try:
149
+ import google.auth
150
+
151
+ _, project = google.auth.default()
152
+ return str(project) if project else ""
153
+ except Exception:
154
+ return ""
155
+
156
+
157
+ def _resolve_project() -> str:
158
+ for var in _PROJECT_ENV_VARS:
159
+ value = os.environ.get(var, "").strip()
160
+ if value:
161
+ return value
162
+ # Fall back to the project associated with Application Default Credentials,
163
+ # so a plain `gcloud auth application-default login` is enough to start.
164
+ return _adc_project()
165
+
166
+
167
+ def _positive_int(spec: dict, key: str, default: int) -> int:
168
+ try:
169
+ value = int(spec.get(key, default))
170
+ except (TypeError, ValueError):
171
+ return default
172
+ return value if value > 0 else default
173
+
174
+
175
+ def _build_environment(name: str, spec, defaults: dict) -> Environment:
176
+ """Turn one registry entry into an Environment.
177
+
178
+ A bare string is accepted as a project id, because ``{"prod": "my-project"}``
179
+ is what people write first and there is no reason to reject it.
180
+ """
181
+ key = str(name).strip().lower()
182
+ if isinstance(spec, str):
183
+ spec = {"project": spec}
184
+ if not isinstance(spec, dict):
185
+ raise RuntimeError(
186
+ f"Environment '{name}' must be an object or a project-id string."
187
+ )
188
+
189
+ project = str(spec.get("project", "")).strip()
190
+ if not project:
191
+ raise RuntimeError(f"Environment '{name}' is missing a 'project'.")
192
+
193
+ aliases = spec.get("aliases") or []
194
+ if isinstance(aliases, str):
195
+ aliases = [aliases]
196
+
197
+ try:
198
+ cost = float(spec.get("cost_per_tib_usd", defaults["cost_per_tib_usd"]))
199
+ except (TypeError, ValueError):
200
+ cost = defaults["cost_per_tib_usd"]
201
+
202
+ return Environment(
203
+ name=key,
204
+ project=project,
205
+ location=str(spec.get("location") or defaults["location"]).strip(),
206
+ impersonate=str(spec.get("impersonate") or defaults["impersonate"]).strip(),
207
+ dataset_allowlist=(
208
+ _as_allowlist(spec["dataset_allowlist"])
209
+ if "dataset_allowlist" in spec
210
+ else defaults["dataset_allowlist"]
211
+ ),
212
+ max_bytes_billed=_positive_int(
213
+ spec, "max_bytes_billed", defaults["max_bytes_billed"]
214
+ ),
215
+ warn_bytes=_positive_int(spec, "warn_bytes", defaults["warn_bytes"]),
216
+ row_limit=_positive_int(spec, "row_limit", defaults["row_limit"]),
217
+ cost_per_tib_usd=cost if cost > 0 else defaults["cost_per_tib_usd"],
218
+ aliases=tuple(str(a).strip().lower() for a in aliases if str(a).strip()),
219
+ )
220
+
221
+
222
+ def _global_defaults() -> dict:
223
+ """Values an environment inherits when it does not set its own.
224
+
225
+ These are the original single-project variables, which keeps a pre-existing
226
+ configuration working when environments are introduced around it.
227
+ """
228
+ return {
229
+ "location": os.environ.get("BQ_LOCATION", "").strip() or DEFAULT_LOCATION,
230
+ "impersonate": os.environ.get("BQ_IMPERSONATE_SERVICE_ACCOUNT", "").strip(),
231
+ "dataset_allowlist": _as_allowlist(os.environ.get("BQ_DATASET_ALLOWLIST", "")),
232
+ "max_bytes_billed": _env_int("BQ_MAX_BYTES_BILLED", DEFAULT_MAX_BYTES_BILLED),
233
+ "warn_bytes": _env_int("BQ_WARN_BYTES", DEFAULT_WARN_BYTES),
234
+ "row_limit": _env_int("BQ_ROW_LIMIT", DEFAULT_ROW_LIMIT),
235
+ "cost_per_tib_usd": _env_float("BQ_COST_PER_TIB_USD", DEFAULT_COST_PER_TIB_USD),
236
+ }
237
+
238
+
239
+ def _parse_registry(table: dict) -> tuple[Environment, ...]:
240
+ if not isinstance(table, dict) or not table:
241
+ raise RuntimeError(
242
+ "The environment registry must be a non-empty mapping of name -> "
243
+ 'settings, e.g. {"staging": {"project": "..."}}'
244
+ )
245
+ defaults = _global_defaults()
246
+ environments = [
247
+ _build_environment(name, spec, defaults)
248
+ for name, spec in table.items()
249
+ if str(name).strip()
250
+ ]
251
+ if not environments:
252
+ raise RuntimeError("The environment registry defined no usable environments.")
253
+ return tuple(environments)
254
+
255
+
256
+ def config_path() -> Path:
257
+ """Path of the config file, whether or not it exists."""
258
+ override = os.environ.get("BQ_MCP_CONFIG", "").strip()
259
+ return Path(override).expanduser() if override else DEFAULT_CONFIG_PATH
260
+
261
+
262
+ def _load_config_file() -> dict:
263
+ path = config_path()
264
+ try:
265
+ raw = path.read_bytes()
266
+ except FileNotFoundError:
267
+ return {}
268
+ except OSError as exc:
269
+ raise RuntimeError(f"Could not read config file {path}: {exc}") from exc
270
+ try:
271
+ parsed = tomllib.loads(raw.decode("utf-8"))
272
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
273
+ raise RuntimeError(f"Config file {path} is not valid TOML: {exc}") from exc
274
+ if not isinstance(parsed, dict):
275
+ raise RuntimeError(f"Config file {path} must define a TOML table.")
276
+ return parsed
277
+
278
+
279
+ @lru_cache(maxsize=1)
280
+ def get_settings() -> Settings:
281
+ file_config = _load_config_file()
282
+
283
+ raw_registry = os.environ.get("BQ_MCP_ENVIRONMENTS", "").strip()
284
+ if raw_registry:
285
+ try:
286
+ parsed = json.loads(raw_registry)
287
+ except json.JSONDecodeError as exc:
288
+ raise RuntimeError(
289
+ f"BQ_MCP_ENVIRONMENTS is not valid JSON: {exc}"
290
+ ) from exc
291
+ environments = _parse_registry(parsed)
292
+ elif file_config.get("environments"):
293
+ table = file_config["environments"]
294
+ if not isinstance(table, dict):
295
+ raise RuntimeError(
296
+ "The [environments] section of the config file must be a table "
297
+ "of named environments, e.g. [environments.warehouse]."
298
+ )
299
+ environments = _parse_registry(table)
300
+ else:
301
+ # Single-environment mode: exactly the previous behaviour.
302
+ defaults = _global_defaults()
303
+ environments = (
304
+ Environment(
305
+ name="default",
306
+ project=_resolve_project(),
307
+ location=defaults["location"],
308
+ impersonate=defaults["impersonate"],
309
+ dataset_allowlist=defaults["dataset_allowlist"],
310
+ max_bytes_billed=defaults["max_bytes_billed"],
311
+ warn_bytes=defaults["warn_bytes"],
312
+ row_limit=defaults["row_limit"],
313
+ cost_per_tib_usd=defaults["cost_per_tib_usd"],
314
+ ),
315
+ )
316
+
317
+ requested = os.environ.get("BQ_MCP_DEFAULT_ENVIRONMENT", "").strip().lower()
318
+ if not requested:
319
+ requested = str(file_config.get("default_environment", "")).strip().lower()
320
+
321
+ known = {e.name for e in environments}
322
+ if requested and requested not in known:
323
+ raise RuntimeError(
324
+ f"BQ_MCP_DEFAULT_ENVIRONMENT='{requested}' is not one of the "
325
+ f"configured environments: {sorted(known)}"
326
+ )
327
+ if requested:
328
+ default_environment = requested
329
+ else:
330
+ default_environment = next(
331
+ (name for name in _PREFERRED_DEFAULTS if name in known),
332
+ environments[0].name,
333
+ )
334
+
335
+ return Settings(
336
+ environments=environments, default_environment=default_environment
337
+ )
338
+
339
+
340
+ @lru_cache(maxsize=1)
341
+ def _lookup_table() -> dict[str, Environment]:
342
+ """Every accepted spelling of an environment, mapped to it."""
343
+ table: dict[str, Environment] = {}
344
+ for env in get_settings().environments:
345
+ table[env.name] = env
346
+ for alias in env.aliases:
347
+ table.setdefault(alias, env)
348
+ # Let the agent name the project id directly.
349
+ if env.project:
350
+ table.setdefault(env.project.lower(), env)
351
+ for alias, canonical in _BUILTIN_ALIASES.items():
352
+ if canonical in table:
353
+ table.setdefault(alias, table[canonical])
354
+ return table
355
+
356
+
357
+ def list_environments() -> tuple[Environment, ...]:
358
+ return get_settings().environments
359
+
360
+
361
+ def resolve_environment(name: str = "") -> Environment:
362
+ """Resolve a name, alias or project id to an Environment.
363
+
364
+ An empty name yields the default. An unknown one raises a ValueError naming
365
+ the valid options rather than falling back, so a typo can never send a
366
+ question about one warehouse to another.
367
+ """
368
+ settings = get_settings()
369
+ key = (name or "").strip().lower()
370
+ table = _lookup_table()
371
+
372
+ if not key:
373
+ env = table.get(settings.default_environment)
374
+ if env is None: # pragma: no cover - guarded by get_settings()
375
+ raise RuntimeError("No environments are configured.")
376
+ return env
377
+
378
+ env = table.get(key)
379
+ if env is None:
380
+ valid = sorted({e.name for e in settings.environments})
381
+ raise ValueError(
382
+ f"Unknown environment '{name}'. Configured environments: "
383
+ f"{', '.join(valid)}."
384
+ )
385
+ return env
386
+
387
+
388
+ def require_environment(name: str = "") -> Environment:
389
+ """Resolve an environment and insist it has a usable project.
390
+
391
+ Called at the top of every tool rather than at import, so the failure is a
392
+ message the agent can relay instead of a dead process.
393
+ """
394
+ env = resolve_environment(name)
395
+ if not env.project:
396
+ raise RuntimeError(
397
+ f"Environment '{env.name}' has no BigQuery project configured. Ask "
398
+ "the user to set BQ_PROJECT to the GCP project whose datasets they "
399
+ "want to query, e.g.\n"
400
+ " BQ_PROJECT=my-gcp-project\n"
401
+ "Alternatively, `gcloud auth application-default set-quota-project "
402
+ "PROJECT_ID` associates a project with their credentials."
403
+ )
404
+ return env
405
+
406
+
407
+ def describe_environments() -> str:
408
+ """One line per environment, for the server instructions."""
409
+ try:
410
+ settings = get_settings()
411
+ except Exception:
412
+ return ""
413
+ lines = []
414
+ for env in settings.environments:
415
+ if not env.project:
416
+ continue
417
+ marker = " (default)" if env.name == settings.default_environment else ""
418
+ detail = f"project {env.project}, location {env.location}"
419
+ if env.dataset_allowlist:
420
+ detail += f", datasets {', '.join(sorted(env.dataset_allowlist))}"
421
+ lines.append(f"- {env.name}{marker}: {detail}")
422
+ return "\n".join(lines)
423
+
424
+
425
+ def config_summary_lines(env: Environment) -> list[str]:
426
+ """The effective limits for one environment, for `doctor` to print.
427
+
428
+ Reporting these matters because they are silently overridable: someone
429
+ debugging "why did that query get refused" needs the caps actually in
430
+ force, not the documented defaults.
431
+ """
432
+ lines = [
433
+ f"warn above: {human_bytes(env.warn_bytes)}",
434
+ f"hard cap: {human_bytes(env.max_bytes_billed)}",
435
+ f"default rows: {env.row_limit}",
436
+ f"price per TiB: ${env.cost_per_tib_usd}",
437
+ f"impersonate: {env.impersonate or '(none — your own credentials)'}",
438
+ ]
439
+ if env.dataset_allowlist:
440
+ lines.append("allowlist: " + ", ".join(sorted(env.dataset_allowlist)))
441
+ else:
442
+ lines.append("allowlist: (none — all datasets readable)")
443
+ return lines