google-cloud-support-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.
- google_cloud_support_mcp/__init__.py +11 -0
- google_cloud_support_mcp/__main__.py +4 -0
- google_cloud_support_mcp/auth.py +61 -0
- google_cloud_support_mcp/clients.py +48 -0
- google_cloud_support_mcp/confirm.py +158 -0
- google_cloud_support_mcp/errors.py +233 -0
- google_cloud_support_mcp/fanout.py +145 -0
- google_cloud_support_mcp/media.py +81 -0
- google_cloud_support_mcp/models.py +185 -0
- google_cloud_support_mcp/prompts.py +37 -0
- google_cloud_support_mcp/redaction.py +97 -0
- google_cloud_support_mcp/resources.py +54 -0
- google_cloud_support_mcp/serializers.py +148 -0
- google_cloud_support_mcp/server.py +172 -0
- google_cloud_support_mcp/settings.py +63 -0
- google_cloud_support_mcp/tools/__init__.py +23 -0
- google_cloud_support_mcp/tools/attachments.py +170 -0
- google_cloud_support_mcp/tools/cases.py +440 -0
- google_cloud_support_mcp/tools/classifications.py +122 -0
- google_cloud_support_mcp/tools/comments.py +124 -0
- google_cloud_support_mcp-0.1.0.dist-info/METADATA +265 -0
- google_cloud_support_mcp-0.1.0.dist-info/RECORD +24 -0
- google_cloud_support_mcp-0.1.0.dist-info/WHEEL +4 -0
- google_cloud_support_mcp-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Credentials, used exactly as the user already configured them.
|
|
2
|
+
|
|
3
|
+
The server stores nothing (FR-020): ADC covers `gcloud auth
|
|
4
|
+
application-default login`, service account keys, Workload Identity, and the
|
|
5
|
+
metadata server without any of them being handled here.
|
|
6
|
+
|
|
7
|
+
Two consumers share one credential object, and they refresh independently — the
|
|
8
|
+
gRPC channel manages its own lifecycle while the media REST path needs a bearer
|
|
9
|
+
token minted on demand. That split is the second-order consequence of the SDK
|
|
10
|
+
not generating the media endpoints.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
|
|
17
|
+
import google.auth
|
|
18
|
+
from google.auth.transport.requests import Request as AuthRequest
|
|
19
|
+
|
|
20
|
+
__all__ = ["SCOPES", "CredentialProvider"]
|
|
21
|
+
|
|
22
|
+
SCOPES = ("https://www.googleapis.com/auth/cloud-platform",)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CredentialProvider:
|
|
26
|
+
"""Resolves ADC once and mints bearer tokens for the media REST calls."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, quota_project: str | None = None) -> None:
|
|
29
|
+
self._quota_project = quota_project
|
|
30
|
+
self._credentials = None
|
|
31
|
+
self._lock = asyncio.Lock()
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def quota_project(self) -> str | None:
|
|
35
|
+
return self._quota_project
|
|
36
|
+
|
|
37
|
+
def resolve(self):
|
|
38
|
+
"""Return the ADC credentials, resolving them on first use."""
|
|
39
|
+
if self._credentials is None:
|
|
40
|
+
credentials, _ = google.auth.default(scopes=list(SCOPES))
|
|
41
|
+
if self._quota_project and hasattr(credentials, "with_quota_project"):
|
|
42
|
+
credentials = credentials.with_quota_project(self._quota_project)
|
|
43
|
+
self._credentials = credentials
|
|
44
|
+
return self._credentials
|
|
45
|
+
|
|
46
|
+
async def bearer_headers(self) -> dict[str, str]:
|
|
47
|
+
"""Headers for the media endpoints, refreshing the token when stale.
|
|
48
|
+
|
|
49
|
+
google-auth's refresh is blocking, so it runs off the event loop. The lock
|
|
50
|
+
keeps concurrent uploads from refreshing the same credential at once.
|
|
51
|
+
"""
|
|
52
|
+
async with self._lock:
|
|
53
|
+
credentials = self.resolve()
|
|
54
|
+
if not credentials.valid:
|
|
55
|
+
await asyncio.to_thread(credentials.refresh, AuthRequest())
|
|
56
|
+
token = credentials.token
|
|
57
|
+
|
|
58
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
59
|
+
if self._quota_project:
|
|
60
|
+
headers["x-goog-user-project"] = self._quota_project
|
|
61
|
+
return headers
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The three GAPIC async clients plus one HTTP client, owned by the server lifespan.
|
|
2
|
+
|
|
3
|
+
Three, not four: SupportEventSubscriptionService is out of scope. And an HTTP
|
|
4
|
+
client alongside them because attachment upload and download are HTTP media
|
|
5
|
+
endpoints that the GAPIC generator does not cover (research.md R-002).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from google.cloud.support_v2.services.case_attachment_service import (
|
|
14
|
+
CaseAttachmentServiceAsyncClient,
|
|
15
|
+
)
|
|
16
|
+
from google.cloud.support_v2.services.case_service import CaseServiceAsyncClient
|
|
17
|
+
from google.cloud.support_v2.services.comment_service import CommentServiceAsyncClient
|
|
18
|
+
|
|
19
|
+
from .auth import CredentialProvider
|
|
20
|
+
|
|
21
|
+
__all__ = ["SupportClients", "build_clients"]
|
|
22
|
+
|
|
23
|
+
SUPPORT_API_ROOT = "https://cloudsupport.googleapis.com"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class SupportClients:
|
|
28
|
+
"""One bundle constructed per server process, never per call."""
|
|
29
|
+
|
|
30
|
+
cases: CaseServiceAsyncClient
|
|
31
|
+
comments: CommentServiceAsyncClient
|
|
32
|
+
attachments: CaseAttachmentServiceAsyncClient
|
|
33
|
+
http: httpx.AsyncClient
|
|
34
|
+
credentials: CredentialProvider
|
|
35
|
+
|
|
36
|
+
async def aclose(self) -> None:
|
|
37
|
+
await self.http.aclose()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_clients(credentials: CredentialProvider) -> SupportClients:
|
|
41
|
+
creds = credentials.resolve()
|
|
42
|
+
return SupportClients(
|
|
43
|
+
cases=CaseServiceAsyncClient(credentials=creds),
|
|
44
|
+
comments=CommentServiceAsyncClient(credentials=creds),
|
|
45
|
+
attachments=CaseAttachmentServiceAsyncClient(credentials=creds),
|
|
46
|
+
http=httpx.AsyncClient(base_url=SUPPORT_API_ROOT, timeout=60.0),
|
|
47
|
+
credentials=credentials,
|
|
48
|
+
)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""User confirmation for anything that leaves this machine (FR-016, FR-017).
|
|
2
|
+
|
|
3
|
+
FastMCP 4 removed `ctx.elicit()` on modern connections and rejects
|
|
4
|
+
`InputRequiredResult` on handshake-era ones, so branching on the negotiated
|
|
5
|
+
protocol version is required, not optional.
|
|
6
|
+
|
|
7
|
+
The design point worth keeping: round one seals the *entire outgoing payload*
|
|
8
|
+
into `request_state`, and round two executes that payload verbatim instead of
|
|
9
|
+
rebuilding it. The framework signs and verifies that token, so "what the user
|
|
10
|
+
approved is what gets sent" is enforced by the framework rather than by
|
|
11
|
+
convention. A `confirm: bool` parameter cannot do this — the model fills that in,
|
|
12
|
+
not the user.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from fastmcp import Context
|
|
23
|
+
from fastmcp.exceptions import ToolError
|
|
24
|
+
from fastmcp.server.context import MODERN_PROTOCOL_VERSIONS
|
|
25
|
+
from mcp.types import ElicitRequest, ElicitRequestFormParams, InputRequiredResult
|
|
26
|
+
|
|
27
|
+
__all__ = ["NO_ELICITATION_MESSAGE", "ConfirmationOutcome", "request_confirmation"]
|
|
28
|
+
|
|
29
|
+
_KEY = "confirm"
|
|
30
|
+
_FIELD = "approve"
|
|
31
|
+
|
|
32
|
+
NO_ELICITATION_MESSAGE = (
|
|
33
|
+
"This action needs your explicit confirmation before anything is sent to Google "
|
|
34
|
+
"Support, but this client cannot prompt you (no elicitation support). Nothing was "
|
|
35
|
+
"changed. Use a client that supports elicitation, or perform this action in the "
|
|
36
|
+
"Cloud Console."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
DECLINED_MESSAGE = "Cancelled — nothing was sent to Google Support and no change was made."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class ConfirmationOutcome:
|
|
44
|
+
"""Either 'ask the user' (return `input_required`) or 'go ahead' (use `payload`)."""
|
|
45
|
+
|
|
46
|
+
input_required: InputRequiredResult | None = None
|
|
47
|
+
payload: dict[str, Any] | None = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def needs_input(self) -> bool:
|
|
51
|
+
return self.input_required is not None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _seal(tool: str, preview: str, payload: dict[str, Any]) -> str:
|
|
55
|
+
return json.dumps(
|
|
56
|
+
{
|
|
57
|
+
"tool": tool,
|
|
58
|
+
"payload": payload,
|
|
59
|
+
"preview": preview,
|
|
60
|
+
"issued_at": datetime.now(UTC).isoformat(),
|
|
61
|
+
},
|
|
62
|
+
separators=(",", ":"),
|
|
63
|
+
sort_keys=True,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _unseal(tool: str, raw: str | None) -> dict[str, Any]:
|
|
68
|
+
if not raw:
|
|
69
|
+
raise ToolError(
|
|
70
|
+
"The confirmation token was lost between rounds. Nothing was sent. Retry the action."
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
state = json.loads(raw)
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise ToolError("The confirmation token is unreadable. Nothing was sent.") from exc
|
|
76
|
+
|
|
77
|
+
# A token issued for one tool must not be redeemable by another.
|
|
78
|
+
if state.get("tool") != tool:
|
|
79
|
+
raise ToolError(
|
|
80
|
+
f"The confirmation token was issued for '{state.get('tool')}', not '{tool}'. "
|
|
81
|
+
"Nothing was sent."
|
|
82
|
+
)
|
|
83
|
+
payload = state.get("payload")
|
|
84
|
+
if not isinstance(payload, dict):
|
|
85
|
+
raise ToolError("The confirmation token carries no payload. Nothing was sent.")
|
|
86
|
+
return payload
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _ask(preview: str, request_state: str) -> InputRequiredResult:
|
|
90
|
+
params = ElicitRequestFormParams(
|
|
91
|
+
message=preview,
|
|
92
|
+
requested_schema={
|
|
93
|
+
"type": "object",
|
|
94
|
+
"properties": {
|
|
95
|
+
_FIELD: {
|
|
96
|
+
"type": "boolean",
|
|
97
|
+
"description": "Send this to Google Support?",
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"required": [_FIELD],
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
return InputRequiredResult(
|
|
104
|
+
result_type="input_required",
|
|
105
|
+
input_requests={_KEY: ElicitRequest(method="elicitation/create", params=params)},
|
|
106
|
+
request_state=request_state,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_modern(ctx: Context) -> bool:
|
|
111
|
+
request_context = ctx.request_context
|
|
112
|
+
if request_context is None:
|
|
113
|
+
return False
|
|
114
|
+
return getattr(request_context, "protocol_version", None) in MODERN_PROTOCOL_VERSIONS
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def request_confirmation(
|
|
118
|
+
ctx: Context,
|
|
119
|
+
*,
|
|
120
|
+
tool: str,
|
|
121
|
+
preview: str,
|
|
122
|
+
payload: dict[str, Any],
|
|
123
|
+
) -> ConfirmationOutcome:
|
|
124
|
+
"""Obtain the user's own approval before an outward-facing action.
|
|
125
|
+
|
|
126
|
+
`preview` is what the user reads; `payload` is exactly what will be sent.
|
|
127
|
+
Returning an outcome with `needs_input` means the caller should return
|
|
128
|
+
`outcome.input_required` unchanged and wait for the next round.
|
|
129
|
+
"""
|
|
130
|
+
if _is_modern(ctx):
|
|
131
|
+
responses = ctx.input_responses
|
|
132
|
+
if not responses or _KEY not in responses:
|
|
133
|
+
# Round one: ask, and carry the payload forward sealed.
|
|
134
|
+
return ConfirmationOutcome(input_required=_ask(preview, _seal(tool, preview, payload)))
|
|
135
|
+
|
|
136
|
+
answer = responses[_KEY]
|
|
137
|
+
# Check the action before touching content — a client may decline or cancel.
|
|
138
|
+
if getattr(answer, "action", None) != "accept":
|
|
139
|
+
raise ToolError(DECLINED_MESSAGE)
|
|
140
|
+
content = getattr(answer, "content", None) or {}
|
|
141
|
+
if content.get(_FIELD) is not True:
|
|
142
|
+
raise ToolError(DECLINED_MESSAGE)
|
|
143
|
+
|
|
144
|
+
return ConfirmationOutcome(payload=_unseal(tool, ctx.request_state))
|
|
145
|
+
|
|
146
|
+
# Handshake era (<= 2025-11-25): the blocking back-channel still exists.
|
|
147
|
+
try:
|
|
148
|
+
result = await ctx.elicit(preview, response_type=bool)
|
|
149
|
+
except ToolError:
|
|
150
|
+
raise
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
# No elicitation capability on this client. That is FR-019's shape exactly:
|
|
153
|
+
# change nothing, and say why it could not run.
|
|
154
|
+
raise ToolError(NO_ELICITATION_MESSAGE) from exc
|
|
155
|
+
|
|
156
|
+
if getattr(result, "action", None) != "accept" or getattr(result, "data", None) is not True:
|
|
157
|
+
raise ToolError(DECLINED_MESSAGE)
|
|
158
|
+
return ConfirmationOutcome(payload=payload)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Normalise failures from both transports into one vocabulary.
|
|
2
|
+
|
|
3
|
+
The server talks to the same Google service over two protocols — gRPC for the
|
|
4
|
+
thirteen GAPIC RPCs and HTTP for the two media endpoints that the SDK does not
|
|
5
|
+
generate. That means two exception families, and a user must not receive a
|
|
6
|
+
different shape of guidance depending on which one they happened to hit.
|
|
7
|
+
|
|
8
|
+
Every mapping goes through `_info`, so the guidance text for a given kind is
|
|
9
|
+
identical on both paths by construction rather than by convention.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from fastmcp.exceptions import ToolError
|
|
16
|
+
from google.api_core import exceptions as gexc
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from .models import ErrorKind
|
|
20
|
+
|
|
21
|
+
__all__ = ["ErrorInfo", "ErrorKind", "custom", "from_grpc", "from_http", "to_tool_error"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ErrorInfo(BaseModel):
|
|
25
|
+
kind: ErrorKind
|
|
26
|
+
detail: str
|
|
27
|
+
retryable: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Guidance is written as the user's *next step*, never as a restatement of the
|
|
31
|
+
# failure. `UNKNOWN` carries real guidance too: FR-021 and SC-006 are all-inclusive,
|
|
32
|
+
# so an empty or passthrough message there is a miss, not a default.
|
|
33
|
+
_GUIDANCE: dict[ErrorKind, tuple[str, bool]] = {
|
|
34
|
+
ErrorKind.PERMISSION_DENIED: (
|
|
35
|
+
"Access denied. Grant roles/cloudsupport.techSupportViewer (read) or "
|
|
36
|
+
"techSupportEditor (write) on this project, folder, or organization. "
|
|
37
|
+
"Organization-level scopes additionally need resourcemanager.organizations.get. "
|
|
38
|
+
"If the roles are already granted, verify the resource is covered by a paid support plan.",
|
|
39
|
+
False,
|
|
40
|
+
),
|
|
41
|
+
ErrorKind.API_NOT_ENABLED: (
|
|
42
|
+
"The Cloud Support API is not enabled on this project. Run "
|
|
43
|
+
"'gcloud services enable cloudsupport.googleapis.com --project=PROJECT_ID' "
|
|
44
|
+
"(use the quota project when the target is an organization), then wait a "
|
|
45
|
+
"minute for it to propagate and retry. This is not a permissions problem.",
|
|
46
|
+
False,
|
|
47
|
+
),
|
|
48
|
+
ErrorKind.SUPPORT_PLAN_REQUIRED: (
|
|
49
|
+
"This resource is not eligible to use the Cloud Support API — it has no paid "
|
|
50
|
+
"support plan. Standard, Enhanced, or Premium Support is required. Attach a "
|
|
51
|
+
"support plan to this project or organization (or use a project that already "
|
|
52
|
+
"has one), then retry. Reading may work where creating does not, so this can "
|
|
53
|
+
"appear only when you try to open a case.",
|
|
54
|
+
False,
|
|
55
|
+
),
|
|
56
|
+
ErrorKind.UNAUTHENTICATED: (
|
|
57
|
+
"No usable credentials. Run 'gcloud auth application-default login', or set "
|
|
58
|
+
"GOOGLE_APPLICATION_CREDENTIALS to a service account key. If you already did, "
|
|
59
|
+
"the credentials may have expired — re-run the login.",
|
|
60
|
+
False,
|
|
61
|
+
),
|
|
62
|
+
ErrorKind.NOT_FOUND: (
|
|
63
|
+
"No such resource. Check the name format: cases are "
|
|
64
|
+
"'projects/{project}/cases/{id}' or 'organizations/{org}/cases/{id}'. "
|
|
65
|
+
"Also confirm the case lives under a parent you configured in "
|
|
66
|
+
"GOOGLE_CLOUD_SUPPORT_MCP_PARENTS.",
|
|
67
|
+
False,
|
|
68
|
+
),
|
|
69
|
+
ErrorKind.INVALID_ARGUMENT: (
|
|
70
|
+
"The request was rejected as malformed. Check required fields and value "
|
|
71
|
+
"formats — classification ids come from search_case_classifications, and "
|
|
72
|
+
"timestamps must be ISO-8601.",
|
|
73
|
+
False,
|
|
74
|
+
),
|
|
75
|
+
ErrorKind.FAILED_PRECONDITION: (
|
|
76
|
+
"The resource is not in a state that allows this operation — for example, "
|
|
77
|
+
"closing or escalating a case that is already closed. Fetch the case with "
|
|
78
|
+
"get_case to see its current state.",
|
|
79
|
+
False,
|
|
80
|
+
),
|
|
81
|
+
ErrorKind.RATE_LIMITED: (
|
|
82
|
+
"Google is rate limiting these requests. Wait a few seconds and retry. If it "
|
|
83
|
+
"persists, lower GOOGLE_CLOUD_SUPPORT_MCP_FANOUT_CONCURRENCY so fewer scopes are "
|
|
84
|
+
"queried at once.",
|
|
85
|
+
True,
|
|
86
|
+
),
|
|
87
|
+
ErrorKind.UNAVAILABLE: (
|
|
88
|
+
"The Cloud Support API is temporarily unreachable or the request timed out. "
|
|
89
|
+
"This is retryable — try again shortly.",
|
|
90
|
+
True,
|
|
91
|
+
),
|
|
92
|
+
ErrorKind.UNKNOWN: (
|
|
93
|
+
"The request failed for a reason this server does not recognise. Retry once; "
|
|
94
|
+
"if it repeats, verify credentials with 'gcloud auth application-default login' "
|
|
95
|
+
"and confirm the Cloud Support API is enabled on the quota project "
|
|
96
|
+
"('gcloud services enable cloudsupport.googleapis.com').",
|
|
97
|
+
False,
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Substrings that mean the blocker is the support contract rather than IAM or state.
|
|
102
|
+
#
|
|
103
|
+
# "not eligible ... with this channel" was observed verbatim from a live 400 on
|
|
104
|
+
# 2026-09-01 when creating a case on a project without a paid plan. Note the
|
|
105
|
+
# status: the plan blocker arrives as FailedPrecondition(400), *not* the 403 the
|
|
106
|
+
# design assumed. Without this branch the user is told their case is "already
|
|
107
|
+
# closed", which is nonsense for a create.
|
|
108
|
+
_PLAN_SIGNATURES = (
|
|
109
|
+
"support plan",
|
|
110
|
+
"support account",
|
|
111
|
+
"not entitled",
|
|
112
|
+
"no entitlement",
|
|
113
|
+
"not eligible",
|
|
114
|
+
"with this channel",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# ...and when the API itself was never turned on. Observed verbatim from a live
|
|
118
|
+
# 403: "Google Cloud Support API has not been used in project X before or it is
|
|
119
|
+
# disabled." That reads as a permissions failure and is not one, so it is checked
|
|
120
|
+
# first — the fix is `gcloud services enable`, not an IAM grant.
|
|
121
|
+
_DISABLED_SIGNATURES = (
|
|
122
|
+
"has not been used in project",
|
|
123
|
+
"it is disabled",
|
|
124
|
+
"api is not enabled",
|
|
125
|
+
"serviceusage",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _info(kind: ErrorKind) -> ErrorInfo:
|
|
130
|
+
detail, retryable = _GUIDANCE[kind]
|
|
131
|
+
return ErrorInfo(kind=kind, detail=detail, retryable=retryable)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_plan_blocked(message: str) -> bool:
|
|
135
|
+
lowered = message.lower()
|
|
136
|
+
return any(sig in lowered for sig in _PLAN_SIGNATURES)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _classify_403(message: str) -> ErrorKind:
|
|
140
|
+
lowered = message.lower()
|
|
141
|
+
if any(sig in lowered for sig in _DISABLED_SIGNATURES):
|
|
142
|
+
return ErrorKind.API_NOT_ENABLED
|
|
143
|
+
if _is_plan_blocked(lowered):
|
|
144
|
+
return ErrorKind.SUPPORT_PLAN_REQUIRED
|
|
145
|
+
# Fall back to the permission reading, whose guidance also tells the user to
|
|
146
|
+
# check the support plan — so an unrecognised 403 still points more than one way.
|
|
147
|
+
return ErrorKind.PERMISSION_DENIED
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_GRPC_MAP: tuple[tuple[type[BaseException], ErrorKind], ...] = (
|
|
151
|
+
(gexc.Unauthenticated, ErrorKind.UNAUTHENTICATED),
|
|
152
|
+
(gexc.NotFound, ErrorKind.NOT_FOUND),
|
|
153
|
+
(gexc.InvalidArgument, ErrorKind.INVALID_ARGUMENT),
|
|
154
|
+
(gexc.FailedPrecondition, ErrorKind.FAILED_PRECONDITION),
|
|
155
|
+
(gexc.ResourceExhausted, ErrorKind.RATE_LIMITED),
|
|
156
|
+
(gexc.ServiceUnavailable, ErrorKind.UNAVAILABLE),
|
|
157
|
+
(gexc.DeadlineExceeded, ErrorKind.UNAVAILABLE),
|
|
158
|
+
# Google returns a bare 500 for some malformed scopes (observed live for a
|
|
159
|
+
# project that does not exist). The HTTP path already maps 500 to UNAVAILABLE,
|
|
160
|
+
# so the gRPC path must too or the two transports disagree.
|
|
161
|
+
(gexc.InternalServerError, ErrorKind.UNAVAILABLE),
|
|
162
|
+
(gexc.GatewayTimeout, ErrorKind.UNAVAILABLE),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
_HTTP_MAP: dict[int, ErrorKind] = {
|
|
166
|
+
400: ErrorKind.INVALID_ARGUMENT,
|
|
167
|
+
401: ErrorKind.UNAUTHENTICATED,
|
|
168
|
+
404: ErrorKind.NOT_FOUND,
|
|
169
|
+
409: ErrorKind.FAILED_PRECONDITION,
|
|
170
|
+
412: ErrorKind.FAILED_PRECONDITION,
|
|
171
|
+
429: ErrorKind.RATE_LIMITED,
|
|
172
|
+
500: ErrorKind.UNAVAILABLE,
|
|
173
|
+
502: ErrorKind.UNAVAILABLE,
|
|
174
|
+
503: ErrorKind.UNAVAILABLE,
|
|
175
|
+
504: ErrorKind.UNAVAILABLE,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def from_grpc(exc: BaseException) -> ErrorInfo:
|
|
180
|
+
"""Classify an exception raised by the GAPIC async clients."""
|
|
181
|
+
if isinstance(exc, gexc.PermissionDenied):
|
|
182
|
+
return _info(_classify_403(str(exc)))
|
|
183
|
+
# The support-plan blocker does not always arrive as 403: creating a case on a
|
|
184
|
+
# project without a plan returns FailedPrecondition. Checked before the generic
|
|
185
|
+
# state-conflict reading, which would otherwise give badly wrong guidance.
|
|
186
|
+
if isinstance(exc, gexc.FailedPrecondition) and _is_plan_blocked(str(exc)):
|
|
187
|
+
return _info(ErrorKind.SUPPORT_PLAN_REQUIRED)
|
|
188
|
+
for exc_type, kind in _GRPC_MAP:
|
|
189
|
+
if isinstance(exc, exc_type):
|
|
190
|
+
return _info(kind)
|
|
191
|
+
return _info(ErrorKind.UNKNOWN)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def from_http(exc: BaseException) -> ErrorInfo:
|
|
195
|
+
"""Classify an exception raised by the media REST path.
|
|
196
|
+
|
|
197
|
+
Returns the same ErrorInfo a gRPC failure with the equivalent status would.
|
|
198
|
+
"""
|
|
199
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
200
|
+
status = exc.response.status_code
|
|
201
|
+
body = exc.response.text or ""
|
|
202
|
+
if status == 403:
|
|
203
|
+
return _info(_classify_403(body))
|
|
204
|
+
if status == 400 and _is_plan_blocked(body):
|
|
205
|
+
return _info(ErrorKind.SUPPORT_PLAN_REQUIRED)
|
|
206
|
+
kind = _HTTP_MAP.get(status)
|
|
207
|
+
if kind is not None:
|
|
208
|
+
return _info(kind)
|
|
209
|
+
return _info(ErrorKind.UNKNOWN)
|
|
210
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
211
|
+
return _info(ErrorKind.UNAVAILABLE)
|
|
212
|
+
if isinstance(exc, httpx.TransportError):
|
|
213
|
+
return _info(ErrorKind.UNAVAILABLE)
|
|
214
|
+
return _info(ErrorKind.UNKNOWN)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def custom(kind: ErrorKind, detail: str, *, retryable: bool = False) -> ErrorInfo:
|
|
218
|
+
"""Classify a failure this server detected itself, before any upstream call.
|
|
219
|
+
|
|
220
|
+
Used where the guidance is more specific than the table's generic text — an
|
|
221
|
+
unusable cursor, or no scopes configured.
|
|
222
|
+
"""
|
|
223
|
+
return ErrorInfo(kind=kind, detail=detail, retryable=retryable)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def to_tool_error(info: ErrorInfo) -> ToolError:
|
|
227
|
+
"""Wrap classified information for the client.
|
|
228
|
+
|
|
229
|
+
`mask_error_details=True` on the server means only ToolError text reaches a
|
|
230
|
+
client, so anything that skips this function leaks nothing.
|
|
231
|
+
"""
|
|
232
|
+
suffix = " (retryable)" if info.retryable else ""
|
|
233
|
+
return ToolError(f"[{info.kind.value}]{suffix} {info.detail}")
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Cross-org fan-out: query every configured scope in parallel and merge.
|
|
2
|
+
|
|
3
|
+
Neither v2 nor v2beta can search more than one parent per call — the query
|
|
4
|
+
grammar's OR only combines values within a single field, never scope identifiers
|
|
5
|
+
(research.md R-004). So cross-org lives here, on the client, and three
|
|
6
|
+
consequences follow from that:
|
|
7
|
+
|
|
8
|
+
* Google's page tokens are per-parent, so a merged result needs a composite
|
|
9
|
+
cursor rather than a passthrough token.
|
|
10
|
+
* Partial failure is the normal case, not an exception: with N parents one may
|
|
11
|
+
lack permission and another may lack a support plan. A single 403 must not
|
|
12
|
+
hide the parents that succeeded.
|
|
13
|
+
* Concurrency multiplies rate-limit exposure, so it is bounded.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import base64
|
|
20
|
+
import binascii
|
|
21
|
+
import json
|
|
22
|
+
from collections.abc import Awaitable, Callable, Iterable, Sequence
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .errors import from_grpc
|
|
26
|
+
from .models import ListEnvelope, ScopeFailure
|
|
27
|
+
|
|
28
|
+
__all__ = ["CursorError", "decode_cursor", "encode_cursor", "fanout_list"]
|
|
29
|
+
|
|
30
|
+
_CURSOR_VERSION = 1
|
|
31
|
+
|
|
32
|
+
# One page fetch for a single parent: (items, next_page_token or None).
|
|
33
|
+
FetchPage = Callable[[str, str | None, int], Awaitable[tuple[list[Any], str | None]]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CursorError(ValueError):
|
|
37
|
+
"""Raised for a cursor this server did not issue, or that no longer fits the request."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def encode_cursor(cursors: dict[str, str | None]) -> str | None:
|
|
41
|
+
"""Pack per-parent page tokens into one opaque string.
|
|
42
|
+
|
|
43
|
+
Returns None when every parent is exhausted — there is nothing left to page.
|
|
44
|
+
"""
|
|
45
|
+
if all(token is None for token in cursors.values()):
|
|
46
|
+
return None
|
|
47
|
+
payload = json.dumps(
|
|
48
|
+
{"v": _CURSOR_VERSION, "cursors": cursors}, separators=(",", ":"), sort_keys=True
|
|
49
|
+
)
|
|
50
|
+
return base64.urlsafe_b64encode(payload.encode()).decode()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def decode_cursor(cursor: str, expected_parents: Iterable[str]) -> dict[str, str | None]:
|
|
54
|
+
"""Unpack a cursor, rejecting anything that would silently skew results."""
|
|
55
|
+
try:
|
|
56
|
+
raw = base64.urlsafe_b64decode(cursor.encode())
|
|
57
|
+
payload = json.loads(raw)
|
|
58
|
+
except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
|
|
59
|
+
raise CursorError(
|
|
60
|
+
"The cursor is not one this server issued. Drop it and start from the first page."
|
|
61
|
+
) from exc
|
|
62
|
+
|
|
63
|
+
if not isinstance(payload, dict) or payload.get("v") != _CURSOR_VERSION:
|
|
64
|
+
raise CursorError(
|
|
65
|
+
"The cursor uses an unsupported format version. Start from the first page."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
cursors = payload.get("cursors")
|
|
69
|
+
if not isinstance(cursors, dict):
|
|
70
|
+
raise CursorError("The cursor is malformed. Start from the first page.")
|
|
71
|
+
|
|
72
|
+
# A cursor is only meaningful for the scope set it was issued against. If the
|
|
73
|
+
# caller changed `parents` mid-pagination, continuing would quietly drop or
|
|
74
|
+
# duplicate results, so refuse instead.
|
|
75
|
+
if set(cursors) != set(expected_parents):
|
|
76
|
+
raise CursorError(
|
|
77
|
+
"The cursor was issued for a different set of parents than this request uses. "
|
|
78
|
+
"Either repeat the original parents, or omit the cursor to start over."
|
|
79
|
+
)
|
|
80
|
+
return cursors
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def fanout_list(
|
|
84
|
+
parents: Sequence[str],
|
|
85
|
+
*,
|
|
86
|
+
page_size: int,
|
|
87
|
+
cursor: str | None,
|
|
88
|
+
fetch: FetchPage,
|
|
89
|
+
concurrency: int = 5,
|
|
90
|
+
) -> ListEnvelope[Any]:
|
|
91
|
+
"""Fetch one page from each live parent concurrently and merge the results.
|
|
92
|
+
|
|
93
|
+
A parent that fails contributes a ScopeFailure rather than aborting the call.
|
|
94
|
+
"""
|
|
95
|
+
parent_list = list(parents)
|
|
96
|
+
tokens: dict[str, str | None] = (
|
|
97
|
+
decode_cursor(cursor, parent_list) if cursor else dict.fromkeys(parent_list)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Parents whose token is None *and* that we have paged before are exhausted.
|
|
101
|
+
# On the first page every token is None, so everything is live.
|
|
102
|
+
live = parent_list if cursor is None else [p for p in parent_list if tokens[p] is not None]
|
|
103
|
+
|
|
104
|
+
semaphore = asyncio.Semaphore(max(1, concurrency))
|
|
105
|
+
|
|
106
|
+
async def run(parent: str) -> tuple[str, list[Any], str | None, Exception | None]:
|
|
107
|
+
async with semaphore:
|
|
108
|
+
try:
|
|
109
|
+
items, next_token = await fetch(parent, tokens[parent], page_size)
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
# Deliberately Exception, not BaseException: asyncio.CancelledError
|
|
112
|
+
# must propagate so a cancelled request stops instead of being
|
|
113
|
+
# recorded as a per-parent failure.
|
|
114
|
+
return parent, [], None, exc
|
|
115
|
+
return parent, items, next_token, None
|
|
116
|
+
|
|
117
|
+
results = await asyncio.gather(*(run(p) for p in live))
|
|
118
|
+
|
|
119
|
+
items: list[Any] = []
|
|
120
|
+
failures: list[ScopeFailure] = []
|
|
121
|
+
next_tokens: dict[str, str | None] = dict.fromkeys(parent_list)
|
|
122
|
+
|
|
123
|
+
for parent, page_items, next_token, exc in results:
|
|
124
|
+
if exc is not None:
|
|
125
|
+
info = from_grpc(exc)
|
|
126
|
+
failures.append(
|
|
127
|
+
ScopeFailure(
|
|
128
|
+
parent=parent,
|
|
129
|
+
reason=info.kind,
|
|
130
|
+
detail=info.detail,
|
|
131
|
+
retryable=info.retryable,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
continue
|
|
135
|
+
items.extend(page_items)
|
|
136
|
+
next_tokens[parent] = next_token
|
|
137
|
+
|
|
138
|
+
next_cursor = encode_cursor(next_tokens)
|
|
139
|
+
return ListEnvelope[Any](
|
|
140
|
+
items=items,
|
|
141
|
+
scopes_queried=parent_list,
|
|
142
|
+
scopes_failed=failures,
|
|
143
|
+
next_cursor=next_cursor,
|
|
144
|
+
truncated=next_cursor is not None,
|
|
145
|
+
)
|