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.
- platform_mcp/__init__.py +3 -0
- platform_mcp/__main__.py +4 -0
- platform_mcp/clients.py +116 -0
- platform_mcp/config.py +329 -0
- platform_mcp/diagnostics.py +173 -0
- platform_mcp/errors.py +101 -0
- platform_mcp/formatting.py +55 -0
- platform_mcp/observability.py +152 -0
- platform_mcp/registration.py +34 -0
- platform_mcp/server.py +94 -0
- platform_mcp/tools/__init__.py +1 -0
- platform_mcp/tools/cost_tools.py +152 -0
- platform_mcp/tools/environment_tools.py +41 -0
- platform_mcp/tools/error_reporting_tools.py +89 -0
- platform_mcp/tools/inventory_tools.py +128 -0
- platform_mcp/tools/logging_tools.py +115 -0
- platform_mcp/tools/monitoring_tools.py +210 -0
- platform_mcp/tools/recommender_tools.py +210 -0
- platform_mcp-0.2.0.dist-info/METADATA +341 -0
- platform_mcp-0.2.0.dist-info/RECORD +24 -0
- platform_mcp-0.2.0.dist-info/WHEEL +5 -0
- platform_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- platform_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
- platform_mcp-0.2.0.dist-info/top_level.txt +1 -0
platform_mcp/__init__.py
ADDED
platform_mcp/__main__.py
ADDED
platform_mcp/clients.py
ADDED
|
@@ -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())
|