pabal-web-mcp 1.3.6 → 1.3.8

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 (2) hide show
  1. package/dist/bin/mcp-server.js +481 -297
  2. package/package.json +1 -1
@@ -212,7 +212,7 @@ var asoToPublicTool = {
212
212
  name: "aso-to-public",
213
213
  description: `Converts ASO data from pullData to public/products/[slug]/ structure.
214
214
 
215
- **IMPORTANT:** The 'slug' parameter is REQUIRED. If the user does not provide a slug, you MUST ask them to provide it. This tool processes only ONE product at a time.
215
+ **IMPORTANT:** Always use 'search-app' tool first to resolve the exact slug before calling this tool. The user may provide an approximate name, bundleId, or packageName - search-app will find and return the correct slug. Never pass user input directly as slug.
216
216
 
217
217
  This tool:
218
218
  1. Loads ASO data from .aso/pullData/products/[slug]/store/ (path from ~/.config/pabal-mcp/config.json dataDir)
@@ -665,7 +665,7 @@ var publicToAsoTool = {
665
665
  name: "public-to-aso",
666
666
  description: `Prepares ASO data from public/products/[slug]/ to pushData format.
667
667
 
668
- **IMPORTANT:** The 'slug' parameter is REQUIRED. If the user does not provide a slug, you MUST ask them to provide it. This tool processes only ONE product at a time.
668
+ **IMPORTANT:** Always use 'search-app' tool first to resolve the exact slug before calling this tool. The user may provide an approximate name, bundleId, or packageName - search-app will find and return the correct slug. Never pass user input directly as slug.
669
669
 
670
670
  This tool:
671
671
  1. Loads ASO data from public/products/[slug]/config.json + locales/
@@ -1727,7 +1727,7 @@ var improvePublicTool = {
1727
1727
  name: "improve-public",
1728
1728
  description: `Optimizes locale JSON in public/products/[slug]/locales for ASO.
1729
1729
 
1730
- **IMPORTANT:** The 'slug' parameter is REQUIRED. If the user does not provide a slug, you MUST ask them to provide it. This tool processes only ONE product at a time.
1730
+ **IMPORTANT:** Always use 'search-app' tool first to resolve the exact slug before calling this tool. The user may provide an approximate name, bundleId, or packageName - search-app will find and return the correct slug. Never pass user input directly as slug.
1731
1731
 
1732
1732
  **CRITICAL: Only processes existing locale files. Does NOT create new locale files.**
1733
1733
  - Only improves locales that already exist in public/products/[slug]/locales/
@@ -2378,10 +2378,8 @@ Writing style reference for ${locale}: Found ${posts.length} existing post(s) us
2378
2378
  // src/tools/keyword-research.ts
2379
2379
  import fs11 from "fs";
2380
2380
  import path11 from "path";
2381
- import { z as z6 } from "zod";
2382
- import { zodToJsonSchema as zodToJsonSchema6 } from "zod-to-json-schema";
2383
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2384
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2381
+ import { z as z7 } from "zod";
2382
+ import { zodToJsonSchema as zodToJsonSchema7 } from "zod-to-json-schema";
2385
2383
 
2386
2384
  // src/utils/registered-apps.util.ts
2387
2385
  import fs10 from "fs";
@@ -2415,58 +2413,302 @@ function findRegisteredApp(slug, filePath) {
2415
2413
  const app = apps.find((a) => a.slug === slug);
2416
2414
  return { app, path: usedPath };
2417
2415
  }
2416
+ function getSupportedLocalesForSlug(slug, platform, filePath) {
2417
+ const { app, path: usedPath } = findRegisteredApp(slug, filePath);
2418
+ if (!app) return { supportedLocales: [], path: usedPath };
2419
+ if (platform === "ios") {
2420
+ return {
2421
+ supportedLocales: app.appStore?.supportedLocales || [],
2422
+ path: usedPath
2423
+ };
2424
+ }
2425
+ return {
2426
+ supportedLocales: app.googlePlay?.supportedLocales || [],
2427
+ path: usedPath
2428
+ };
2429
+ }
2418
2430
 
2419
- // src/tools/keyword-research.ts
2420
- var TOOL_NAME = "keyword-research";
2421
- var keywordResearchInputSchema = z6.object({
2422
- slug: z6.string().trim().describe("Product slug"),
2423
- locale: z6.string().trim().describe(
2424
- "Locale code (e.g., en-US, ko-KR). Stored under .aso/keywordResearch/products/[slug]/locales/."
2431
+ // src/tools/search-app.ts
2432
+ import { z as z6 } from "zod";
2433
+ import { zodToJsonSchema as zodToJsonSchema6 } from "zod-to-json-schema";
2434
+ var TOOL_NAME = "search-app";
2435
+ var searchAppInputSchema = z6.object({
2436
+ query: z6.string().trim().optional().describe(
2437
+ "Search term (slug, bundleId, packageName, name). Returns all apps if empty."
2425
2438
  ),
2426
- platform: z6.enum(["ios", "android"]).default("ios").describe("Store to target ('ios' or 'android'). Run separately per platform."),
2427
- country: z6.string().length(2).optional().describe(
2439
+ store: z6.enum(["all", "appStore", "googlePlay"]).default("all").describe("Store filter (default: all)")
2440
+ });
2441
+ var jsonSchema6 = zodToJsonSchema6(searchAppInputSchema, {
2442
+ name: "SearchAppInput",
2443
+ $refStrategy: "none"
2444
+ });
2445
+ var inputSchema6 = jsonSchema6.definitions?.SearchAppInput || jsonSchema6;
2446
+ var searchAppTool = {
2447
+ name: TOOL_NAME,
2448
+ description: `Search registered apps from registered-apps.json.
2449
+
2450
+ - Called without query: Returns all app list
2451
+ - Called with query: Search by slug, bundleId, packageName, name
2452
+ - Use store filter to narrow results to appStore or googlePlay only`,
2453
+ inputSchema: inputSchema6
2454
+ };
2455
+ function matchesQuery(app, query) {
2456
+ const lowerQuery = query.toLowerCase();
2457
+ if (app.slug.toLowerCase().includes(lowerQuery)) return true;
2458
+ if (app.name?.toLowerCase().includes(lowerQuery)) return true;
2459
+ if (app.appStore?.bundleId?.toLowerCase().includes(lowerQuery)) return true;
2460
+ if (app.appStore?.name?.toLowerCase().includes(lowerQuery)) return true;
2461
+ if (app.googlePlay?.packageName?.toLowerCase().includes(lowerQuery))
2462
+ return true;
2463
+ if (app.googlePlay?.name?.toLowerCase().includes(lowerQuery)) return true;
2464
+ return false;
2465
+ }
2466
+ function filterByStore(apps, store) {
2467
+ if (store === "all") return apps;
2468
+ return apps.filter((app) => {
2469
+ if (store === "appStore") return !!app.appStore;
2470
+ if (store === "googlePlay") return !!app.googlePlay;
2471
+ return true;
2472
+ });
2473
+ }
2474
+ function formatAppInfo(app) {
2475
+ const lines = [];
2476
+ lines.push(`\u{1F4F1} **${app.name || app.slug}** (\`${app.slug}\`)`);
2477
+ if (app.appStore) {
2478
+ lines.push(` \u{1F34E} App Store: \`${app.appStore.bundleId || "N/A"}\``);
2479
+ if (app.appStore.appId) {
2480
+ lines.push(` App ID: ${app.appStore.appId}`);
2481
+ }
2482
+ if (app.appStore.name) {
2483
+ lines.push(` Name: ${app.appStore.name}`);
2484
+ }
2485
+ }
2486
+ if (app.googlePlay) {
2487
+ lines.push(
2488
+ ` \u{1F916} Google Play: \`${app.googlePlay.packageName || "N/A"}\``
2489
+ );
2490
+ if (app.googlePlay.name) {
2491
+ lines.push(` Name: ${app.googlePlay.name}`);
2492
+ }
2493
+ }
2494
+ return lines.join("\n");
2495
+ }
2496
+ async function handleSearchApp(input) {
2497
+ const { query, store = "all" } = input;
2498
+ try {
2499
+ const { apps: allApps, path: configPath } = loadRegisteredApps();
2500
+ let results;
2501
+ if (!query) {
2502
+ results = allApps;
2503
+ } else {
2504
+ const { app: exactMatch } = findRegisteredApp(query);
2505
+ const partialMatches = allApps.filter((app) => matchesQuery(app, query));
2506
+ const seenSlugs = /* @__PURE__ */ new Set();
2507
+ results = [];
2508
+ if (exactMatch && !seenSlugs.has(exactMatch.slug)) {
2509
+ results.push(exactMatch);
2510
+ seenSlugs.add(exactMatch.slug);
2511
+ }
2512
+ for (const app of partialMatches) {
2513
+ if (!seenSlugs.has(app.slug)) {
2514
+ results.push(app);
2515
+ seenSlugs.add(app.slug);
2516
+ }
2517
+ }
2518
+ }
2519
+ results = filterByStore(results, store);
2520
+ if (results.length === 0) {
2521
+ const message = query ? `No apps found matching "${query}".` : "No apps registered.";
2522
+ return {
2523
+ content: [
2524
+ {
2525
+ type: "text",
2526
+ text: `\u274C ${message}
2527
+
2528
+ \u{1F4A1} Register apps in ${configPath}`
2529
+ }
2530
+ ],
2531
+ _meta: { apps: [], count: 0, configPath }
2532
+ };
2533
+ }
2534
+ const header = query ? `\u{1F50D} Search results for "${query}": ${results.length} app(s)` : `\u{1F4CB} Registered app list: ${results.length} app(s)`;
2535
+ const appList = results.map(formatAppInfo).join("\n\n");
2536
+ return {
2537
+ content: [
2538
+ {
2539
+ type: "text",
2540
+ text: `${header}
2541
+
2542
+ ${appList}
2543
+
2544
+ ---
2545
+ Config: ${configPath}`
2546
+ }
2547
+ ],
2548
+ _meta: { apps: results, count: results.length, configPath }
2549
+ };
2550
+ } catch (error) {
2551
+ const message = error instanceof Error ? error.message : String(error);
2552
+ return {
2553
+ content: [
2554
+ {
2555
+ type: "text",
2556
+ text: `\u274C App search failed: ${message}`
2557
+ }
2558
+ ]
2559
+ };
2560
+ }
2561
+ }
2562
+
2563
+ // src/tools/keyword-research.ts
2564
+ var TOOL_NAME2 = "keyword-research";
2565
+ var keywordResearchInputSchema = z7.object({
2566
+ slug: z7.string().trim().describe("Product slug"),
2567
+ locale: z7.string().trim().describe("Locale code (e.g., en-US, ko-KR). Used for storage under .aso/keywordResearch/products/[slug]/locales/."),
2568
+ platform: z7.enum(["ios", "android"]).default("ios").describe("Store to target ('ios' or 'android'). Run separately per platform."),
2569
+ country: z7.string().length(2).optional().describe(
2428
2570
  "Two-letter store country code. If omitted, derived from locale region (e.g., ko-KR -> kr), else 'us'."
2429
2571
  ),
2430
- seedKeywords: z6.array(z6.string().trim()).default([]),
2431
- competitorApps: z6.array(
2432
- z6.object({
2433
- appId: z6.string().trim().describe("App ID (package name or iOS ID/bundle)"),
2434
- platform: z6.enum(["ios", "android"])
2572
+ seedKeywords: z7.array(z7.string().trim()).default([]).describe("Seed keywords to start from."),
2573
+ competitorApps: z7.array(
2574
+ z7.object({
2575
+ appId: z7.string().trim().describe("App ID (package name or iOS ID/bundle)"),
2576
+ platform: z7.enum(["ios", "android"])
2435
2577
  })
2436
- ).default([]),
2437
- serverCwd: z6.string().trim().optional().describe("Path to mcp-appstore cwd (default: external-tools/mcp-appstore)"),
2438
- serverCommand: z6.array(z6.string()).optional().describe("Command + args to start mcp-appstore (default: ['node','server.js'])"),
2439
- numSuggestions: z6.number().int().positive().max(50).default(20).describe("Number of keyword suggestions per strategy (default: 20)")
2578
+ ).default([]).describe("Known competitor apps to probe."),
2579
+ filename: z7.string().trim().optional().describe("Override output filename. Defaults to keyword-research-[platform]-[country].json"),
2580
+ writeTemplate: z7.boolean().default(false).describe("If true, write a JSON template at the output path."),
2581
+ researchData: z7.string().trim().optional().describe(
2582
+ "Optional JSON string with research results (e.g., from mcp-appstore tools). If provided, saves it to the output path."
2583
+ ),
2584
+ researchDataPath: z7.string().trim().optional().describe(
2585
+ "Optional path to a JSON file containing research results. If set, file content is saved to the output path (preferred to avoid escape errors)."
2586
+ )
2440
2587
  });
2441
- var jsonSchema6 = zodToJsonSchema6(keywordResearchInputSchema, {
2588
+ var jsonSchema7 = zodToJsonSchema7(keywordResearchInputSchema, {
2442
2589
  name: "KeywordResearchInput",
2443
2590
  $refStrategy: "none"
2444
2591
  });
2445
- var inputSchema6 = jsonSchema6.definitions?.KeywordResearchInput || jsonSchema6;
2592
+ var inputSchema7 = jsonSchema7.definitions?.KeywordResearchInput || jsonSchema7;
2446
2593
  var keywordResearchTool = {
2447
- name: TOOL_NAME,
2448
- description: "Runs mcp-appstore calls to collect 10\u201315 keywords per locale and saves to .aso/keywordResearch/products/[slug]/locales/[locale]/keyword-research-[platform]-[country].json.",
2449
- inputSchema: inputSchema6
2594
+ name: TOOL_NAME2,
2595
+ description: `Prep + persist keyword research ahead of improve-public using mcp-appstore outputs.
2596
+
2597
+ **IMPORTANT:** Always use 'search-app' tool first to resolve the exact slug before calling this tool. The user may provide an approximate name, bundleId, or packageName - search-app will find and return the correct slug. Never pass user input directly as slug.
2598
+
2599
+ Run this before improve-public. It gives a concrete MCP-powered research plan and a storage path under .aso/keywordResearch/products/[slug]/locales/[locale]/. Optionally writes a template or saves raw JSON from mcp-appstore tools.`,
2600
+ inputSchema: inputSchema7
2450
2601
  };
2602
+ function buildTemplate({
2603
+ slug,
2604
+ locale,
2605
+ platform,
2606
+ country,
2607
+ seedKeywords,
2608
+ competitorApps
2609
+ }) {
2610
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2611
+ return {
2612
+ meta: {
2613
+ slug,
2614
+ locale,
2615
+ platform,
2616
+ country,
2617
+ seedKeywords,
2618
+ competitorApps,
2619
+ source: "mcp-appstore",
2620
+ updatedAt: timestamp
2621
+ },
2622
+ plan: {
2623
+ steps: [
2624
+ "1. SETUP: Start mcp-appstore server (node server.js in external-tools/mcp-appstore).",
2625
+ "2. APP IDENTITY: get_app_details(appId) \u2192 confirm exact app name, category, current keywords, and store listing quality.",
2626
+ "3. COMPETITOR DISCOVERY: search_app(term=seed, num=15) + get_similar_apps(appId=your app, num=20) \u2192 identify 5-10 direct competitors (same category, similar size) + 3-5 aspirational competitors (top performers).",
2627
+ "4. COMPETITOR KEYWORD MINING: For top 5 competitors, run suggest_keywords_by_apps \u2192 extract keywords they rank for but you don't.",
2628
+ "5. KEYWORD EXPANSION (run ALL, num=30 each): suggest_keywords_by_seeds (your app name + core features), by_category (your primary category), by_similarity (semantic variations), by_competition (gap analysis), by_search (autocomplete/trending).",
2629
+ "6. KEYWORD SCORING: get_keyword_scores for ALL candidates (50-100 keywords) \u2192 filter by: traffic \u226510, difficulty \u226470, relevance to your app.",
2630
+ "7. REVIEW INTELLIGENCE: analyze_reviews(num=200) + fetch_reviews(num=100) on top 3 competitors \u2192 extract: user pain points, feature requests, emotional language, native phrases users actually use.",
2631
+ "8. KEYWORD CATEGORIZATION: Group into tiers - Tier1 (3-5): high traffic (\u22651000), high relevance, moderate difficulty (\u226450); Tier2 (5-7): medium traffic (100-1000), exact feature match; Tier3 (5-8): longtail (<100 traffic), very low difficulty (\u226430), high conversion intent.",
2632
+ "9. LOCALIZATION CHECK: Verify keywords are natural in target locale - avoid direct translations, prefer native expressions found in reviews.",
2633
+ "10. GAP ANALYSIS: Compare your current keywords vs competitor keywords \u2192 identify missed opportunities and over-saturated terms to avoid.",
2634
+ "11. VALIDATION: For final 15-20 keywords, ensure each has: score data, clear user intent, natural locale fit, and specific rationale for inclusion.",
2635
+ "Keep rationale/nextActions in English by default unless you intentionally localize them."
2636
+ ],
2637
+ selectionCriteria: {
2638
+ tier1_core: "High traffic (\u22651000), relevance score \u22650.8, difficulty \u226450, brand-safe",
2639
+ tier2_feature: "Medium traffic (100-1000), exact feature/benefit match, difficulty \u226460",
2640
+ tier3_longtail: "Low traffic (<100), very low difficulty (\u226430), high purchase/download intent phrases",
2641
+ avoid: "Generic terms (difficulty \u226580), irrelevant categories, competitor brand names, terms with no search volume"
2642
+ },
2643
+ qualityChecks: [
2644
+ "Each keyword has traffic + difficulty scores (no gaps)",
2645
+ "Mix of 3 tiers represented (not all longtail, not all high-competition)",
2646
+ "Keywords validated against actual user language from reviews",
2647
+ "No duplicate semantic meanings (e.g., 'photo edit' and 'edit photo')",
2648
+ "Locale-appropriate phrasing verified"
2649
+ ],
2650
+ note: "Run per platform/country. Target 15-20 keywords per locale with clear tier distribution. Save ALL raw data for audit trail."
2651
+ },
2652
+ data: {
2653
+ raw: {
2654
+ searchApp: [],
2655
+ getAppDetails: [],
2656
+ similarApps: [],
2657
+ keywordSuggestions: {
2658
+ bySeeds: [],
2659
+ byCategory: [],
2660
+ bySimilarity: [],
2661
+ byCompetition: [],
2662
+ bySearchHints: [],
2663
+ byApps: []
2664
+ },
2665
+ keywordScores: [],
2666
+ reviewsAnalysis: [],
2667
+ reviewsRaw: []
2668
+ },
2669
+ summary: {
2670
+ recommendedKeywords: [],
2671
+ keywordsByTier: {
2672
+ tier1_core: [],
2673
+ tier2_feature: [],
2674
+ tier3_longtail: []
2675
+ },
2676
+ competitorInsights: {
2677
+ topCompetitors: [],
2678
+ keywordGaps: [],
2679
+ userLanguagePatterns: []
2680
+ },
2681
+ rationale: "",
2682
+ confidence: {
2683
+ dataQuality: "",
2684
+ localeRelevance: "",
2685
+ competitivePosition: ""
2686
+ },
2687
+ nextActions: [
2688
+ "Feed tiered keywords into improve-public Stage 1 (prioritize Tier1 for title, Tier2-3 for keyword field)",
2689
+ "Monitor keyword rankings post-update",
2690
+ "Re-run research quarterly or after major competitor changes"
2691
+ ]
2692
+ }
2693
+ }
2694
+ };
2695
+ }
2696
+ function saveJsonFile({
2697
+ researchDir,
2698
+ fileName,
2699
+ payload
2700
+ }) {
2701
+ fs11.mkdirSync(researchDir, { recursive: true });
2702
+ const outputPath = path11.join(researchDir, fileName);
2703
+ fs11.writeFileSync(outputPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
2704
+ return outputPath;
2705
+ }
2451
2706
  function normalizeKeywords(raw) {
2452
2707
  if (!raw) return [];
2453
2708
  if (Array.isArray(raw)) {
2454
- return raw.map((k) => k.trim()).filter(Boolean);
2709
+ return raw.map((k) => k.trim()).filter((k) => k.length > 0);
2455
2710
  }
2456
- return raw.split(",").map((k) => k.trim()).filter(Boolean);
2457
- }
2458
- async function callToolJSON(client, name, args) {
2459
- const result = await client.callTool({ name, arguments: args });
2460
- const text = result.content?.[0]?.text;
2461
- if (!text) return null;
2462
- try {
2463
- return JSON.parse(text);
2464
- } catch {
2465
- return text;
2466
- }
2467
- }
2468
- function ensureDir(dir) {
2469
- fs11.mkdirSync(dir, { recursive: true });
2711
+ return raw.split(",").map((k) => k.trim()).filter((k) => k.length > 0);
2470
2712
  }
2471
2713
  async function handleKeywordResearch(input) {
2472
2714
  const {
@@ -2476,28 +2718,36 @@ async function handleKeywordResearch(input) {
2476
2718
  country,
2477
2719
  seedKeywords = [],
2478
2720
  competitorApps = [],
2479
- serverCwd,
2480
- serverCommand,
2481
- numSuggestions = 20
2721
+ filename,
2722
+ writeTemplate = false,
2723
+ researchData,
2724
+ researchDataPath
2482
2725
  } = input;
2726
+ const searchResult = await handleSearchApp({ query: slug, store: "all" });
2727
+ const registeredApps = searchResult._meta?.apps || [];
2728
+ const registeredApp = registeredApps.length > 0 ? registeredApps[0] : void 0;
2483
2729
  const { config, locales } = loadProductLocales(slug);
2484
2730
  const primaryLocale = resolvePrimaryLocale(config, locales);
2485
2731
  const primaryLocaleData = locales[primaryLocale];
2486
- const { app: registeredApp } = findRegisteredApp(slug);
2487
- const resolvedCountry = country || (locale.includes("-") ? locale.split("-")[1].toLowerCase() : "us");
2732
+ const { supportedLocales, path: supportedPath } = getSupportedLocalesForSlug(slug, platform);
2488
2733
  const autoSeeds = [];
2489
- if (primaryLocaleData?.aso?.title) autoSeeds.push(primaryLocaleData.aso.title);
2734
+ const autoCompetitors = [];
2735
+ if (primaryLocaleData?.aso?.title) {
2736
+ autoSeeds.push(primaryLocaleData.aso.title);
2737
+ }
2490
2738
  const parsedKeywords = normalizeKeywords(primaryLocaleData?.aso?.keywords);
2491
2739
  autoSeeds.push(...parsedKeywords.slice(0, 5));
2492
2740
  if (config?.name) autoSeeds.push(config.name);
2493
2741
  if (config?.tagline) autoSeeds.push(config.tagline);
2494
- if (registeredApp?.name) autoSeeds.push(registeredApp.name);
2495
- if (platform === "ios" && registeredApp?.appStore?.name)
2496
- autoSeeds.push(registeredApp.appStore.name);
2497
- if (platform === "android" && registeredApp?.googlePlay?.name)
2498
- autoSeeds.push(registeredApp.googlePlay.name);
2499
- const seeds = Array.from(/* @__PURE__ */ new Set([...seedKeywords, ...autoSeeds])).slice(0, 10);
2500
- const autoCompetitors = [];
2742
+ if (!config?.name && registeredApp?.name) autoSeeds.push(registeredApp.name);
2743
+ if (!primaryLocaleData?.aso?.title) {
2744
+ if (platform === "ios" && registeredApp?.appStore?.name) {
2745
+ autoSeeds.push(registeredApp.appStore.name);
2746
+ }
2747
+ if (platform === "android" && registeredApp?.googlePlay?.name) {
2748
+ autoSeeds.push(registeredApp.googlePlay.name);
2749
+ }
2750
+ }
2501
2751
  if (platform === "ios") {
2502
2752
  if (config?.appStoreAppId) {
2503
2753
  autoCompetitors.push({ appId: String(config.appStoreAppId), platform });
@@ -2514,212 +2764,17 @@ async function handleKeywordResearch(input) {
2514
2764
  platform
2515
2765
  });
2516
2766
  }
2517
- } else {
2518
- if (config?.packageName) {
2519
- autoCompetitors.push({ appId: config.packageName, platform });
2520
- } else if (registeredApp?.googlePlay?.packageName) {
2521
- autoCompetitors.push({
2522
- appId: registeredApp.googlePlay.packageName,
2523
- platform
2524
- });
2525
- }
2526
- }
2527
- const competitors = Array.from(
2528
- new Map(
2529
- [...competitorApps, ...autoCompetitors].map((c) => [
2530
- `${c.platform}:${c.appId}`,
2531
- c
2532
- ])
2533
- ).values()
2534
- ).slice(0, 5);
2535
- const cwd = serverCwd || path11.resolve(process.cwd(), "external-tools", "mcp-appstore");
2536
- const cmd = serverCommand?.[0] || "node";
2537
- const args = serverCommand?.length ? serverCommand.slice(1) : ["server.js"];
2538
- const transport = new StdioClientTransport({ command: cmd, args, cwd });
2539
- const client = new Client(
2540
- { name: "keyword-research", version: "1.0.0" },
2541
- { capabilities: { tools: {} } }
2542
- );
2543
- await client.connect(transport);
2544
- const raw = {
2545
- searchApp: [],
2546
- getAppDetails: [],
2547
- similarApps: [],
2548
- keywordSuggestions: {
2549
- bySeeds: [],
2550
- byCategory: [],
2551
- bySimilarity: [],
2552
- byCompetition: [],
2553
- bySearchHints: [],
2554
- byApps: []
2555
- },
2556
- keywordScores: [],
2557
- reviewsAnalysis: [],
2558
- reviewsRaw: []
2559
- };
2560
- for (const term of seeds.slice(0, 3)) {
2561
- const res = await callToolJSON(client, "search_app", {
2562
- term,
2563
- platform,
2564
- num: 20,
2565
- country: resolvedCountry
2566
- });
2567
- raw.searchApp.push({
2568
- query: term,
2569
- platform,
2570
- country: resolvedCountry,
2571
- results: res?.results ?? res
2572
- });
2573
- }
2574
- for (const comp of competitors.slice(0, 5)) {
2575
- const res = await callToolJSON(client, "get_app_details", {
2576
- appId: comp.appId,
2577
- platform: comp.platform,
2578
- country: resolvedCountry
2579
- });
2580
- raw.getAppDetails.push(res);
2581
- }
2582
- if (competitors.length > 0) {
2583
- const first = competitors[0];
2584
- const res = await callToolJSON(client, "get_similar_apps", {
2585
- appId: first.appId,
2586
- platform,
2587
- num: 10,
2588
- country: resolvedCountry
2589
- });
2590
- raw.similarApps = res?.similarApps || res || [];
2591
- }
2592
- const firstComp = competitors[0]?.appId;
2593
- const appsList = competitors.map((c) => c.appId);
2594
- const suggestCalls = [
2595
- [
2596
- "suggest_keywords_by_seeds",
2597
- {
2598
- keywords: seeds,
2599
- platform,
2600
- num: numSuggestions,
2601
- country: resolvedCountry
2602
- },
2603
- "bySeeds"
2604
- ],
2605
- [
2606
- "suggest_keywords_by_category",
2607
- firstComp ? {
2608
- appId: firstComp,
2609
- platform,
2610
- num: numSuggestions,
2611
- country: resolvedCountry
2612
- } : null,
2613
- "byCategory"
2614
- ],
2615
- [
2616
- "suggest_keywords_by_similarity",
2617
- firstComp ? {
2618
- appId: firstComp,
2619
- platform,
2620
- num: numSuggestions,
2621
- country: resolvedCountry
2622
- } : null,
2623
- "bySimilarity"
2624
- ],
2625
- [
2626
- "suggest_keywords_by_competition",
2627
- firstComp ? {
2628
- appId: firstComp,
2629
- platform,
2630
- num: numSuggestions,
2631
- country: resolvedCountry
2632
- } : null,
2633
- "byCompetition"
2634
- ],
2635
- [
2636
- "suggest_keywords_by_search",
2637
- {
2638
- keywords: seeds.slice(0, 5),
2639
- platform,
2640
- num: numSuggestions,
2641
- country: resolvedCountry
2642
- },
2643
- "bySearchHints"
2644
- ],
2645
- [
2646
- "suggest_keywords_by_apps",
2647
- appsList.length ? {
2648
- apps: appsList,
2649
- platform,
2650
- num: numSuggestions,
2651
- country: resolvedCountry
2652
- } : null,
2653
- "byApps"
2654
- ]
2655
- ];
2656
- for (const [tool, args2, key] of suggestCalls) {
2657
- if (!args2) continue;
2658
- const res = await callToolJSON(client, tool, args2);
2659
- const suggestions = res?.suggestions || res || [];
2660
- raw.keywordSuggestions[key] = suggestions;
2661
- }
2662
- for (const comp of competitors.slice(0, 2)) {
2663
- const analysis = await callToolJSON(client, "analyze_reviews", {
2664
- appId: comp.appId,
2665
- platform: comp.platform,
2666
- country: resolvedCountry,
2667
- num: 150
2668
- });
2669
- raw.reviewsAnalysis.push(analysis);
2670
- const rawReviews = await callToolJSON(client, "fetch_reviews", {
2671
- appId: comp.appId,
2672
- platform: comp.platform,
2673
- country: resolvedCountry,
2674
- num: 80
2675
- });
2676
- raw.reviewsRaw.push(rawReviews);
2677
- }
2678
- const candidateSet = /* @__PURE__ */ new Set();
2679
- const addCandidates = (arr) => {
2680
- if (!arr) return;
2681
- if (Array.isArray(arr)) {
2682
- arr.forEach((v) => {
2683
- if (typeof v === "string") candidateSet.add(v);
2684
- if (v?.keyword) candidateSet.add(String(v.keyword));
2685
- if (v?.suggestions) addCandidates(v.suggestions);
2686
- });
2687
- }
2688
- };
2689
- seeds.forEach((s) => candidateSet.add(s));
2690
- Object.values(raw.keywordSuggestions).forEach((arr) => addCandidates(arr));
2691
- const candidates = Array.from(candidateSet).slice(0, 30);
2692
- for (const kw of candidates) {
2693
- const res = await callToolJSON(client, "get_keyword_scores", {
2694
- keyword: kw,
2695
- platform,
2696
- country: resolvedCountry
2767
+ } else if (platform === "android" && config?.packageName) {
2768
+ autoCompetitors.push({ appId: config.packageName, platform });
2769
+ } else if (platform === "android" && registeredApp?.googlePlay?.packageName) {
2770
+ autoCompetitors.push({
2771
+ appId: registeredApp.googlePlay.packageName,
2772
+ platform
2697
2773
  });
2698
- raw.keywordScores.push(res);
2699
2774
  }
2700
- const scored = raw.keywordScores.map((s) => {
2701
- const trafficScore = typeof s?.scores?.traffic?.score === "number" ? s.scores.traffic.score : parseFloat(s?.traffic?.score ?? s?.scores?.traffic?.score ?? "0") || 0;
2702
- const difficultyScore = typeof s?.scores?.difficulty?.score === "number" ? s.scores.difficulty.score : parseFloat(
2703
- s?.difficulty?.score ?? s?.scores?.difficulty?.score ?? "0"
2704
- ) || 0;
2705
- return {
2706
- keyword: s.keyword || s?.scores?.keyword,
2707
- trafficScore,
2708
- difficultyScore,
2709
- raw: s
2710
- };
2711
- }).filter((s) => s.keyword);
2712
- const recommended = scored.sort((a, b) => b.trafficScore - a.trafficScore).slice(0, 15);
2713
- const summary = {
2714
- recommendedKeywords: recommended.map((r) => ({
2715
- keyword: r.keyword,
2716
- trafficScore: r.trafficScore,
2717
- difficultyScore: r.difficultyScore
2718
- })),
2719
- rationale: "Sorted by traffic score (desc). Blend of core/high-traffic and mid/longtail technical terms.",
2720
- nextActions: "Feed top 10\u201315 into improve-public Stage 1. If any sections are empty (suggestions/reviews), add more competitors/seeds and rerun."
2721
- };
2722
- await client.close();
2775
+ const resolvedSeeds = seedKeywords.length > 0 ? seedKeywords : Array.from(new Set(autoSeeds));
2776
+ const resolvedCompetitors = competitorApps.length > 0 ? competitorApps : autoCompetitors;
2777
+ const resolvedCountry = country || (locale?.includes("-") ? locale.split("-")[1].toLowerCase() : "us");
2723
2778
  const researchDir = path11.join(
2724
2779
  getKeywordResearchDir(),
2725
2780
  "products",
@@ -2727,52 +2782,172 @@ async function handleKeywordResearch(input) {
2727
2782
  "locales",
2728
2783
  locale
2729
2784
  );
2730
- ensureDir(researchDir);
2731
- const fileName = `keyword-research-${platform}-${resolvedCountry}.json`;
2732
- const outputPath = path11.join(researchDir, fileName);
2733
- const payload = {
2734
- meta: {
2785
+ const defaultFileName = `keyword-research-${platform}-${resolvedCountry}.json`;
2786
+ const fileName = filename || defaultFileName;
2787
+ let outputPath = path11.join(researchDir, fileName);
2788
+ let fileAction;
2789
+ const parseJsonWithContext = (text) => {
2790
+ try {
2791
+ return JSON.parse(text);
2792
+ } catch (err) {
2793
+ const message = err instanceof Error ? err.message : String(err);
2794
+ const match = /position (\d+)/i.exec(message) || /column (\d+)/i.exec(message) || /char (\d+)/i.exec(message);
2795
+ if (match) {
2796
+ const pos = Number(match[1]);
2797
+ const start = Math.max(0, pos - 40);
2798
+ const end = Math.min(text.length, pos + 40);
2799
+ const context = text.slice(start, end);
2800
+ throw new Error(
2801
+ `Failed to parse researchData JSON: ${message}
2802
+ Context around ${pos}: ${context}`
2803
+ );
2804
+ }
2805
+ throw new Error(`Failed to parse researchData JSON: ${message}`);
2806
+ }
2807
+ };
2808
+ const loadResearchDataFromPath = (p) => {
2809
+ if (!fs11.existsSync(p)) {
2810
+ throw new Error(`researchDataPath not found: ${p}`);
2811
+ }
2812
+ const raw = fs11.readFileSync(p, "utf-8");
2813
+ return parseJsonWithContext(raw);
2814
+ };
2815
+ if (writeTemplate || researchData) {
2816
+ const payload = researchData ? parseJsonWithContext(researchData) : researchDataPath ? loadResearchDataFromPath(researchDataPath) : buildTemplate({
2735
2817
  slug,
2736
2818
  locale,
2737
2819
  platform,
2738
2820
  country: resolvedCountry,
2739
- seedKeywords: seeds,
2740
- competitorApps: competitors,
2741
- source: "mcp-appstore",
2742
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2743
- },
2744
- plan: {
2745
- steps: [
2746
- "Start mcp-appstore server (node server.js in external-tools/mcp-appstore).",
2747
- "Confirm app IDs/locales: get_app_details(appId from config/registered-apps) to lock country/lang and 3\u20135 competitors.",
2748
- "Discover competitors: search_app(term=seed keyword, num=10\u201320), get_similar_apps(appId=top competitor, num=10).",
2749
- "Collect candidates (all of these, num=20\u201330): suggest_keywords_by_seeds/by_category/by_similarity/by_competition/by_search; suggest_keywords_by_apps(apps=[top competitors]).",
2750
- "Score shortlist: get_keyword_scores for 15\u201330 candidates per platform/country.",
2751
- "Context check: analyze_reviews (num=100\u2013200) and fetch_reviews (num=50\u2013100) on top apps for language/tone cues.",
2752
- "If keywordSuggestions/similar/reviews are sparse, rerun calls (add more competitors/seeds) until you have 10\u201315 strong keywords.",
2753
- "For any recommended keyword without scores, rerun get_keyword_scores to fill traffic/difficulty.",
2754
- "Keep rationale/nextActions in English by default unless you intentionally localize them."
2755
- ],
2756
- note: "Generated automatically by keyword-research"
2757
- },
2758
- data: { raw, summary }
2759
- };
2760
- fs11.writeFileSync(outputPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
2821
+ seedKeywords: resolvedSeeds,
2822
+ competitorApps: resolvedCompetitors
2823
+ });
2824
+ outputPath = saveJsonFile({ researchDir, fileName, payload });
2825
+ fileAction = researchData ? "Saved provided researchData" : "Wrote template";
2826
+ }
2827
+ const templatePreview = JSON.stringify(
2828
+ buildTemplate({
2829
+ slug,
2830
+ locale,
2831
+ platform,
2832
+ country: resolvedCountry,
2833
+ seedKeywords: resolvedSeeds,
2834
+ competitorApps: resolvedCompetitors
2835
+ }),
2836
+ null,
2837
+ 2
2838
+ );
2761
2839
  const lines = [];
2762
- lines.push(`# keyword-research`);
2763
- lines.push(`Saved to ${outputPath}`);
2840
+ lines.push(`# Keyword research plan (${slug})`);
2841
+ lines.push(`Locale: ${locale} | Platform: ${platform} | Country: ${resolvedCountry}`);
2842
+ lines.push(`Primary locale detected: ${primaryLocale}`);
2843
+ if (supportedLocales.length > 0) {
2844
+ lines.push(
2845
+ `Registered supported locales (${platform}): ${supportedLocales.join(
2846
+ ", "
2847
+ )} (source: ${supportedPath})`
2848
+ );
2849
+ if (!supportedLocales.includes(locale)) {
2850
+ lines.push(
2851
+ `WARNING: locale ${locale} not in registered supported locales. Confirm this locale or update registered-apps.json.`
2852
+ );
2853
+ }
2854
+ } else {
2855
+ lines.push(
2856
+ `Registered supported locales not found for ${platform} (checked: ${supportedPath}).`
2857
+ );
2858
+ }
2859
+ lines.push(
2860
+ `Seeds: ${resolvedSeeds.length > 0 ? resolvedSeeds.join(", ") : "(none set; add seedKeywords or ensure ASO keywords/title exist)"}`
2861
+ );
2764
2862
  lines.push(
2765
- `Candidates scored: ${scored.length}, Recommended: ${summary.recommendedKeywords.length}`
2863
+ `Competitors (from config if empty): ${resolvedCompetitors.length > 0 ? resolvedCompetitors.map((c) => `${c.platform}:${c.appId}`).join(", ") : "(none set; add competitorApps or set appStoreAppId/bundleId/packageName in config.json)"}`
2766
2864
  );
2767
- if (scored.length < 10 || summary.recommendedKeywords.length < 10 || Object.values(raw.keywordSuggestions).some(
2768
- (arr) => Array.isArray(arr) && arr.length === 0
2769
- )) {
2865
+ lines.push("");
2866
+ lines.push("## Research Workflow (mcp-appstore)");
2867
+ lines.push("");
2868
+ lines.push("### Phase 1: Setup & Discovery");
2869
+ lines.push(
2870
+ `1) Start mcp-appstore server: node server.js (cwd: external-tools/mcp-appstore)`
2871
+ );
2872
+ lines.push(
2873
+ `2) get_app_details(appId) \u2192 confirm app identity, category, current metadata`
2874
+ );
2875
+ lines.push(
2876
+ `3) search_app(term=seed, num=15, platform=${platform}, country=${resolvedCountry}) \u2192 find direct competitors`
2877
+ );
2878
+ lines.push(
2879
+ `4) get_similar_apps(appId=your app, num=20) \u2192 discover related apps in your space`
2880
+ );
2881
+ lines.push("");
2882
+ lines.push("### Phase 2: Keyword Mining (run ALL of these)");
2883
+ lines.push(
2884
+ `5) suggest_keywords_by_apps(apps=[top 5 competitors]) \u2192 steal competitor keywords`
2885
+ );
2886
+ lines.push(
2887
+ `6) suggest_keywords_by_seeds(seeds=[app name, core features], num=30)`
2888
+ );
2889
+ lines.push(
2890
+ `7) suggest_keywords_by_category(category=your primary category, num=30)`
2891
+ );
2892
+ lines.push(
2893
+ `8) suggest_keywords_by_similarity + by_competition + by_search (num=30 each)`
2894
+ );
2895
+ lines.push("");
2896
+ lines.push("### Phase 3: Scoring & Filtering");
2897
+ lines.push(
2898
+ `9) get_keyword_scores for ALL candidates (50-100 keywords) \u2192 get traffic & difficulty`
2899
+ );
2900
+ lines.push(
2901
+ `10) Filter: traffic \u226510, difficulty \u226470, relevant to your app's core value`
2902
+ );
2903
+ lines.push("");
2904
+ lines.push("### Phase 4: User Language Intelligence");
2905
+ lines.push(
2906
+ `11) analyze_reviews(appId=top 3 competitors, num=200) \u2192 sentiment & themes`
2907
+ );
2908
+ lines.push(
2909
+ `12) fetch_reviews(appId=top 3 competitors, num=100) \u2192 extract exact phrases users say`
2910
+ );
2911
+ lines.push(
2912
+ `13) Cross-reference keywords with review language \u2192 validate natural phrasing`
2913
+ );
2914
+ lines.push("");
2915
+ lines.push("### Phase 5: Final Selection");
2916
+ lines.push(
2917
+ `14) Categorize into tiers: Tier1 (3-5 high-traffic core), Tier2 (5-7 feature-match), Tier3 (5-8 longtail)`
2918
+ );
2919
+ lines.push(
2920
+ `15) Validate each keyword has: score, intent, locale fit, inclusion rationale`
2921
+ );
2922
+ lines.push(
2923
+ `16) Save to: ${outputPath}`
2924
+ );
2925
+ lines.push("");
2926
+ lines.push("### Quality Checklist");
2927
+ lines.push("- [ ] 15-20 keywords with complete score data");
2928
+ lines.push("- [ ] All 3 tiers represented (not just longtail)");
2929
+ lines.push("- [ ] Keywords validated against actual review language");
2930
+ lines.push("- [ ] No semantic duplicates");
2931
+ lines.push("- [ ] Locale-appropriate (not direct translations)");
2932
+ if (fileAction) {
2933
+ lines.push(`File: ${fileAction} at ${outputPath}`);
2934
+ } else {
2770
2935
  lines.push(
2771
- `Warning: results look sparse. Add more competitors/seeds and rerun.`
2936
+ `Tip: set writeTemplate=true to create the JSON skeleton at ${outputPath}`
2772
2937
  );
2773
2938
  }
2939
+ lines.push("");
2940
+ lines.push("Suggested JSON shape:");
2941
+ lines.push("```json");
2942
+ lines.push(templatePreview);
2943
+ lines.push("```");
2774
2944
  return {
2775
- content: [{ type: "text", text: lines.join("\n") }]
2945
+ content: [
2946
+ {
2947
+ type: "text",
2948
+ text: lines.join("\n")
2949
+ }
2950
+ ]
2776
2951
  };
2777
2952
  }
2778
2953
 
@@ -2825,6 +3000,14 @@ var tools = [
2825
3000
  zodSchema: keywordResearchInputSchema,
2826
3001
  handler: handleKeywordResearch,
2827
3002
  category: "ASO Research"
3003
+ },
3004
+ {
3005
+ name: searchAppTool.name,
3006
+ description: searchAppTool.description,
3007
+ inputSchema: searchAppTool.inputSchema,
3008
+ zodSchema: searchAppInputSchema,
3009
+ handler: handleSearchApp,
3010
+ category: "App Management"
2828
3011
  }
2829
3012
  ];
2830
3013
  function getToolDefinitions() {
@@ -2834,7 +3017,8 @@ function getToolDefinitions() {
2834
3017
  improvePublicTool,
2835
3018
  initProjectTool,
2836
3019
  createBlogHtmlTool,
2837
- keywordResearchTool
3020
+ keywordResearchTool,
3021
+ searchAppTool
2838
3022
  ];
2839
3023
  }
2840
3024
  function getToolHandler(name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pabal-web-mcp",
3
- "version": "1.3.6",
3
+ "version": "1.3.8",
4
4
  "type": "module",
5
5
  "description": "MCP server for ASO data management with shared types and utilities",
6
6
  "author": "skyu",