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.
@@ -0,0 +1,11 @@
1
+ """MCP server for Google Cloud Support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["main"]
6
+
7
+
8
+ def main() -> None:
9
+ from .server import create_server
10
+
11
+ create_server().run()
@@ -0,0 +1,4 @@
1
+ from google_cloud_support_mcp import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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
+ )