google-analytics-mcp-server 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.
Files changed (34) hide show
  1. google_analytics_mcp_server/__init__.py +18 -0
  2. google_analytics_mcp_server/auth.py +71 -0
  3. google_analytics_mcp_server/capabilities.py +172 -0
  4. google_analytics_mcp_server/catalog.py +219 -0
  5. google_analytics_mcp_server/client.py +207 -0
  6. google_analytics_mcp_server/config.py +42 -0
  7. google_analytics_mcp_server/contracts.py +349 -0
  8. google_analytics_mcp_server/discovery/SOURCES.md +10 -0
  9. google_analytics_mcp_server/discovery/analyticsadmin.v1alpha.json +9477 -0
  10. google_analytics_mcp_server/discovery/analyticsadmin.v1beta.json +3572 -0
  11. google_analytics_mcp_server/discovery/analyticsdata.v1alpha.json +3442 -0
  12. google_analytics_mcp_server/discovery/analyticsdata.v1beta.json +2380 -0
  13. google_analytics_mcp_server/discovery/routes.json +766 -0
  14. google_analytics_mcp_server/examples/README.md +15 -0
  15. google_analytics_mcp_server/examples/admin.json +63 -0
  16. google_analytics_mcp_server/examples/data.json +58 -0
  17. google_analytics_mcp_server/names.py +75 -0
  18. google_analytics_mcp_server/policy.py +133 -0
  19. google_analytics_mcp_server/projection.py +220 -0
  20. google_analytics_mcp_server/registry.py +60 -0
  21. google_analytics_mcp_server/render.py +281 -0
  22. google_analytics_mcp_server/server.py +75 -0
  23. google_analytics_mcp_server/tools/__init__.py +5 -0
  24. google_analytics_mcp_server/tools/admin.py +359 -0
  25. google_analytics_mcp_server/tools/chat.py +37 -0
  26. google_analytics_mcp_server/tools/generic.py +233 -0
  27. google_analytics_mcp_server/tools/jobs.py +110 -0
  28. google_analytics_mcp_server/tools/meta.py +91 -0
  29. google_analytics_mcp_server/tools/reports.py +264 -0
  30. google_analytics_mcp_server-0.1.0.dist-info/METADATA +266 -0
  31. google_analytics_mcp_server-0.1.0.dist-info/RECORD +34 -0
  32. google_analytics_mcp_server-0.1.0.dist-info/WHEEL +4 -0
  33. google_analytics_mcp_server-0.1.0.dist-info/entry_points.txt +3 -0
  34. google_analytics_mcp_server-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,18 @@
