google-play-mcp 0.2.0__tar.gz

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,4 @@
1
+ # Path to your Google service account JSON key file.
2
+ # The service account must have the "Release Manager" role
3
+ # in Google Play Console (or Editor/Owner).
4
+ GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
@@ -0,0 +1,27 @@
1
+ # Environment & secrets
2
+ .env
3
+ *.json
4
+ !*example*.json
5
+ !server.json
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.py[cod]
10
+ *.pyo
11
+ *.pyd
12
+ .Python
13
+ *.egg-info/
14
+ dist/
15
+ build/
16
+ .eggs/
17
+ *.egg
18
+
19
+ # Virtual envs
20
+ .venv/
21
+ venv/
22
+ env/
23
+
24
+ # IDE
25
+ .idea/
26
+ .vscode/
27
+ *.swp
@@ -0,0 +1,204 @@
1
+ Metadata-Version: 2.4
2
+ Name: google-play-mcp
3
+ Version: 0.2.0
4
+ Summary: Google Play Console MCP Server — manage production releases from your AI assistant.
5
+ License: MIT
6
+ Keywords: android,google-play,mcp,play-console
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: google-api-python-client>=2.120.0
9
+ Requires-Dist: google-auth-httplib2>=0.2.0
10
+ Requires-Dist: google-auth>=2.28.0
11
+ Requires-Dist: mcp[cli]>=1.3.0
12
+ Requires-Dist: python-dotenv>=1.0.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Google Play Console MCP
16
+
17
+ A Python [Model Context Protocol](https://modelcontextprotocol.io/) server that
18
+ lets AI assistants (Claude, etc.) manage your Google Play Store production
19
+ releases directly.
20
+
21
+ <!-- mcp-name: io.github.agimaulana/google-play-mcp -->
22
+
23
+ ---
24
+
25
+ ## Quick start
26
+
27
+ **stdio — one command, no install:**
28
+ ```bash
29
+ claude mcp add google-play-mcp \
30
+ -e GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json \
31
+ -- uvx google-play-mcp
32
+ ```
33
+
34
+ **HTTP — run as a local server:**
35
+ ```bash
36
+ # Terminal 1 — start the server
37
+ GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json \
38
+ uvx google-play-mcp --transport http --port 8080
39
+
40
+ # Terminal 2 — register with Claude
41
+ claude mcp add --transport http google-play-mcp http://localhost:8080
42
+ ```
43
+
44
+ > Requires `uv` — install with `brew install uv` or `curl -Lsf https://astral.sh/uv/install.sh | sh`
45
+
46
+ ---
47
+
48
+ ## Features
49
+
50
+ | Tool | Description |
51
+ |---|---|
52
+ | `promote_to_production` | Promote a build from internal track to production with a custom rollout % |
53
+ | `check_review_status` | Check production release status (draft / inProgress / halted / completed) |
54
+ | `update_rollout_percentage` | Increase or complete an existing staged rollout |
55
+ | `get_production_release_info` | Fetch current rollout %, version codes, and release notes |
56
+ | `get_crash_rate` | Fetch user-perceived crash rate via the Play Developer Reporting API |
57
+
58
+ ---
59
+
60
+ ## Prerequisites
61
+
62
+ 1. **`uv`** — [install guide](https://docs.astral.sh/uv/getting-started/installation/)
63
+ 2. A **Google Cloud service account** with the JSON key downloaded.
64
+ 3. The service account added to **Google Play Console** with the correct permissions (see below).
65
+ 4. These APIs enabled in your Google Cloud project:
66
+ - [Google Play Android Developer API](https://console.cloud.google.com/apis/library/androidpublisher.googleapis.com)
67
+ - [Google Play Developer Reporting API](https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com)
68
+
69
+ ### Required Play Console permissions
70
+
71
+ | Tool | Minimum permission required |
72
+ |---|---|
73
+ | `promote_to_production` | **Release to production, exclude devices, and use app signing by Google Play** |
74
+ | `update_rollout_percentage` | **Release to production, exclude devices, and use app signing by Google Play** |
75
+ | `check_review_status` | **View app information and download bulk reports (read-only)** |
76
+ | `get_production_release_info` | **View app information and download bulk reports (read-only)** |
77
+ | `get_crash_rate` | **View app information and download bulk reports (read-only)** |
78
+
79
+ > **Important:** Release Manager does **not** grant Reporting API access. You must also enable
80
+ > **View app information and download bulk reports (read-only)** — both at account level and
81
+ > per-app level — for `get_crash_rate` to work.
82
+
83
+ ---
84
+
85
+ ## Claude Desktop integration
86
+
87
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
88
+
89
+ ```json
90
+ {
91
+ "mcpServers": {
92
+ "google-play": {
93
+ "command": "uvx",
94
+ "args": ["google-play-mcp"],
95
+ "env": {
96
+ "GOOGLE_APPLICATION_CREDENTIALS": "/absolute/path/to/service-account.json"
97
+ }
98
+ }
99
+ }
100
+ }
101
+ ```
102
+
103
+ Restart Claude Desktop after saving.
104
+
105
+ ---
106
+
107
+ ## Service account setup
108
+
109
+ 1. Go to [IAM & Admin → Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts) in your GCP project.
110
+ 2. Create a service account (or use an existing one) and download a JSON key.
111
+ 3. In [Google Play Console](https://play.google.com/console) → **Setup → API access**:
112
+ - Link your Google Cloud project.
113
+ - Find the service account → **Manage Play Console permissions**.
114
+ - Under **Account permissions**, enable **View app information and download bulk reports (read-only)**.
115
+ - Under **App permissions** for each app, enable:
116
+ - **View app information and download bulk reports (read-only)**
117
+ - **Release to production…** _(if you need write access)_
118
+ - Click **Apply** → **Invite user**.
119
+
120
+ > Permissions must be granted at **both** account level and per-app level.
121
+ > Account-level alone is not sufficient for the Reporting API.
122
+
123
+ ---
124
+
125
+ ## Tool reference
126
+
127
+ ### `promote_to_production`
128
+
129
+ ```
130
+ package_name : str — e.g. "com.example.myapp"
131
+ version_codes : list[int] — e.g. [1042]
132
+ rollout_percentage : float — 1–100 (use 100 for immediate full release)
133
+ release_name : str — optional, e.g. "2.4.1"
134
+ release_notes_en : str — optional English release notes
135
+ ```
136
+
137
+ ### `check_review_status`
138
+
139
+ ```
140
+ package_name : str
141
+ ```
142
+
143
+ Returns each production release with its status and rollout percentage.
144
+
145
+ ### `update_rollout_percentage`
146
+
147
+ ```
148
+ package_name : str
149
+ rollout_percentage : float — new %, 0 < value <= 100
150
+ version_codes : list[int] — optional; targets the first inProgress release if omitted
151
+ ```
152
+
153
+ ### `get_production_release_info`
154
+
155
+ ```
156
+ package_name : str
157
+ ```
158
+
159
+ Returns the active release's rollout percentage, version codes, and release notes.
160
+
161
+ > **Note:** Raw install/update event counts are not available via the public API.
162
+ > Use **Play Console → Statistics**, [BigQuery exports](https://support.google.com/googleplay/android-developer/answer/10668107), or Firebase Analytics.
163
+
164
+ ### `get_crash_rate`
165
+
166
+ ```
167
+ package_name : str
168
+ days : int — look-back window, 1–30 (default 7)
169
+ version_code : str — optional single version code to filter
170
+ ```
171
+
172
+ Returns daily `crashRate`, `userPerceivedCrashRate`, and `distinctUsers` per version code.
173
+
174
+ ---
175
+
176
+ ## Troubleshooting
177
+
178
+ ### `403 Forbidden` on `get_crash_rate`
179
+
180
+ ```
181
+ 403 Client Error: Forbidden for url: https://playdeveloperreporting.googleapis.com/...
182
+ ```
183
+
184
+ The service account lacks per-app Reporting API access. Retrying will not help.
185
+
186
+ **Fix:**
187
+ 1. Play Console → **Setup → API access** → find the service account → **Manage Play Console permissions**.
188
+ 2. Under **App permissions**, select the app and enable **View app information and download bulk reports (read-only)**.
189
+ 3. Save and wait a few minutes for the change to propagate.
190
+
191
+ ---
192
+
193
+ ## Marketplaces
194
+
195
+ | Registry | Identifier |
196
+ |---|---|
197
+ | [Smithery](https://smithery.ai) | search `google-play-mcp` |
198
+ | [Official MCP Registry](https://registry.modelcontextprotocol.io) | `io.github.agimaulana/google-play-mcp` |
199
+
200
+ ---
201
+
202
+ ## License
203
+
204
+ MIT
@@ -0,0 +1,190 @@
1
+ # Google Play Console MCP
2
+
3
+ A Python [Model Context Protocol](https://modelcontextprotocol.io/) server that
4
+ lets AI assistants (Claude, etc.) manage your Google Play Store production
5
+ releases directly.
6
+
7
+ <!-- mcp-name: io.github.agimaulana/google-play-mcp -->
8
+
9
+ ---
10
+
11
+ ## Quick start
12
+
13
+ **stdio — one command, no install:**
14
+ ```bash
15
+ claude mcp add google-play-mcp \
16
+ -e GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json \
17
+ -- uvx google-play-mcp
18
+ ```
19
+
20
+ **HTTP — run as a local server:**
21
+ ```bash
22
+ # Terminal 1 — start the server
23
+ GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json \
24
+ uvx google-play-mcp --transport http --port 8080
25
+
26
+ # Terminal 2 — register with Claude
27
+ claude mcp add --transport http google-play-mcp http://localhost:8080
28
+ ```
29
+
30
+ > Requires `uv` — install with `brew install uv` or `curl -Lsf https://astral.sh/uv/install.sh | sh`
31
+
32
+ ---
33
+
34
+ ## Features
35
+
36
+ | Tool | Description |
37
+ |---|---|
38
+ | `promote_to_production` | Promote a build from internal track to production with a custom rollout % |
39
+ | `check_review_status` | Check production release status (draft / inProgress / halted / completed) |
40
+ | `update_rollout_percentage` | Increase or complete an existing staged rollout |
41
+ | `get_production_release_info` | Fetch current rollout %, version codes, and release notes |
42
+ | `get_crash_rate` | Fetch user-perceived crash rate via the Play Developer Reporting API |
43
+
44
+ ---
45
+
46
+ ## Prerequisites
47
+
48
+ 1. **`uv`** — [install guide](https://docs.astral.sh/uv/getting-started/installation/)
49
+ 2. A **Google Cloud service account** with the JSON key downloaded.
50
+ 3. The service account added to **Google Play Console** with the correct permissions (see below).
51
+ 4. These APIs enabled in your Google Cloud project:
52
+ - [Google Play Android Developer API](https://console.cloud.google.com/apis/library/androidpublisher.googleapis.com)
53
+ - [Google Play Developer Reporting API](https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com)
54
+
55
+ ### Required Play Console permissions
56
+
57
+ | Tool | Minimum permission required |
58
+ |---|---|
59
+ | `promote_to_production` | **Release to production, exclude devices, and use app signing by Google Play** |
60
+ | `update_rollout_percentage` | **Release to production, exclude devices, and use app signing by Google Play** |
61
+ | `check_review_status` | **View app information and download bulk reports (read-only)** |
62
+ | `get_production_release_info` | **View app information and download bulk reports (read-only)** |
63
+ | `get_crash_rate` | **View app information and download bulk reports (read-only)** |
64
+
65
+ > **Important:** Release Manager does **not** grant Reporting API access. You must also enable
66
+ > **View app information and download bulk reports (read-only)** — both at account level and
67
+ > per-app level — for `get_crash_rate` to work.
68
+
69
+ ---
70
+
71
+ ## Claude Desktop integration
72
+
73
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "google-play": {
79
+ "command": "uvx",
80
+ "args": ["google-play-mcp"],
81
+ "env": {
82
+ "GOOGLE_APPLICATION_CREDENTIALS": "/absolute/path/to/service-account.json"
83
+ }
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ Restart Claude Desktop after saving.
90
+
91
+ ---
92
+
93
+ ## Service account setup
94
+
95
+ 1. Go to [IAM & Admin → Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts) in your GCP project.
96
+ 2. Create a service account (or use an existing one) and download a JSON key.
97
+ 3. In [Google Play Console](https://play.google.com/console) → **Setup → API access**:
98
+ - Link your Google Cloud project.
99
+ - Find the service account → **Manage Play Console permissions**.
100
+ - Under **Account permissions**, enable **View app information and download bulk reports (read-only)**.
101
+ - Under **App permissions** for each app, enable:
102
+ - **View app information and download bulk reports (read-only)**
103
+ - **Release to production…** _(if you need write access)_
104
+ - Click **Apply** → **Invite user**.
105
+
106
+ > Permissions must be granted at **both** account level and per-app level.
107
+ > Account-level alone is not sufficient for the Reporting API.
108
+
109
+ ---
110
+
111
+ ## Tool reference
112
+
113
+ ### `promote_to_production`
114
+
115
+ ```
116
+ package_name : str — e.g. "com.example.myapp"
117
+ version_codes : list[int] — e.g. [1042]
118
+ rollout_percentage : float — 1–100 (use 100 for immediate full release)
119
+ release_name : str — optional, e.g. "2.4.1"
120
+ release_notes_en : str — optional English release notes
121
+ ```
122
+
123
+ ### `check_review_status`
124
+
125
+ ```
126
+ package_name : str
127
+ ```
128
+
129
+ Returns each production release with its status and rollout percentage.
130
+
131
+ ### `update_rollout_percentage`
132
+
133
+ ```
134
+ package_name : str
135
+ rollout_percentage : float — new %, 0 < value <= 100
136
+ version_codes : list[int] — optional; targets the first inProgress release if omitted
137
+ ```
138
+
139
+ ### `get_production_release_info`
140
+
141
+ ```
142
+ package_name : str
143
+ ```
144
+
145
+ Returns the active release's rollout percentage, version codes, and release notes.
146
+
147
+ > **Note:** Raw install/update event counts are not available via the public API.
148
+ > Use **Play Console → Statistics**, [BigQuery exports](https://support.google.com/googleplay/android-developer/answer/10668107), or Firebase Analytics.
149
+
150
+ ### `get_crash_rate`
151
+
152
+ ```
153
+ package_name : str
154
+ days : int — look-back window, 1–30 (default 7)
155
+ version_code : str — optional single version code to filter
156
+ ```
157
+
158
+ Returns daily `crashRate`, `userPerceivedCrashRate`, and `distinctUsers` per version code.
159
+
160
+ ---
161
+
162
+ ## Troubleshooting
163
+
164
+ ### `403 Forbidden` on `get_crash_rate`
165
+
166
+ ```
167
+ 403 Client Error: Forbidden for url: https://playdeveloperreporting.googleapis.com/...
168
+ ```
169
+
170
+ The service account lacks per-app Reporting API access. Retrying will not help.
171
+
172
+ **Fix:**
173
+ 1. Play Console → **Setup → API access** → find the service account → **Manage Play Console permissions**.
174
+ 2. Under **App permissions**, select the app and enable **View app information and download bulk reports (read-only)**.
175
+ 3. Save and wait a few minutes for the change to propagate.
176
+
177
+ ---
178
+
179
+ ## Marketplaces
180
+
181
+ | Registry | Identifier |
182
+ |---|---|
183
+ | [Smithery](https://smithery.ai) | search `google-play-mcp` |
184
+ | [Official MCP Registry](https://registry.modelcontextprotocol.io) | `io.github.agimaulana/google-play-mcp` |
185
+
186
+ ---
187
+
188
+ ## License
189
+
190
+ MIT
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "google-play-mcp"
7
+ version = "0.2.0"
8
+ description = "Google Play Console MCP Server — manage production releases from your AI assistant."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ keywords = ["mcp", "google-play", "android", "play-console"]
13
+ dependencies = [
14
+ "mcp[cli]>=1.3.0",
15
+ "google-auth>=2.28.0",
16
+ "google-auth-httplib2>=0.2.0",
17
+ "google-api-python-client>=2.120.0",
18
+ "python-dotenv>=1.0.0",
19
+ ]
20
+
21
+ [project.scripts]
22
+ google-play-mcp = "google_play_mcp.server:main"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/google_play_mcp"]
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.agimaulana/google-play-mcp",
4
+ "description": "Manage Google Play Store production releases from your AI assistant — promote builds, adjust rollout percentages, check review status, and fetch crash-rate vitals.",
5
+ "version": "0.2.0",
6
+ "packages": [
7
+ {
8
+ "registryType": "pypi",
9
+ "name": "google-play-mcp",
10
+ "version": "0.2.0",
11
+ "runtimeArguments": ["uvx", "google-play-mcp"],
12
+ "environmentVariables": [
13
+ {
14
+ "name": "GOOGLE_APPLICATION_CREDENTIALS",
15
+ "description": "Absolute path to the Google Cloud service account JSON key file"
16
+ }
17
+ ]
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,18 @@
1
+ startCommand:
2
+ type: stdio
3
+ configSchema:
4
+ type: object
5
+ required:
6
+ - googleApplicationCredentials
7
+ properties:
8
+ googleApplicationCredentials:
9
+ type: string
10
+ description: "Absolute path to your Google Cloud service account JSON key file"
11
+ commandFunction: |-
12
+ (config) => ({
13
+ command: "uvx",
14
+ args: ["google-play-mcp"],
15
+ env: {
16
+ GOOGLE_APPLICATION_CREDENTIALS: config.googleApplicationCredentials
17
+ }
18
+ })
@@ -0,0 +1 @@
1
+ """Google Play Console MCP Server."""
@@ -0,0 +1,253 @@
1
+ """Google Play API clients for the Android Publisher API and Reporting API."""
2
+
3
+ import os
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ import google.auth
8
+ from google.auth.transport.requests import AuthorizedSession
9
+ from google.oauth2 import service_account
10
+ from googleapiclient.discovery import build
11
+
12
+ PUBLISHER_SCOPE = "https://www.googleapis.com/auth/androidpublisher"
13
+ REPORTING_SCOPE = "https://www.googleapis.com/auth/playdeveloperreporting"
14
+ REPORTING_BASE_URL = "https://playdeveloperreporting.googleapis.com/v1beta1"
15
+
16
+
17
+ def _get_credentials(
18
+ scopes: List[str], credentials_file: Optional[str] = None
19
+ ):
20
+ """Return Google credentials from a service account file or ADC."""
21
+ creds_path = credentials_file or os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
22
+ if creds_path:
23
+ return service_account.Credentials.from_service_account_file(
24
+ creds_path, scopes=scopes
25
+ )
26
+ creds, _ = google.auth.default(scopes=scopes)
27
+ return creds
28
+
29
+
30
+ class PublisherClient:
31
+ """Wraps the Google Play Android Publisher API v3 (edits + tracks)."""
32
+
33
+ def __init__(self, credentials_file: Optional[str] = None) -> None:
34
+ self.creds = _get_credentials([PUBLISHER_SCOPE], credentials_file)
35
+ self.service = build("androidpublisher", "v3", credentials=self.creds)
36
+
37
+ # ------------------------------------------------------------------
38
+ # Low-level edit helpers
39
+ # ------------------------------------------------------------------
40
+
41
+ def _create_edit(self, package_name: str) -> str:
42
+ result = self.service.edits().insert(
43
+ packageName=package_name, body={}
44
+ ).execute()
45
+ return result["id"]
46
+
47
+ def _commit_edit(self, package_name: str, edit_id: str) -> Dict[str, Any]:
48
+ return self.service.edits().commit(
49
+ packageName=package_name, editId=edit_id
50
+ ).execute()
51
+
52
+ def _delete_edit(self, package_name: str, edit_id: str) -> None:
53
+ """Best-effort cleanup for read-only edits."""
54
+ try:
55
+ self.service.edits().delete(
56
+ packageName=package_name, editId=edit_id
57
+ ).execute()
58
+ except Exception:
59
+ pass
60
+
61
+ def _get_track(
62
+ self, package_name: str, edit_id: str, track: str
63
+ ) -> Dict[str, Any]:
64
+ return self.service.edits().tracks().get(
65
+ packageName=package_name, editId=edit_id, track=track
66
+ ).execute()
67
+
68
+ def _update_track(
69
+ self,
70
+ package_name: str,
71
+ edit_id: str,
72
+ track: str,
73
+ body: Dict[str, Any],
74
+ ) -> Dict[str, Any]:
75
+ return self.service.edits().tracks().update(
76
+ packageName=package_name, editId=edit_id, track=track, body=body
77
+ ).execute()
78
+
79
+ # ------------------------------------------------------------------
80
+ # Public API
81
+ # ------------------------------------------------------------------
82
+
83
+ def get_track(self, package_name: str, track: str) -> Dict[str, Any]:
84
+ """Read-only fetch of a track. Creates and deletes a temporary edit."""
85
+ edit_id = self._create_edit(package_name)
86
+ try:
87
+ return self._get_track(package_name, edit_id, track)
88
+ finally:
89
+ self._delete_edit(package_name, edit_id)
90
+
91
+ def promote_to_production(
92
+ self,
93
+ package_name: str,
94
+ version_codes: List[int],
95
+ rollout_percentage: float,
96
+ release_name: Optional[str] = None,
97
+ release_notes: Optional[List[Dict[str, str]]] = None,
98
+ ) -> Dict[str, Any]:
99
+ """
100
+ Promote the given version codes to the production track.
101
+
102
+ rollout_percentage: 0 < value <= 100
103
+ """
104
+ if not (0 < rollout_percentage <= 100):
105
+ raise ValueError(
106
+ "rollout_percentage must be > 0 and <= 100."
107
+ )
108
+
109
+ release: Dict[str, Any] = {
110
+ "versionCodes": [str(vc) for vc in version_codes],
111
+ "status": "completed" if rollout_percentage == 100 else "inProgress",
112
+ }
113
+ if rollout_percentage < 100:
114
+ release["userFraction"] = round(rollout_percentage / 100.0, 4)
115
+ if release_name:
116
+ release["name"] = release_name
117
+ if release_notes:
118
+ release["releaseNotes"] = release_notes
119
+
120
+ edit_id = self._create_edit(package_name)
121
+ try:
122
+ track_body = {"track": "production", "releases": [release]}
123
+ updated_track = self._update_track(
124
+ package_name, edit_id, "production", track_body
125
+ )
126
+ commit = self._commit_edit(package_name, edit_id)
127
+ return {"track": updated_track, "commit": commit}
128
+ except Exception:
129
+ self._delete_edit(package_name, edit_id)
130
+ raise
131
+
132
+ def update_rollout_percentage(
133
+ self,
134
+ package_name: str,
135
+ rollout_percentage: float,
136
+ version_codes: Optional[List[int]] = None,
137
+ ) -> Dict[str, Any]:
138
+ """
139
+ Update the rollout percentage of an existing production release.
140
+
141
+ Targets the first release whose status is inProgress or halted.
142
+ If version_codes is provided, only a release containing those codes
143
+ will be updated.
144
+ """
145
+ if not (0 < rollout_percentage <= 100):
146
+ raise ValueError(
147
+ "rollout_percentage must be > 0 and <= 100."
148
+ )
149
+
150
+ edit_id = self._create_edit(package_name)
151
+ try:
152
+ track_data = self._get_track(package_name, edit_id, "production")
153
+ releases: List[Dict[str, Any]] = track_data.get("releases", [])
154
+
155
+ target_vcs = (
156
+ {str(vc) for vc in version_codes} if version_codes else None
157
+ )
158
+
159
+ updated = False
160
+ for release in releases:
161
+ if release.get("status") not in ("inProgress", "halted", "completed"):
162
+ continue
163
+ if target_vcs:
164
+ existing_vcs = set(release.get("versionCodes", []))
165
+ if not existing_vcs.intersection(target_vcs):
166
+ continue
167
+
168
+ if rollout_percentage >= 100:
169
+ release["status"] = "completed"
170
+ release.pop("userFraction", None)
171
+ else:
172
+ release["status"] = "inProgress"
173
+ release["userFraction"] = round(rollout_percentage / 100.0, 4)
174
+ updated = True
175
+ break
176
+
177
+ if not updated:
178
+ raise ValueError(
179
+ "No matching inProgress/halted/completed release found "
180
+ "in the production track."
181
+ )
182
+
183
+ track_body = {"track": "production", "releases": releases}
184
+ updated_track = self._update_track(
185
+ package_name, edit_id, "production", track_body
186
+ )
187
+ commit = self._commit_edit(package_name, edit_id)
188
+ return {"track": updated_track, "commit": commit}
189
+ except Exception:
190
+ self._delete_edit(package_name, edit_id)
191
+ raise
192
+
193
+
194
+ class ReportingClient:
195
+ """Wraps the Google Play Developer Reporting API v1beta1."""
196
+
197
+ def __init__(self, credentials_file: Optional[str] = None) -> None:
198
+ self.creds = _get_credentials([REPORTING_SCOPE], credentials_file)
199
+ self.session = AuthorizedSession(self.creds)
200
+
201
+ def _app_path(self, package_name: str) -> str:
202
+ return f"apps/{package_name}"
203
+
204
+ def query_crash_rate(
205
+ self,
206
+ package_name: str,
207
+ days: int = 7,
208
+ version_code: Optional[str] = None,
209
+ ) -> Dict[str, Any]:
210
+ """
211
+ Query crash-rate metrics for the app.
212
+
213
+ Returns crashRate, userPerceivedCrashRate, and distinctUsers
214
+ aggregated daily, broken down by versionCode.
215
+ """
216
+ app = self._app_path(package_name)
217
+ url = f"{REPORTING_BASE_URL}/{app}/crashRateMetricSet:query"
218
+
219
+ # API data lags by 1 day; using today as endTime returns 400
220
+ end = datetime.now(timezone.utc) - timedelta(days=1)
221
+ start = end - timedelta(days=days)
222
+
223
+ def _date(dt: datetime) -> Dict[str, int]:
224
+ return {"year": dt.year, "month": dt.month, "day": dt.day}
225
+
226
+ body: Dict[str, Any] = {
227
+ "timelineSpec": {
228
+ "aggregationPeriod": "DAILY",
229
+ "startTime": _date(start),
230
+ "endTime": _date(end),
231
+ },
232
+ "metrics": [
233
+ "crashRate",
234
+ "userPerceivedCrashRate",
235
+ "distinctUsers",
236
+ ],
237
+ "dimensions": ["versionCode"],
238
+ "pageSize": days * 10,
239
+ }
240
+ if version_code:
241
+ body["filter"] = f'versionCode = "{version_code}"'
242
+
243
+ resp = self.session.post(url, json=body)
244
+ resp.raise_for_status()
245
+ return resp.json()
246
+
247
+ def list_available_metrics(self, package_name: str) -> Dict[str, Any]:
248
+ """List all available metric sets for the app (for debugging)."""
249
+ app = self._app_path(package_name)
250
+ url = f"{REPORTING_BASE_URL}/{app}/crashRateMetricSet"
251
+ resp = self.session.get(url)
252
+ resp.raise_for_status()
253
+ return resp.json()
@@ -0,0 +1,373 @@
1
+ """Google Play Console MCP Server.
2
+
3
+ Exposes Google Play Console operations as MCP tools so AI assistants
4
+ (Claude, etc.) can manage production releases programmatically.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from typing import Optional
10
+
11
+ from dotenv import load_dotenv
12
+ from mcp.server.fastmcp import FastMCP
13
+
14
+ from .client import PublisherClient, ReportingClient
15
+
16
+ load_dotenv()
17
+
18
+ mcp = FastMCP(
19
+ "Google Play Console MCP",
20
+ instructions=(
21
+ "Tools for managing Google Play Store production releases: "
22
+ "promoting builds, adjusting rollout percentages, checking review "
23
+ "status, and fetching crash-rate vitals."
24
+ ),
25
+ )
26
+
27
+ _CREDS = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
28
+
29
+
30
+ def _publisher() -> PublisherClient:
31
+ return PublisherClient(_CREDS)
32
+
33
+
34
+ def _reporting() -> ReportingClient:
35
+ return ReportingClient(_CREDS)
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Helpers
40
+ # ---------------------------------------------------------------------------
41
+
42
+ def _format_release(release: dict) -> dict:
43
+ """Return a clean summary dict for a single release."""
44
+ fraction = release.get("userFraction")
45
+ rollout_pct = round(fraction * 100, 2) if fraction is not None else (
46
+ 100.0 if release.get("status") == "completed" else None
47
+ )
48
+ return {
49
+ "name": release.get("name"),
50
+ "versionCodes": release.get("versionCodes", []),
51
+ "status": release.get("status"),
52
+ "rolloutPercentage": rollout_pct,
53
+ "releaseNotes": release.get("releaseNotes", []),
54
+ }
55
+
56
+
57
+ def _format_track(track_data: dict) -> dict:
58
+ return {
59
+ "track": track_data.get("track"),
60
+ "releases": [_format_release(r) for r in track_data.get("releases", [])],
61
+ }
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Tool: promote_to_production
66
+ # ---------------------------------------------------------------------------
67
+
68
+ @mcp.tool()
69
+ def promote_to_production(
70
+ package_name: str,
71
+ version_codes: list[int],
72
+ rollout_percentage: float,
73
+ release_name: str = "",
74
+ release_notes_en: str = "",
75
+ ) -> str:
76
+ """Promote an app build from the internal track to production with a
77
+ staged rollout.
78
+
79
+ Args:
80
+ package_name: App package name, e.g. com.example.myapp
81
+ version_codes: List of version codes to promote (e.g. [1234]).
82
+ rollout_percentage: Initial rollout percentage, 0 < value <= 100.
83
+ Use 100 for an immediate full release.
84
+ release_name: Optional human-readable release name.
85
+ release_notes_en: Optional English release notes (plain text).
86
+ """
87
+ try:
88
+ notes = (
89
+ [{"language": "en-US", "text": release_notes_en}]
90
+ if release_notes_en
91
+ else None
92
+ )
93
+ result = _publisher().promote_to_production(
94
+ package_name=package_name,
95
+ version_codes=version_codes,
96
+ rollout_percentage=rollout_percentage,
97
+ release_name=release_name or None,
98
+ release_notes=notes,
99
+ )
100
+ track_summary = _format_track(result["track"])
101
+ commit = result.get("commit", {})
102
+ return json.dumps(
103
+ {
104
+ "success": True,
105
+ "message": (
106
+ f"Version codes {version_codes} promoted to production "
107
+ f"at {rollout_percentage}% rollout."
108
+ ),
109
+ "track": track_summary,
110
+ "editId": commit.get("editId"),
111
+ },
112
+ indent=2,
113
+ )
114
+ except Exception as exc:
115
+ return json.dumps({"success": False, "error": str(exc)}, indent=2)
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Tool: check_review_status
120
+ # ---------------------------------------------------------------------------
121
+
122
+ @mcp.tool()
123
+ def check_review_status(package_name: str) -> str:
124
+ """Check the current production release status for an app.
125
+
126
+ Returns all production releases with their status (draft, inProgress,
127
+ halted, completed) and rollout percentages. A release in 'inProgress'
128
+ state indicates it is either under review or actively rolling out.
129
+
130
+ Args:
131
+ package_name: App package name, e.g. com.example.myapp
132
+ """
133
+ try:
134
+ track_data = _publisher().get_track(package_name, "production")
135
+ releases = track_data.get("releases", [])
136
+
137
+ if not releases:
138
+ return json.dumps(
139
+ {
140
+ "packageName": package_name,
141
+ "message": "No releases found in the production track.",
142
+ "releases": [],
143
+ },
144
+ indent=2,
145
+ )
146
+
147
+ formatted = [_format_release(r) for r in releases]
148
+
149
+ # Derive a human-readable overall status
150
+ statuses = {r["status"] for r in formatted if r["status"]}
151
+ if "inProgress" in statuses:
152
+ summary = "Staged rollout in progress."
153
+ elif "draft" in statuses:
154
+ summary = "Release is in draft / under Google Play review."
155
+ elif "halted" in statuses:
156
+ summary = "Rollout is halted."
157
+ elif statuses == {"completed"}:
158
+ summary = "Release fully rolled out (100%)."
159
+ else:
160
+ summary = f"Status: {', '.join(statuses)}"
161
+
162
+ return json.dumps(
163
+ {
164
+ "packageName": package_name,
165
+ "summary": summary,
166
+ "releases": formatted,
167
+ },
168
+ indent=2,
169
+ )
170
+ except Exception as exc:
171
+ return json.dumps({"success": False, "error": str(exc)}, indent=2)
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Tool: update_rollout_percentage
176
+ # ---------------------------------------------------------------------------
177
+
178
+ @mcp.tool()
179
+ def update_rollout_percentage(
180
+ package_name: str,
181
+ rollout_percentage: float,
182
+ version_codes: list[int] | None = None,
183
+ ) -> str:
184
+ """Update the rollout percentage for the current production release.
185
+
186
+ Args:
187
+ package_name: App package name, e.g. com.example.myapp
188
+ rollout_percentage: New rollout percentage, 0 < value <= 100.
189
+ Pass 100 to complete the rollout for all users.
190
+ version_codes: Optional list of version codes to target. If omitted,
191
+ the most recent inProgress or halted release is updated.
192
+ """
193
+ try:
194
+ result = _publisher().update_rollout_percentage(
195
+ package_name=package_name,
196
+ rollout_percentage=rollout_percentage,
197
+ version_codes=version_codes,
198
+ )
199
+ track_summary = _format_track(result["track"])
200
+ commit = result.get("commit", {})
201
+ return json.dumps(
202
+ {
203
+ "success": True,
204
+ "message": f"Rollout updated to {rollout_percentage}%.",
205
+ "track": track_summary,
206
+ "editId": commit.get("editId"),
207
+ },
208
+ indent=2,
209
+ )
210
+ except Exception as exc:
211
+ return json.dumps({"success": False, "error": str(exc)}, indent=2)
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Tool: get_production_release_info
216
+ # ---------------------------------------------------------------------------
217
+
218
+ @mcp.tool()
219
+ def get_production_release_info(package_name: str) -> str:
220
+ """Fetch the current production release info: rollout percentage,
221
+ version codes, status, and release notes.
222
+
223
+ Note: Detailed install / update event analytics (raw install counts,
224
+ update events) are not exposed by the public Google Play Developer API.
225
+ They are available in Google Play Console UI, BigQuery data exports, or
226
+ Firebase Analytics if integrated with your app.
227
+
228
+ Args:
229
+ package_name: App package name, e.g. com.example.myapp
230
+ """
231
+ try:
232
+ track_data = _publisher().get_track(package_name, "production")
233
+ releases = track_data.get("releases", [])
234
+
235
+ formatted = [_format_release(r) for r in releases]
236
+
237
+ # Find the active / latest release
238
+ active = next(
239
+ (r for r in formatted if r["status"] in ("inProgress", "completed")),
240
+ formatted[0] if formatted else None,
241
+ )
242
+
243
+ return json.dumps(
244
+ {
245
+ "packageName": package_name,
246
+ "activeRelease": active,
247
+ "allReleases": formatted,
248
+ "note": (
249
+ "Install & Update event counts are not available via the "
250
+ "public API. Use Play Console > Statistics or set up "
251
+ "BigQuery exports for detailed analytics."
252
+ ),
253
+ },
254
+ indent=2,
255
+ )
256
+ except Exception as exc:
257
+ return json.dumps({"success": False, "error": str(exc)}, indent=2)
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Tool: get_crash_rate
262
+ # ---------------------------------------------------------------------------
263
+
264
+ @mcp.tool()
265
+ def get_crash_rate(
266
+ package_name: str,
267
+ days: int = 7,
268
+ version_code: str = "",
269
+ ) -> str:
270
+ """Fetch user-perceived crash rate for the production release.
271
+
272
+ Uses the Google Play Developer Reporting API v1beta1.
273
+ Returns daily crashRate, userPerceivedCrashRate, and distinctUsers
274
+ broken down by version code for the requested period.
275
+
276
+ Args:
277
+ package_name: App package name, e.g. com.example.myapp
278
+ days: Number of past days to include (default 7, max 30).
279
+ version_code: Optional single version code string to filter results.
280
+ """
281
+ days = max(1, min(days, 30))
282
+ try:
283
+ raw = _reporting().query_crash_rate(
284
+ package_name=package_name,
285
+ days=days,
286
+ version_code=version_code or None,
287
+ )
288
+
289
+ rows = raw.get("rows", [])
290
+ if not rows:
291
+ return json.dumps(
292
+ {
293
+ "packageName": package_name,
294
+ "message": (
295
+ "No crash data found. Data may not be available yet "
296
+ "or the app has no crashes in this period."
297
+ ),
298
+ "rows": [],
299
+ },
300
+ indent=2,
301
+ )
302
+
303
+ # Parse rows into a readable format
304
+ parsed_rows = []
305
+ for row in rows:
306
+ dims = {d.get("dimension"): d.get("stringValue") or d.get("int64Value")
307
+ for d in row.get("dimensions", [])}
308
+ metrics = {}
309
+ for m in row.get("metrics", []):
310
+ name = m.get("metric")
311
+ val = m.get("decimalValue") or m.get("int64Value")
312
+ if val is not None:
313
+ # decimalValue comes as a string like "0.0012"
314
+ try:
315
+ val = float(val)
316
+ except (TypeError, ValueError):
317
+ pass
318
+ metrics[name] = val
319
+
320
+ date_info = row.get("startTime", {})
321
+ parsed_rows.append(
322
+ {
323
+ "date": date_info,
324
+ "versionCode": dims.get("versionCode"),
325
+ "crashRate": metrics.get("crashRate"),
326
+ "userPerceivedCrashRate": metrics.get("userPerceivedCrashRate"),
327
+ "distinctUsers": metrics.get("distinctUsers"),
328
+ }
329
+ )
330
+
331
+ return json.dumps(
332
+ {
333
+ "packageName": package_name,
334
+ "periodDays": days,
335
+ "totalRows": len(parsed_rows),
336
+ "rows": parsed_rows,
337
+ },
338
+ indent=2,
339
+ )
340
+ except Exception as exc:
341
+ return json.dumps({"success": False, "error": str(exc)}, indent=2)
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # Entry point
346
+ # ---------------------------------------------------------------------------
347
+
348
+ def main() -> None:
349
+ import argparse
350
+
351
+ parser = argparse.ArgumentParser(description="Google Play Console MCP Server")
352
+ parser.add_argument(
353
+ "--transport",
354
+ choices=["stdio", "http"],
355
+ default="stdio",
356
+ help="Transport mode (default: stdio)",
357
+ )
358
+ parser.add_argument(
359
+ "--port",
360
+ type=int,
361
+ default=8080,
362
+ help="Port for HTTP transport (default: 8080)",
363
+ )
364
+ args = parser.parse_args()
365
+
366
+ if args.transport == "http":
367
+ mcp.run(transport="streamable-http", host="0.0.0.0", port=args.port)
368
+ else:
369
+ mcp.run()
370
+
371
+
372
+ if __name__ == "__main__":
373
+ main()