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.
- google_analytics_mcp_server/__init__.py +18 -0
- google_analytics_mcp_server/auth.py +71 -0
- google_analytics_mcp_server/capabilities.py +172 -0
- google_analytics_mcp_server/catalog.py +219 -0
- google_analytics_mcp_server/client.py +207 -0
- google_analytics_mcp_server/config.py +42 -0
- google_analytics_mcp_server/contracts.py +349 -0
- google_analytics_mcp_server/discovery/SOURCES.md +10 -0
- google_analytics_mcp_server/discovery/analyticsadmin.v1alpha.json +9477 -0
- google_analytics_mcp_server/discovery/analyticsadmin.v1beta.json +3572 -0
- google_analytics_mcp_server/discovery/analyticsdata.v1alpha.json +3442 -0
- google_analytics_mcp_server/discovery/analyticsdata.v1beta.json +2380 -0
- google_analytics_mcp_server/discovery/routes.json +766 -0
- google_analytics_mcp_server/examples/README.md +15 -0
- google_analytics_mcp_server/examples/admin.json +63 -0
- google_analytics_mcp_server/examples/data.json +58 -0
- google_analytics_mcp_server/names.py +75 -0
- google_analytics_mcp_server/policy.py +133 -0
- google_analytics_mcp_server/projection.py +220 -0
- google_analytics_mcp_server/registry.py +60 -0
- google_analytics_mcp_server/render.py +281 -0
- google_analytics_mcp_server/server.py +75 -0
- google_analytics_mcp_server/tools/__init__.py +5 -0
- google_analytics_mcp_server/tools/admin.py +359 -0
- google_analytics_mcp_server/tools/chat.py +37 -0
- google_analytics_mcp_server/tools/generic.py +233 -0
- google_analytics_mcp_server/tools/jobs.py +110 -0
- google_analytics_mcp_server/tools/meta.py +91 -0
- google_analytics_mcp_server/tools/reports.py +264 -0
- google_analytics_mcp_server-0.1.0.dist-info/METADATA +266 -0
- google_analytics_mcp_server-0.1.0.dist-info/RECORD +34 -0
- google_analytics_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- google_analytics_mcp_server-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|