platform-mcp 0.2.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,3 @@
1
+ """Read-only GCP platform-engineer MCP server."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,4 @@
1
+ from .server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,116 @@
1
+ """Lazy, per-environment GCP client factory.
2
+
3
+ Base credentials are resolved once via Application Default Credentials. If
4
+ ``GOOGLE_APPLICATION_CREDENTIALS`` points at a service-account key, google-auth
5
+ picks it up automatically. Each configured environment may name its own
6
+ read-only service account to impersonate, so staging and production are reached
7
+ through separate identities from the same process.
8
+
9
+ Every getter takes the resolved :class:`~platform_mcp.config.Environment` and is
10
+ cached per environment: switching back and forth costs nothing after the first
11
+ call, and no client is ever shared across projects.
12
+
13
+ Every client returned here is read-only in practice: the server never calls a
14
+ mutating method, and this is paired with viewer-only IAM identities.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from functools import lru_cache
20
+
21
+ import google.auth
22
+ from google.auth import impersonated_credentials
23
+
24
+ from .config import Environment
25
+
26
+ _SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
27
+
28
+ # Number of distinct environments to keep clients for. Generous relative to any
29
+ # realistic registry, so caches never thrash between staging and production.
30
+ _CACHE_SIZE = 16
31
+
32
+
33
+ @lru_cache(maxsize=1)
34
+ def _base_credentials():
35
+ creds, _ = google.auth.default(scopes=_SCOPES)
36
+ return creds
37
+
38
+
39
+ @lru_cache(maxsize=_CACHE_SIZE)
40
+ def get_credentials(impersonate: str = ""):
41
+ """Credentials for a target identity; impersonates when one is configured."""
42
+ creds = _base_credentials()
43
+ if impersonate:
44
+ creds = impersonated_credentials.Credentials(
45
+ source_credentials=creds,
46
+ target_principal=impersonate,
47
+ target_scopes=_SCOPES,
48
+ )
49
+ return creds
50
+
51
+
52
+ def _creds_for(env: Environment):
53
+ return get_credentials(env.impersonate)
54
+
55
+
56
+ @lru_cache(maxsize=_CACHE_SIZE)
57
+ def get_logging_client(env: Environment):
58
+ from google.cloud import logging_v2
59
+
60
+ return logging_v2.Client(project=env.project, credentials=_creds_for(env))
61
+
62
+
63
+ @lru_cache(maxsize=_CACHE_SIZE)
64
+ def get_error_stats_client(env: Environment):
65
+ from google.cloud import errorreporting_v1beta1
66
+
67
+ return errorreporting_v1beta1.ErrorStatsServiceClient(credentials=_creds_for(env))
68
+
69
+
70
+ @lru_cache(maxsize=_CACHE_SIZE)
71
+ def get_metric_client(env: Environment):
72
+ from google.cloud import monitoring_v3
73
+
74
+ return monitoring_v3.MetricServiceClient(credentials=_creds_for(env))
75
+
76
+
77
+ @lru_cache(maxsize=_CACHE_SIZE)
78
+ def get_alert_policy_client(env: Environment):
79
+ from google.cloud import monitoring_v3
80
+
81
+ return monitoring_v3.AlertPolicyServiceClient(credentials=_creds_for(env))
82
+
83
+
84
+ @lru_cache(maxsize=_CACHE_SIZE)
85
+ def get_uptime_client(env: Environment):
86
+ from google.cloud import monitoring_v3
87
+
88
+ return monitoring_v3.UptimeCheckServiceClient(credentials=_creds_for(env))
89
+
90
+
91
+ @lru_cache(maxsize=_CACHE_SIZE)
92
+ def get_recommender_client(env: Environment):
93
+ from google.cloud import recommender_v1
94
+
95
+ return recommender_v1.RecommenderClient(credentials=_creds_for(env))
96
+
97
+
98
+ @lru_cache(maxsize=_CACHE_SIZE)
99
+ def get_asset_client(env: Environment):
100
+ from google.cloud import asset_v1
101
+
102
+ return asset_v1.AssetServiceClient(credentials=_creds_for(env))
103
+
104
+
105
+ @lru_cache(maxsize=_CACHE_SIZE)
106
+ def get_billing_client(env: Environment):
107
+ from google.cloud import billing_v1
108
+
109
+ return billing_v1.CloudBillingClient(credentials=_creds_for(env))
110
+
111
+
112
+ @lru_cache(maxsize=_CACHE_SIZE)
113
+ def get_bigquery_client(env: Environment):
114
+ from google.cloud import bigquery
115
+
116
+ return bigquery.Client(project=env.project, credentials=_creds_for(env))
platform_mcp/config.py ADDED
@@ -0,0 +1,329 @@
1
+ """Runtime settings for the platform-mcp server.
2
+
3
+ Holds a registry of named GCP environments (staging, production, ...) so a
4
+ single server process can answer questions about any of them. Every tool takes
5
+ an optional ``environment`` argument that is resolved here; when it is omitted
6
+ the configured default environment is used.
7
+
8
+ The registry comes from ``PLATFORM_MCP_ENVIRONMENTS``, a JSON object mapping an
9
+ environment name to its settings::
10
+
11
+ {
12
+ "staging": {
13
+ "project": "my-app-staging",
14
+ "impersonate": "platform-mcp-ro@my-app-staging.iam.gserviceaccount.com"
15
+ },
16
+ "production": {
17
+ "project": "my-app",
18
+ "impersonate": "platform-mcp-ro@my-app.iam.gserviceaccount.com",
19
+ "billing_export_table": "my-app.billing.gcp_billing_export_v1_XXXXXX",
20
+ "aliases": ["live"]
21
+ }
22
+ }
23
+
24
+ A value may also be a bare project-id string when no impersonation is needed.
25
+ If ``PLATFORM_MCP_ENVIRONMENTS`` is unset the server falls back to the original
26
+ single-project behaviour (``GCP_PROJECT`` + ``IMPERSONATE_SERVICE_ACCOUNT``),
27
+ exposed as one environment named ``default``.
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
+ # Where the config file lives when PLATFORM_MCP_CONFIG does not name one.
40
+ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "platform-mcp" / "config.toml"
41
+
42
+ _PROJECT_ENV_VARS = (
43
+ "GCP_PROJECT",
44
+ "GOOGLE_CLOUD_PROJECT",
45
+ "GOOGLE_CLOUD_QUOTA_PROJECT",
46
+ "GCLOUD_PROJECT",
47
+ )
48
+
49
+ # Spoken shorthands an agent is likely to pick up from a prompt ("check prod").
50
+ # Listed in both directions so the environment can be named either way, and
51
+ # only applied when they do not collide with a real environment name.
52
+ _BUILTIN_ALIASES = {
53
+ "prod": "production",
54
+ "prd": "production",
55
+ "live": "production",
56
+ "production": "prod",
57
+ "stg": "staging",
58
+ "stage": "staging",
59
+ "qa": "staging",
60
+ "staging": "stage",
61
+ "dev": "development",
62
+ "development": "dev",
63
+ "test": "testing",
64
+ "testing": "test",
65
+ }
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Environment:
70
+ """One resolvable GCP target: a project plus how to authenticate to it."""
71
+
72
+ name: str
73
+ project: str
74
+ impersonate: str = ""
75
+ billing_export_table: str = ""
76
+ aliases: tuple[str, ...] = field(default_factory=tuple)
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Settings:
81
+ environments: tuple[Environment, ...]
82
+ default_environment: str
83
+ default_location: str = "global"
84
+ default_limit: int = 50
85
+
86
+
87
+ def _adc_project() -> str:
88
+ """Project associated with Application Default Credentials, if any."""
89
+ try:
90
+ import google.auth
91
+
92
+ _, project = google.auth.default()
93
+ return str(project) if project else ""
94
+ except Exception:
95
+ return ""
96
+
97
+
98
+ def _resolve_single_project() -> str:
99
+ for var in _PROJECT_ENV_VARS:
100
+ value = os.environ.get(var)
101
+ if value:
102
+ return value
103
+ # Fall back to the ADC-associated project so the server still works with a
104
+ # bare `gcloud auth application-default login` and no explicit config.
105
+ return _adc_project()
106
+
107
+
108
+ def _parse_registry(raw: str) -> tuple[Environment, ...]:
109
+ try:
110
+ parsed = json.loads(raw)
111
+ except json.JSONDecodeError as exc:
112
+ raise RuntimeError(
113
+ f"PLATFORM_MCP_ENVIRONMENTS is not valid JSON: {exc}"
114
+ ) from exc
115
+ if not isinstance(parsed, dict) or not parsed:
116
+ raise RuntimeError(
117
+ "PLATFORM_MCP_ENVIRONMENTS must be a non-empty JSON object mapping "
118
+ 'environment name -> settings, e.g. {"staging": {"project": "..."}}'
119
+ )
120
+
121
+ # Global values act as the fallback for environments that don't set them,
122
+ # which keeps a pre-existing single-project config working unchanged.
123
+ global_impersonate = os.environ.get("IMPERSONATE_SERVICE_ACCOUNT", "").strip()
124
+ global_billing = os.environ.get("BILLING_EXPORT_TABLE", "").strip()
125
+
126
+ environments = []
127
+ for name, spec in parsed.items():
128
+ key = str(name).strip().lower()
129
+ if not key:
130
+ continue
131
+ if isinstance(spec, str):
132
+ spec = {"project": spec}
133
+ if not isinstance(spec, dict):
134
+ raise RuntimeError(
135
+ f"PLATFORM_MCP_ENVIRONMENTS['{name}'] must be an object or a "
136
+ "project-id string."
137
+ )
138
+ project = str(spec.get("project", "")).strip()
139
+ if not project:
140
+ raise RuntimeError(
141
+ f"PLATFORM_MCP_ENVIRONMENTS['{name}'] is missing a 'project'."
142
+ )
143
+ aliases = spec.get("aliases") or []
144
+ if isinstance(aliases, str):
145
+ aliases = [aliases]
146
+ environments.append(
147
+ Environment(
148
+ name=key,
149
+ project=project,
150
+ impersonate=str(spec.get("impersonate", "") or global_impersonate).strip(),
151
+ billing_export_table=str(
152
+ spec.get("billing_export_table", "") or global_billing
153
+ ).strip(),
154
+ aliases=tuple(str(a).strip().lower() for a in aliases if str(a).strip()),
155
+ )
156
+ )
157
+ if not environments:
158
+ raise RuntimeError("PLATFORM_MCP_ENVIRONMENTS defined no usable environments.")
159
+ return tuple(environments)
160
+
161
+
162
+ def config_path() -> Path:
163
+ """Path of the config file, whether or not it exists."""
164
+ override = os.environ.get("PLATFORM_MCP_CONFIG", "").strip()
165
+ return Path(override).expanduser() if override else DEFAULT_CONFIG_PATH
166
+
167
+
168
+ def _load_config_file() -> dict:
169
+ """Read the TOML config file, or return an empty mapping if there is none.
170
+
171
+ A config file is far kinder than a JSON blob squeezed into an environment
172
+ variable, and it can be committed and shared across a team. Environment
173
+ variables still win, so an existing setup keeps working and a one-off
174
+ override needs no edit.
175
+ """
176
+ path = config_path()
177
+ try:
178
+ raw = path.read_bytes()
179
+ except FileNotFoundError:
180
+ return {}
181
+ except OSError as exc:
182
+ raise RuntimeError(f"Could not read config file {path}: {exc}") from exc
183
+ try:
184
+ parsed = tomllib.loads(raw.decode("utf-8"))
185
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
186
+ raise RuntimeError(f"Config file {path} is not valid TOML: {exc}") from exc
187
+ if not isinstance(parsed, dict):
188
+ raise RuntimeError(f"Config file {path} must define a TOML table.")
189
+ return parsed
190
+
191
+
192
+ def _environments_from_file(file_config: dict) -> tuple[Environment, ...]:
193
+ table = file_config.get("environments")
194
+ if not table:
195
+ return ()
196
+ if not isinstance(table, dict):
197
+ raise RuntimeError(
198
+ "The [environments] section of the config file must be a table of "
199
+ 'named environments, e.g. [environments.staging].'
200
+ )
201
+ # Reuse the JSON parser so file and environment variable cannot drift apart.
202
+ return _parse_registry(json.dumps(table))
203
+
204
+
205
+ @lru_cache(maxsize=1)
206
+ def get_settings() -> Settings:
207
+ file_config = _load_config_file()
208
+
209
+ limit_raw = os.environ.get("PLATFORM_MCP_DEFAULT_LIMIT", "").strip()
210
+ if not limit_raw:
211
+ limit_raw = str(file_config.get("default_limit", 50))
212
+ try:
213
+ default_limit = max(1, int(limit_raw))
214
+ except ValueError:
215
+ default_limit = 50
216
+
217
+ raw_registry = os.environ.get("PLATFORM_MCP_ENVIRONMENTS", "").strip()
218
+ file_environments = _environments_from_file(file_config)
219
+ if raw_registry:
220
+ environments = _parse_registry(raw_registry)
221
+ elif file_environments:
222
+ environments = file_environments
223
+ else:
224
+ environments = (
225
+ Environment(
226
+ name="default",
227
+ project=_resolve_single_project(),
228
+ impersonate=os.environ.get("IMPERSONATE_SERVICE_ACCOUNT", "").strip(),
229
+ billing_export_table=os.environ.get("BILLING_EXPORT_TABLE", "").strip(),
230
+ ),
231
+ )
232
+
233
+ requested_default = os.environ.get("PLATFORM_MCP_DEFAULT_ENVIRONMENT", "").strip().lower()
234
+ if not requested_default:
235
+ requested_default = str(file_config.get("default_environment", "")).strip().lower()
236
+ known = {e.name for e in environments}
237
+ if requested_default and requested_default not in known:
238
+ raise RuntimeError(
239
+ f"PLATFORM_MCP_DEFAULT_ENVIRONMENT='{requested_default}' is not one of "
240
+ f"the configured environments: {sorted(known)}"
241
+ )
242
+ # With no explicit default, prefer the safest target when it exists rather
243
+ # than silently defaulting to whichever key happened to be listed first.
244
+ if requested_default:
245
+ default_environment = requested_default
246
+ elif "staging" in known:
247
+ default_environment = "staging"
248
+ else:
249
+ default_environment = environments[0].name
250
+
251
+ return Settings(
252
+ environments=environments,
253
+ default_environment=default_environment,
254
+ default_location=os.environ.get("GCP_LOCATION", "global"),
255
+ default_limit=default_limit,
256
+ )
257
+
258
+
259
+ @lru_cache(maxsize=1)
260
+ def _lookup_table() -> dict[str, Environment]:
261
+ """Map every accepted spelling of an environment to its Environment."""
262
+ environments = get_settings().environments
263
+ table: dict[str, Environment] = {}
264
+ for env in environments:
265
+ table[env.name] = env
266
+ for alias in env.aliases:
267
+ table.setdefault(alias, env)
268
+ # Let the agent name the project id directly ("check my-app-prod").
269
+ if env.project:
270
+ table.setdefault(env.project.lower(), env)
271
+ for alias, canonical in _BUILTIN_ALIASES.items():
272
+ if canonical in table:
273
+ table.setdefault(alias, table[canonical])
274
+ return table
275
+
276
+
277
+ def list_environments() -> tuple[Environment, ...]:
278
+ return get_settings().environments
279
+
280
+
281
+ def resolve_environment(name: str = "") -> Environment:
282
+ """Resolve an environment name (or alias, or project id) to an Environment.
283
+
284
+ An empty name yields the default environment. Unknown names raise a
285
+ ``ValueError`` naming the valid options rather than silently falling back,
286
+ so a typo can never send a production question to staging or vice versa.
287
+ """
288
+ settings = get_settings()
289
+ key = (name or "").strip().lower()
290
+ table = _lookup_table()
291
+
292
+ if not key:
293
+ env = table.get(settings.default_environment)
294
+ if env is None: # pragma: no cover - guarded by get_settings()
295
+ raise RuntimeError("No environments are configured.")
296
+ else:
297
+ env = table.get(key)
298
+ if env is None:
299
+ valid = sorted({e.name for e in settings.environments})
300
+ raise ValueError(
301
+ f"Unknown environment '{name}'. Configured environments: "
302
+ f"{', '.join(valid)}."
303
+ )
304
+
305
+ if not env.project:
306
+ raise RuntimeError(
307
+ f"Environment '{env.name}' has no GCP project configured. Set "
308
+ "PLATFORM_MCP_ENVIRONMENTS (or GCP_PROJECT for single-project mode)."
309
+ )
310
+ return env
311
+
312
+
313
+ def require_project(environment: str = "") -> str:
314
+ """Return the project id for an environment, raising if it is unset."""
315
+ return resolve_environment(environment).project
316
+
317
+
318
+ def describe_environments() -> str:
319
+ """One-line-per-environment summary used in the server instructions."""
320
+ settings = get_settings()
321
+ try:
322
+ environments = settings.environments
323
+ except RuntimeError: # pragma: no cover - defensive
324
+ return ""
325
+ lines = []
326
+ for env in environments:
327
+ marker = " (default)" if env.name == settings.default_environment else ""
328
+ lines.append(f"- {env.name}{marker}: project {env.project}")
329
+ return "\n".join(lines)
@@ -0,0 +1,173 @@
1
+ """``platform-mcp doctor``: check that this machine can actually reach GCP.
2
+
3
+ Every prerequisite here fails at a different layer -- credentials, IAM, API
4
+ enablement, cross-project BigQuery grants -- and each one produces a different
5
+ opaque error at the first tool call. Checking them up front, per environment,
6
+ with the fix printed next to the failure, is the difference between onboarding
7
+ in a minute and onboarding in a support thread.
8
+
9
+ This runs as a CLI command, not over the protocol, so printing to stdout here
10
+ is safe. The server itself must never do that.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+
17
+ from .config import Environment, config_path, get_settings
18
+
19
+ OK = " ok "
20
+ FAIL = " FAIL "
21
+ SKIP = " skip "
22
+
23
+
24
+ def _line(status: str, message: str) -> None:
25
+ print(f"[{status}] {message}")
26
+
27
+
28
+ def _fix(text: str) -> None:
29
+ for line in text.strip().splitlines():
30
+ print(f" {line}")
31
+
32
+
33
+ def _check_adc() -> bool:
34
+ try:
35
+ import google.auth
36
+
37
+ credentials, project = google.auth.default(
38
+ scopes=["https://www.googleapis.com/auth/cloud-platform"]
39
+ )
40
+ except Exception as exc:
41
+ _line(FAIL, "Application Default Credentials")
42
+ _fix(f"{exc}\nFix: gcloud auth application-default login")
43
+ return False
44
+ kind = type(credentials).__name__
45
+ _line(OK, f"Application Default Credentials ({kind}, quota project: {project or 'unset'})")
46
+ if not project:
47
+ _fix(
48
+ "No quota project is set. Some APIs bill quota to it and will fail "
49
+ "without one.\nFix: gcloud auth application-default set-quota-project PROJECT_ID"
50
+ )
51
+ return True
52
+
53
+
54
+ def _check_impersonation(env: Environment) -> bool:
55
+ if not env.impersonate:
56
+ _line(SKIP, "impersonation not configured (using your own credentials)")
57
+ return True
58
+ from google.auth.transport.requests import Request
59
+
60
+ from .clients import get_credentials
61
+
62
+ try:
63
+ credentials = get_credentials(env.impersonate)
64
+ credentials.refresh(Request())
65
+ except Exception as exc:
66
+ _line(FAIL, f"impersonate {env.impersonate}")
67
+ _fix(
68
+ f"{str(exc)[:200]}\n"
69
+ "Fix: gcloud iam service-accounts add-iam-policy-binding \\\n"
70
+ f" {env.impersonate} \\\n"
71
+ " --member=user:YOUR_EMAIL \\\n"
72
+ " --role=roles/iam.serviceAccountTokenCreator"
73
+ )
74
+ return False
75
+ _line(OK, f"impersonate {env.impersonate}")
76
+ return True
77
+
78
+
79
+ def _check_read_api(env: Environment) -> bool:
80
+ """One real read, so API enablement and viewer roles are proven, not assumed."""
81
+ try:
82
+ from google.cloud import logging_v2
83
+
84
+ from .clients import get_logging_client
85
+
86
+ client = get_logging_client(env)
87
+ entries = client.list_entries(
88
+ filter_="timestamp>=\"1970-01-01T00:00:00Z\"",
89
+ order_by=logging_v2.DESCENDING,
90
+ max_results=1,
91
+ page_size=1,
92
+ )
93
+ next(iter(entries), None)
94
+ except Exception as exc:
95
+ _line(FAIL, f"read Cloud Logging in {env.project}")
96
+ _fix(
97
+ f"{str(exc)[:200]}\n"
98
+ f"Fix: grant roles/logging.viewer on {env.project} to "
99
+ f"{env.impersonate or 'your user'}, and enable the Cloud Logging API."
100
+ )
101
+ return False
102
+ _line(OK, f"read Cloud Logging in {env.project}")
103
+ return True
104
+
105
+
106
+ def _check_billing_export(env: Environment) -> bool:
107
+ if not env.billing_export_table:
108
+ _line(SKIP, "billing export not configured (get_cost_breakdown unavailable)")
109
+ return True
110
+ try:
111
+ from google.cloud import bigquery
112
+
113
+ from .clients import get_bigquery_client
114
+
115
+ client = get_bigquery_client(env)
116
+ # A dry run costs nothing and still proves both grants: permission to
117
+ # start a job here, and permission to read a table that usually lives
118
+ # in a different project.
119
+ job_config = bigquery.QueryJobConfig(dry_run=True, use_query_cache=False)
120
+ client.query(
121
+ f"SELECT cost FROM `{env.billing_export_table}` LIMIT 1",
122
+ job_config=job_config,
123
+ )
124
+ except Exception as exc:
125
+ _line(FAIL, f"read billing export {env.billing_export_table}")
126
+ _fix(
127
+ f"{str(exc)[:200]}\n"
128
+ f"Fix: the identity needs roles/bigquery.jobUser on {env.project} "
129
+ "AND roles/bigquery.dataViewer on the dataset holding the export "
130
+ "(often a different project)."
131
+ )
132
+ return False
133
+ _line(OK, f"read billing export {env.billing_export_table}")
134
+ return True
135
+
136
+
137
+ def run_doctor() -> int:
138
+ """Print a per-environment readiness report. Returns a process exit code."""
139
+ path = config_path()
140
+ print("platform-mcp doctor")
141
+ print(f"config file: {path}{'' if path.exists() else ' (not found)'}")
142
+ print()
143
+
144
+ try:
145
+ settings = get_settings()
146
+ except Exception as exc:
147
+ _line(FAIL, "configuration")
148
+ _fix(str(exc))
149
+ return 1
150
+
151
+ healthy = _check_adc()
152
+ print()
153
+
154
+ for env in settings.environments:
155
+ marker = " (default)" if env.name == settings.default_environment else ""
156
+ print(f"environment: {env.name}{marker} -> {env.project}")
157
+ results = [
158
+ _check_impersonation(env),
159
+ _check_read_api(env),
160
+ _check_billing_export(env),
161
+ ]
162
+ healthy = healthy and all(results)
163
+ print()
164
+
165
+ if healthy:
166
+ print("All checks passed.")
167
+ return 0
168
+ print("Some checks failed. Fix the items marked FAIL above, then re-run.")
169
+ return 1
170
+
171
+
172
+ def main() -> None: # pragma: no cover - thin CLI wrapper
173
+ sys.exit(run_doctor())