@blocktopus/mcp-google-play
Version:
MCP server for Google Play Store command line tools
588 lines • 23.1 kB
JavaScript
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { google } from 'googleapis';
import { GoogleAuth } from 'google-auth-library';
import { z } from 'zod';
import * as fs from 'fs/promises';
// Get API key from command line arguments or environment
const args = process.argv.slice(2);
let apiKeyPath;
// Parse command line arguments
for (let i = 0; i < args.length; i++) {
if (args[i] === '--api-key' && i + 1 < args.length) {
apiKeyPath = args[i + 1];
break;
}
}
// Fall back to environment variable if not provided via CLI
if (!apiKeyPath) {
apiKeyPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
}
// Allow test mode without API key
const isTestMode = args.includes('--test');
if (!apiKeyPath && !isTestMode) {
console.error('Error: No API key provided. Use --api-key <path> or set GOOGLE_APPLICATION_CREDENTIALS');
console.error('For testing without API key, use --test flag');
process.exit(1);
}
// Initialize Google Auth with the provided key
let auth;
let androidpublisher;
// Initialize auth and API in an async function
async function initializeAuth() {
if (isTestMode) {
console.error('Running in test mode - API calls will not work without credentials');
return;
}
try {
// Verify the file exists
await fs.access(apiKeyPath);
auth = new GoogleAuth({
keyFile: apiKeyPath,
scopes: ['https://www.googleapis.com/auth/androidpublisher']
});
// Initialize Play Developer API with auth
const authClient = await auth.getClient();
androidpublisher = google.androidpublisher({
version: 'v3',
auth: authClient
});
}
catch (error) {
console.error(`Error: Cannot access API key file at ${apiKeyPath}`);
process.exit(1);
}
}
// Tool schemas
const ListAppsSchema = z.object({});
const GetAppInfoSchema = z.object({
packageName: z.string().describe('The package name of the app (e.g., com.example.app)')
});
const ListReleasesSchema = z.object({
packageName: z.string().describe('The package name of the app'),
track: z.enum(['internal', 'alpha', 'beta', 'production']).describe('The release track')
});
const GetReviewsSchema = z.object({
packageName: z.string().describe('The package name of the app'),
maxResults: z.number().optional().default(10).describe('Maximum number of reviews to return')
});
const ReplyToReviewSchema = z.object({
packageName: z.string().describe('The package name of the app'),
reviewId: z.string().describe('The ID of the review to reply to'),
replyText: z.string().describe('The reply text to post')
});
const UpdateListingSchema = z.object({
packageName: z.string().describe('The package name of the app'),
language: z.string().describe('Language code (e.g., en-US)'),
title: z.string().optional().describe('App title'),
shortDescription: z.string().optional().describe('Short description'),
fullDescription: z.string().optional().describe('Full description'),
video: z.string().optional().describe('YouTube video URL')
});
const GetListingSchema = z.object({
packageName: z.string().describe('The package name of the app'),
language: z.string().describe('Language code (e.g., en-US)')
});
const CreateReleaseSchema = z.object({
packageName: z.string().describe('The package name of the app'),
track: z.enum(['internal', 'alpha', 'beta', 'production']).describe('The release track'),
versionCode: z.number().describe('Version code of the APK/AAB to release'),
releaseNotes: z.string().optional().describe('Release notes for this version'),
userFraction: z.number().optional().describe('Fraction of users to get update (0.0-1.0)')
});
const GetStatisticsSchema = z.object({
packageName: z.string().describe('The package name of the app'),
metric: z.enum(['installs', 'ratings', 'crashes']).describe('Type of statistics to retrieve')
});
// Create MCP server
const server = new Server({
name: 'google-play',
version: '0.1.2',
}, {
capabilities: {
tools: {},
},
});
// Handle list tools request
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'list_apps',
description: 'List all apps in your Google Play Console',
inputSchema: {
type: 'object',
properties: {},
required: []
},
},
{
name: 'get_app_info',
description: 'Get detailed information about a specific app',
inputSchema: {
type: 'object',
properties: {
packageName: {
type: 'string',
description: 'The package name of the app (e.g., com.example.app)'
}
},
required: ['packageName']
},
},
{
name: 'list_releases',
description: 'List releases for an app in a specific track',
inputSchema: {
type: 'object',
properties: {
packageName: {
type: 'string',
description: 'The package name of the app'
},
track: {
type: 'string',
enum: ['internal', 'alpha', 'beta', 'production'],
description: 'The release track'
}
},
required: ['packageName', 'track']
},
},
{
name: 'get_reviews',
description: 'Get recent reviews for an app',
inputSchema: {
type: 'object',
properties: {
packageName: {
type: 'string',
description: 'The package name of the app'
},
maxResults: {
type: 'number',
description: 'Maximum number of reviews to return',
default: 10
}
},
required: ['packageName']
},
},
{
name: 'reply_to_review',
description: 'Reply to a user review',
inputSchema: {
type: 'object',
properties: {
packageName: {
type: 'string',
description: 'The package name of the app'
},
reviewId: {
type: 'string',
description: 'The ID of the review to reply to'
},
replyText: {
type: 'string',
description: 'The reply text to post'
}
},
required: ['packageName', 'reviewId', 'replyText']
},
},
{
name: 'get_listing',
description: 'Get store listing information for an app',
inputSchema: {
type: 'object',
properties: {
packageName: {
type: 'string',
description: 'The package name of the app'
},
language: {
type: 'string',
description: 'Language code (e.g., en-US)'
}
},
required: ['packageName', 'language']
},
},
{
name: 'update_listing',
description: 'Update store listing information',
inputSchema: {
type: 'object',
properties: {
packageName: {
type: 'string',
description: 'The package name of the app'
},
language: {
type: 'string',
description: 'Language code (e.g., en-US)'
},
title: {
type: 'string',
description: 'App title'
},
shortDescription: {
type: 'string',
description: 'Short description'
},
fullDescription: {
type: 'string',
description: 'Full description'
},
video: {
type: 'string',
description: 'YouTube video URL'
}
},
required: ['packageName', 'language']
},
},
{
name: 'create_release',
description: 'Create a new release for an app',
inputSchema: {
type: 'object',
properties: {
packageName: {
type: 'string',
description: 'The package name of the app'
},
track: {
type: 'string',
enum: ['internal', 'alpha', 'beta', 'production'],
description: 'The release track'
},
versionCode: {
type: 'number',
description: 'Version code of the APK/AAB to release'
},
releaseNotes: {
type: 'string',
description: 'Release notes for this version'
},
userFraction: {
type: 'number',
description: 'Fraction of users to get update (0.0-1.0)'
}
},
required: ['packageName', 'track', 'versionCode']
},
},
{
name: 'get_statistics',
description: 'Get app statistics (installs, ratings, crashes)',
inputSchema: {
type: 'object',
properties: {
packageName: {
type: 'string',
description: 'The package name of the app'
},
metric: {
type: 'string',
enum: ['installs', 'ratings', 'crashes'],
description: 'Type of statistics to retrieve'
}
},
required: ['packageName', 'metric']
},
},
],
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
// Check if we're in test mode
if (isTestMode) {
return {
content: [
{
type: 'text',
text: 'Server is running in test mode. Please provide API credentials to use this tool.',
},
],
};
}
// Auth is already set up in androidpublisher instance
switch (name) {
case 'list_apps': {
// Note: The Play Developer API doesn't have a direct "list all apps" endpoint
// This would typically require knowing package names beforehand
return {
content: [
{
type: 'text',
text: 'To list apps, you need to provide package names. The Play Developer API requires knowing package names in advance.',
},
],
};
}
case 'get_app_info': {
const { packageName } = GetAppInfoSchema.parse(args);
const response = await androidpublisher.edits.insert({
packageName,
});
const editId = response.data.id;
// Get app details
const appDetails = await androidpublisher.edits.details.get({
packageName,
editId: editId,
});
// Delete the edit (we're just reading)
await androidpublisher.edits.delete({
packageName,
editId: editId,
});
return {
content: [
{
type: 'text',
text: JSON.stringify(appDetails.data, null, 2),
},
],
};
}
case 'list_releases': {
const { packageName, track } = ListReleasesSchema.parse(args);
const response = await androidpublisher.edits.insert({
packageName,
});
const editId = response.data.id;
// Get track information
const trackInfo = await androidpublisher.edits.tracks.get({
packageName,
editId: editId,
track,
});
// Delete the edit
await androidpublisher.edits.delete({
packageName,
editId: editId,
});
return {
content: [
{
type: 'text',
text: JSON.stringify(trackInfo.data, null, 2),
},
],
};
}
case 'get_reviews': {
const { packageName, maxResults } = GetReviewsSchema.parse(args);
const reviews = await androidpublisher.reviews.list({
packageName,
maxResults,
});
return {
content: [
{
type: 'text',
text: JSON.stringify(reviews.data, null, 2),
},
],
};
}
case 'reply_to_review': {
const { packageName, reviewId, replyText } = ReplyToReviewSchema.parse(args);
const result = await androidpublisher.reviews.reply({
packageName,
reviewId,
requestBody: {
replyText,
},
});
return {
content: [
{
type: 'text',
text: `Successfully replied to review ${reviewId}`,
},
],
};
}
case 'get_listing': {
const { packageName, language } = GetListingSchema.parse(args);
const response = await androidpublisher.edits.insert({
packageName,
});
const editId = response.data.id;
// Get listing details
const listing = await androidpublisher.edits.listings.get({
packageName,
editId: editId,
language,
});
// Delete the edit
await androidpublisher.edits.delete({
packageName,
editId: editId,
});
return {
content: [
{
type: 'text',
text: JSON.stringify(listing.data, null, 2),
},
],
};
}
case 'update_listing': {
const { packageName, language, title, shortDescription, fullDescription, video } = UpdateListingSchema.parse(args);
const response = await androidpublisher.edits.insert({
packageName,
});
const editId = response.data.id;
// Update listing
const updateData = {};
if (title)
updateData.title = title;
if (shortDescription)
updateData.shortDescription = shortDescription;
if (fullDescription)
updateData.fullDescription = fullDescription;
if (video)
updateData.video = video;
await androidpublisher.edits.listings.update({
packageName,
editId: editId,
language,
requestBody: updateData,
});
// Commit the edit
const commitResult = await androidpublisher.edits.commit({
packageName,
editId: editId,
});
return {
content: [
{
type: 'text',
text: `Successfully updated ${language} listing for ${packageName}`,
},
],
};
}
case 'create_release': {
const { packageName, track, versionCode, releaseNotes, userFraction } = CreateReleaseSchema.parse(args);
const response = await androidpublisher.edits.insert({
packageName,
});
const editId = response.data.id;
// Create release
const releaseData = {
versionCodes: [versionCode],
status: userFraction && userFraction < 1 ? 'inProgress' : 'completed',
};
if (releaseNotes) {
releaseData.releaseNotes = [{
language: 'en-US',
text: releaseNotes,
}];
}
if (userFraction) {
releaseData.userFraction = userFraction;
}
await androidpublisher.edits.tracks.update({
packageName,
editId: editId,
track,
requestBody: {
track,
releases: [releaseData],
},
});
// Commit the edit
const commitResult = await androidpublisher.edits.commit({
packageName,
editId: editId,
});
return {
content: [
{
type: 'text',
text: `Successfully created release for version ${versionCode} on ${track} track`,
},
],
};
}
case 'get_statistics': {
const { packageName, metric } = GetStatisticsSchema.parse(args);
// Note: Statistics require different API endpoints based on metric
// This is a simplified example
let result;
switch (metric) {
case 'installs':
// This would typically use the Reports API
result = 'Install statistics would be retrieved from Play Console Reports API';
break;
case 'ratings':
// Get app details which includes ratings
const response = await androidpublisher.edits.insert({
packageName,
});
const editId = response.data.id;
const appDetails = await androidpublisher.edits.details.get({
packageName,
editId: editId,
});
await androidpublisher.edits.delete({
packageName,
editId: editId,
});
result = JSON.stringify({
appDetails: appDetails.data,
note: 'For detailed statistics, use Play Console Reports API',
}, null, 2);
break;
case 'crashes':
result = 'Crash statistics would be retrieved from Play Console Vitals API';
break;
default:
result = 'Unknown metric';
}
return {
content: [
{
type: 'text',
text: result,
},
],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
catch (error) {
return {
content: [
{
type: 'text',
text: `Error: ${error instanceof Error ? error.message : 'Unknown error occurred'}`,
},
],
};
}
});
// Start the server
async function main() {
await initializeAuth();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MCP Google Play server started');
}
main().catch((error) => {
console.error('Server error:', error);
process.exit(1);
});
//# sourceMappingURL=index.js.map