1
+ """Google Analytics MCP server entry point."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+
6
+ def package_version() -> str:
7
+ """Return the installed distribution version."""
8
+ try:
9
+ return version('google-analytics-mcp-server')
10
+ except PackageNotFoundError:
11
+ return '0.1.0'
12
+
13
+
14
+ def main() -> None:
15
+ """Start the stdio server without writing application output to stdout."""
16
+ from google_analytics_mcp_server.server import run_server
17
+
18
+ run_server()
@@ -0,0 +1,71 @@
1
+ """Lazy, process-local Application Default Credentials."""
2
+
3
+ import subprocess
4
+ import threading
5
+ from unittest.mock import patch
6
+
7
+ import google.auth
8
+ from google.auth.credentials import Credentials
9
+ from google.auth.exceptions import DefaultCredentialsError
10
+ from mcp.server.mcpserver.exceptions import ToolError
11
+
12
+ from google_analytics_mcp_server import config
13
+
14
+ PREFIX = 'https://www.googleapis.com/auth/'
15
+ BASE_SCOPES = ('analytics.readonly', 'analytics.edit', 'analytics.manage.users')
16
+ _lock = threading.Lock()
17
+ _credentials: Credentials | None = None
18
+
19
+
20
+ def _gcloud_output(command: list[str]) -> bytes:
21
+ """Keep google-auth's project lookup subprocess off MCP's stdin."""
22
+ return subprocess.check_output(
23
+ command, stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30
24
+ )
25
+
26
+
27
+ def scopes() -> tuple[str, ...]:
28
+ """Request chat scope only when the chat tool can be registered."""
29
+ extra = (
30
+ ('analytics.chatbot.read',)
31
+ if config.enabled('ENABLE_CHAT') and not config.read_only()
32
+ else ()
33
+ )
34
+ return tuple(PREFIX + scope for scope in (*BASE_SCOPES, *extra))
35
+
36
+
37
+ def setup_hint() -> str:
38
+ """Explain explicit user login without performing it."""
39
+ return (
40
+ 'Enable analyticsadmin.googleapis.com and analyticsdata.googleapis.com in your project. '
41
+ 'Create a Desktop OAuth client and run:\n'
42
+ 'gcloud auth application-default login --client-id-file=YOUR_DESKTOP_CLIENT.json '
43
+ f'--scopes={",".join(scopes())}\n'
44
+ 'If this ADC file is shared with GTM or other applications, include their scopes too. '
45
+ 'Alternatively set GOOGLE_APPLICATION_CREDENTIALS per MCP process. '
46
+ 'Passing scopes in code does not grant additional consent to existing user credentials.'
47
+ )
48
+
49
+
50
+ def credentials() -> Credentials:
51
+ """Resolve ADC only on the first permitted API request."""
52
+ global _credentials
53
+ with _lock:
54
+ if _credentials is None:
55
+ try:
56
+ # The installed google-auth helper inherits stdin by default.
57
+ # Patch only its project-lookup helper, not process-wide Popen.
58
+ with patch(
59
+ 'google.auth._cloud_sdk._run_subprocess_ignore_stderr',
60
+ _gcloud_output,
61
+ ):
62
+ _credentials, _ = google.auth.default(scopes=scopes())
63
+ except DefaultCredentialsError as error:
64
+ raise ToolError(
65
+ f'No usable Google credentials. {setup_hint()}'
66
+ ) from error
67
+ except subprocess.TimeoutExpired as error:
68
+ raise ToolError(
69
+ 'gcloud project lookup timed out. Set GOOGLE_CLOUD_PROJECT explicitly or repair the local gcloud setup.'
70
+ ) from error
71
+ return _credentials
@@ -0,0 +1,172 @@
1
+ """Declarative endpoint and field routes, separate from verification evidence."""
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from google_analytics_mcp_server import catalog, config, policy
7
+ from google_analytics_mcp_server.catalog import Method
8
+
9
+ TRANSPORT_OVERRIDES = frozenset(
10
+ {
11
+ 'key',
12
+ 'access_token',
13
+ 'oauth_token',
14
+ 'uploadType',
15
+ 'upload_protocol',
16
+ 'callback',
17
+ }
18
+ )
19
+ QUERY_ARGUMENTS = {
20
+ 'pageSize': 'limit',
21
+ 'pageToken': 'page_token',
22
+ 'showDeleted': 'show_deleted',
23
+ 'filter': 'filter',
24
+ 'names': 'names',
25
+ 'updateMask': 'update_mask',
26
+ 'calculatedMetricId': 'resource_id',
27
+ }
28
+
29
+
30
+ def availability(item: Method) -> dict[str, Any]:
31
+ """Describe current reachability without claiming live API eligibility."""
32
+ feature = 'GA_MCP_ENABLE_CHAT' if item.owner == 'ga_chat' else None
33
+ enabled = (not config.read_only() or policy.is_read(item)) and (
34
+ feature is None or config.enabled('ENABLE_CHAT')
35
+ )
36
+ return {
37
+ 'native_enabled': enabled,
38
+ 'feature_gate': feature,
39
+ 'read_only_eligible': policy.is_read(item),
40
+ 'confirmation_required': item.owner in policy.DESTRUCTIVE_OWNERS,
41
+ 'raw_enabled': enabled
42
+ and config.enabled('ENABLE_RAW_API')
43
+ and not config.read_only(),
44
+ }
45
+
46
+
47
+ def parameter_route(item: Method, name: str, spec: dict[str, Any]) -> str:
48
+ """Map a business parameter to the corresponding native tool argument."""
49
+ if spec['location'] == 'query':
50
+ return QUERY_ARGUMENTS.get(name, 'raw.params.' + name)
51
+ if item.owner in {
52
+ 'ga_start_async_job',
53
+ 'ga_submit_user_deletion',
54
+ 'ga_get_metadata',
55
+ }:
56
+ return 'property'
57
+ return name
58
+
59
+
60
+ def request_fields(item: Method) -> list[dict[str, Any]]:
61
+ """Enumerate recursive field routes; mark repeated and recursive references."""
62
+ if not item.request_schema:
63
+ return []
64
+ argument = 'patch' if item.owner in {'ga_update', 'ga_update_settings'} else 'body'
65
+ generated = item.owner in {
66
+ 'ga_acknowledge_user_data_collection',
67
+ 'ga_delete',
68
+ 'ga_review_dv360_link_proposal',
69
+ }
70
+ result: list[dict[str, Any]] = []
71
+
72
+ def visit(
73
+ spec: dict[str, Any], path: str, ancestors: frozenset[str], writable: bool
74
+ ) -> None:
75
+ writable = writable and not spec.get('readOnly', False)
76
+ if argument == 'patch' and re.search(
77
+ r'\bImmutable\.', spec.get('description', '')
78
+ ):
79
+ writable = False
80
+ entry: dict[str, Any] = {
81
+ 'path': path,
82
+ 'native': 'generated' if generated else f'{argument}.{path}',
83
+ 'raw': f'body.{path}',
84
+ 'writable': writable,
85
+ }
86
+ if '$ref' in spec:
87
+ ref = spec['$ref']
88
+ if ref in ancestors:
89
+ entry['recursive_schema'] = ref
90
+ result.append(entry)
91
+ return
92
+ spec = item.schema(ref)
93
+ ancestors = ancestors | {ref}
94
+ if spec.get('type') == 'array':
95
+ visit(spec.get('items', {}), path + '[]', ancestors, writable)
96
+ elif spec.get('properties'):
97
+ for name, child in spec['properties'].items():
98
+ visit(child, f'{path}.{name}' if path else name, ancestors, writable)
99
+ else:
100
+ entry.update({k: spec[k] for k in ('type', 'format', 'enum') if k in spec})
101
+ result.append(entry)
102
+
103
+ visit({'$ref': item.request_schema}, '', frozenset(), True)
104
+ return result
105
+
106
+
107
+ def endpoint(item: Method) -> dict[str, Any]:
108
+ """Describe field access and intentional value restrictions for one endpoint."""
109
+ global_params = catalog.document(item.api, item.version).get('parameters', {})
110
+ restrictions = [
111
+ 'Google enforces business rules, roles, feature eligibility and preview access.',
112
+ 'Read output is bounded by GA_MCP_MAX_OUTPUT_BYTES; no silent row clipping.',
113
+ ]
114
+ if 'updateMask' in item.parameters:
115
+ restrictions.extend(
116
+ [
117
+ 'An explicit nonempty updateMask is required.',
118
+ 'Native patches require exact writable leaf masks; wildcard and parent-message masks require raw dispatch and confirmation.',
119
+ 'Unverified null clearing is rejected by native patches; array semantics are method-specific.',
120
+ ]
121
+ )
122
+ if 'accessBindings' in item.resource and not policy.is_read(item):
123
+ restrictions.append(
124
+ 'All binding writes require confirmation; updates require a complete intended roles array, with [] denoting deletion.'
125
+ )
126
+ return {
127
+ 'method': item.key,
128
+ 'native_tool': item.owner,
129
+ 'default_version': catalog.routes()[item.logical_key]['default_version'],
130
+ **availability(item),
131
+ 'parameters': {
132
+ name: {'native': parameter_route(item, name, spec), 'raw': f'params.{name}'}
133
+ for name, spec in item.parameters.items()
134
+ },
135
+ 'request_fields': request_fields(item),
136
+ 'global_query_parameters': {
137
+ name: f'raw.params.{name}'
138
+ for name in global_params
139
+ if name not in TRANSPORT_OVERRIDES
140
+ },
141
+ 'restrictions': restrictions,
142
+ 'transport': 'ADC authentication and JSON responses; auth/upload overrides are not exposed.',
143
+ 'verification': 'Declared routes from pinned discovery; not evidence of offline or live test coverage.',
144
+ }
145
+
146
+
147
+ def ledger() -> dict[str, Any]:
148
+ """Return the versioned ledger and actual configured method availability."""
149
+ entries = [
150
+ {
151
+ 'method': item.key,
152
+ 'tool': item.owner,
153
+ 'default': catalog.routes()[item.logical_key]['default_version']
154
+ == item.version,
155
+ **availability(item),
156
+ }
157
+ for item in catalog.methods()
158
+ ]
159
+ enabled = [entry for entry in entries if entry['native_enabled']]
160
+ return {
161
+ 'logical_methods': len(catalog.routes()),
162
+ 'versioned_endpoints': len(entries),
163
+ 'enabled_logical_methods': len(
164
+ {
165
+ (entry['method'].split(':')[0], entry['method'].split(':')[2])
166
+ for entry in enabled
167
+ }
168
+ ),
169
+ 'enabled_versioned_endpoints': len(enabled),
170
+ 'verification': 'Declared routes, not a test result. Describe a method to inspect its field routes and value restrictions.',
171
+ 'methods': entries,
172
+ }
@@ -0,0 +1,219 @@
1
+ """Pinned, versioned discovery catalog and reviewed tool ownership."""
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from functools import cache
7
+ from importlib.resources import files
8
+ from typing import Any, cast
9
+
10
+ from mcp.server.mcpserver.exceptions import ToolError
11
+
12
+ from google_analytics_mcp_server.config import ApiVersion, Version
13
+
14
+ SERVICES = {'admin': 'analyticsadmin', 'data': 'analyticsdata'}
15
+ VERSIONS: tuple[Version, ...] = ('v1beta', 'v1alpha')
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Method:
20
+ """A concrete discovery method; identity always includes its version."""
21
+
22
+ api: str
23
+ version: Version
24
+ id: str
25
+ resource: str
26
+ name: str
27
+ spec: dict[str, Any]
28
+ owner: str
29
+
30
+ @property
31
+ def logical_key(self) -> str:
32
+ """Return the version-independent identity."""
33
+ return f'{self.api}:{self.id}'
34
+
35
+ @property
36
+ def key(self) -> str:
37
+ """Return the fully qualified identity."""
38
+ return f'{self.api}:{self.version}:{self.id}'
39
+
40
+ @property
41
+ def parameters(self) -> dict[str, Any]:
42
+ """Return method-specific path and query parameters."""
43
+ return dict(self.spec.get('parameters', {}))
44
+
45
+ @property
46
+ def path_parameters(self) -> dict[str, Any]:
47
+ """Return path parameters, retaining their wire names."""
48
+ return {k: v for k, v in self.parameters.items() if v['location'] == 'path'}
49
+
50
+ @property
51
+ def request_schema(self) -> str | None:
52
+ """Return the request schema name when a body is supported."""
53
+ return cast(str | None, self.spec.get('request', {}).get('$ref'))
54
+
55
+ @property
56
+ def response_schema(self) -> str | None:
57
+ """Return the response schema name."""
58
+ return cast(str | None, self.spec.get('response', {}).get('$ref'))
59
+
60
+ def schema(self, name: str) -> dict[str, Any]:
61
+ """Resolve a schema in this method's exact discovery version."""
62
+ return cast(dict[str, Any], document(self.api, self.version)['schemas'][name])
63
+
64
+
65
+ @cache
66
+ def document(api: str, version: str) -> dict[str, Any]:
67
+ """Load a bundled discovery snapshot without network access."""
68
+ if api not in SERVICES or version not in VERSIONS:
69
+ raise ToolError('Use api=admin|data and version=v1alpha|v1beta.')
70
+ path = files('google_analytics_mcp_server').joinpath(
71
+ 'discovery', f'{SERVICES[api]}.{version}.json'
72
+ )
73
+ result: dict[str, Any] = json.loads(path.read_text('utf-8'))
74
+ return result
75
+
76
+
77
+ @cache
78
+ def routes() -> dict[str, dict[str, str]]:
79
+ """Load reviewed default versions and logical owners."""
80
+ result: dict[str, dict[str, str]] = json.loads(
81
+ files('google_analytics_mcp_server')
82
+ .joinpath('discovery', 'routes.json')
83
+ .read_text('utf-8')
84
+ )
85
+ return result
86
+
87
+
88
+ @cache
89
+ def methods() -> tuple[Method, ...]:
90
+ """Enumerate every versioned endpoint, including non-default variants."""
91
+ found: list[Method] = []
92
+
93
+ def visit(api: str, version: Version, node: dict[str, Any], resource: str) -> None:
94
+ for name, spec in node.get('methods', {}).items():
95
+ identity = spec['id'].removeprefix(f'{SERVICES[api]}.')
96
+ key = f'{api}:{identity}'
97
+ if key not in routes():
98
+ raise ToolError(
99
+ f'{key} needs a reviewed entry in discovery/routes.json.'
100
+ )
101
+ found.append(
102
+ Method(
103
+ api, version, identity, resource, name, spec, routes()[key]['tool']
104
+ )
105
+ )
106
+ for name, child in node.get('resources', {}).items():
107
+ visit(api, version, child, '.'.join(filter(None, (resource, name))))
108
+
109
+ for api in SERVICES:
110
+ for version in VERSIONS:
111
+ visit(api, version, document(api, version), '')
112
+ return tuple(found)
113
+
114
+
115
+ def method(api: str, identity: str, version: ApiVersion = 'auto') -> Method:
116
+ """Resolve a method without falling back from an explicitly selected version."""
117
+ identity = identity.removeprefix(f'{SERVICES.get(api, api)}.')
118
+ route = routes().get(f'{api}:{identity}')
119
+ if route is None:
120
+ raise ToolError(f'Unknown {api} method {identity}. Use ga_describe_schema.')
121
+ chosen = route['default_version'] if version == 'auto' else version
122
+ for item in methods():
123
+ if (item.api, item.id, item.version) == (api, identity, chosen):
124
+ return item
125
+ raise ToolError(
126
+ f'{api}:{identity} is unavailable in {chosen}; choose its published version.'
127
+ )
128
+
129
+
130
+ def candidates(owner: str, version: ApiVersion = 'auto') -> list[Method]:
131
+ """Return the routes served by one native tool in the requested version."""
132
+ return [
133
+ item
134
+ for item in methods()
135
+ if item.owner == owner
136
+ and (
137
+ item.version == version
138
+ if version != 'auto'
139
+ else item.version == routes()[item.logical_key]['default_version']
140
+ )
141
+ ]
142
+
143
+
144
+ def items_key(item: Method) -> str:
145
+ """Find the collection in a list response."""
146
+ fields = item.schema(item.response_schema or '').get('properties', {})
147
+ arrays: list[str] = [k for k, v in fields.items() if v.get('type') == 'array']
148
+ if len(arrays) != 1:
149
+ raise ToolError(f'{item.key} does not have an unambiguous list response.')
150
+ return arrays[0]
151
+
152
+
153
+ def short_schema(name: str) -> str:
154
+ """Remove the Admin version prefix from a schema name."""
155
+ return re.sub(r'^GoogleAnalyticsAdminV1(?:alpha|beta)', '', name)
156
+
157
+
158
+ def describe(
159
+ name: str, api: str | None = None, version: ApiVersion = 'auto'
160
+ ) -> dict[str, Any]:
161
+ """Describe an exact method or schema with references, scopes and ownership."""
162
+ parts = name.split(':')
163
+ if len(parts) == 3:
164
+ api, selected, name = parts
165
+ if selected not in VERSIONS:
166
+ raise ToolError('Qualified names use admin|data:v1alpha|v1beta:name.')
167
+ version = selected
168
+ elif len(parts) == 2:
169
+ api, name = parts
170
+ if '.' in api:
171
+ api, selected = api.split('.', 1)
172
+ if selected not in VERSIONS:
173
+ raise ToolError(
174
+ 'Qualified names use admin.v1alpha:Name or data.v1beta:Name.'
175
+ )
176
+ version = selected
177
+ for alias, service_name in SERVICES.items():
178
+ if name.startswith(f'{service_name}.'):
179
+ api = alias
180
+ name = name.removeprefix(f'{service_name}.')
181
+ break
182
+ if api is not None and api not in SERVICES:
183
+ raise ToolError('api must be admin or data.')
184
+ matches = [m for m in methods() if (api is None or m.api == api) and m.id == name]
185
+ if matches:
186
+ apis = {m.api for m in matches}
187
+ if len(apis) != 1:
188
+ raise ToolError('Qualify the method with api=admin or data.')
189
+ chosen = method(next(iter(apis)), name, version)
190
+ return {
191
+ 'method': chosen.key,
192
+ 'tool': chosen.owner,
193
+ 'available_versions': sorted({m.version for m in matches}),
194
+ **chosen.spec,
195
+ 'request_schema': chosen.schema(chosen.request_schema)
196
+ if chosen.request_schema
197
+ else None,
198
+ 'response_schema': chosen.schema(chosen.response_schema)
199
+ if chosen.response_schema
200
+ else None,
201
+ }
202
+ schemas: list[tuple[str, Version, str, dict[str, Any]]] = []
203
+ for service in SERVICES:
204
+ if api and service != api:
205
+ continue
206
+ for selected in VERSIONS:
207
+ if version != 'auto' and version != selected:
208
+ continue
209
+ for identity, schema in document(service, selected)['schemas'].items():
210
+ if name in {identity, short_schema(identity)}:
211
+ schemas.append((service, selected, identity, schema))
212
+ if not schemas:
213
+ raise ToolError(
214
+ f'Unknown schema or method: {name}. Use a discovery name, e.g. Audience or properties.runReport.'
215
+ )
216
+ if len({s[0] for s in schemas}) > 1:
217
+ raise ToolError(f'{name} exists in both APIs. Specify api=admin or data.')
218
+ service, selected, identity, schema = schemas[0]
219
+ return {'api': service, 'api_version': selected, 'name': identity, **schema}
@@ -0,0 +1,207 @@
1
+ """One authorized execution path with policy, serialization and bounded retries."""
2
+
3
+ import json
4
+ import random
5
+ import threading
6
+ import time
7
+ from datetime import UTC, datetime
8
+ from email.utils import parsedate_to_datetime
9
+ from typing import Any
10
+
11
+ import httplib2
12
+ from google.auth.exceptions import RefreshError, TransportError
13
+ from google_auth_httplib2 import AuthorizedHttp
14
+ from googleapiclient.discovery import build_from_document, fix_method_name, key2param
15
+ from googleapiclient.errors import HttpError
16
+ from mcp.server.mcpserver.exceptions import ToolError
17
+
18
+ from google_analytics_mcp_server import (
19
+ auth,
20
+ catalog,
21
+ config,
22
+ contracts,
23
+ package_version,
24
+ policy,
25
+ )
26
+ from google_analytics_mcp_server.capabilities import TRANSPORT_OVERRIDES
27
+ from google_analytics_mcp_server.catalog import Method
28
+
29
+ _lock = threading.RLock()
30
+ _services: dict[tuple[str, str], Any] = {}
31
+
32
+
33
+ def service(item: Method) -> Any:
34
+ """Build the exact vendored service while holding the transport lock."""
35
+ key = (item.api, item.version)
36
+ if key not in _services:
37
+ transport = httplib2.Http(timeout=config.positive_int('HTTP_TIMEOUT', 60))
38
+ _services[key] = build_from_document(
39
+ json.dumps(catalog.document(*key)),
40
+ http=AuthorizedHttp(auth.credentials(), http=transport),
41
+ )
42
+ return _services[key]
43
+
44
+
45
+ def execute(
46
+ item: Method,
47
+ params: dict[str, Any] | None = None,
48
+ body: dict[str, Any] | None = None,
49
+ *,
50
+ confirm: bool = False,
51
+ ) -> dict[str, Any]:
52
+ """Send a validated request; native and raw calls both enter here."""
53
+ config.validate_limits()
54
+ wire_params = dict(params or {})
55
+ policy.guard(item, wire_params, body, confirm)
56
+ available = {
57
+ **catalog.document(item.api, item.version).get('parameters', {}),
58
+ **item.parameters,
59
+ }
60
+ unknown = set(wire_params) - set(available)
61
+ if unknown:
62
+ raise ToolError(
63
+ f'{item.key} has no query/path parameter: {", ".join(sorted(unknown))}.'
64
+ )
65
+ if set(wire_params) & TRANSPORT_OVERRIDES:
66
+ raise ToolError(
67
+ 'Authentication and upload transport overrides are not supported; configure ADC on the server.'
68
+ )
69
+ if wire_params.get('alt', 'json') != 'json':
70
+ raise ToolError('MCP responses require alt=json.')
71
+ for key, spec in item.parameters.items():
72
+ if spec.get('required') and key not in wire_params:
73
+ raise ToolError(f'{item.key} requires parameter {key}.')
74
+ wire_params = {
75
+ k: contracts.validate(
76
+ v,
77
+ {'type': 'array', 'items': available[k]}
78
+ if available[k].get('repeated')
79
+ else available[k],
80
+ item,
81
+ k,
82
+ )
83
+ for k, v in wire_params.items()
84
+ }
85
+ if body is not None and not item.request_schema:
86
+ raise ToolError(f'{item.id} does not accept a body.')
87
+ if item.request_schema:
88
+ body = contracts.validate(
89
+ body if body is not None else {}, {'$ref': item.request_schema}, item
90
+ )
91
+ contracts.enforce_request(item, body, wire_params)
92
+ # Run the policy on the exact normalized payload that is about to be sent.
93
+ policy.guard(item, wire_params, body, confirm)
94
+ with _lock:
95
+ try:
96
+ resource = service(item)
97
+ for part in item.resource.split('.'):
98
+ resource = getattr(resource, fix_method_name(part))()
99
+ kwargs = {key2param(k): v for k, v in wire_params.items()}
100
+ if body is not None:
101
+ kwargs['body'] = body
102
+ request = getattr(resource, fix_method_name(item.name))(**kwargs)
103
+ request.headers['user-agent'] = (
104
+ f'google-analytics-mcp-server/{package_version()}'
105
+ )
106
+ return _send(request, item)
107
+ except RefreshError as error:
108
+ raise ToolError(
109
+ f'Google credentials could not refresh. {auth.setup_hint()}'
110
+ ) from error
111
+ except (TypeError, ValueError) as error:
112
+ raise ToolError(f'{item.key}: {error}') from error
113
+ except (OSError, httplib2.HttpLib2Error, TransportError) as error:
114
+ hint = (
115
+ ''
116
+ if policy.is_read(item)
117
+ else ' The outcome may be unknown; inspect the target before repeating this mutation.'
118
+ )
119
+ raise ToolError(
120
+ f'Network request to {catalog.SERVICES[item.api]}.googleapis.com failed: {error}.{hint}'
121
+ ) from error
122
+
123
+
124
+ def _retry_delay(error: HttpError, attempt: int) -> float:
125
+ value = error.resp.get('retry-after')
126
+ if value:
127
+ try:
128
+ return max(0.0, float(value))
129
+ except ValueError:
130
+ try:
131
+ return max(
132
+ 0.0,
133
+ (parsedate_to_datetime(value) - datetime.now(UTC)).total_seconds(),
134
+ )
135
+ except ValueError, TypeError:
136
+ pass
137
+ return min(16.0, 2.0**attempt + random.random())
138
+
139
+
140
+ def _retry_limit(error: HttpError, item: Method) -> int:
141
+ if not policy.is_read(item):
142
+ return 0
143
+ status = int(error.resp.status)
144
+ text = error.content.decode('utf-8', errors='replace').lower()
145
+ if status in {429, 403}:
146
+ if any(
147
+ word in text
148
+ for word in (
149
+ 'token',
150
+ 'daily',
151
+ 'per day',
152
+ 'per hour',
153
+ 'threshold',
154
+ 'server error',
155
+ )
156
+ ):
157
+ return 0
158
+ if 'concurrent' in text:
159
+ return 3
160
+ if item.api == 'admin' and any(
161
+ word in text for word in ('ratelimitexceeded', 'rate limit', 'per minute')
162
+ ):
163
+ return 5
164
+ return 0
165
+ if 500 <= status < 600:
166
+ return 1 if item.api == 'data' or item.name == 'runAccessReport' else 3
167
+ return 0
168
+
169
+
170
+ def _send(request: Any, item: Method) -> dict[str, Any]:
171
+ attempt = 0
172
+ while True:
173
+ try:
174
+ response = request.execute(num_retries=0)
175
+ return response if isinstance(response, dict) else {'response': response}
176
+ except HttpError as error:
177
+ if attempt < _retry_limit(error, item):
178
+ delay = _retry_delay(error, attempt)
179
+ if delay <= 30:
180
+ time.sleep(delay)
181
+ attempt += 1
182
+ continue
183
+ message = error.content.decode('utf-8', errors='replace')
184
+ try:
185
+ data = json.loads(message).get('error', {})
186
+ message = data.get('message', message)
187
+ except json.JSONDecodeError, AttributeError:
188
+ pass
189
+ hints = ''
190
+ lower = error.content.decode('utf-8', errors='replace').lower()
191
+ if 'scope' in lower:
192
+ hints = f' Required scopes: {item.spec.get("scopes", [])}. {auth.setup_hint()}'
193
+ elif 'service_disabled' in lower or 'accessnotconfigured' in lower:
194
+ hints = f' Enable {catalog.SERVICES[item.api]}.googleapis.com on the project named in the error.'
195
+ elif error.resp.status == 403:
196
+ hints = ' Check the account/property role and any Analytics 360 or preview eligibility.'
197
+ elif error.resp.status == 400:
198
+ hints = ' Inspect ga_describe_schema; report dimensions/metrics can be checked with ga_get_metadata and ga_check_compatibility.'
199
+ elif error.resp.status == 404:
200
+ hints = ' Copy an existing resource name from ga_list.'
201
+ elif error.resp.status == 429:
202
+ hints = ' Do not retry exhausted or unknown quotas. Hourly quotas refresh within an hour, not necessarily at a whole-hour boundary.'
203
+ if error.resp.status >= 500 and not policy.is_read(item):
204
+ hints += ' The mutation was not retried. Its outcome may be unknown; inspect the target before repeating it.'
205
+ raise ToolError(
206
+ f'{item.key}: HTTP {error.resp.status}: {message}.{hints}'
207
+ ) from error