{"_id":"@asolens/mcp","_rev":"7-ac3f2f01cb5b892df78e873e627c6ff3","name":"@asolens/mcp","dist-tags":{"latest":"0.4.0"},"versions":{"0.1.0":{"name":"@asolens/mcp","version":"0.1.0","keywords":["aso","app-store-optimization","app-store","ios","keyword-research","app-store-connect","rankings","mcp","mcp-server","aso-tools"],"author":{"name":"Daniel Han"},"license":"MIT","_id":"@asolens/mcp@0.1.0","maintainers":[{"name":"hex0cter","email":"hex0cter@gmail.com"}],"bugs":{"email":"asolens@danielhan.dev"},"bin":{"asolens-mcp":"dist/cli.js"},"dist":{"shasum":"173a2c29cfe6c2a058fb1778014fb2d2a7f57b07","tarball":"https://registry.npmjs.org/@asolens/mcp/-/mcp-0.1.0.tgz","fileCount":4,"integrity":"sha512-bHpsQZ42pptiqP+ZiSZo3+sNSeSQmA9UD2aKJ4AParXGOQWo8d+6beX6DOxLSCbb4bTQ7+8DQt6DOz+pOKzeTQ==","signatures":[{"sig":"MEUCIG4Gyq99dLd4VftI4s4spR0upril9XWzxkRX4gK2V4+yAiEAvIXHrmpFq5TSkreSVSZi0FTUa0t/MRZBYGOd9fNx4vE=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":226123},"type":"module","engines":{"node":">=22.13.0"},"gitHead":"66ae5524ab5256765a4d519bdaabd390018cc963","scripts":{"test":"vitest run","build":"esbuild src/cli.ts --bundle --platform=node --format=esm --target=node22 --external:@modelcontextprotocol/sdk --external:zod --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\" && chmod +x dist/cli.js","prepack":"npm run build","test:e2e":"vitest run test/cli.e2e.test.ts","typecheck":"tsc --noEmit -p tsconfig.json","smoke:live":"npm run build && node scripts/smoke-live.mjs","prepublishOnly":"node -e \"const s=require('fs').readFileSync('README.md','utf8');if(s.includes('**Pre-release.**')){console.error('\\\\nRefusing to publish: remove the pre-release banner from packages/mcp/README.md first (it tells users the package is not on npm).\\\\n');process.exit(1)}\"","acceptance:live":"npm run build && node scripts/acceptance-live.mjs"},"_npmUser":{"name":"hex0cter","email":"hex0cter@gmail.com"},"_npmVersion":"11.17.0","description":"ASOLens MCP server: App Store ranks, competitors and weaknesses, from your own machine","directories":{},"_nodeVersion":"26.4.0","dependencies":{"zod":"^4.0.0","@modelcontextprotocol/sdk":"^1.30.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^3.0.0","esbuild":"^0.25.0","typescript":"^5.6.0","@types/node":"^22.0.0","@asolens/core":"0.0.0"},"_npmOperationalInternal":{"tmp":"tmp/mcp_0.1.0_1788118643304_0.6744397259912134","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@asolens/mcp","version":"0.2.0","keywords":["aso","app-store-optimization","app-store","ios","keyword-research","app-store-connect","rankings","mcp","mcp-server","aso-tools"],"author":{"name":"Daniel Han"},"license":"MIT","_id":"@asolens/mcp@0.2.0","maintainers":[{"name":"hex0cter","email":"hex0cter@gmail.com"}],"bugs":{"email":"asolens@danielhan.dev"},"bin":{"asolens-mcp":"dist/cli.js"},"dist":{"shasum":"8e1a4f142091122bfba4afb64fc6efe37f10bb45","tarball":"https://registry.npmjs.org/@asolens/mcp/-/mcp-0.2.0.tgz","fileCount":4,"integrity":"sha512-Yn8DDq74J4AMbR8SWH2plAczwlJgvY/gla/mlubWESoGIsfksRDVccLcMjRm30xkC4pP+VVy5VwjsiDRF8ASVg==","signatures":[{"sig":"MEUCIDAI7fvNSOVw6Z+XE5oFTa2FcbZW4cJtM6ZBdVSi18VlAiEAvqzTIAcp0X2cOHmYLNQFAKYzOTyAHBR9x3soLSKLI2k=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":246791},"type":"module","engines":{"node":">=22.13.0"},"gitHead":"c6c8679bc00158f10cc5a41a5c6bf235717c097a","scripts":{"test":"vitest run","build":"esbuild src/cli.ts --bundle --platform=node --format=esm --target=node22 --external:@modelcontextprotocol/sdk --external:zod --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\" && chmod +x dist/cli.js","prepack":"npm run build","test:e2e":"vitest run test/cli.e2e.test.ts","typecheck":"tsc --noEmit -p tsconfig.json","smoke:live":"npm run build && node scripts/smoke-live.mjs","prepublishOnly":"node -e \"const s=require('fs').readFileSync('README.md','utf8');if(s.includes('**Pre-release.**')){console.error('\\\\nRefusing to publish: remove the pre-release banner from packages/mcp/README.md first (it tells users the package is not on npm).\\\\n');process.exit(1)}\"","acceptance:live":"npm run build && node scripts/acceptance-live.mjs"},"_npmUser":{"name":"hex0cter","email":"hex0cter@gmail.com"},"_npmVersion":"11.17.0","description":"ASOLens MCP server: App Store ranks, competitors and weaknesses, from your own machine","directories":{},"_nodeVersion":"26.4.0","dependencies":{"zod":"^4.0.0","@modelcontextprotocol/sdk":"^1.30.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^3.0.0","esbuild":"^0.25.0","typescript":"^5.6.0","@types/node":"^22.0.0","@asolens/core":"0.0.0","@asolens/store":"0.0.0"},"_npmOperationalInternal":{"tmp":"tmp/mcp_0.2.0_1788375247279_0.5493722787378787","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"name":"@asolens/mcp","version":"0.2.1","keywords":["aso","app-store-optimization","app-store","ios","keyword-research","app-store-connect","rankings","mcp","mcp-server","aso-tools"],"author":{"name":"Daniel Han"},"license":"MIT","_id":"@asolens/mcp@0.2.1","maintainers":[{"name":"hex0cter","email":"hex0cter@gmail.com"}],"bugs":{"email":"asolens@danielhan.dev"},"bin":{"asolens-mcp":"dist/cli.js"},"dist":{"shasum":"706d18bbedb8120bc2176928de945833ac32aff1","tarball":"https://registry.npmjs.org/@asolens/mcp/-/mcp-0.2.1.tgz","fileCount":4,"integrity":"sha512-G6XMB2QFT/piPqQrbKo8aXDALiGk+CX/6GKw4wh173oHGv9hqXJndckG6V9plZPH1V4clilXA6amoVX3RiDtqg==","signatures":[{"sig":"MEUCIDovjLiupDOY9qQlY3RzIZ4AiMDdvAkjYNkMfExzVWDbAiEA8IW75oFCqP9Zp2DaBDwqzRL672xq+Wu/fEOBVwGqwRU=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":255921},"type":"module","engines":{"node":">=22.13.0"},"gitHead":"513196b50f1cec20b741c5ec775301bcfafcd20c","scripts":{"test":"vitest run","build":"esbuild src/cli.ts --bundle --platform=node --format=esm --target=node22 --external:@modelcontextprotocol/sdk --external:zod --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\" && chmod +x dist/cli.js","prepack":"npm run build","test:e2e":"vitest run test/cli.e2e.test.ts","typecheck":"tsc --noEmit -p tsconfig.json","smoke:live":"npm run build && node scripts/smoke-live.mjs","prepublishOnly":"node -e \"const s=require('fs').readFileSync('README.md','utf8');if(s.includes('**Pre-release.**')){console.error('\\\\nRefusing to publish: remove the pre-release banner from packages/mcp/README.md first (it tells users the package is not on npm).\\\\n');process.exit(1)}\"","acceptance:live":"npm run build && node scripts/acceptance-live.mjs"},"_npmUser":{"name":"hex0cter","email":"hex0cter@gmail.com"},"_npmVersion":"11.17.0","description":"ASOLens MCP server: App Store ranks, competitors and weaknesses, from your own machine","directories":{},"_nodeVersion":"26.4.0","dependencies":{"zod":"^4.0.0","@modelcontextprotocol/sdk":"^1.30.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^3.0.0","esbuild":"^0.25.0","typescript":"^5.6.0","@types/node":"^22.0.0","@asolens/core":"0.0.0","@asolens/store":"0.0.0"},"_npmOperationalInternal":{"tmp":"tmp/mcp_0.2.1_1788541790378_0.03826165775073531","host":"s3://npm-registry-packages-npm-production"}},"0.2.2":{"name":"@asolens/mcp","version":"0.2.2","keywords":["aso","app-store-optimization","app-store","ios","keyword-research","app-store-connect","rankings","mcp","mcp-server","aso-tools"],"author":{"name":"Daniel Han"},"license":"MIT","_id":"@asolens/mcp@0.2.2","maintainers":[{"name":"hex0cter","email":"hex0cter@gmail.com"}],"bugs":{"email":"asolens@danielhan.dev"},"bin":{"asolens-mcp":"dist/cli.js"},"dist":{"shasum":"1dbdd2cfbf26400e9eb370b90108061addb267a9","tarball":"https://registry.npmjs.org/@asolens/mcp/-/mcp-0.2.2.tgz","fileCount":4,"integrity":"sha512-h2DQYEOeB024WsY1WNO5yFU0Emnae49d0a/ZGnpjGsxt45dgfBHL3wKasifiTzTIJV3wILUo8Ckfy/RuxBedCA==","signatures":[{"sig":"MEYCIQCEzFXzhIMc4PoEb6i6hRZthUGPQ9blm6CEZ19PHU+3TgIhAMOsyOxgICBjON9Jx2LB6bAtE9CyTRMs7y6sTUNgrP+R","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":257733},"type":"module","engines":{"node":">=22.13.0"},"gitHead":"5b72f0fe4e17392be74c84f09776f5b418a591e2","scripts":{"test":"vitest run","build":"esbuild src/cli.ts --bundle --platform=node --format=esm --target=node22 --external:@modelcontextprotocol/sdk --external:zod --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\" && chmod +x dist/cli.js","prepack":"npm run build","test:e2e":"vitest run test/cli.e2e.test.ts","typecheck":"tsc --noEmit -p tsconfig.json","smoke:live":"npm run build && node scripts/smoke-live.mjs","prepublishOnly":"node -e \"const s=require('fs').readFileSync('README.md','utf8');if(s.includes('**Pre-release.**')){console.error('\\\\nRefusing to publish: remove the pre-release banner from packages/mcp/README.md first (it tells users the package is not on npm).\\\\n');process.exit(1)}\"","acceptance:live":"npm run build && node scripts/acceptance-live.mjs"},"_npmUser":{"name":"hex0cter","email":"hex0cter@gmail.com"},"_npmVersion":"11.17.0","description":"ASOLens MCP server: App Store ranks, competitors and weaknesses, from your own machine","directories":{},"_nodeVersion":"26.4.0","dependencies":{"zod":"^4.0.0","@modelcontextprotocol/sdk":"^1.30.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^3.0.0","esbuild":"^0.25.0","typescript":"^5.6.0","@types/node":"^22.0.0","@asolens/core":"0.0.0","@asolens/store":"0.0.0"},"_npmOperationalInternal":{"tmp":"tmp/mcp_0.2.2_1788598900095_0.006899048185081691","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@asolens/mcp","version":"0.3.0","keywords":["aso","app-store-optimization","app-store","ios","keyword-research","app-store-connect","rankings","mcp","mcp-server","aso-tools"],"author":{"name":"Daniel Han"},"license":"MIT","_id":"@asolens/mcp@0.3.0","maintainers":[{"name":"hex0cter","email":"hex0cter@gmail.com"}],"bugs":{"email":"asolens@danielhan.dev"},"bin":{"asolens-mcp":"dist/cli.js"},"dist":{"shasum":"252976de3057763c4e6030739239646799472e04","tarball":"https://registry.npmjs.org/@asolens/mcp/-/mcp-0.3.0.tgz","fileCount":4,"integrity":"sha512-Nk9dzdmCt/82xJn7mtsXi3iIuc8ZSn+tGZtXtWPQGA8WcDUrcLw0oAeax5svx8brUrrmRezv4Fqxk2hV1wgzzQ==","signatures":[{"sig":"MEQCICFlMOpWEICK8coS9XToJD3hpEDxwe3RUFbid5TiS7eIAiBh5kfPq8D0Gpm1nZK7oPQw2WFx47obLwGt1weV3fMbtQ==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":281994},"type":"module","engines":{"node":">=22.13.0"},"gitHead":"230eaa3a6fde85c54d78547fe6ddfd5103492d24","scripts":{"test":"vitest run","build":"esbuild src/cli.ts --bundle --platform=node --format=esm --target=node22 --external:@modelcontextprotocol/sdk --external:zod --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\" && chmod +x dist/cli.js","prepack":"npm run build","test:e2e":"vitest run test/cli.e2e.test.ts","typecheck":"tsc --noEmit -p tsconfig.json","smoke:live":"npm run build && node scripts/smoke-live.mjs","prepublishOnly":"node -e \"const s=require('fs').readFileSync('README.md','utf8');if(s.includes('**Pre-release.**')){console.error('\\\\nRefusing to publish: remove the pre-release banner from packages/mcp/README.md first (it tells users the package is not on npm).\\\\n');process.exit(1)}\"","acceptance:live":"npm run build && node scripts/acceptance-live.mjs"},"_npmUser":{"name":"hex0cter","email":"hex0cter@gmail.com"},"_npmVersion":"11.17.0","description":"ASOLens MCP server: App Store ranks, competitors and weaknesses, from your own machine","directories":{},"_nodeVersion":"26.4.0","dependencies":{"zod":"^4.0.0","@modelcontextprotocol/sdk":"^1.30.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^3.0.0","esbuild":"^0.25.0","typescript":"^5.6.0","@types/node":"^22.0.0","@asolens/core":"0.0.0","@asolens/store":"0.0.0"},"_npmOperationalInternal":{"tmp":"tmp/mcp_0.3.0_1788803777143_0.06253319520997636","host":"s3://npm-registry-packages-npm-production"}},"0.3.1":{"name":"@asolens/mcp","version":"0.3.1","keywords":["aso","app-store-optimization","app-store","ios","keyword-research","app-store-connect","rankings","mcp","mcp-server","aso-tools"],"author":{"name":"Daniel Han"},"license":"MIT","_id":"@asolens/mcp@0.3.1","maintainers":[{"name":"hex0cter","email":"hex0cter@gmail.com"}],"bugs":{"email":"asolens@danielhan.dev"},"bin":{"asolens-mcp":"dist/cli.js"},"dist":{"shasum":"484fbc6c6c1ce84665b42787efa08148517e23b1","tarball":"https://registry.npmjs.org/@asolens/mcp/-/mcp-0.3.1.tgz","fileCount":4,"integrity":"sha512-zYhfEYB46MDiGuW7rSiu048tl3fOFu4VAyH97rBgekfa8YclSyHit1qSkD+yFEf3fdAxIwvHgauI9Wq4TK+Rlg==","signatures":[{"sig":"MEQCH26ZFUGDxvxv0eHHWuVvNBcpxSrwOB52XHWNpzLEleICIQC7PbfG/GbcrEGDds1P1Bm5lAjssOGbv6KI43GdknmkAQ==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEUCIH33yJuudmHGewqZ/sn2rWvbdQm4VwL9Y/v8EIflgYgwAiEAuhuUrAgGNZLyhLzSTAOkf9a2rkPNKecfi5mzua8P6DY=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":290256},"type":"module","engines":{"node":">=22.13.0"},"gitHead":"500659e5f5365d054689c63d9faa740e3e235363","scripts":{"test":"vitest run","build":"esbuild src/cli.ts --bundle --platform=node --format=esm --target=node22 --external:@modelcontextprotocol/sdk --external:zod --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\" && chmod +x dist/cli.js","prepack":"npm run build","test:e2e":"vitest run test/cli.e2e.test.ts","typecheck":"tsc --noEmit -p tsconfig.json","smoke:live":"npm run build && node scripts/smoke-live.mjs","prepublishOnly":"node -e \"const s=require('fs').readFileSync('README.md','utf8');if(s.includes('**Pre-release.**')){console.error('\\\\nRefusing to publish: remove the pre-release banner from packages/mcp/README.md first (it tells users the package is not on npm).\\\\n');process.exit(1)}\"","acceptance:live":"npm run build && node scripts/acceptance-live.mjs"},"_npmUser":{"name":"hex0cter","email":"hex0cter@gmail.com"},"_npmVersion":"11.17.0","description":"ASOLens MCP server: App Store ranks, competitors and weaknesses, from your own machine","directories":{},"_nodeVersion":"26.4.0","dependencies":{"zod":"^4.0.0","@modelcontextprotocol/sdk":"^1.30.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^3.0.0","esbuild":"^0.25.0","typescript":"^5.6.0","@types/node":"^22.0.0","@asolens/core":"0.0.0","@asolens/store":"0.0.0"},"_npmOperationalInternal":{"tmp":"tmp/mcp_0.3.1_1789507086284_0.6914064414540095","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"_id":"@asolens/mcp@0.4.0","bin":{"asolens-mcp":"dist/cli.js"},"bugs":{"email":"asolens@danielhan.dev"},"dist":{"shasum":"22207989572e9d0bd767b6b25dd43f514ab21ff1","tarball":"https://registry.npmjs.org/@asolens/mcp/-/mcp-0.4.0.tgz","fileCount":4,"integrity":"sha512-UY7R31HwBQPjA4fZPHnZ2p3z4957YH6fR3P0+n0EV+sXKPL9su7wYYkXmB9VH45RhGCtNVex9t4gtZhz+X7eXQ==","signatures":[{"sig":"MEUCIGWHzxWO104eztdTuiBSH23MBqCB0ZGPwO1VfSvZUEnqAiEAhmc0fnlauOIyyTR79sUQB5bfKSPZ8rbax5uiA5TcpsI=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIBinpBLA0RQPG8YBEJoWjisZzWFTnJIIsX9Y00NGi1W2AiEApTNN+UF8BSZp08xuXLAh5b3ovaGTd4agwiqNp93/VYg="}],"unpackedSize":288680},"name":"@asolens/mcp","type":"module","author":{"name":"Daniel Han"},"engines":{"node":">=22.13.0"},"gitHead":"a2c5b33eb5369342f6a50c99c868a6211133c3c5","license":"MIT","scripts":{"test":"vitest run","build":"esbuild src/cli.ts --bundle --platform=node --format=esm --target=node22 --external:@modelcontextprotocol/sdk --external:zod --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\" && chmod +x dist/cli.js","prepack":"npm run build","test:e2e":"vitest run test/cli.e2e.test.ts","typecheck":"tsc --noEmit -p tsconfig.json","smoke:live":"npm run build && node scripts/smoke-live.mjs","prepublishOnly":"node -e \"const s=require('fs').readFileSync('README.md','utf8');if(s.includes('**Pre-release.**')){console.error('\\\\nRefusing to publish: remove the pre-release banner from packages/mcp/README.md first (it tells users the package is not on npm).\\\\n');process.exit(1)}\"","acceptance:live":"npm run build && node scripts/acceptance-live.mjs"},"version":"0.4.0","_npmUser":{"name":"hex0cter","email":"hex0cter@gmail.com"},"keywords":["aso","app-store-optimization","app-store","ios","keyword-research","app-store-connect","rankings","mcp","mcp-server","aso-tools"],"_npmVersion":"11.17.0","description":"ASOLens MCP server: App Store ranks, competitors and weaknesses, from your own machine","directories":{},"maintainers":[{"name":"hex0cter","email":"hex0cter@gmail.com"}],"_nodeVersion":"26.4.0","dependencies":{"zod":"^4.0.0","@modelcontextprotocol/sdk":"^1.30.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^3.0.0","esbuild":"^0.25.0","typescript":"^5.6.0","@types/node":"^22.0.0","@asolens/core":"0.0.0","@asolens/store":"0.0.0"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/mcp_0.4.0_1789590598658_0.5452469777223117"}}},"time":{"created":"2026-08-30T19:37:23.145Z","modified":"2026-09-16T20:29:58.984Z","0.1.0":"2026-08-30T19:37:23.429Z","0.2.0":"2026-09-02T18:54:07.435Z","0.2.1":"2026-09-04T17:09:50.515Z","0.2.2":"2026-09-05T09:01:40.282Z","0.3.0":"2026-09-07T17:56:17.287Z","0.3.1":"2026-09-15T21:18:06.422Z","0.4.0":"2026-09-16T20:29:58.802Z"},"bugs":{"email":"asolens@danielhan.dev"},"author":{"name":"Daniel Han"},"license":"MIT","keywords":["aso","app-store-optimization","app-store","ios","keyword-research","app-store-connect","rankings","mcp","mcp-server","aso-tools"],"description":"ASOLens MCP server: App Store ranks, competitors and weaknesses, from your own machine","maintainers":[{"name":"hex0cter","email":"hex0cter@gmail.com"}],"readme":"# @asolens/mcp\n\n**See how your app really performs in App Store search — and how much to trust\nevery number you get.**\n\nASOLens gives your AI assistant live App Store data. You ask a question in plain\nlanguage; it goes and looks.\n\n```bash\nclaude mcp add asolens -s user -- npx -y @asolens/mcp\n```\n\nMost of it needs no account and no API key — install it and start asking. Only\ntwo things need a credential you set yourself: reading your own listing, and\nsearch volume ([paid, and `us` only](#keyword-search-volume-paid-us-only)).\n\n## What you can do with it\n\n- **Find where you rank** for any keyword, in any of 17 storefronts — and how\n  many apps you were ranked against, which is what decides whether the rank\n  means anything at all.\n- **Check what a keyword really means to searchers** before you build a strategy\n  on it. That is the example below, and it is the thing a rank alone cannot tell\n  you.\n- **Audit your keyword field** — which words are working, which are dead weight,\n  and which could not be tested.\n- **Size up the competition** — who owns your niche, where they are weak, and\n  what their users complain about.\n- **Prove a change worked.** Snapshot before, ship the metadata, diff after.\n\n## The thing a rank cannot tell you\n\nAsk the App Store what people type after `division`:\n\n```\ndivision -> the division resurgence  ·  division math games  ·  the division 2\n            division games  ·  division math  ·  division flash cards\n            division 2 map  ·  division mobile  ·  division two\n            lexus, a division of toyota motor sales, u.s.a.\n```\n\nThree unrelated audiences in one word: people hunting Ubisoft's shooter, people\nwanting maths practice, and someone looking for a car dealership. Ranking near\nthe top for `division` tells you nothing about which of them you reached.\n\nIt is not a rare case:\n\n- After `addition`, the top two suggestions are `addition financial` and\n  `addition financial credit union` — **a credit union**.\n- After `table`, people type `open table`, `tablecheck`, `tabelog` — restaurant\n  booking — and `tableau`, the business-intelligence tool.\n\nA maths app can rank beautifully for all three and get no users from any of\nthem. ASOLens checks this for free, in every storefront, before you commit.\n\n*(Measured 2026-09-06 in the `us` storefront. Autocomplete drifts — run it\nyourself and see what you get.)*\n\n## What makes it different\n\n**Every number tells you how much to trust it.** Each value comes back labelled\n`observed` (with where it came from and when), `modelled` (with the model and\nthe inputs it used), or `unavailable` (with the reason, and what would fix it).\nNo score with hidden inputs, ever.\n\n**It refuses to guess.** No download estimates, no revenue estimates. Nobody\noutside Apple knows those, and a number you cannot check is worse than no number\n— because you will act on it.\n\n**It tells you when its own answer is suspect.** That is the unusual part:\n\n- It tests its probe word against the real store before trusting it. A word your\n  own app already dominates makes every keyword look perfect — one auto-picked\n  word read a keyword field as 29 of 29 words working, which was pure brand\n  recall. Words like that are detected and rejected now, and it says so.\n- It separates real placements from arithmetic ones. Ranking in the top 10 of a\n  search that returns 7 apps is not a result, and is counted separately.\n- It flags a keyword it could not test, rather than scoring it anyway.\n\n**It runs locally and needs no account.** Storage is a SQLite file in\n`~/.asolens/`, and with no credentials set it reaches only Apple's public\nendpoints, carrying no key, no account and nothing that identifies you or\nyour install. Two features reach past that, and each needs credentials you\nset yourself — see [What leaves your machine](#what-leaves-your-machine).\n\n**It cannot change your listing.** ASOLens only ever reads from Apple. There is\nno write path to your App Store record, for any tool.\n\n## Keyword search volume (paid, `us` only)\n\n> **Works, and costs money.** Search volume needs your own DataForSEO account\n> (set `DATAFORSEO_LOGIN` and `DATAFORSEO_PASSWORD`), and every lookup is billed\n> to it. There is no managed key or billing on our side.\n>\n> **Everything else in this README is free and needs no account.** If you are\n> evaluating ASOLens, you can skip this section.\n\n**Volume is `us`-only, from every source we tested.** DataForSEO's Apple\ndataset covers the United States and English only: the endpoint's own docs say\nso, every other storefront is rejected with `40501 Invalid Field:\n'location_code'` (verified live for all 16), and DataForSEO support confirmed\nit in writing. The alternative their support offers for other storefronts,\n`app_data/apple/app_searches`, returns App Store rankings, not volume -- what\n`search_ranks` already returns free. Apple Ads' Search Popularity is no\nsubstitute either: measured in one Apple Ads panel, at the 1-5 scale its web UI\nexposes, common Swedish terms and two established local competitor apps all\nread 1 of 5, the same as terms known to be near-dead; only global names\n(instagram, tiktok) reach 5/5. Seeing even that 1-5 reading takes a full\nad-account signup (legal entity, tax ID, permanent currency and time zone), and\nthe finer 5-100 index third-party blogs describe is documented nowhere by Apple\n-- do not sign up expecting it. Every other signal works in\nall 17 storefronts.\n\n**Even in `us`, a blank is not a demand signal.** No vendor sells \"volume for\nthis keyword\"; DataForSEO has only \"the keywords this app ranks for\", so a term\ngets a number when a competitor app the report queried ranks for it. An app's\nunfiltered window is its top 50 by volume; `niche_report` and `keyword_table`\nthen look up what the window missed **by name**, across the app's full ranked\nlist, and `price_keywords` does the same for a list you already have. A blank\nis therefore either **not asked** (window only) or **asked by name and still\nnot found** -- each `unavailable` reason says which -- and neither is about the\nkeyword: the first is about how deep the window went, the second about which\napps were queried. Measured 2026-09-02 in `us`:\n`math for kids` came back unpriced while 248 apps compete for it.\n\n**So the tool is built to be useful without volume.** Ranks, keyword coverage,\n`audit_keywords`, cohesion, autocomplete depth and low-star reviews work in\nevery storefront, and in the week this was written every metadata change\nshipped from a real audit came from those, not from a volume number. The\nclosest demand signal that works everywhere is autocomplete -- see [The thing\na rank cannot tell you](#the-thing-a-rank-cannot-tell-you).\n\n**Honesty rule.** Every number is labelled **observed** (read from Apple, with\nits source and fetch time), **modelled** (computed, with the model name and its\ninputs -- `niche_report` omits the per-row inputs by default to stay small; pass\n`include_inputs: true`), or **unavailable** (with the reason and what would\nunlock it). A field with no label is a plain observed passthrough, such as\n`rank` or `resultCount`, and carries `day`/`fetchedAt`/`fromCache`.\n\n## Install\n\nRequires **Node.js >= 22.13.0**, for the built-in `node:sqlite` module (check\nwith `node --version`); the database needs no separate install.\n\n**Claude Code**\n\n```bash\nclaude mcp add asolens -s user -- npx -y @asolens/mcp\n```\n\n`-s user` makes it available in every project. Drop the flag for the current\nproject only, or use `-s project` to commit the registration for your team.\nCheck it with `claude mcp list`, then restart Claude Code.\n\n**Claude Desktop** — add to your MCP config file:\n\n```json\n{\n  \"mcpServers\": {\n    \"asolens\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@asolens/mcp\"]\n    }\n  }\n}\n```\n\n**Codex** — add to `config.toml` (check your Codex version's docs — the MCP\nconfig key and format have changed across releases):\n\n```toml\n[mcp_servers.asolens]\ncommand = \"npx\"\nargs = [\"-y\", \"@asolens/mcp\"]\n```\n\nThe first run creates `~/.asolens/` (config file + SQLite database) and\nprints a ready line on stderr once connected. To remove it again:\n`claude mcp remove asolens -s user`.\n\n## Recipes\n\nASOLens has no CLI — you talk to your assistant, and it calls the tools. So\nevery example here is just a sentence you type.\n\n| You want to know | Ask something like |\n|---|---|\n| **Is my keyword field pulling its weight?** | *\"Audit my keyword field in br.\"* — [worked example](#auditing-your-keyword-field-audit_keywords) |\n| **Which market should I work on next?** | *\"Compare jp, br, mx and de for my app.\"* — [worked example](#which-market-to-work-on-compare_markets) |\n| **Did my metadata change actually help?** | *\"Snapshot these keywords as 'before' for br.\"* …ship the change… *\"Now diff 'before' against 'after'.\"* — [worked example](#beforeafter-a-metadata-change-snapshot_save-snapshot_diff) |\n| **Does this keyword mean what I think it means?** | *\"What ranks for 'division' in us, and what else do people search near it?\"* — [worked example](#autocomplete-depth-free-every-storefront) |\n| **Are these two keywords the same search?** | *\"Compare 'math games' and 'math games for kids' in my main market.\"* — [worked example](#comparing-two-queries-compare_queries) |\n| **Who am I actually competing with?** | *\"Build a niche report for these three keywords against my app.\"* — [worked example](#keyword-cohesion-and-competitor-subtitles) |\n| **What do users actually complain about?** | *\"Pull the low-star reviews for app 284882215 in us.\"* |\n\n### A first session\n\nIf you have just installed it and want the shortest useful path:\n\n1. *\"Find my app on the App Store.\"* — resolves the id everything else uses.\n2. *\"What ranks for `<your main keyword>` in `<your biggest market>`?\"* — the\n   ground truth you are about to reason from.\n3. *\"Suggest autocomplete keywords for `<that keyword>`.\"* — free, and the\n   fastest way to find out whether searchers mean what you assume. This is the\n   step that catches a `division` before you build a strategy on it.\n4. *\"Audit my keyword field in `<that market>`.\"* — needs App Store Connect\n   credentials; without them, steps 1-3 still work.\n\n**Nothing here can change your App Store listing.** ASOLens only ever reads\nfrom Apple — the App Store Connect client issues no write of any kind, not for\nany tool. It writes plenty locally: almost every tool caches what it fetched\ninto the SQLite file under `~/.asolens/`, which is what makes a second call\ncheap and a `snapshot_diff` possible. One write leaves your machine, and not to\nApple: a DataForSEO lookup POSTs an app id to create a billable task -- carrying\nthe keyword text too, when you are pricing keywords by name.\n\n## Config\n\nSettings live in `~/.asolens/config.json`, created on first run. Each one can\nbe overridden per run with an environment variable.\n\n### Settings\n\n| Setting | Env override | Default | What it does |\n|---|---|---|---|\n| `defaultCountry` | `ASOLENS_DEFAULT_COUNTRY` | `us` | Storefront used when a tool call omits `country`. |\n| `verbose` | `ASOLENS_VERBOSE` | `false` | One line per remote call on stderr, plus a `calls` array on every response — see [Verbose call log](#verbose-call-log). |\n| — | `ASOLENS_HOME` | `~/.asolens` | Where the config file and SQLite database live. Point it elsewhere to run isolated instances. |\n\n### Optional credentials\n\nBoth are off unless you set them, and **neither is required to install**.\nWithout them, `keyword_table` and `niche_report` still run in full --\nvolume just comes back `unavailable` with a reason and a fix, never an\nerror. `price_keywords` is the one exception: it exists only to buy this\ndata, so without credentials it has nothing to do and returns `unavailable`\nfor every keyword rather than an error.\n\n| Set | Unlocks | Cost |\n|---|---|---|\n| `DATAFORSEO_LOGIN`<br>`DATAFORSEO_PASSWORD` | Keyword search volume in `keyword_table` and `niche_report`; all of `price_keywords` ([see notice](#keyword-search-volume-paid-us-only)) | Paid, and `us` only — [details](#keyword-search-volume-dataforseo) |\n| `ASC_API_ISSUER_ID`<br>`ASC_API_KEY_ID` | Your own listing text and private keyword field: `my_listing`, `audit_keywords`, `compare_markets`. Two more use the key once it exists: `keyword_ranks` (unless `coverage: false`) and `snapshot_save` (always) | Free — [details](#your-own-listing-and-keyword-coverage-app-store-connect) |\n\n### Two keys you will see but need not set\n\n`config.json` also contains `installId`, a random UUID generated once and sent\nnowhere in this release, and `shareCache`, reserved for a future shared-cache\ntier and with no effect today. Both are listed here only so they are not a\nsurprise when you open the file.\n\n### What leaves your machine\n\nRequests to **Apple's public App Store endpoints** — search, lookup,\nautocomplete, reviews — which are what answer the tool calls. Worth knowing:\n`audit_keywords` sends each comma-separated entry of your private keywords\nfield (up to 30) to Apple as a probe, because measuring whether a word works\nmeans asking the store for it. It goes to two endpoints, not one: search, and\n— unless you pass `suggestions: false` — autocomplete. `compare_markets` runs\nthat audit once per storefront, up to eight, reading whichever localisation\neach storefront resolves to. How much of your listing that reaches depends on\nhow localised your app is: a fully localised one sends up to eight different\nkeyword fields, plus anchors from eight localised names and subtitles; an app\nwith a single localisation sends that one field eight times, because Apple\nserves the primary locale wherever a localisation is missing. It never asks\nautocomplete, which it turns off deliberately.\n\nThen two paths you have to switch on yourself, by setting the credentials\nabove:\n\n- **DataForSEO**, for keyword volume: sends the app ids being priced through, a\n  country/language pair, and -- since 0.3.0 -- **the keyword text itself**,\n  because naming keywords to the vendor is how a term outside an app's free\n  top-50 window gets priced at all. The apps are the competitors a report\n  found, which can include YOUR app when it ranks for those keywords: nothing\n  excludes it. (This said \"never your app, never your keywords\" until\n  2026-09-07 -- true before 0.3.0, false the moment by-name pricing shipped.)\n- **App Store Connect** (`api.appstoreconnect.apple.com`, a third Apple host\n  beside the public `itunes.apple.com` and `search.itunes.apple.com`), to\n  read your own listing text: a request signed with\n  your own `.p8` key. Five tools reach it -- `my_listing`, `audit_keywords`\n  and `compare_markets` by their nature, plus `keyword_ranks` (whose\n  `coverage` defaults on, so pass `coverage: false` to keep it local) and\n  `snapshot_save` (which records coverage with no flag to turn it off).\n  Apple returns data only for apps that key can see, but that limits what\n  comes BACK, not what goes out: the request carries whichever app id you\n  asked about, and for anyone else's app it returns nothing and coverage is\n  silently absent.\n\nThere is **no telemetry**. Your `installId` is sent nowhere, and vendor results\nare never pooled into a shared cache — they stay on your machine, tied to the\naccount that paid for them. Nothing is sent about you except what a tool call\nasks for -- which, for `audit_keywords` and `compare_markets`, includes words\nread from your own listing rather than typed by you.\n\n### Keyword search volume (DataForSEO)\n\n*Paid, and `us` only — see [the notice above](#keyword-search-volume-paid-us-only).*\n\nApple doesn't publish App Store search volume anywhere, and there is no\nendpoint that returns it for an arbitrary keyword list. The only path in is\nvia [DataForSEO](https://dataforseo.com/)'s Labs product: \"the keywords a\ngiven app ranks for, with volume.\" So ASOLens queries the competitor apps\nalready surfaced by your keywords' own top rankings (free -- they're already\nfetched) and merges their ranked-keyword lists into one volume lookup. This\nis why `niche_report` also returns `discoveredKeywords`: the top 10\nhigher-volume terms those competitors rank for that aren't in your own\nkeyword list yet. `price_keywords` skips the window entirely and names your\nown keyword list to the vendor directly -- see its row in the tools table.\n\n- **`us` storefront only** -- see [above](#keyword-search-volume-paid-us-only).\n  Elsewhere `volume` comes back `unavailable` at no cost, and the rest of the\n  report is unaffected.\n- **Costs money.** Each competitor app queried is a paid DataForSEO call\n  (typically a cent or two, billed per keyword row returned), and\n  `keyword_table` and `niche_report` query at most 3 per call by default.\n  Keywords the window missed then cost one more task fee per 150, per app --\n  even for an app whose window was already free today. Every payload that\n  spent money carries `vendorCost` (dollars, that call), and a verbose-mode\n  line for a vendor request names its cost. If a vendor call fails part-way,\n  a tool reports what had already been billed rather than `0`; `price_keywords`\n  reports `vendorCost: null` when the amount cannot be determined, while\n  `niche_report` and `keyword_table` keep a numeric field, so `0` is the floor\n  they can express. `price_keywords` also returns `estimatedCost` (a min/max\n  range with its basis) BEFORE spending.\n- **Cached per app, per country, per UTC day.** A repeat call for a\n  competitor app already queried today costs nothing -- it's served from the\n  local SQLite cache. Cross-app or cross-day results are never assumed; a\n  new day (or a new competitor app) means a new paid lookup. The one reader\n  that looks further back is `suggest_keywords`, which spends nothing and\n  shows a price bought on any of the last 7 UTC days, labelled with that day.\n- **Never shared, never pooled.** Vendor results are per-install only, same\n  as the rest of this release -- DataForSEO's terms don't address\n  redistribution, so results from your paid calls never leave your machine\n  and are never merged with another install's cache.\n- **Never `observed`.** A volume value is always `modelled` (with\n  `model: \"dataforseo-labs-apple\"` and, with `include_inputs: true`, the\n  source competitor app id it came from) -- it's a vendor estimate, not a\n  number Apple returned.\n- Pass `volume: false` on either tool to skip DataForSEO entirely for that\n  call (e.g. to conserve balance), even with credentials configured.\n  `price_keywords` has no such flag -- naming keywords by hand is its whole\n  job, so it always spends when it runs.\n\n### Your own listing, and keyword coverage (App Store Connect)\n\nThe App Store Connect keywords field is private -- no public endpoint\nexposes it -- so for your own apps ASOLens reads it from the App Store\nConnect API. Set two environment variables:\n\n```bash\nexport ASC_API_ISSUER_ID=...        # App Store Connect > Users and Access > Integrations\nexport ASC_API_KEY_ID=...\n# the .p8 is read from ~/.appstoreconnect/private_keys/AuthKey_<keyId>.p8,\n# the same place Apple's own tools look. Override with ASC_API_PRIVATE_KEY_PATH.\n```\n\nNothing is written anywhere: the key is read once at startup and held in\nmemory, never copied into the config file or any payload. Without the\ncredentials every tool still works -- `my_listing` returns an `unavailable`\nsaying what to set, and coverage is simply absent.\n\n`my_listing` shows the name, subtitle and keywords field per locale for the\niOS version App Store Connect treats as **live** -- which can be one approved\nbut not yet released, so check `appStoreState` -- and reports a draft in review\nseparately, since what you are about to ship is not what is being indexed. It also tells you\nwhen a storefront has no localization at all and Apple is serving your\nprimary locale there instead.\n\nThe other tools that read that listing -- `keyword_ranks` (with coverage on),\n`audit_keywords`, `compare_markets` and `snapshot_save` -- name the version\nthey read in `listingVersion`: `versionString` and `appStoreState` (a version\napproved but not yet released can count as live, so check the state), any\n`editableVersion` whose text was *not* read, and `fetchedAt`. Keywords that\nexist only in your own files or an unreleased version are not in it. The\nlisting is read once per server process, so restart the server after a\nrelease. `snapshot_diff` reports both sides' versions; `null` means that\nsnapshot recorded none (saved before this existed, or without coverage).\n\n`keyword_ranks` then reports `coverage` per keyword: which of its words\nappear in your indexed text, **checked across name + subtitle + keywords\ntogether**, because Apple indexes them as one bag. Live, in `br`:\n\n| keyword | rank | coverage | |\n|---|---|---|---|\n| tabuada de multiplicação | **#11** | 3/3 | `tabuada`←subtitle, `multiplicação`←keywords |\n| jogos de matemática | **#19** | 3/3 | |\n| jogos de matemática 1 ano | #34 | 4/5 | |\n| matemática divertida para crianças | — | 2/4 | missing `divertida`, `para` |\n\nThe first row is why coverage is not computed per field: it is only covered\nbecause two different fields each supply a word.\n\n**`looseOnly: true` is a weaker match than it looks.** A token normally has\nto appear in your text exactly. `looseOnly` means it matched only after\naccents were stripped from both sides -- right when you wrote\n`multiplicação` and the query says `multiplicacao`, but it crosses languages\ncarelessly: checking the Swedish phrase `matte för barn` against the\n**English** listing marks `för` covered, because stripping accents turns it\ninto `for`, which sits in \"Practice **for** Years 1-3\". That is a false\nfriend, not a match. Read `looseOnly: true` as \"possibly\", and check the\n`fields` it claims to have matched in.\n\n**`covered: null` means the check cannot tell.** Chinese, Japanese, Thai,\nLao, Khmer and Burmese are written without spaces between words, and Korean\ncan write compounds unspaced (MathQuest's Korean name holds `수학게임`).\nCoverage compares whole words, so a word inside a longer run of such text is\ninvisible to it -- including a Latin word joined to it, as `line` is in\n`LINEマンガ`. When that could be happening, the word comes back `covered: null`\nwith a `note`, is listed under `cannotTell`, and counts as neither covered nor\nmissing: it may or may not be there. That is when the word is not a separate\nword in your listing and either sits inside such a run (a Latin word only as a\nwhole piece, so `quest` in `MathQuest算数ゲーム` is still a miss), or contains\na character from one of those scripts with every one of its characters\nsomewhere in the listing. Otherwise it is `false` as usual. Before this,\n`수학 게임` checked against a listing holding `수학게임` reported both words\nmissing, so a `snapshot_diff` against a snapshot saved before this change can\nshow a coverage change such as `0/2` to `0/2, 2 cannot tell`: that is the older\nsnapshot's misreading, not an edit to your listing.\n\n**Coverage is an observation about your text, not a ranking model.** Apple\nstems and matches in ways nobody outside Apple can enumerate, so a missing\nword is a lead worth investigating, not proof you cannot rank -- and full\ncoverage promises nothing. Two live cases where coverage is **2/2 and the\napp does not rank at all**: measured in `br` and `au`, two queries whose words\nwere all present in the listing and which still returned nothing.\nCoverage is necessary, not sufficient. Common function words (`de`, `para`)\nare counted too; whether Apple ignores them is not something this tool can\nobserve, so weigh missing content words more heavily.\n\n**What coverage is genuinely for** is settling questions about the indexed\ntext that no amount of rank-watching can answer. A real one: does Apple\nsplit compound words? The Swedish listing contains \"Matte**träning**\" and\n\"**Matte**spel\", so whether the app already covered the bare word `matte`\nwas an open question. Coverage answers it by reading the text: for the query\n`matte för barn`, `matte` comes back **not covered** -- Apple's index holds\nthe compounds, not their parts, so the bare word had to be added explicitly.\nThat had been an unverified hypothesis for hours; one coverage call settled\nit.\n\n## Walkthroughs\n\nReal runs, with real numbers, measured on the dates given. Each one is a\nquestion a solo developer actually has — and several of them are here because\nthe obvious answer turned out to be wrong.\n\n### Which market to work on (`compare_markets`)\n\n*Measured in `jp`, `br`, `mx` and `de` at once. Foreign words are glossed at\nfirst mention.*\n\nRuns the keyword-field audit across several storefronts and puts them side\nby side. This is the question a solo developer with sixteen locales actually\nhas, and no single-market answer settles it.\n\n```\ncompare_markets app=<your bundle id> countries=[jp, br, mx, de]\n\nmarket  anchor        top50/of  ratings  median (n)\njp      算数ゲーム        12/29        1     240 (55)\nbr      matemática       1/11        0       8 (43)\nmx      matemáticos      2/11        0     117 (44)\nde      lernspiele       1/11        0    2127 (42)\n```\n\n**The anchor is a column because it is the caveat.** Each market is audited\nwith its own anchor against its own keyword field in its own language, so\nthe medians are *not* commensurable — 8 and 2,127 are not the same kind of\nnumber. Showing the anchors lets you see that rather than being told it. The\nresponse says whether the spread is large enough to survive: a ~266x range\nis far too big to be an artefact, so the *ordering* is real even though the\nnumbers are not comparable one to one; a 2x range would not be.\n\nRows come back in the order you asked for and are **never sorted by median**,\nsince sorting would imply exactly the ranking the medians cannot support.\n\n**Read the ordering, not the exact counts.** `inTop50` moves with the\nanchor, because a mechanically built probe can ask a question nobody types.\nMeasured in `br` on one day, the same eleven words scored **1 of 11** with\nthe mechanical anchor and **3 of 11** with natural phrasing — phrases like\n`soma e subtração` (Portuguese for \"addition and subtraction\") or\n`exercícios de matemática` (Portuguese for \"maths exercises\"), instead of a\ngenerated anchor-plus-word pair:\n\n| | rank | results |\n|---|---|---|\n| `soma e subtração` (natural) | **#23** | 28 |\n| the same word, `<anchor> <word>` (mechanical) | #118 | 170 |\n| `exercícios de matemática` (natural) | **#35** | 175 |\n| the same word, `<anchor> <word>` (mechanical) | #105 | 149 |\n\nAcross two independent runs of four markets the *ordering* was stable and\nthe counts moved by up to 3x. So compare markets by their ordering, their\nrating counts and their medians — and where you know a market's real\nphrasing, run `audit_keywords` for it with `probes=` and use that count. A\nlong `notReturned` list under a mechanical anchor usually means the probe is\nwrong, not the words.\n\n**Bring your own phrasing where you have it.** `anchors=` and `probes=`\noverride per market, so a first pass on auto anchors can be refined market\nby market without leaving the tool:\n\n```\ncompare_markets app=... countries=[br, de] probes=[\n  {country: \"br\", word: \"soma\",        probe: \"soma e subtração\"},\n  {country: \"br\", word: \"exercícios\",  probe: \"exercícios de matemática\"},\n]\n  br  1/11  ->  3/11\n```\n\nThe response marks which markets were refined (`anchorSource`,\n`probeOverrides`) and warns when the table is **mixed**: a count from\nnatural phrasing and one from a generated phrase are different\nmeasurements, so a refined market and an unrefined one are less comparable\nto each other than either is internally. Refine all of them or none for a\nfair ranking. An override naming a storefront that is not being compared is\nrejected rather than ignored, since a silent no-op would leave you believing\nyour phrasing was used.\n\n**Anchors are tested, not guessed**, as described under `audit_keywords`\nbelow: a brand-like anchor is discarded (the first auto-anchor for `jp`, the\ntransliterated brand, scored 29 of 29), and if every candidate is brand-like\nthe tool says the results are unusable rather than presenting them.\n\n### Before/after a metadata change (`snapshot_save`, `snapshot_diff`)\n\n*Measured mostly in `br`, with a Swedish (`se`) `pairs` example. The mechanics\nare the same everywhere.*\n\nApple publishes no rank history, and this tool's own cache is per-UTC-day.\nSo the \"before\" of a before/after has to be captured deliberately, while the\nold build is still live -- once you ship, it is gone.\n\n```\nsnapshot_save name=\"pre-release\" app=<your app id> countries=[\"br\"] keywords=[\n  {keyword: \"matemática divertida\",   label: \"target\"},\n  {keyword: \"jogos de matemática\",    label: \"regression watch\"},\n  {keyword: \"jogos de matemática\",    label: \"control\"},\n]\n```\n\nWhen storefronts need **different** keywords, pass `pairs` instead of the\n`keywords` x `countries` cross product:\n\n```\nsnapshot_save name=\"baseline\" app=<your app id> pairs=[\n  {keyword: \"jogos de matemática\",  country: \"br\", label: \"control\"},\n  {keyword: \"matemática divertida\", country: \"br\", label: \"target\"},\n  {keyword: \"matematik för barn\",   country: \"se\", label: \"control\"},\n  {keyword: \"times tables\",         country: \"au\", label: \"control\"},\n]\n```\n\nA real baseline of 13 Brazilian and 9 Nordic keywords is 22 rows as pairs\nand 198 as a cross product, nearly all of them nonsense (\"matteträning\" —\nSwedish for \"maths training\" — in France). Without pairs it has to be split into two snapshots -- and then two\ndiffs, both of which someone has to remember exist later. `keyword_ranks`\ntakes the same `pairs` input, and returns rows in the order given.\n\nEach row keeps the rank (**including `null`** -- \"did not rank\" is a value,\nand \"unranked → ranked\" is usually the very thing being tested),\n`resultCount`, the top 10 app ids, keyword coverage, and your rating count\nagainst the competitor median, with `competitorSampleSize` and\n`competitorSampleTopN` beside it, since size alone cannot say how deep the\nsample went. That median is over the union of each keyword's top 10 -- the apps\nat the TOP of those lists, not the storefront -- and is not comparable with\n`audit_keywords`' (a shallower slice) or `niche_report`'s (which includes your\nown app and drops bundles, even where its depth matches).\n\n`snapshot_diff before after` groups rows by your labels and reports, per\nrow: the rank change, how `resultCount` moved, which apps entered or left\nthe top 10, and -- when your rank is inside the stored top 10 -- how many of\nthe apps above you are new.\n\n**Read the controls first.** If keywords you expected to hold still have\nmoved, the comparison is drift and the targets prove nothing. That is what\nlabels are for; they are free text and never interpreted.\n\nThree deliberate refusals:\n\n- **No significance or confidence scoring.** With small numbers there is no\n  statistical power, and a confidence label would be theatre. The numbers\n  and the `resultCount` context are shown; you judge.\n- **No silently dropped rows.** A keyword present in only one snapshot is\n  listed under `onlyInA`/`onlyInB`, because a vanished row is how a\n  regression disappears from a report.\n- **Coverage absence is recorded, not omitted.** A snapshot taken without\n  App Store Connect credentials records *why*, so a later diff cannot read a\n  configuration change as a change to your listing text.\n\n### Auditing your keyword field (`audit_keywords`)\n\n*Measured mostly in `se`, with comparisons in `de`, `jp`, `kr` and `tw`.\nForeign words are glossed at first mention.*\n\nWhich of the words you spent 100 characters on are actually doing anything?\nFor each word, `audit_keywords` searches a phrase where that word is the\ndistinguishing one -- an anchor your app certainly covers, plus the word\nunder test:\n\n```\naudit_keywords app=<your app> country=se\n  anchor: \"mattespel\"  (auto, tested against the store -- override with anchor=)\n\n    #7/26    mattespel addition      #33/81   mattespel barn\n    #6/22    mattespel subtraktion   #35/67   mattespel matematik\n    ...\n  Apple returned your app for 12 of 12 probes, but only 11 inside the top 50\n  and 8 inside the top 10. 2 of those 8 were in result sets of 10 apps or\n  fewer, where anything returned is inside the top 10 -- discount them and\n  the real count is 6.\n```\n\nThe four words above are ones a real competitor publishes itself, in its own\nname and subtitle: *\"Matematik Kul: lär dig siffror\"* / *\"Barn addition och\nsubtraktion\"*. They are shown here because they are public. **Your** field is\nread only through your own App Store Connect key, is never stored anywhere\nbut your machine, and appears nowhere in this README. It is not sealed off,\nthough: auditing an entry means asking the store about it, so each\ncomma-separated entry (up to 30) goes to Apple's public search endpoint as a\nprobe, and to autocomplete as well unless you pass `suggestions: false`. The\nsearch half is the measurement and cannot be avoided; the autocomplete half\ncan, and `compare_markets` always does -- it runs only the search probes, once\nper storefront, up to eight, reading the localisation each storefront\nresolves to.\n\nThe Swedish words here: `mattespel` is \"maths game\", `barn` is \"children\",\n`subtraktion` and `matematik` are what they look like. The argument below does\nnot depend on knowing them — only on the *shape* of the numbers.\n\nIt answers the strategic question as well as the tactical one. Same app,\nsame day, same tool, two storefronts — Germany's anchor is \"mathe\" (German\nfor \"maths\"), Japan's is 算数 (Japanese for \"arithmetic/maths\"):\n\n| | `de`, anchor \"mathe\" | `jp`, anchor 算数 |\n|---|---|---|\n| returned | 0 of 11 | 26 of 29 |\n| inside top 50 | **0** | **11** |\n| your ratings | 0 | 1 |\n| competitor median | 2,747 | 243 |\n\nGermany says *the apps at the top of these results are out of reach* -- no\namount of keyword editing closes a gap of 2,747 ratings to zero. (It does not\nsay the market is wrong: the median is over the union of each probe's top 5,\nso it describes those apps, not the storefront.) Japan says *the words are mostly right* on a\nsingle rating, with three of its words worth revisiting (they returned\nnothing for their probe). Telling those two apart is the entire point of the summary.\n\nThe verdict says when its own count is suspect. Two or more words returning\nnothing under a *generated* probe is the tell that the anchor is asking the\nwrong question, so the verdict names them and says the count is probably too\nlow — rather than listing them neutrally beside a number they contradict.\nWords the caller phrased themselves get no such excuse: those look genuinely\ndead.\n\n**Read the summary before the rows.** \"Returned at all\" and \"returned where\nanyone would see it\" are different questions, and only the second one\nmatters -- 9 of 11 sounds healthy until you notice one of them is inside the\ntop 50. Where the app sits versus the apps at the TOP of those probes' results\n-- not the competitive field, which this number does not measure -- is\nreported as two plain numbers for the same reason: in `de` the same audit shows 0 ratings\nagainst a competitor median of **2,158**, which means keyword edits are not\nthe lever in that storefront at all, whatever the individual rows say. That\nis a different problem from a badly chosen word, and worth knowing before\nspending an afternoon on the keywords field.\n\n**In `jp`, `kr` and `tw` the probe's space is not neutral.** Probes are\nbuilt by joining an anchor to a word with a space, and those languages do\nnot use one between words. Measured by comparing full ranked id lists: kr\n\"수학 어린이\" (Korean for \"math kids\") and \"수학게임\" (Korean for \"math\ngame\")'s counterpart behave completely differently --\n`수학 어린이` vs `수학어린이` returned byte-identical results, while\n`수학 게임` vs `수학게임` shared only **12 of their top 25**. Japanese pairs\noverlapped 23-24 of 25 with ranks shifting (#12 → #16, #168 → #182). So the\nspace usually barely matters and occasionally matters a great deal -- the\nsame shape as the accent finding, and the same conclusion: test both rather\nthan guess. The tool says this in its own output for those storefronts, and\n`probes=` lets you re-test a word with the unspaced form.\n\nSearching each word *alone* instead would mostly measure how small your app\nis, which is why probes are anchored. Measured live in `se`, every keyword\nsearched on its own returned nothing findable -- `bråk` (Swedish for\n\"fraction\") in a field of 220, `tabell` (Swedish for \"table\") at #122 of\n211, `räkna` (Swedish for \"to count\") unranked in 227 -- because a one-word\nsearch is a fight with 200+ apps that a small app always loses. Anchored to\n`mattespel` (Swedish for \"maths game\"), the same words separate: `mattespel\ntabell` #2 of 16, `mattespel bråk` #4 of 13, `mattespel barn` (Swedish for\n\"kids\") #33 of 81. **That spread is the point.** The tool cannot tell\nyou a word is good in the abstract; it tells you which of *your* words is\nworking hardest, which is the decision you face with only 100 characters.\n\n**The anchor is tested against the store, not guessed.** Each candidate is\nsearched on its own first and discarded on either of two grounds, with the\nreason reported in `anchorRejected`:\n\n- **brand** -- your app is already top 3 for it, so every probe would return\n  you whatever the word was. Live: the auto anchor for `jp` was マスクエスト,\n  the transliterated brand, which scored **29 of 29 inside the top 10** -- pure\n  brand recall, and a market that looked like the best investment going. With\n  a real anchor (算数ゲーム, Japanese for \"maths game\") the same field reads\n  12 of 29.\n- **narrow** -- its own search returns 50 apps or fewer, so probes built on it\n  land in fields too small to distinguish a working word from a dead one. Live\n  in `se`: `matteträning` (Swedish for \"maths training\") returns 32 apps,\n  `matteträning barn` returns 7, and\n  the audit read **12 of 12 inside the top 10**. The next candidate,\n  `mattespel`, returns 219 and gives a real measurement.\n\n**A probe cannot test a word the anchor already contains.** `mattespel` swallows\n`spel`, so that probe re-runs the anchor; the row is flagged `echoesAnchor` and\ncalled neither a pass nor a fail rather than scored. `echoesAnchor` is\nthree-valued: `true` echoes, `false` does not, and **`null` means the check\ncould not be run at all** — the anchor's list could not be fetched, or that\nprobe returned no apps, or the *anchor's own search* returned none (which\nleaves nothing to compare against even for a probe that returned hundreds). A `null` is not a `false`, and\n`probesEchoingAnchor` counts only the known `true`s. It takes two signals to\ndetect, because each alone is wrong: `practice` contains `ice` and\n`practice ice` is a perfectly good probe, while overlap alone flagged German\n`kinder` at 56% -- a word `lernspiele` does not contain -- simply because\nGerman kids' learning games are the same apps.\n\nIf the anchor still produces a phrase nobody would type, pass `anchor=`, or\noverride individual words with `probes=[{word, probe}]` (natural phrasing like\n`aprender a contar` beats `matemática contar`). A word that returns nothing is\na lead, not proof -- a different phrasing can rank where one does not.\n\n#### What each word's searchers actually want\n\nEvery word row carries `suggestions`: what App Store autocomplete says the\npeople typing that word are looking for. **This is the one signal a rank cannot\ngive you, and reading it is usually the fastest way to find a dead keyword.**\n\n`tabell` was the best-ranking word in a Swedish keyword field -- **#2 of 16**.\nThe people who type it want Swedish football league tables:\n\n```\ntabell   #2/16   tabellen.se / allsvenskan tabell / fotboll tabell\n```\n\nRanking first for a word whose searchers want something else is worth nothing.\nThis is the coverage lesson one step further out: **coverage is necessary and\nnot sufficient, and so is ranking.**\n\nThe same *kinds* of word are hijacked in every language, which is worth knowing\nbefore you audit your own:\n\n| The word you meant | Who actually owns that search |\n|---|---|\n| \"learn\" verbs -- `lära`, `aprender`, `apprendre` | **language-learning apps**, in every locale tested |\n| \"count\" verbs -- `räkna`, `contar` | calorie and step counters |\n| `dividir` | PDF splitters and bill-splitting apps |\n| `division` | Ubisoft's *The Division* |\n| \"exercise\" -- `exercícios`, `ejercicio` | gym and home-workout apps |\n| school words -- `escola`, `primaire` | school admin portals |\n\nPlus outright false friends: French `addition` is the restaurant bill; Mexican\nSpanish `kinder` is chocolate eggs (it ranks #11 of 18 precisely because that\nfield is tiny and irrelevant). What survives everywhere is the specific noun\nfor the thing you actually do -- `bruchrechnen` (German: \"fraction\ncalculation\"), `fracciones` (Spanish: \"fractions\"), `subtração` (Portuguese:\n\"subtraction\").\n\nFree, works in all 17 storefronts, and adds no measurable wall-clock\n(autocomplete has its own request lane). It is also the only intent signal\navailable outside `us`, where keyword volume cannot be bought at all. Same\ncaveats as the depth column below: it is *prefix completion* capped at 10, so\nan empty list means nothing popular **extends** the word rather than that\nnobody searches it, and it shows direction, never size. Pass\n`suggestions: false` to leave it out.\n\n### Comparing two queries (`compare_queries`)\n\n*Measured in `br`. The behaviour is the same everywhere.*\n\nRank is relative to whoever else is in that result set, so an app at #19 for\none phrase and #131 for another has not necessarily been hurt by the extra\nword -- the two searches may simply have different competitors. That\ninference is the most repeated analysis mistake in this project's history,\nmade three times in one session by an experienced caller and once by the\nauthor of this file. `compare_queries` makes the check one call:\n\n```\ncompare_queries a=\"jogos de matemática\" b=\"jogos de matemática escolares\"\n                country=br app=<your app id>\n  -> #19 vs #131, 1 of the top 25 shared\n     \"these are substantially different result sets, so do NOT read a rank\n      difference between them as one query being better\"\n```\n\n(`jogos de matemática` is Portuguese for \"math games\"; the second query\nadds `escolares`, \"school\" as an adjective, e.g. \"school math games\".)\n\n`identicalResults: true` means Apple answered one search for both, so any\nrank difference is noise. A high overlap means the fields are comparable and\na rank gap is real. A low one means you are comparing positions in two\ndifferent races. **`null` means both queries returned nothing** — there was no\nlist to compare, so it says nothing either way. (Comparing a query with itself\nstays `true`, empty or not: it is the same search.)\n\n### Accent variants (`keyword_ranks`)\n\n*Measured mostly in `br`, with Hindi, Japanese, Swedish and German notes.\nForeign words are glossed at first mention.*\n\nApple usually treats an accented and an unaccented spelling as two different\nsearches, sometimes answers both with one identical result list, and nothing\nobservable tells you which case you are in (16 pairs measured; the obvious\nrule fails in both directions). So the only reliable method is to run the\npair -- which is what `variants: true` does:\n\n```\nkeyword_ranks app=<your app id> keywords=[\"jogos de matemática\",\n              \"tabuada de multiplicação\", \"jogos de matemática 1 ano\"]\n              countries=[\"br\"] variants=true depth=true\n```\n\nThe three keywords: `jogos de matemática` (\"math games\"), `tabuada de\nmultiplicação` (\"multiplication times table\"), and `jogos de matemática 1\nano` (\"math games, grade 1\").\n\n| keyword | accented | plain | `identicalResults` |\n|---|---|---|---|\n| jogos de matemática | **#19** | not returned | `false` |\n| tabuada de multiplicação | **#12** | **#114** | `false` |\n| jogos de matemática 1 ano | #34 | #34 | **`true`** |\n\n`identicalResults` is the bit that matters: `true` means Apple answered one\nquery for both spellings, so the spelling you target makes no difference;\n`false` means they are genuinely separate searches and the rank gap above is\nreal; `null` means neither spelling returned anything, so there was nothing to\ncompare — not evidence that the two behave alike. Each variant is one more App Store search, so it counts against the\n25 keyword-country pair cap. Accents are only ever stripped, never added --\npass the accented spelling if you want the comparison.\n\nA mark counts as an accent only after a Latin, Greek or Cyrillic letter.\nWhere it is part of the word -- Devanagari vowel signs (`गणित`, Hindi for\n\"mathematics\"), a kana dakuten (`ゲ`), Thai tone marks -- nothing is stripped,\nno variant is searched and no note appears, because the result would be another\nword or none (`算数ゲーム`, \"arithmetic game\", would become `算数ケーム`).\nScripts not checked are left alone.\n\nCall it without `variants` and an accented keyword still gets a note in the\nresponse telling you the plain spelling and inviting you to re-run, because\nchecking one spelling alone can make a live keyword look dead.\n\nIn `se` and `de` the note adds a warning: Swedish å/ä/ö and German umlauts\nare **letters, not accents**, so the stripped form is a different word --\nuseful as \"what someone without the right keyboard types\", not as an\nequivalent spelling.\n\n### Keyword cohesion, and competitor subtitles\n\n*Measured in `se`. The behaviour is the same everywhere.*\n\n`niche_report` keyword rows carry `cohesion`: how many of that keyword's own\ntop-N apps also appear in the top-N of another keyword in the same report.\nIt costs nothing -- it compares results already fetched -- and it catches\nsomething no other signal here can: a keyword that pulls a different\naudience entirely.\n\nMeasured in `se`: \"lågstadiet\" (Swedish for \"the early primary grades\")\nshares **0 of 10** with four maths keywords, which share 2-7 with each\nother. Its top results are spelling and alphabet apps. Its difficulty score\nis **0**, which reads as \"easy, go for it\".\n\nGenre cannot see this: every one of those apps, and the app under audit, is in\n\"Utbildning\" (Education), so a genre-fit score would rate the wrong keyword and\nthe right one alike. Read cohesion as a count, not a score: a long-tail keyword\ncan legitimately share little.\n\nCompetitor rows also carry `subtitle` and `genres`. A **null** subtitle means\nApple did not hydrate that app in these search results -- it hydrates roughly\nthe top 8 per keyword -- not that the app has no subtitle; call `app_snapshot`\non that id to settle it.\n\n### Autocomplete depth (free, every storefront)\n\n*Measured mostly in `se`, with one `br` example. The behaviour is the same\neverywhere.*\n\n`keyword_table` and `niche_report` carry an `autocompleteDepth` column: how\nmany App Store autocomplete suggestions begin with that keyword. It costs\nnothing, needs no account, and works in all 17 storefronts -- which makes it\nthe only demand-ish signal available outside `us`, where keyword volume\ncannot be bought at all. It runs on its own request lane: a cold 6-keyword\n`niche_report` measured 4.9 s without it and 4.1 s with (the gap is network\nnoise).\n\n**Read it for what it is.** Apple's autocomplete is *prefix completion* --\n9-10 of every 10 suggestions literally begin with the text sent -- so this\ncounts popular queries that **extend** your keyword, not searches for the\nkeyword itself. Two consequences:\n\n- **It caps at 10, so it cannot rank head terms.** \"cool math games\"\n  (316,880 searches) and \"math games for kids\" (1,109) both score 10.\n- **A 0 does not mean nobody searches it.** It means nothing popular\n  extends that exact string. \"math quiz for kids\" scores 0 and is obviously\n  a real search. The informative range is the middle: 4 for \"math games for\n  adults\", 2 for \"learn math for kids\", 9 for `se` \"matematik för barn\"\n  (Swedish for \"maths for kids\"), 0 for `se` \"lågstadiet\" (Swedish for \"the\n  early primary grades\").\n\n**The words beat the number.** Measured on one Swedish field, nine of twelve\nkeywords scored 10, so the count separated almost nothing -- while the\nsuggestion *lists* exposed four dead keywords in minutes. Use the count as a\nprompt to look closer, never as proof a keyword is dead, and read the actual\nsuggestions: `audit_keywords` puts them on every word row (see\n[The thing a rank cannot tell you](#the-thing-a-rank-cannot-tell-you)),\nand `suggest_keywords` shows them for any term. The clearest case, measured live: in `br`, \"jogos de matemática 1\nano\" (Portuguese for \"math games, year 1\") scores **0** while the app ranks\n**34th** for it, against 52 results and five real competitors above it. Pass `depth: false` on either tool to\nleave the column out.\n\n### Verbose call log\n\n*Uses the Swedish storefront (`se`) as an example in the log lines below.\nThe behaviour is the same everywhere; only the words differ.*\n\nTurn on `verbose` (or set `ASOLENS_VERBOSE=1`) to see exactly what each\ntool call asked Apple and what came back — the fastest way to catch a\nwrong-storefront bug (e.g. querying `se` when you meant `us`) without reading\nthe source. The log itself goes to **stderr** — never stdout, which is the MCP\nJSON-RPC channel. The same lines are also returned inside that call's tool\nresponse as `calls: string[]`, so you can see them in the conversation itself\nrather than only in the server's log. Note what that means for\n`audit_keywords` and `compare_markets`: their probe strings are built from\nyour private keywords field, so verbose mode puts that field in front of your\nAI assistant -- for `compare_markets`, one locale's field per storefront.\n\n```\n[asolens] search se \"mattespel\" -> 200, 190 results, 1180ms\n[asolens] lookup us id=<your app id> -> 200, 1 record, 240ms\n[asolens] reviews se id=1609226786 page=1 -> 200, 50 entries, lastPage=10, 300ms\n[asolens] hints us \"math\" -> 200, 10 suggestions, 190ms\n```\n\nIf you expected `se` and see `us` in these lines (or the reverse), that's the\nbug. Off by default, and costs nothing when off.\n\n## Tools\n\nFifteen tools. **Eleven need nothing at all** -- no account, no key, no\nsignup. `price_keywords` needs `DATAFORSEO_LOGIN`/`DATAFORSEO_PASSWORD` to do\nanything at all -- without them it returns `unavailable` rather than working\nnormally -- and every call is billed to your DataForSEO account (see\n[Keyword search volume](#keyword-search-volume-paid-us-only)). Three more -- `my_listing`, `audit_keywords` and\n`compare_markets` -- read your app's **keywords field**, which Apple keeps\nprivate to the developer, so they need your own App Store Connect API key;\nwithout it they return a labelled `unavailable` saying what to set, rather\nthan failing. Three tools -- `keyword_table`, `niche_report` and `price_keywords`\n-- can spend a little real money on keyword search volume if you set\n`DATAFORSEO_LOGIN`/`DATAFORSEO_PASSWORD` -- see\n[Keyword search volume](#keyword-search-volume-dataforseo) above.\n`price_keywords` is the only one of the three with no `volume: false`\nescape hatch: naming keywords by hand is its whole job, so it always spends\nwhen it runs (and returns `unavailable` at $0 with no credentials\nconfigured, rather than spending). Without those credentials set, every other\ntool is exactly as free as before.\n\n| Tool | What it does | Example prompt |\n|---|---|---|\n| `find_app` | Looks up an app by id, App Store URL, or name; returns candidate matches. | \"Find my app on the App Store.\" |\n| `app_snapshot` | Current listing data for one app plus that developer's other apps in the storefront. | \"Give me a snapshot of app <id> in us.\" |\n| `search_ranks` | Ranked apps for one keyword in one country, as the App Store search lists them today. | \"What ranks for 'math games for kids' in se right now?\" |\n| `keyword_ranks` | Where one app ranks across a list of keywords and countries, with trend where available; `variants: true` checks accented and unaccented spellings side by side. | \"Where does my app rank for 'math games for kids' and 'kids math games' in se and us?\" |\n| `my_listing` | Your own app's indexed text -- name, subtitle and the private keywords field -- per locale, from App Store Connect. | \"Show my listing in br.\" |\n| `snapshot_save` / `snapshot_diff` | Capture a named before/after of a keyword set, then compare them — ranks, result-set sizes, who entered the top 10, coverage, the listing version it was read from, and ratings. | \"Snapshot these keywords as 'pre-release' for br, then diff it against 'post-release' in two weeks.\" |\n| `compare_markets` | The same audit across several storefronts side by side, to answer which market is worth working on. | \"Compare jp, br, mx and de for my app.\" |\n| `audit_keywords` | Tests every word in your own keywords field with an anchored probe, shows what people typing each word are actually searching for, and says whether keyword work is even the right lever for that storefront. | \"Audit my keyword field in br.\" |\n| `compare_queries` | How much two searches' ranked results overlap, so you know whether a rank difference between them means anything. | \"Compare 'jogos de matemática' and 'jogos de matemática escolares' in br for my app.\" |\n| `niche_report` | Competitive report across a keyword set: top apps, weakness score per rival, difficulty per keyword, gaps for your app; with DataForSEO configured, keyword volume and `discoveredKeywords` (higher-volume terms you aren't targeting). | \"Build a niche report for 'math games for kids', 'kids math games', 'math practice' in se, comparing against my app.\" |\n| `low_star_reviews` | Recent 1-3★ reviews for an app, for reading what's actually bothering users. | \"Pull the low-star reviews for app <id> in us.\" |\n| `suggest_keywords` | Shows what people actually search for, so you can check a keyword's intent matches what you assume. Never spends money: it prices a suggestion only if a `niche_report`, `keyword_table` or `price_keywords` run on any of the last 7 UTC days already bought its price, and names the day it was bought. | \"Suggest autocomplete keywords for 'math'.\" |\n| `keyword_table` | Your saved keyword list with current rank, difficulty, trend, free autocomplete depth and (with DataForSEO configured, `us` only) volume; can add keywords and notes in the same call. | \"Add 'math games for kids' and 'kids math games' to my keyword table for us, then show it refreshed.\" |\n| `price_keywords` | Search volume for a list of keywords you already have, by naming each one to DataForSEO -- reaches an app's full ranked list, not just its top-50 window, so it can rank a list `niche_report`/`keyword_table` can't fully price. Needs DataForSEO configured (`us` only); always spends when it runs, with no `volume: false` off switch. Billed per call — [see notice](#keyword-search-volume-paid-us-only). | \"Which of these 40 keywords has the most search volume: 'math games for kids', 'kids math games', ...\" |\n\n## Rate limits & errors\n\nTool calls can fail with a structured error instead of a result. The three\nworth knowing:\n\n- **`rate_limited`** — Apple's App Store endpoints throttled this machine.\n  The error includes `retryAfterSeconds`; wait that long and retry. This is\n  normal under heavy use, not a bug.\n- **`endpoint_changed`** — an Apple response no longer matches the shape\n  ASOLens expects. Apple's search/lookup/autocomplete endpoints are\n  undocumented and can change without notice; rather than guess at a\n  malformed response, ASOLens fails loudly. The fix is\n  `npx @asolens/mcp@latest` to pick up an update; if that doesn't help,\n  it's worth reporting. A search Apple simply has nothing for is NOT this:\n  it comes back as a normal answer with `resultCount: 0`, and `rank: null`\n  wherever a rank is reported.\n- **`unreachable`** — ASOLens couldn't reach Apple's servers from this\n  machine. Usually this means a network issue (no connection, DNS failure)\n  or Apple is temporarily down. Check your network connection and retry.\n\n(There are also `bad_input` for a bad argument like an unsupported country\ncode, `not_found` when an app can't be resolved, and `vendor_error` for a\nDataForSEO problem on a `keyword_table`/`niche_report` call with `volume`\non -- rejected credentials, an empty account balance, or DataForSEO itself\nbeing unreachable; the error's `fix` says which. This only ever happens when\ncredentials are configured and `volume` wasn't set to `false` -- with no\nDataForSEO key, or `volume: false`, volume is `unavailable` instead, never\nan error.)\n\n## Known limitations\n\n- **App Store (iOS) only.** No Google Play data.\n- **Keyword search volume is `us` only, and needs your own DataForSEO\n  account.** DataForSEO's Apple data covers the United States alone, and every\n  lookup is billed to you. Every other signal works in all 17 storefronts.\n- **`suggest_keywords` never buys volume.** No vendor sells volume for a bare\n  App Store keyword. It only shows prices another tool already bought on any of\n  the last 7 UTC days.\n- **Your App Store Connect listing is read once per server process.** Restart\n  the server after a release to see the new version.\n","readmeFilename":"README.md"}